diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index 730cfb413c..608fe717d5 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md -2026-07-23-client-plugin-loading-model.md: a8d016a70792ad4ac6a71083677ce5d82a30af0a -2026-07-23-client-plugin-loading-model.zh.md: 1b94909c9611f01e5a6d7fcb50ec3f91ee88fab1 +2026-07-23-client-plugin-loading-model.md: b8c23108a7a1cf3a04ded373d8705bae626c70c2 +2026-07-23-client-plugin-loading-model.zh.md: efaea843120e5b023bbc8f59663ea47468944c71 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index a8d016a707..b8c23108a7 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -46,7 +46,7 @@ A `require.async("./client..js")` call requests the exact revisioned `/plu The shared tsdown preset emits a map for every plugin entry and chunk and rewrites first-party source paths into the browser-resolvable repository form `/packages///src/...`. The production Client pass consumes `lib/types`; the preset supplies each tsc map to Rolldown and fills `sourcesContent` from the original files, so the final map reaches TypeScript/TSX instead of stopping at emitted JavaScript. Other workspace sources inlined into a bundle likewise resolve to their `packages/` owner, while dependency paths remain unchanged. Combo generation strips each local debug directive, records its generated-line offset, resolves every authored source against the original per-plugin map URL, and emits an Indexed Source Map v3. An authored map supplies its section; otherwise an identity section embeds the generated bundle and uses the packer's `sourceURL` as its source name when present. The absolute combo map URL mirrors the script resource list by changing every `client.js` suffix to `client.js.map`, so `/plugins/??/client.js,/client.js&rev=` points to `/plugins/??/client.js.map,/client.js.map&rev=`. A chunk script likewise points to its own map URL. Source-map files are read and combined only when those map URLs receive `GET`; `HEAD` materializes neither script nor map bodies. The Vite shell also emits source maps, letting shell code and externally loaded plugins map stacks and performance profiles back to TypeScript/TSX. -The graph retains each row's revisioned one-resource combo URL for HMR and adds a revisioned descriptor for every startup combo request; several descriptors may carry the same scheduling phase. Initial row revisions are opaque process nonces rather than content hashes; they keep the snapshotted one-resource response immutable without hashing every plugin at startup. The shared preset stamps the entry after every package output is written. After the watcher observes that completed-build stamp, `rebuilt(id)` hashes the entry bytes together with its timestamp and publishes the resulting revision; a chunk-only build therefore advances the owner revision without a Host-side sibling scan. Startup combo revisions derive from the ordered row revisions. Script bodies are assembled on their first `GET`; source-map files are read and combined separately on their first map `GET`. `HEAD` materializes neither body. Versioned scripts and maps use immutable caching, and unrelated graph recomposition retains already-materialized chunk responses at the same revision. The Host serves only exact generated URLs; stale revisions and unadvertised resource lists return 404 instead of aliasing different bytes. An external script's `error` event exposes neither response status nor body, so failure diagnostics name only the URL; the same-origin Host and build-stamped registration id form the identity boundary, while the post-`load` factory-presence check rejects an artifact that did not register the expected id. +The graph retains each row's revisioned one-resource combo URL for HMR and adds a revisioned descriptor for every startup combo request; several descriptors may carry the same scheduling phase. Initial publication and `rebuilt(id)` use the same hash of entry mtime, ctime, and size; artifact contents never enter the hash. Ctime distinguishes rewrites that preserve mtime and size. A Host restart over unchanged artifacts therefore retains every row revision, so the reconnecting SSE snapshot does not trigger browser plugin replacement. The shared preset stamps the entry after every package output is written; a chunk-only build therefore advances the owner revision without a Host-side sibling scan. Startup combo revisions derive from the ordered row revisions. Script bodies are assembled on their first `GET`; source-map files are read and combined separately on their first map `GET`. `HEAD` materializes neither body. Versioned scripts and maps use immutable caching, and unrelated graph recomposition retains already-materialized chunk responses at the same revision. The Host serves only exact generated URLs; stale revisions and unadvertised resource lists return 404 instead of aliasing different bytes. An external script's `error` event exposes neither response status nor body, so failure diagnostics name only the URL; the same-origin Host and build-stamped registration id form the identity boundary, while the post-`load` factory-presence check rejects an artifact that did not register the expected id. ### The loading flow, end to end @@ -56,7 +56,7 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the 1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, including the always-mounted `client-hmr` row. `auditStartupEntries` reports failed imports, activation errors with their original stacks, and pending dependencies; optional entries warn and required entries reject startup ([startup policy](2026-09-09-consumer-owned-startup-strictness.md)). 2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) resolves each live Loader entry through the same `name` and owning-tree `baseUrl` inputs that imported its Host face, then reads the nearest owning package.json `dsh.client` declaration and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`. The manifest package name is the browser module identity even when an overlay names a relative source or built entry file. Distinct active Loader sources resolving to one package name fail composition; after one source unloads, the surviving source supplies the row without a fiber restart. The row's three optional fields come from manifests, never hand-copied. Composition orders requested dynamic rows before their consumers, rejects synchronous request cycles, and assigns every row to exactly one initial batch. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the Host audit reports either error from the FAILED fiber. -3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per entry name and owning-tree base URL for the process lifetime and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows receive an opaque process nonce plus sequence without hashing their artifacts; startup combo revisions hash the combined script inputs plus indexed map, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows. +3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per entry name and owning-tree base URL for the process lifetime and changed bundle publication reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows hash their pre-read filesystem metadata; startup combo revisions hash the ordered row identities and revisions, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows. Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a package declaring `dsh.client` in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted. @@ -80,7 +80,7 @@ The Host SSE adapter forwards existing graph-change notifications and sends the Hot reload is a composition decision: the web bundle mounts the `client-hmr` row (a normal plugin package) unconditionally; its node half brings the bundle watch and the SSE channel, and the chain stays idle until a rebuild watcher rewrites client bundles. A composition that must not expose it disables the row. -How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. Before reading each startup snapshot, the module host captures the entry stat baseline and exposes it through `ctx.clientModules.artifactBaseline(id)`. One HMR-owned interval compares every current graph row with that baseline. An unchanged row starts watching without a content read or hash; a write after baseline capture is already a stat delta and only that row enters `rebuilt(id)`. This avoids both an initial all-row re-hash and `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and an entry missing at poll time keeps its row dirty so reappearance forces a new revision even with identical metadata. The shared tsdown preset stamps `client.js` after every sibling output has been written; on an entry mtime or size delta, or a dirty row, `rebuilt(id)` derives the revision from the entry bytes and completed-build timestamp. A chunk-only edit therefore changes the revision, and a partial write followed by the completion stamp supplies a later stat change that self-heals. When the `rev` changes, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; one entry stat per row and interval is sufficient, the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding artifacts is any process using the shared Client tsdown preset — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share no notification protocol. +How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. Before reading each startup snapshot, the module host captures the entry stat baseline and exposes it through `ctx.clientModules.artifactBaseline(id)`. One HMR-owned interval compares every current graph row with that baseline. An unchanged row starts watching without a content read or hash; a write after baseline capture is already a stat delta and only that row enters `rebuilt(id)`. This avoids redundant reads when installing the watcher and avoids `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and an entry missing at poll time keeps its row dirty so reappearance retries publication even when mtime and size match the baseline. The shared tsdown preset stamps `client.js` after every sibling output has been written; on an entry mtime, ctime, or size delta, or a dirty row, `rebuilt(id)` derives the revision from those three metadata values. It reads the new bundle snapshot only if the revision changes. A chunk-only edit therefore changes the revision, and a partial write followed by the completion stamp supplies a later stat change that self-heals. When the `rev` changes, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; one entry stat per row and interval is sufficient, the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding artifacts is any process using the shared Client tsdown preset — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share no notification protocol. On the browser side, the transport delegates code replacement to the same modules controller that owns graph reconciliation: @@ -120,4 +120,4 @@ Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordi | Full ctx-ification now (React and libraries via services, no module table) | Static UI libraries still expose synchronous values, so removing the table would leave those imports without a shared identity | | Eager instantiation with a frozen table | Runs bundle side effects at script arrival; lazy registration keeps execution at Cordis import while recursive `require` materializes registered requests | | Fetch response text, then inject an inline ` + + +
+ + diff --git a/apps/desktop/scripts/desktop-build-commit.d.mts b/apps/desktop/scripts/desktop-build-commit.d.mts new file mode 100644 index 0000000000..82118e24e1 --- /dev/null +++ b/apps/desktop/scripts/desktop-build-commit.d.mts @@ -0,0 +1,32 @@ +/** Commit a build came from, and whether its checkout carried uncommitted changes. */ +export interface DesktopBuildCommit { + readonly commit: string + readonly dirty: boolean +} + +/** Environment variable that carries the packaged commit. */ +export const DESKTOP_BUILD_COMMIT_ENV: 'DSH_DESKTOP_BUILD_COMMIT' + +/** Environment variable that records whether the packaged checkout had uncommitted changes. */ +export const DESKTOP_BUILD_DIRTY_ENV: 'DSH_DESKTOP_BUILD_DIRTY' + +/** + * Read the checkout's current commit and whether it carries uncommitted changes. + * @param repositoryRoot - Directory to inspect. + * @returns The commit being packaged and whether its tree was modified. + */ +export function readDesktopBuildCommit(repositoryRoot: string): DesktopBuildCommit + +/** + * Resolve the commit a parent packaging process recorded. + * @param env - Packaging environment. + * @returns The packaged commit, or undefined outside a packaging run. + */ +export function resolveDesktopBuildCommit(env: NodeJS.ProcessEnv): DesktopBuildCommit | undefined + +/** + * Describe the packaged commit as the environment variables child processes read. + * @param packaged - Commit and tree state to pass down. + * @returns Variables to merge into a child environment. + */ +export function desktopBuildCommitEnvironment(packaged: DesktopBuildCommit): Record diff --git a/apps/desktop/scripts/desktop-build-commit.mjs b/apps/desktop/scripts/desktop-build-commit.mjs new file mode 100644 index 0000000000..28d7cda513 --- /dev/null +++ b/apps/desktop/scripts/desktop-build-commit.mjs @@ -0,0 +1,57 @@ +/** + * Carry the commit a build came from into its artifacts. + * + * A build that reaches a colleague or a test feed is not reachable from a tag, + * so the only way back to its sources is what the build recorded about itself. + * The packaging entry reads the checkout once and passes the result to every + * child process, which run against a build tree rather than a checkout. + */ + +import { execFileSync } from 'node:child_process' + +/** Environment variable that carries the packaged commit. */ +export const DESKTOP_BUILD_COMMIT_ENV = 'DSH_DESKTOP_BUILD_COMMIT' + +/** Environment variable that records whether the packaged checkout had uncommitted changes. */ +export const DESKTOP_BUILD_DIRTY_ENV = 'DSH_DESKTOP_BUILD_DIRTY' + +/** + * Read the checkout's current commit and whether it carries uncommitted changes. + * @param {string} repositoryRoot - Directory to inspect. + * @returns {{ commit: string, dirty: boolean }} The commit being packaged and whether its tree was modified. + */ +export function readDesktopBuildCommit(repositoryRoot) { + const git = (args) => execFileSync('git', args, { cwd: repositoryRoot, encoding: 'utf8' }).trim() + return { + commit: git(['rev-parse', 'HEAD']), + dirty: git(['status', '--porcelain', '--untracked-files=normal']) !== '', + } +} + +/** + * Resolve the commit a parent packaging process recorded. + * @param {NodeJS.ProcessEnv} env - Packaging environment. + * @returns {{ commit: string, dirty: boolean } | undefined} The packaged commit, or undefined outside a packaging run. + */ +export function resolveDesktopBuildCommit(env) { + const commit = env[DESKTOP_BUILD_COMMIT_ENV]?.trim() + if (commit === undefined || commit === '') return undefined + if (!/^[0-9a-f]{40}$/u.test(commit)) throw new Error(`desktop build commit: ${DESKTOP_BUILD_COMMIT_ENV} must be a commit hash`) + const dirty = env[DESKTOP_BUILD_DIRTY_ENV]?.trim() + if (dirty !== undefined && dirty !== '' && dirty !== '0' && dirty !== '1') { + throw new Error(`desktop build commit: ${DESKTOP_BUILD_DIRTY_ENV} must be 0 or 1`) + } + return { commit, dirty: dirty === '1' } +} + +/** + * Describe the packaged commit as the environment variables child processes read. + * @param {{ commit: string, dirty: boolean }} packaged - Commit and tree state to pass down. + * @returns {Record} Variables to merge into a child environment. + */ +export function desktopBuildCommitEnvironment(packaged) { + return { + [DESKTOP_BUILD_COMMIT_ENV]: packaged.commit, + [DESKTOP_BUILD_DIRTY_ENV]: packaged.dirty ? '1' : '0', + } +} diff --git a/apps/desktop/scripts/desktop-build-version-discovery.ts b/apps/desktop/scripts/desktop-build-version-discovery.ts new file mode 100644 index 0000000000..32ed5a37b2 --- /dev/null +++ b/apps/desktop/scripts/desktop-build-version-discovery.ts @@ -0,0 +1,156 @@ +/** + * Suggest the next build version for a product version, so the sequence number + * is derived rather than remembered. + * + * `apps/desktop/README.md` gives the form as `..` + * for a prerelease base and `-test..` for a + * stable one. What makes an index correct is which ones are taken, and that is + * recorded where the builds went: the update bucket for an uploaded build, the + * local output directory for one that was only handed over. Asking the bucket + * first is what keeps a test feed's numbering unique across machines, and a + * fresh CI checkout has no local artifacts to fall back on. + * + * Reusing an index would overwrite a published installer and invalidate the + * checksum in the feed that already points at it, so a bucket that answers + * must answer completely: the listing follows its pages, and a query that + * cannot finish within the deadline falls back to the local scan rather than + * numbering against a partial answer. + */ + +import { readdir } from 'node:fs/promises' +import { parse } from 'semver' +import { desktopBuildVersionPrefix, validateDesktopBuildVersion } from './desktop-build-version.mjs' +import { DESKTOP_AUTO_UPDATE_ENV, resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs' +import { createDesktopCos, DESKTOP_COS_REGION } from './desktop-cos.ts' +import type { DesktopPackageTargetName } from './package-target.ts' + +/** How long the whole bucket listing may take before the suggestion falls back to local artifacts. */ +const LISTING_DEADLINE_MS = 8_000 + +/** Objects one listing page may return. */ +const LISTING_PAGE_SIZE = 1000 + +/** Artifact name electron-builder writes for one build, on either platform. */ +const ARTIFACT = /(?:^|\/)deepseek-harness-(?.+)-(?:mac|win)-(?:arm64|x64)\.(?:exe|dmg|zip)$/u + +/** Inputs that decide which versions are already taken. */ +export interface DesktopBuildVersionSuggestionOptions { + readonly productVersion: string + readonly target: DesktopPackageTargetName + readonly environment: NodeJS.ProcessEnv + /** Date segment to number within; defaults to today where the build runs. */ + readonly date?: string + /** Directory electron-builder writes installers into for this build. */ + readonly artifactsRoot: string +} + +/** + * Format a date as the convention's segment. + * @param date - Date to format. + * @returns The date as `YYYYMMDD` in the build host's own time zone. + */ +export function desktopBuildDateSegment(date: Date = new Date()): string { + const month = String(date.getMonth() + 1).padStart(2, '0') + return `${String(date.getFullYear())}${month}${String(date.getDate()).padStart(2, '0')}` +} + +/** + * Read the sequence numbers already used for one product version and date. + * @param versions - Versions found in a bucket or directory. + * @param prefix - Everything a numbered build carries before its index. + * @returns Every index present, unordered. + */ +function sequenceNumbers(versions: Iterable, prefix: string): number[] { + const numbers: number[] = [] + for (const version of versions) { + if (!version.startsWith(prefix)) continue + const index = version.slice(prefix.length) + if (/^\d+$/u.test(index)) numbers.push(Number(index)) + } + return numbers +} + +/** + * Read the versions one output directory already holds. + * @param artifactsRoot - Directory electron-builder wrote installers into. + * @returns Versions parsed from artifact names. + */ +async function localVersions(artifactsRoot: string): Promise { + const entries = await readdir(artifactsRoot).catch(() => []) + return entries.map(entry => ARTIFACT.exec(entry)?.groups?.version) + .filter((version): version is string => version !== undefined && parse(version) !== null) +} + +/** + * Read every version one update bucket publishes for a target. + * @param options - Product version, target, and environment naming the bucket. + * @returns Versions parsed from object names, or undefined when the bucket cannot be listed completely in time. + */ +async function remoteVersions(options: DesktopBuildVersionSuggestionOptions): Promise { + const platform = options.target === 'win-x64' ? 'win32' as const : 'darwin' as const + const arch = options.target === 'mac-arm64' ? 'arm64' : 'x64' + // An unconfigured destination has nothing to be unique against; an invalid one must not be mistaken for it. + if (options.environment[DESKTOP_AUTO_UPDATE_ENV] === undefined + && options.environment.DOWNLOAD_TEST_ORIGIN === undefined) return undefined + const update = resolveDesktopUploadConfig(options.environment, platform, arch) + const secretId = options.environment[update.secretIdEnvName]?.trim() + const secretKey = options.environment[update.secretKeyEnvName]?.trim() + if (secretId === undefined || secretId === '' || secretKey === undefined || secretKey === '') return undefined + // The SDK has no per-request abort, so its own timeout is what releases an unanswered socket. + const cos = createDesktopCos({ secretId, secretKey }, LISTING_DEADLINE_MS) + const deadline = Date.now() + LISTING_DEADLINE_MS + const versions: string[] = [] + let marker: string | undefined + try { + do { + const page = await Promise.race([ + new Promise<{ keys: string[]; next: string | undefined }>((resolveListing, rejectListing) => { + cos.getBucket({ + Bucket: update.bucket, Region: DESKTOP_COS_REGION, MaxKeys: LISTING_PAGE_SIZE, + Prefix: `${update.binaryKeyPrefix}/deepseek-harness-`, ...marker === undefined ? {} : { Marker: marker }, + }, (error, data) => { + if (error !== null && error !== undefined) rejectListing(error instanceof Error ? error : new Error(String(error))) + else { + resolveListing({ + keys: (data?.Contents ?? []).map(object => object.Key), + next: data?.IsTruncated === 'true' ? data.NextMarker ?? data.Contents?.at(-1)?.Key : undefined, + }) + } + }) + }), + new Promise((_resolveDeadline, rejectDeadline) => { + setTimeout(() => rejectDeadline(new Error('listing deadline')), Math.max(0, deadline - Date.now())).unref() + }), + ]) + for (const key of page.keys) { + const version = ARTIFACT.exec(key)?.groups?.version + if (version !== undefined && parse(version) !== null) versions.push(version) + } + marker = page.next + } while (marker !== undefined) + } + catch (unfinished) { + // Numbering against a partial listing could reuse a published index, so an unfinished query yields nothing. + process.stdout.write(`desktop package: the update bucket did not answer completely (${ + unfinished instanceof Error ? unfinished.message : String(unfinished)}); numbering from local artifacts\n`) + return undefined + } + return versions +} + +/** + * Suggest the next build version for today, numbering after what is already taken. + * @param options - Product version, target, and environment to search. + * @returns A validated build version whose index is free. + */ +export async function suggestDesktopBuildVersion(options: DesktopBuildVersionSuggestionOptions): Promise { + const prefix = `${desktopBuildVersionPrefix(options.productVersion)}${options.date ?? desktopBuildDateSegment()}.` + const published = await remoteVersions(options) + const taken = published ?? await localVersions(options.artifactsRoot) + const used = sequenceNumbers(taken, prefix) + const next = used.length === 0 ? 1 : Math.max(...used) + 1 + const suggestion = validateDesktopBuildVersion(`${prefix}${String(next)}`, options.productVersion) + process.stdout.write(`desktop package: numbering ${suggestion} after ${String(used.length)} ${ + published === undefined ? 'local artifact' : 'published build'}${used.length === 1 ? '' : 's'} for ${prefix}*\n`) + return suggestion +} diff --git a/apps/desktop/scripts/desktop-build-version.d.mts b/apps/desktop/scripts/desktop-build-version.d.mts new file mode 100644 index 0000000000..969b601043 --- /dev/null +++ b/apps/desktop/scripts/desktop-build-version.d.mts @@ -0,0 +1,25 @@ +/** Environment variable that carries the build version through one packaging and upload run. */ +export const DESKTOP_BUILD_VERSION_ENV: 'DSH_DESKTOP_BUILD_VERSION' + +/** + * Validate a build version against the product version it extends. + * @param buildVersion - Version this build publishes. + * @param productVersion - Version the manifests declare. + * @returns The version as semver normalizes it, which is what the artifacts will carry. + */ +export function validateDesktopBuildVersion(buildVersion: string, productVersion: string): string + +/** + * Resolve the version a build publishes. + * @param env - Packaging or upload environment. + * @param productVersion - Version the manifests declare. + * @returns The build version when one is present, otherwise the product version. + */ +export function resolveDesktopBuildVersion(env: NodeJS.ProcessEnv, productVersion: string): string + +/** + * Everything a build version carries before its date, including the trailing separator. + * @param productVersion - Version the manifests declare. + * @returns The prefix shared by every build version of that product version. + */ +export function desktopBuildVersionPrefix(productVersion: string): string diff --git a/apps/desktop/scripts/desktop-build-version.mjs b/apps/desktop/scripts/desktop-build-version.mjs new file mode 100644 index 0000000000..e2152ce0d3 --- /dev/null +++ b/apps/desktop/scripts/desktop-build-version.mjs @@ -0,0 +1,100 @@ +/** + * Resolve the version one build publishes, which is not always the version the + * repository declares. + * + * A production release publishes the version in the manifests, aligned with the + * `dsh` npm package. A test build publishes a version that appends a date and a + * sequence number, so one test feed can carry several builds of a single + * product version: `0.1.6-alpha.1.20260916.1` from a prerelease base and + * `0.1.6-test.20260916.1` from a stable one, as the release versions table in + * `apps/desktop/README.md` gives them. Passing that version here keeps it out + * of the manifests, so the tracked version stays the product's while the build + * version reaches electron-builder, the update feed, and the upload validation + * as one input. + * + * `electron-updater` compares feed versions with `semver.gt` against the + * installed `app.getVersion()`, so validation uses the same library. Sequence + * numbers order builds within a feed under either form. The forms differ only + * against the base itself: builds extending a prerelease outrank it, while + * `0.1.6-test.1` ranks below `0.1.6`, which is why the stable form belongs to a + * test feed that never serves the production release. + */ + +import { parse } from 'semver' + +/** Environment variable that carries the build version through one packaging and upload run. */ +export const DESKTOP_BUILD_VERSION_ENV = 'DSH_DESKTOP_BUILD_VERSION' + +/** Prerelease field that opens a test build's suffix on a stable product version. */ +const STABLE_TEST_FIELD = 'test' + +/** + * Parse a version the updater would accept. + * @param {string} version - Version to read. + * @param {string} label - Description used in failures. + * @returns {import('semver').SemVer} The parsed version. + */ +function parseVersion(version, label) { + const parsed = parse(version, { loose: false }) + if (parsed === null) throw new Error(`desktop build version: ${label} ${version} is not a version`) + if (parsed.build.length > 0) { + // Build metadata does not participate in precedence, so two builds would compare equal to the updater. + throw new Error(`desktop build version: ${label} ${version} cannot carry build metadata`) + } + return parsed +} + +/** + * The prerelease fields every build version for one product version starts with. + * @param {import('semver').SemVer} product - Parsed product version. + * @returns {readonly (string | number)[]} Fields a build version must repeat before its date. + */ +function requiredFields(product) { + return product.prerelease.length === 0 ? [STABLE_TEST_FIELD] : product.prerelease +} + +/** + * Validate a build version against the product version it extends. + * @param {string} buildVersion - Version this build publishes. + * @param {string} productVersion - Version the manifests declare. + * @returns {string} The version as semver normalizes it, which is what the artifacts will carry. + */ +export function validateDesktopBuildVersion(buildVersion, productVersion) { + const build = parseVersion(buildVersion, 'build version') + const product = parseVersion(productVersion, 'product version') + if (build.version === product.version) return build.version + if (build.compareMain(product) !== 0) { + throw new Error(`desktop build version: ${buildVersion} must extend product version ${productVersion}`) + } + const required = requiredFields(product) + const extendsProduct = build.prerelease.length > required.length + && required.every((field, index) => build.prerelease[index] === field) + if (!extendsProduct) { + throw new Error(`desktop build version: ${buildVersion} must extend ${productVersion} as ${ + desktopBuildVersionPrefix(productVersion)}.`) + } + return build.version +} + +/** + * Resolve the version a build publishes. + * @param {NodeJS.ProcessEnv} env - Packaging or upload environment. + * @param {string} productVersion - Version the manifests declare. + * @returns {string} The build version when one is present, otherwise the product version. + */ +export function resolveDesktopBuildVersion(env, productVersion) { + const buildVersion = env[DESKTOP_BUILD_VERSION_ENV]?.trim() + if (buildVersion === undefined || buildVersion === '') return productVersion + return validateDesktopBuildVersion(buildVersion, productVersion) +} + +/** + * Everything a build version carries before its date, including the trailing separator. + * @param {string} productVersion - Version the manifests declare. + * @returns {string} The prefix shared by every build version of that product version. + */ +export function desktopBuildVersionPrefix(productVersion) { + const product = parseVersion(productVersion, 'product version') + const [release] = product.version.split('-') + return `${release}-${requiredFields(product).join('.')}.` +} diff --git a/apps/desktop/scripts/desktop-cos.ts b/apps/desktop/scripts/desktop-cos.ts index 68306ffc64..6074d84a05 100644 --- a/apps/desktop/scripts/desktop-cos.ts +++ b/apps/desktop/scripts/desktop-cos.ts @@ -29,9 +29,10 @@ interface CosRequestOptions { * caller names none; that header is removed here so uploading leaves cache policy to deployment * infrastructure. * @param credentials SecretId and SecretKey for the selected deployment. + * @param timeoutMs Request-level inactivity deadline; a caller that must not wait out a transfer passes its own. * @returns A COS client that sends HTTPS requests to the region named by each call. */ -export function createDesktopCos(credentials: DesktopCosCredentials): COS { +export function createDesktopCos(credentials: DesktopCosCredentials, timeoutMs: number = TRANSFER_TIMEOUT_MS): COS { const cos = new COS({ SecretId: credentials.secretId, SecretKey: credentials.secretKey, @@ -41,7 +42,7 @@ export function createDesktopCos(credentials: DesktopCosCredentials): COS { AutoSwitchHost: false, CorrectClockSkew: false, ChunkRetryTimes: 0, - Timeout: TRANSFER_TIMEOUT_MS, + Timeout: timeoutMs, UploadCheckContentMd5: false, }) cos.on('before-send', (options: CosRequestOptions) => { diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index 71dd9a6655..3addf3ff65 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -4,7 +4,7 @@ import { accessSync, constants, readFileSync, statSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { parseEnv } from 'node:util' -import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, resolveMacOSSigningEnvironment } from './desktop-release-environment.mjs' +import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, resolveMacOSSigningEnvironment, resolveNpmRegistry } from './desktop-release-environment.mjs' import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environment.mjs' import { createWindowsTokenSigner } from './windows-sign.mjs' import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs' @@ -13,7 +13,7 @@ import { resolveWindowsSignatureCacheDirectory } from './windows-signature-cache import { resolveWindowsPackageSettings } from './windows-package-settings.mjs' const APP_ROOT = fileURLToPath(new URL('..', import.meta.url)) -const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u +const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY)$/u const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu @@ -78,6 +78,7 @@ function requireReadableFile(environment, name) { */ export function validateDesktopPackageEnvironment(environment, target, options = {}) { resolveDesktopAppId(environment) + resolveNpmRegistry(environment) resolveDesktopPolicyEnvironment(environment) if (target.platform === 'darwin') resolveMacOSPackageSettings(environment) else resolveWindowsPackageSettings(environment) diff --git a/apps/desktop/scripts/desktop-release-environment.d.mts b/apps/desktop/scripts/desktop-release-environment.d.mts index ad48933f65..26190ab2b8 100644 --- a/apps/desktop/scripts/desktop-release-environment.d.mts +++ b/apps/desktop/scripts/desktop-release-environment.d.mts @@ -7,6 +7,9 @@ export const MACOS_SIGNING_IDENTITY_ENV: 'DSH_DESKTOP_MACOS_SIGNING_IDENTITY' /** Environment variable that supplies the expected Apple Developer Team ID. */ export const MACOS_TEAM_ID_ENV: 'DSH_DESKTOP_MACOS_TEAM_ID' +/** Environment variable that selects the npm registry used for the bundled runtime install. */ +export const NPM_REGISTRY_ENV: 'DSH_DESKTOP_NPM_REGISTRY' + /** Public identity expected on a macOS release. */ export interface MacOSSigningEnvironment { readonly signingIdentity: string @@ -46,6 +49,13 @@ export type MacOSNotarizationEnvironment = */ export function resolveDesktopAppId(env: NodeJS.ProcessEnv): string +/** + * Resolve the npm registry used to materialize the bundled runtime and its external dependencies. + * @param env - Packaging environment. + * @returns Registry origin; the public registry unless a local mirror is configured. + */ +export function resolveNpmRegistry(env: NodeJS.ProcessEnv): string + /** * Resolve and validate the public identity expected on a macOS release. * @param env - Packaging environment. diff --git a/apps/desktop/scripts/desktop-release-environment.mjs b/apps/desktop/scripts/desktop-release-environment.mjs index 46746428d9..635a57a3c9 100644 --- a/apps/desktop/scripts/desktop-release-environment.mjs +++ b/apps/desktop/scripts/desktop-release-environment.mjs @@ -9,6 +9,11 @@ export const MACOS_SIGNING_IDENTITY_ENV = 'DSH_DESKTOP_MACOS_SIGNING_IDENTITY' /** Environment variable that supplies the expected Apple Developer Team ID. */ export const MACOS_TEAM_ID_ENV = 'DSH_DESKTOP_MACOS_TEAM_ID' +/** Environment variable that selects the npm registry used for the bundled runtime install. */ +export const NPM_REGISTRY_ENV = 'DSH_DESKTOP_NPM_REGISTRY' + +const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org/' + const APPLE_API_KEY_ENV = 'APPLE_API_KEY' const APPLE_API_KEY_ID_ENV = 'APPLE_API_KEY_ID' const APPLE_API_ISSUER_ENV = 'APPLE_API_ISSUER' @@ -32,6 +37,24 @@ function requireEnvironmentValue(env, name) { return value } +/** + * Resolve the npm registry used to materialize the bundled runtime and its external dependencies. + * @param {NodeJS.ProcessEnv} env - Packaging environment. + * @returns {string} Registry origin; the public registry unless a local mirror is configured. + */ +export function resolveNpmRegistry(env) { + const configured = env[NPM_REGISTRY_ENV]?.trim() ?? '' + if (configured === '') return DEFAULT_NPM_REGISTRY + let url + try { url = new URL(configured) } + catch { throw new Error(`desktop release environment: ${NPM_REGISTRY_ENV} must be an HTTPS origin`) } + if (url.protocol !== 'https:' || url.username !== '' || url.password !== '' || url.search !== '' || url.hash !== '' + || (url.pathname !== '/' && url.pathname !== '')) { + throw new Error(`desktop release environment: ${NPM_REGISTRY_ENV} must be an HTTPS origin without credentials, path, query, or fragment`) + } + return url.origin +} + /** * Resolve and validate the application identifier shared by every platform target. * @param {NodeJS.ProcessEnv} env - Packaging environment. diff --git a/apps/desktop/scripts/desktop-release-tag.ts b/apps/desktop/scripts/desktop-release-tag.ts new file mode 100644 index 0000000000..6b31938f55 --- /dev/null +++ b/apps/desktop/scripts/desktop-release-tag.ts @@ -0,0 +1,80 @@ +/** + * Record a production release in git after it reaches the download domain. + * + * A build that only goes to a colleague or a test feed carries its commit + * inside the artifact, which is enough to trace it. A build that external users + * install should also be reachable from the repository, so a production upload + * tags the commit it was packaged from. The tag is written after the upload + * succeeds: a tag for a release that never shipped would be worse than none. + * + * Tagging never fails an upload that already completed. The artifacts are + * public by then, and nothing about a missing tag can be repaired by reporting + * the upload as failed; the operator is told what to run instead. + */ + +import { execFileSync } from 'node:child_process' + +/** What tagging one uploaded release did, or why it did nothing. */ +export interface DesktopReleaseTagResult { + readonly tag: string + readonly status: 'created' | 'present' | 'failed' + /** Commands that finish the tagging a failure interrupted, in order. */ + readonly recovery?: readonly string[] + readonly detail?: string +} + +/** + * Name the tag one published version carries. + * @param version - Version the release published. + * @returns The tag name, distinct from the `dsh-v*` tags the npm family publishes from. + */ +export function desktopReleaseTag(version: string): string { + return `desktop-v${version}` +} + +/** + * Tag and push the commit a production release was packaged from. + * @param options - Published version, packaged commit, repository, and remote. + * @returns What tagging did; a failure is reported rather than thrown. + */ +export function tagDesktopRelease(options: { + readonly version: string + readonly commit: string + readonly repositoryRoot: string + readonly remote?: string + readonly run?: (command: string, args: readonly string[]) => string +}): DesktopReleaseTagResult { + const tag = desktopReleaseTag(options.version) + const remote = options.remote ?? 'origin' + const run = options.run + ?? ((command: string, args: readonly string[]) => + execFileSync(command, [...args], { cwd: options.repositoryRoot, encoding: 'utf8' }).trim()) + const create = `git tag ${tag} ${options.commit}` + const push = `git push ${remote} ${tag}` + // A local tag that already exists makes `git tag` fail, so recovery must name only the steps still outstanding. + let tagged = false + try { + const existing = run('git', ['tag', '--list', tag]) + if (existing !== '') { + const named = run('git', ['rev-list', '-n', '1', tag]) + if (named !== options.commit) { + return { tag, status: 'failed', detail: `${tag} already names ${named}, not the uploaded commit ${options.commit}` } + } + tagged = true + run('git', ['push', remote, tag]) + return { tag, status: 'present' } + } + run('git', ['tag', tag, options.commit]) + tagged = true + run('git', ['push', remote, tag]) + return { tag, status: 'created' } + } + catch (error) { + return { + tag, + status: 'failed', + recovery: tagged ? [push] : [create, push], + detail: error instanceof Error ? error.message : String(error), + } + } +} diff --git a/apps/desktop/scripts/desktop-toolchain-preflight.ts b/apps/desktop/scripts/desktop-toolchain-preflight.ts new file mode 100644 index 0000000000..ff855cfd09 --- /dev/null +++ b/apps/desktop/scripts/desktop-toolchain-preflight.ts @@ -0,0 +1,102 @@ +/** + * Probe the external tools a packaging run will shell out to, before it starts. + * + * The packaging sequence reaches its first archive step minutes in, and its + * first installer step later still, so a tool that is absent or behaves + * differently than the scripts assume wastes a whole run. Each probe here + * exercises the specific behaviour the run depends on rather than merely + * checking that the executable resolves. + */ + +import { execFile } from 'node:child_process' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { promisify } from 'node:util' + +const run = promisify(execFile) + +/** One tool the packaging run requires, and what went wrong with it. */ +export interface DesktopToolchainProbeFailure { + readonly tool: string + readonly detail: string +} + +async function probeTar(): Promise { + // GNU tar reads the colon in an absolute Windows path as a remote-host separator, which the + // release scripts avoid by running beside the archive; this probe confirms the archive reader works there. + const directory = await mkdtemp(join(tmpdir(), 'dsh-tar-probe-')) + try { + const member = join(directory, 'probe.txt') + await writeFile(member, 'probe\n') + const archive = join(directory, 'probe.tar.gz') + await run('tar', ['-czf', basename(archive), basename(member)], { cwd: directory, windowsHide: true, timeout: 20_000 }) + const listed = await run('tar', ['-tzf', basename(archive)], { cwd: dirname(archive), windowsHide: true, timeout: 20_000 }) + if (!listed.stdout.includes('probe.txt')) return `tar listed ${JSON.stringify(listed.stdout)} instead of the archived member` + return undefined + } + catch (error) { + return error instanceof Error ? error.message : String(error) + } + finally { + await rm(directory, { recursive: true, force: true }) + } +} + +async function probeWindowsInstallerToolchain(environment: NodeJS.ProcessEnv): Promise { + const failures: DesktopToolchainProbeFailure[] = [] + const programFiles = environment['ProgramFiles(x86)'] + const vswhere = programFiles === undefined + ? undefined + : join(programFiles, 'Microsoft Visual Studio/Installer/vswhere.exe') + if (vswhere === undefined) { + failures.push({ tool: 'vswhere', detail: 'ProgramFiles(x86) is not set, so Visual Studio cannot be located' }) + } + else { + try { + const located = await run(vswhere, ['-latest', '-products', '*', + '-requires', 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64', '-property', 'installationPath'], + { windowsHide: true, timeout: 30_000 }) + if (located.stdout.trim() === '') { + failures.push({ tool: 'vswhere', detail: 'Visual Studio C++ build tools are not installed, so the installer helper cannot compile' }) + } + } + catch (error) { + failures.push({ tool: 'vswhere', detail: error instanceof Error ? error.message : String(error) }) + } + } + return failures +} + +/** + * Probe every external tool one packaging run needs. + * @param platform - Target platform; a Windows target already requires a Windows build host. + * @param environment - Packaging environment used to locate Windows tooling. + * @returns Every probe that failed, empty when the host can run the packaging sequence. + */ +export async function probeDesktopToolchain( + platform: 'darwin' | 'win32', + environment: NodeJS.ProcessEnv = process.env, +): Promise { + const failures: DesktopToolchainProbeFailure[] = [] + const tar = await probeTar() + if (tar !== undefined) failures.push({ tool: 'tar', detail: tar }) + if (platform === 'win32') failures.push(...await probeWindowsInstallerToolchain(environment)) + return failures +} + +/** + * Probe the toolchain and fail with every problem the host has. + * @param platform - Target platform; a Windows target already requires a Windows build host. + * @param environment - Packaging environment used to locate Windows tooling. + * @returns Resolves when every probe passes. + */ +export async function requireDesktopToolchain( + platform: 'darwin' | 'win32', + environment: NodeJS.ProcessEnv = process.env, +): Promise { + const failures = await probeDesktopToolchain(platform, environment) + if (failures.length === 0) return + throw new Error(`desktop package: the build host cannot run this packaging sequence:\n${ + failures.map(failure => ` ${failure.tool}: ${failure.detail}`).join('\n')}`) +} diff --git a/apps/desktop/scripts/desktop-upload-plan.ts b/apps/desktop/scripts/desktop-upload-plan.ts index 32f891dcc8..e14228cf69 100644 --- a/apps/desktop/scripts/desktop-upload-plan.ts +++ b/apps/desktop/scripts/desktop-upload-plan.ts @@ -13,6 +13,7 @@ import { resolveDesktopUploadConfig, } from './desktop-auto-update-environment.mjs' import { desktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { validateDesktopBuildVersion } from './desktop-build-version.mjs' const APP_ROOT = resolve(import.meta.dirname, '..') const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..') @@ -47,6 +48,10 @@ export interface DesktopUploadPlan { readonly secretIdEnvName: string readonly secretKeyEnvName: string readonly artifacts: readonly DesktopUploadArtifact[] + /** Commit the artifacts were packaged from, absent for a package built before builds recorded it. */ + readonly commit?: string + /** Whether that checkout carried uncommitted changes. */ + readonly dirty?: boolean } /** Filesystem and environment inputs used to validate one upload. */ @@ -191,15 +196,24 @@ export async function createDesktopUploadPlan( join(artifactsRoot, desktopBuildRecordFilename(targetName)), `${targetName} package completion record`, ) + // Packaging wrote the version it published; reading it back keeps release settings out of shell variables. + const recordedVersion = stringField(buildRecord.version, `${targetName} package completion record.version`) + let buildVersion: string + try { + buildVersion = validateDesktopBuildVersion(recordedVersion, dshVersion) + } + catch (error) { + throw new Error(`desktop upload: ${targetName} package completion record holds ${recordedVersion}, which is not a build of dsh ${dshVersion}: ${ + error instanceof Error ? error.message : String(error)}`) + } if (buildRecord.schemaVersion !== 1 || buildRecord.target !== targetName - || buildRecord.version !== dshVersion || buildRecord.environment !== update.environment || buildRecord.publicUrl !== update.publicUrl) { - throw new Error(`desktop upload: ${targetName} package completion record does not match dsh ${dshVersion} and ${update.environment} update destination`) + throw new Error(`desktop upload: ${targetName} package completion record for ${buildVersion} does not match the ${update.environment} update destination`) } - const metadataFilename = desktopUpdateMetadataFilename(dshVersion, target.platform) + const metadataFilename = desktopUpdateMetadataFilename(buildVersion, target.platform) const metadataPath = join(artifactsRoot, metadataFilename) let metadataValue: unknown try { @@ -210,14 +224,14 @@ export async function createDesktopUploadPlan( } const metadata = object(metadataValue, metadataFilename) const metadataVersion = stringField(metadata.version, `${metadataFilename}.version`) - if (metadataVersion !== dshVersion) { - throw new Error(`desktop upload: ${metadataFilename} version ${metadataVersion} does not match current dsh version ${dshVersion}`) + if (metadataVersion !== buildVersion) { + throw new Error(`desktop upload: ${metadataFilename} version ${metadataVersion} does not match published version ${buildVersion}`) } if (!Array.isArray(metadata.files) || metadata.files.length !== 1) { throw new Error(`desktop upload: ${metadataFilename}.files must contain exactly one target update file`) } - const base = `deepseek-harness-${dshVersion}-${target.os}-${target.arch}` + const base = `deepseek-harness-${buildVersion}-${target.os}-${target.arch}` const updaterExtension = target.platform === 'darwin' ? 'zip' : 'exe' const updaterInfo = updateFileInfo(metadata.files[0], `${metadataFilename}.files[0]`, `${base}.${updaterExtension}`) const updaterPath = await verifyChecksummedArtifact(artifactsRoot, updaterInfo) @@ -254,18 +268,20 @@ export async function createDesktopUploadPlan( contents: dump(published), } artifacts.push(channelArtifact) - if (prerelease(dshVersion) === null) { + if (prerelease(buildVersion) === null) { const stableFilename = metadataFilename.replace('nightly', 'latest') artifacts.push({ ...channelArtifact, filename: stableFilename, key: `${update.keyPrefix}/${stableFilename}` }) } return { environment: update.environment, target: targetName, - version: dshVersion, + version: buildVersion, publicUrl: update.publicUrl, bucket: update.bucket, secretIdEnvName: update.secretIdEnvName, secretKeyEnvName: update.secretKeyEnvName, artifacts, + ...typeof buildRecord.commit === 'string' ? { commit: buildRecord.commit } : {}, + ...typeof buildRecord.dirty === 'boolean' ? { dirty: buildRecord.dirty } : {}, } } diff --git a/apps/desktop/scripts/dev.ts b/apps/desktop/scripts/dev.ts index 05e1bdbf0e..36d57325dc 100644 --- a/apps/desktop/scripts/dev.ts +++ b/apps/desktop/scripts/dev.ts @@ -8,6 +8,7 @@ import { parseArgs } from 'node:util' import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' import type { DesktopRelease } from '../src/release.ts' import { prepareDevelopmentProject } from './development-project.ts' +import { prepareDevelopmentApp } from './development-app.ts' import { preparePrimaryRuntime } from './prepare-primary-runtime.ts' const APP_ROOT = resolve(import.meta.dirname, '..') @@ -62,7 +63,7 @@ async function launchElectron(): Promise { const rendererPort = debugPort('DSH_DESKTOP_RENDERER_DEBUG_PORT', 9222) const hostPort = debugPort('DSH_DESKTOP_HOST_INSPECT_PORT', 9230) const home = resolve(process.env.DSH_HOME ?? join(DEVELOPMENT_ROOT, 'home')) - const userData = join(DEVELOPMENT_ROOT, 'electron-user-data') + const userData = resolve(process.env.DSH_DESKTOP_USER_DATA_DIR ?? join(DEVELOPMENT_ROOT, 'electron-user-data')) const environment: NodeJS.ProcessEnv = { ...process.env, DSH_HOME: home, @@ -71,7 +72,14 @@ async function launchElectron(): Promise { ELECTRON_ENABLE_LOGGING: process.env.ELECTRON_ENABLE_LOGGING ?? '1', } console.log(`desktop development: DSH_HOME=${home}`) + console.log(`desktop development: userData=${userData}`) console.log(`desktop development: inspectors main=${String(mainPort)}, renderer=${String(rendererPort)}, host=${String(hostPort)}`) + if (process.platform === 'darwin') { + const executable = prepareDevelopmentApp({ electron, appRoot: APP_ROOT, directory: DEVELOPMENT_ROOT, home, userData, + mainPort, rendererPort, hostPort, openDevtools: environment.DSH_DESKTOP_OPEN_DEVTOOLS! }) + await run(executable, [], APP_ROOT, environment) + return + } await run(electron, [ `--inspect=127.0.0.1:${String(mainPort)}`, `--remote-debugging-port=${String(rendererPort)}`, diff --git a/apps/desktop/scripts/development-app.ts b/apps/desktop/scripts/development-app.ts new file mode 100644 index 0000000000..ff09b8b783 --- /dev/null +++ b/apps/desktop/scripts/development-app.ts @@ -0,0 +1,84 @@ +/** macOS development bundle that loads the current workspace through Electron. */ +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' + +/** Workspace and runtime settings captured for Launch Services cold starts. */ +export interface DevelopmentAppOptions { + readonly electron: string + readonly appRoot: string + readonly directory: string + readonly home: string + readonly userData: string + readonly mainPort: number + readonly rendererPort: number + readonly hostPort: number + readonly openDevtools: string +} + +function quote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'` } + +/** + * Prepare and register a disposable .app without changing the installed Electron package. + * @param options - Current workspace, Electron binary, and private development locations. + * @returns executable used by the supported desktop development launcher. + */ +export function prepareDevelopmentApp(options: DevelopmentAppOptions): string { + const source = dirname(dirname(dirname(options.electron))) + const bundle = join(options.directory, 'Harness Dev.app') + const executable = join(bundle, 'Contents', 'MacOS', 'HarnessDev') + const stamp = join(bundle, 'Contents', 'Resources', 'dsh-development.json') + const launcher = developmentLauncher(options, bundle) + const identity = JSON.stringify({ ...options, launcher, plist: readFileSync(join(source, 'Contents', 'Info.plist'), 'utf8') }) + if (!existsSync(stamp) || readFileSync(stamp, 'utf8') !== identity) { + rmSync(bundle, { recursive: true, force: true }) + execFileSync('/usr/bin/ditto', [source, bundle]) + const plist = join(bundle, 'Contents', 'Info.plist') + const values = { + CFBundleIdentifier: `com.deepseek.harness.dev.${createHash('sha256').update(options.appRoot).digest('hex').slice(0, 12)}`, + CFBundleName: 'Harness Dev', + CFBundleDisplayName: 'Harness Dev', + CFBundleExecutable: 'HarnessDev', + CFBundleURLTypes: [{ CFBundleURLName: 'DeepSeek Harness', CFBundleURLSchemes: ['dsh'], CFBundleTypeRole: 'Viewer' }], + } + for (const [key, value] of Object.entries(values)) { + execFileSync('/usr/bin/plutil', ['-replace', key, '-json', JSON.stringify(value), plist]) + } + writeFileSync(executable, launcher, { mode: 0o755 }) + writeFileSync(stamp, identity, { mode: 0o600 }) + execFileSync('/usr/bin/codesign', ['--force', '--deep', '--sign', '-', bundle], { stdio: 'pipe' }) + } + execFileSync('/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister', ['-f', bundle]) + return executable +} + +/** + * Create the development executable used by both the CLI and Launch Services. + * @param options - Persisted development locations and debugging settings. + * @param bundle - Generated application bundle. + * @returns shell program with literal arguments and environment values. + */ +export function developmentLauncher(options: DevelopmentAppOptions, bundle: string): string { + const environment = { + DSH_HOME: options.home, + DSH_DESKTOP_DEV_APP: '1', + DSH_DESKTOP_HOST_INSPECT_PORT: String(options.hostPort), + DSH_DESKTOP_OPEN_DEVTOOLS: options.openDevtools, + ELECTRON_ENABLE_LOGGING: '1', + } + const args = [ + join(bundle, 'Contents', 'MacOS', 'Electron'), + `--inspect=127.0.0.1:${String(options.mainPort)}`, + `--remote-debugging-port=${String(options.rendererPort)}`, + `--user-data-dir=${options.userData}`, + options.appRoot, + ] + return [ + '#!/bin/sh', + ...Object.entries(environment).map(([key, value]) => `export ${key}=${quote(value)}`), + `cd ${quote(options.appRoot)}`, + `exec ${args.map(quote).join(' ')} "$@"`, + '', + ].join('\n') +} diff --git a/apps/desktop/scripts/development-project.ts b/apps/desktop/scripts/development-project.ts index eddd683161..36c70baff0 100644 --- a/apps/desktop/scripts/development-project.ts +++ b/apps/desktop/scripts/development-project.ts @@ -20,6 +20,7 @@ import { DESKTOP_RUNTIME_FILE, type DesktopRuntimeDescriptor } from '../src/runt interface PackageManifest { readonly name?: string readonly version?: string + readonly dependencies?: Readonly> } /** Inputs whose locations differ between the launcher and isolated tests. */ @@ -86,6 +87,32 @@ function mirrorDependencyLinks(sourceRoot: string, destinationRoot: string): str return names } +/** Configured workspace plugins must resolve from the profile even when pnpm does not hoist them. */ +function mirrorWorkspaceDependencies(roots: readonly string[], destinationRoot: string): string[] { + const names: string[] = [] + const visited = new Set() + const visit = (directory: string): void => { + const source = realpathSync(directory) + if (visited.has(source)) return + visited.add(source) + const manifest = readManifest(join(source, 'package.json')) + for (const [name, specifier] of Object.entries(manifest.dependencies ?? {})) { + if (!specifier.startsWith('workspace:')) continue + const dependency = join(source, 'node_modules', ...name.split('/')) + if (!existsSync(dependency)) { + throw new Error(`desktop development: ${name} is missing from ${source}; run pnpm install`) + } + const destination = join(destinationRoot, ...name.split('/')) + removeOwnedPath(destination) + linkDirectory(dependency, destination) + names.push(name) + visit(dependency) + } + } + for (const directory of roots) visit(directory) + return names +} + /** * Replace one disposable project with links to the current built workspace. * @param options - Project destination, CLI package, and release identity. @@ -117,7 +144,10 @@ export function prepareDevelopmentProject(options: DevelopmentProjectOptions): s createDevelopmentProjectMetadata(options.projectDir, options.release) const destinationModules = join(options.projectDir, 'node_modules') mkdirSync(destinationModules, { recursive: true }) - const names = mirrorDependencyLinks(options.dependencyDir, destinationModules) + const names = [ + ...mirrorDependencyLinks(options.dependencyDir, destinationModules), + ...mirrorWorkspaceDependencies([options.cliDir, options.hostDir], destinationModules), + ] const dshLink = join(destinationModules, '@deepseek-ai', 'dsh') removeOwnedPath(dshLink) linkDirectory(options.cliDir, dshLink) diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index 3ce50573d1..e3873de35f 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -1,4 +1,5 @@ import { X509Certificate } from 'node:crypto' +import { readFileSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -18,6 +19,8 @@ import { scrubWindowsSigningEnvironment, } from './windows-sign.mjs' import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environment.mjs' +import { resolveDesktopBuildCommit } from './desktop-build-commit.mjs' +import { resolveDesktopBuildVersion } from './desktop-build-version.mjs' import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs' import { desktopTargetBuildPaths, resolveDesktopBuildTarget } from './desktop-build-paths.mjs' import { installWindowsDirectoryInstaller } from './windows-directory-installer.mjs' @@ -36,6 +39,7 @@ import { * @param {NodeJS.Platform} hostPlatform - Build-host platform used when no explicit target is present. * @param {string} hostArch - Build-host architecture used when no explicit target is present. * @param {string | undefined} preparedRuntime - Verified private dsh tree for installed-update qualification; ordinary releases use the target tree. + * @param {string | undefined} preparedRuntimeVersion - Version that private tree declares, which qualification rewrites away from the product version. * @returns {object} electron-builder configuration. */ export function createElectronBuilderConfig( @@ -43,6 +47,7 @@ export function createElectronBuilderConfig( hostPlatform = process.platform, hostArch = process.arch, preparedRuntime = undefined, + preparedRuntimeVersion = undefined, ) { const appId = resolveDesktopAppId(env) const policy = resolveDesktopPolicyEnvironment(env) @@ -63,7 +68,7 @@ export function createElectronBuilderConfig( let primaryRuntimeDestination let dshDestination let windowsCode = [] - const unpack = ['**/*.{node,dylib,dll,so,exe}', '**/*.so.*', '**/spawn-helper', '**/@vscode/ripgrep/bin/rg', + const unpack = ['**/*.{node,dylib,dll,so,exe}', '**/*.so.*', '**/spawn-helper', '**/@vscode/ripgrep-*/bin/rg', `**/node_modules/@deepseek-ai/libreoffice-kit-${resolvedPlatform}-${resolvedArch}/**/*`] const windowsSigner = packagesWindows && !unsigned ? createWindowsTokenSigner({ @@ -86,9 +91,20 @@ export function createElectronBuilderConfig( } const update = unsigned ? undefined : resolveDesktopAutoUpdateConfig(env, resolvedPlatform, resolvedArch) if (preparedRuntime !== undefined) buildPaths.dsh = preparedRuntime + // electron-builder merges extraMetadata into the packaged manifest, so a build version here reaches + // the artifact names, the update feed, and the installed app.getVersion() the updater compares against. + const productVersion = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')).version + const buildVersion = resolveDesktopBuildVersion(env, productVersion) + const packaged = resolveDesktopBuildCommit(env) return { appId, - extraMetadata: { dshDesktopAppId: appId, dshMandatoryUpdatePolicy: policy }, + protocols: [{ name: 'DeepSeek Harness', schemes: ['dsh'] }], + extraMetadata: { + dshDesktopAppId: appId, + dshMandatoryUpdatePolicy: policy, + ...buildVersion === productVersion ? {} : { version: buildVersion }, + ...packaged === undefined ? {} : { dshBuildCommit: packaged.commit, dshBuildDirty: packaged.dirty }, + }, productName: 'DeepSeek Harness', artifactName: 'deepseek-harness-${version}-${os}-${arch}.${ext}', directories: { output: unsigned ? buildPaths.unsignedArtifacts : buildPaths.artifacts }, @@ -110,9 +126,12 @@ export function createElectronBuilderConfig( }, files: [ 'lib/main.js', + 'lib/welcome/**/*', 'lib/preload-app.cjs', 'lib/preload-mandatory.cjs', + 'lib/preload-platform-account.cjs', 'lib/preload-update-dialog.cjs', + 'lib/preload-welcome.cjs', 'renderer/**/*', 'package.json', { from: buildPaths.dsh, to: 'dsh', filter: ['**/*'] }, @@ -159,8 +178,10 @@ export function createElectronBuilderConfig( await writeMacOSAppUpdateConfig(resourcesDir, resolveMacOSAppUpdateFeed(context.packager.config.publish), context.packager.appInfo.updaterCacheDirName) } + // The bundled runtime declares whichever version prepared it: the product version for an ordinary + // release, and a rewritten one for installed-update qualification. await verifyDesktopRuntime(buildPaths.dsh, - context.packager.appInfo.version, { platform: resolvedPlatform, arch: resolvedArch }) + preparedRuntimeVersion ?? productVersion, { platform: resolvedPlatform, arch: resolvedArch }) // Unsigned Windows builds skip electron-builder's afterSign hook. if (packagesWindows && unsigned) await verifyWindowsAsarUnpack(buildPaths.dsh, resourcesDir, windowsCode) }, diff --git a/apps/desktop/scripts/installed-update-builder.ts b/apps/desktop/scripts/installed-update-builder.ts index c42ae2347b..2f0cbefe6c 100644 --- a/apps/desktop/scripts/installed-update-builder.ts +++ b/apps/desktop/scripts/installed-update-builder.ts @@ -23,7 +23,7 @@ export async function createInstalledUpdateBuilderConfig(manifest: string, versi const dsh = join(run.root, version, 'dsh') await verifyDesktopRuntime(dsh, version, { platform: 'win32', arch: 'x64' }) const config = createElectronBuilderConfig({ ...environment, DSH_DESKTOP_APP_ID: run.appId, - DSH_DESKTOP_TARGET_PLATFORM: 'win32', DSH_DESKTOP_TARGET_ARCH: 'x64', DSH_DESKTOP_UNSIGNED: '0' }, 'win32', 'x64', dsh) + DSH_DESKTOP_TARGET_PLATFORM: 'win32', DSH_DESKTOP_TARGET_ARCH: 'x64', DSH_DESKTOP_UNSIGNED: '0' }, 'win32', 'x64', dsh, version) return { ...config, productName: run.productName, directories: { ...config.directories, output: join(run.root, version, 'installer') }, diff --git a/apps/desktop/scripts/macos-signing-keychain.d.mts b/apps/desktop/scripts/macos-signing-keychain.d.mts index 452878aad1..f95a370c98 100644 --- a/apps/desktop/scripts/macos-signing-keychain.d.mts +++ b/apps/desktop/scripts/macos-signing-keychain.d.mts @@ -3,14 +3,15 @@ /** * Import and authorize the required p12 before work; delete the owned keychain after work settles. * Children receive only its path, never the p12 password. Existing login keychains are not unlocked. + * The owned keychain is prepended to the user search list for the run and the previous list is restored. * Abrupt process termination requires the CI runner to clean its temporary directory. * @param environment Validated platform configuration with local CSC_LINK and CSC_KEY_PASSWORD. * @param action All signing work, settled before cleanup. - * @param run Apple command executor. + * @param run Apple command executor returning command standard output. * @returns Resolves after work and cleanup; rejects on setup, work, or cleanup failure. */ export function withMacOSSigningKeychain( environment: NodeJS.ProcessEnv, action: (environment: NodeJS.ProcessEnv) => Promise, - run?: (command: string, args: string[]) => void, + run?: (command: string, args: string[]) => string, ): Promise diff --git a/apps/desktop/scripts/macos-signing-keychain.mjs b/apps/desktop/scripts/macos-signing-keychain.mjs index e71d469395..425eb6a96c 100644 --- a/apps/desktop/scripts/macos-signing-keychain.mjs +++ b/apps/desktop/scripts/macos-signing-keychain.mjs @@ -9,23 +9,40 @@ import { join } from 'node:path' * Execute a credential-bearing Apple command without exposing arguments or tool output on failure. * @param {string} command Absolute executable path. * @param {string[]} args Command arguments, potentially containing secrets. - * @returns {void} + * @returns {string} Command standard output, needed when the caller reads a security query. */ function execute(command, args) { - try { execFileSync(command, args, { stdio: 'pipe', timeout: 120_000 }) } + try { + return execFileSync(command, args, { stdio: 'pipe', timeout: 120_000, encoding: 'utf8' }) + } catch (error) { // execFile errors contain the command line, including private-key passwords. throw new Error(`desktop macOS signing: ${command} ${args[0]} failed; check certificate, password, and signing access`) } } +/** + * Read the user's keychain search list so this invocation can extend and later restore it. + * @param {(command: string, args: string[]) => string} run Apple command executor. + * @returns {string[]} Keychain paths on the user search list. + */ +function readUserKeychains(run) { + const output = run('/usr/bin/security', ['list-keychains', '-d', 'user']) + if (typeof output !== 'string') { + throw new Error('desktop macOS signing: the user keychain search list could not be read') + } + return output.split('\n').map(line => line.trim().replace(/^"|"$/gu, '')).filter(path => path !== '') +} + /** * Import and authorize the required p12 before work; delete the owned keychain after work settles. * Children receive only its path, never the p12 password. Existing login keychains are not unlocked. + * codesign resolves an identity only from a keychain on the user search list, so the owned keychain + * is prepended during the run and the previous list is restored before deletion. * Abrupt process termination requires the CI runner to clean its temporary directory. * @param {NodeJS.ProcessEnv} environment Validated platform configuration with local CSC_LINK and CSC_KEY_PASSWORD. * @param {(environment: NodeJS.ProcessEnv) => Promise} action All signing work, settled before cleanup. - * @param {(command: string, args: string[]) => void} run Apple command executor. + * @param {(command: string, args: string[]) => string} run Apple command executor. * @returns {Promise} Resolves after work and cleanup; rejects on setup, work, or cleanup failure. */ export async function withMacOSSigningKeychain(environment, action, run = execute) { @@ -38,6 +55,7 @@ export async function withMacOSSigningKeychain(environment, action, run = execut /** @param {string[]} args Security command arguments. */ const security = args => run('/usr/bin/security', args) let created = false + let previousKeychains try { security(['create-keychain', '-p', password, keychain]) created = true @@ -45,6 +63,10 @@ export async function withMacOSSigningKeychain(environment, action, run = execut security(['set-keychain-settings', keychain]) security(['import', certificate, '-k', keychain, '-P', exportPassword, '-T', '/usr/bin/codesign', '-T', '/usr/bin/productbuild']) security(['set-key-partition-list', '-S', 'apple-tool:,apple:', '-s', '-k', password, keychain]) + // codesign ignores --keychain unless that file is also on the search list + // (https://github.com/electron-userland/electron-builder/issues/1457). + previousKeychains = readUserKeychains(run) + security(['list-keychains', '-d', 'user', '-s', keychain, ...previousKeychains]) const probe = join(directory, 'probe') run('/bin/cp', ['/usr/bin/true', probe]) run('/usr/bin/codesign', ['--force', '--sign', `Developer ID Application: ${environment.DSH_DESKTOP_MACOS_SIGNING_IDENTITY}`, '--keychain', keychain, '--timestamp', '--options', 'runtime', probe]) @@ -54,7 +76,10 @@ export async function withMacOSSigningKeychain(environment, action, run = execut delete childEnvironment.CSC_KEY_PASSWORD await action(childEnvironment) } finally { - try { if (created) security(['delete-keychain', keychain]) } + try { + if (previousKeychains !== undefined) security(['list-keychains', '-d', 'user', '-s', ...previousKeychains]) + if (created) security(['delete-keychain', keychain]) + } finally { rmSync(directory, { recursive: true, force: true }) } } } diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index 6909fc4371..62f4fcbbb2 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -1,6 +1,6 @@ /** Build one release target with matching Electron and dsh architecture. */ -import { execFileSync, spawn } from 'node:child_process' +import { spawn } from 'node:child_process' import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' import { parseArgs } from 'node:util' import { join, resolve } from 'node:path' @@ -19,6 +19,10 @@ import { macOSDownloadEnvironment, resolveMacOSPackageSettings } from './macos-p import { packagingErrorDetails, packagingStep } from './packaging-step.mjs' import { notarizeMacOS } from './notarize-macos.mjs' import { resolveMacOSNotarizationEnvironment } from './desktop-release-environment.mjs' +import { DESKTOP_BUILD_VERSION_ENV, resolveDesktopBuildVersion, validateDesktopBuildVersion } from './desktop-build-version.mjs' +import { suggestDesktopBuildVersion } from './desktop-build-version-discovery.ts' +import { desktopBuildCommitEnvironment, readDesktopBuildCommit, resolveDesktopBuildCommit } from './desktop-build-commit.mjs' +import { requireDesktopToolchain } from './desktop-toolchain-preflight.ts' import { withMacOSNotarizationProxy } from './macos-notarization-proxy.ts' const APP_ROOT = resolve(import.meta.dirname, '..') @@ -39,6 +43,9 @@ const DESKTOP_UPLOAD_CREDENTIAL_ENV_NAMES = new Set([ 'DOWNLOAD_PROD_COS_SECRET_KEY', ]) +/** `--build-version` value that numbers a build after the ones already taken. */ +const AUTOMATIC_BUILD_VERSION = 'auto' + /** Fixed platform and architecture identifiers exposed by package scripts. */ export type DesktopPackageTargetName = 'mac-arm64' | 'mac-x64' | 'win-x64' @@ -136,15 +143,19 @@ function writeReleaseRecord( if (desktopVersion !== dshVersion) { throw new Error(`desktop package: desktop version ${desktopVersion} does not match dsh version ${dshVersion}`) } + const buildVersion = resolveDesktopBuildVersion(environment, dshVersion) + const packaged = resolveDesktopBuildCommit(environment) const update = resolveDesktopAutoUpdateConfig(environment, target.platform, target.arch) const recordPath = join(artifactsRoot, desktopBuildRecordFilename(target.name)) const temporaryPath = `${recordPath}.tmp` writeFileSync(temporaryPath, `${JSON.stringify({ schemaVersion: 1, target: target.name, - version: dshVersion, + version: buildVersion, environment: update.environment, publicUrl: update.publicUrl, + // Upload reads this to tag the commit a production release was packaged from. + ...packaged === undefined ? {} : { commit: packaged.commit, dirty: packaged.dirty }, }, null, 2)}\n`) renameSync(temporaryPath, recordPath) } @@ -186,6 +197,8 @@ interface DesktopPackageInvocation { readonly prepareOnly: boolean readonly unsigned: boolean readonly check: boolean + /** Build identifier to publish under, when this build does not publish the product version. */ + readonly requestedBuildVersion: string | undefined } function hostTargetName(platform: NodeJS.Platform, arch: string): DesktopPackageTargetName { @@ -207,25 +220,33 @@ export function parseDesktopPackageInvocation( hostArch: string = process.arch, ): DesktopPackageInvocation { const { values, positionals } = parseArgs({ - args: [...argv], + // `pnpm run `) + } else { + // Native account subscribers focus the login window; retain the browser’s Platform document. + attempt.callback?.writeHead(204, { 'cache-control': 'no-store' }).end() + } + } +} +export default PlatformAccount diff --git a/packages/credentials/deepseek-account-platform/src/logout.ts b/packages/credentials/deepseek-account-platform/src/logout.ts new file mode 100644 index 0000000000..7c165d3be6 --- /dev/null +++ b/packages/credentials/deepseek-account-platform/src/logout.ts @@ -0,0 +1,37 @@ +/** Bounded, memory-only revocation of a token already removed from local credentials. */ +import { setTimeout } from 'node:timers/promises' +import { logoutAccount } from './protocol.ts' + +/** Resolved logout retry settings owned by the account provider. */ +export interface LogoutRetryPolicy { + readonly maxRetries: number + readonly delayMs: number + readonly requestTimeoutMs: number +} + +/** + * Revoke one captured grant without changing local account state. + * @param origin - Issuer verified before local grant removal. + * @param token - Removed grant, never replaced with a later login's token. + * @param policy - Request deadline and exponential retry limits. + * @param signal - Provider lifetime; shutdown aborts requests and delays. + * @param headers - Validated issuer-only deployment headers. + * @returns After success, exhaustion, or cancellation; remote failures stay in the background. + */ +export async function revokeAccount(origin: string, token: string, policy: LogoutRetryPolicy, + signal: AbortSignal, headers: Record): Promise { + for (let attempt = 0; attempt <= policy.maxRetries; attempt++) { + if (signal.aborted) return + if (attempt > 0) { + try { await setTimeout(policy.delayMs * 2 ** (attempt - 1), undefined, { signal, ref: false }) } + catch { return } // The validated delay rejects only when the provider lifetime ends. + } + try { + await logoutAccount(origin, token, + AbortSignal.any([signal, AbortSignal.timeout(policy.requestTimeoutMs)]), headers) + return + } catch { + // HTTP, business, and transport failures consume one attempt without restoring credentials. + } + } +} diff --git a/packages/credentials/deepseek-account-platform/src/protocol.ts b/packages/credentials/deepseek-account-platform/src/protocol.ts new file mode 100644 index 0000000000..c437f67ac3 --- /dev/null +++ b/packages/credentials/deepseek-account-platform/src/protocol.ts @@ -0,0 +1,200 @@ +/** Validated platform HTTP messages and restricted browser destinations. */ +import { z } from 'zod' + +/** Protocol errors expose a stable code, never a response body or authorization URL. */ +export class PlatformAuthError extends Error { + /** @param code - safe error classification. */ + constructor(readonly code: 'network' | 'protocol' | 'expired' | 'storage') { super(`account: ${code}`) } +} + +/** + * Accept HTTPS platform endpoints, or explicitly configured loopback development HTTP. + * @param value - configured origin. + * @param allowLoopbackHttp - development-only opt-in. + * @returns normalized origin. + */ +export function platformOrigin(value: string, allowLoopbackHttp: boolean): string { + const url = new URL(value) + const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) + if (url.username || url.password || url.pathname !== '/' || url.search || url.hash + || !(url.protocol === 'https:' || (allowLoopbackHttp && loopback && url.protocol === 'http:'))) { + throw new Error('account: platformOrigin must be an HTTPS origin or explicitly enabled loopback HTTP origin') + } + return url.origin +} + +/** + * Validate platform-owned browser destinations without forwarding arbitrary URLs. + * @param value - returned browser URL. + * @param origin - configured platform origin. + * @param path - fixed authorize or completion path. + * @param rewriteOrigin - map validated browser pages to the configured development origin. + * @returns normalized URL on the configured origin. + */ +export function browserUrl(value: string, origin: string, path: string, rewriteOrigin = false): string { + let url: URL + try { url = new URL(value) } catch { + console.info('[deepseek-account] browser URL rejected', { path, reason: 'invalid-url' }) + throw new PlatformAuthError('protocol') + } + const allowedOrigin = url.origin === origin || (rewriteOrigin && url.protocol === 'https:') + if (!allowedOrigin || url.pathname !== path || url.username || url.password || url.hash) { + console.info('[deepseek-account] browser URL rejected', { + path, originMismatch: !allowedOrigin, pathMismatch: url.pathname !== path, + hasCredentials: Boolean(url.username || url.password), hasFragment: Boolean(url.hash), + }) + throw new PlatformAuthError('protocol') + } + return rewriteOrigin ? `${origin}${url.pathname}${url.search}` : url.href +} + +/** + * Validate Host-only deployment headers without exposing their values in diagnostics. + * @param values - configured headers for the Platform origin. + * @returns normalized headers; authorization, routing and framing remain provider-owned. + */ +export function platformHeaders(values: Record): Record { + const headers = new Headers() + const names = new Set() + for (const [name, value] of Object.entries(values)) { + const key = name.toLowerCase() + if (['authorization', 'x-dsh-auth-token', 'host', 'content-length', 'transfer-encoding', 'connection', 'content-type'].includes(key) + || names.has(key)) throw new Error('account: requestHeaders contains a reserved or duplicate header') + names.add(key) + try { headers.set(name, value) } + catch { throw new Error('account: requestHeaders contains an invalid header') } + } + return Object.fromEntries(headers) +} + +const envelope = z.object({ code: z.literal(0), data: z.object({ biz_code: z.number().int(), biz_data: z.unknown() }) }) +/** Successful initialization response. */ +export const initialization = z.object({ + authorize_url: z.url(), authorize_id: z.string().min(1), expires_in: z.number().positive(), +}) +/** Successful code exchange response. */ +export const exchange = z.object({ token: z.string().regex(/^[\x21-\x7e]+$/), authorized_url: z.url(), user: z.unknown().optional() }) + +/** + * Read one bounded platform response with stable, non-secret diagnostics. + * @param origin - validated platform origin. + * @param method - platform endpoint suffix. + * @param body - protocol request, never logged. + * @param signal - attempt cancellation and timeout. + * @param headers - validated deployment headers for this origin. + * @returns successful business payload, validated by its caller. + */ +export async function requestPlatform(origin: string, method: string, body: unknown, + signal: AbortSignal, headers: Record): Promise { + return platformRequest(`${origin}/auth-api/v0/dsh/${method}`, { + method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify(body), + }, signal) +} + +/** + * Fetch a fixed Platform account endpoint with the grant kept in Host request headers. + * @param origin - configured and grant-matched origin. + * @param path - account endpoint. + * @param token - account grant. + * @param signal - credential lifetime and request timeout. + * @param headers - validated deployment headers for this origin. + * @returns successful business payload. + */ +export function requestAccount(origin: string, path: '/auth-api/v0/users/current' | '/api/v0/users/get_user_summary', + token: string, + signal: AbortSignal, headers: Record): Promise { + return platformRequest(`${origin}${path}`, { method: 'GET', headers: { ...headers, 'x-dsh-auth-token': token } }, signal) +} + +/** + * End the Platform session using its existing logout endpoint. + * @param origin - configured origin matching the grant issuer. + * @param token - stored account token. + * @param signal - logout request deadline. + * @param headers - validated deployment headers for this origin. + * @returns after Platform confirms logout. + */ +export async function logoutAccount(origin: string, token: string, + signal: AbortSignal, headers: Record): Promise { + await platformRequest(`${origin}/auth-api/v0/users/logout`, { + method: 'POST', headers: { ...headers, 'x-dsh-auth-token': token }, + }, signal) +} + +async function platformRequest(url: string, init: RequestInit, signal: AbortSignal): Promise { + const path = new URL(url).pathname + console.info('[deepseek-account] request', { path, method: init.method }) + let response: Response + try { + response = await fetch(url, { ...init, redirect: 'error', signal }) + } catch { + console.info('[deepseek-account] request failed', { path, errorCode: 'network', aborted: signal.aborted }) + throw new PlatformAuthError('network') + } + console.info('[deepseek-account] response', { path, status: response.status }) + if (!response.ok || response.body === null) { + await response.body?.cancel() + throw new PlatformAuthError('network') + } + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let size = 0 + let stage = 'read-body' + try { + while (true) { + const next = await reader.read() + if (next.done) break + size += next.value.byteLength + if (size > 65_536) { + stage = 'body-limit' + throw new PlatformAuthError('protocol') + } + chunks.push(next.value) + } + stage = 'parse-json' + const payload: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8')) + const codes = z.object({ code: z.number().int(), data: z.object({ biz_code: z.number().int() }).optional() }).safeParse(payload) + if (codes.success) console.info('[deepseek-account] response codes', { + path, code: codes.data.code, bizCode: codes.data.data?.biz_code, + }) + stage = 'envelope' + const parsed = envelope.safeParse(payload) + if (!parsed.success) { + console.info('[deepseek-account] envelope rejected', { + path, issues: parsed.error.issues.map(issue => ({ path: issue.path, code: issue.code })), + }) + throw new PlatformAuthError('protocol') + } + stage = 'business-code' + if (parsed.data.data.biz_code !== 0) { + // TODO(product-error-ui): Apply product-defined copy and UI behavior for the supplied biz_code values. + // Business failures use the existing generic failure UI until then; backend messages stay Host-only. + throw new PlatformAuthError('protocol') + } + return parsed.data.data.biz_data + } catch (error) { + console.info('[deepseek-account] response rejected', { path, stage, errorCode: 'protocol' }) + if (error instanceof PlatformAuthError) throw error + throw new PlatformAuthError('protocol') + } finally { + await reader.cancel().catch(() => undefined) + reader.releaseLock() + } +} + +/** + * Accept a browser-accessible loopback HTTP origin for local or SSH-forwarded login. + * @param value - loopback HTTP origin with an explicit port supplied by the authenticated initiating client. + * @returns normalized origin; remote domains and path-based proxies are unsupported. + */ +export function loginOrigin(value: string): string { + let url: URL + try { url = new URL(value) } catch { throw new PlatformAuthError('protocol') } + const explicitPort = /^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\]):([0-9]+)\/?$/i.exec(value)?.[1] + if (explicitPort === undefined || Number(explicitPort) === 0 + || url.protocol !== 'http:' || !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) + || url.username || url.password || url.pathname !== '/' || url.search || url.hash) { + throw new PlatformAuthError('protocol') + } + return `${url.protocol}//${url.hostname}:${Number(explicitPort)}` +} diff --git a/packages/credentials/deepseek-account-platform/src/types.ts b/packages/credentials/deepseek-account-platform/src/types.ts new file mode 100644 index 0000000000..ed3d43d0f8 --- /dev/null +++ b/packages/credentials/deepseek-account-platform/src/types.ts @@ -0,0 +1,2 @@ +/** Platform provider reuses the account service's client-safe state. */ +export type { AccountView, SignInAttemptView } from '@deepseek-ai/dsh-deepseek-account/types' diff --git a/packages/credentials/deepseek-account-platform/tests/account.spec.ts b/packages/credentials/deepseek-account-platform/tests/account.spec.ts new file mode 100644 index 0000000000..f1b22c47b0 --- /dev/null +++ b/packages/credentials/deepseek-account-platform/tests/account.spec.ts @@ -0,0 +1,1192 @@ +import { createHash } from 'node:crypto' +import { afterEach, expect, it, vi } from 'vitest' +import { Context, Service } from '@deepseek-ai/cordis' +import { mkdtemp, rm, readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { createServer as createTcpServer, connect, Socket } from 'node:net' +import { join } from 'node:path' +import { createServer, IncomingMessage, ServerResponse } from 'node:http' +import WebServer from '@deepseek-ai/dsh-host-webserver' +import AuthorizationService from '@deepseek-ai/dsh-authorization' +import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local' +import { credentialKey, credentialRef } from '@deepseek-ai/dsh-credentials' +import { Config, PlatformAccount } from '../src/index.ts' +import { browserUrl, platformHeaders, platformOrigin, loginOrigin } from '../src/protocol.ts' + +const cleanups: Array<() => Promise> = [] +afterEach(async () => { + vi.useRealTimers() + while (cleanups.length) await cleanups.pop()!() +}) + +async function fixture( + contact: { email: string; mobile?: string; mobile_number?: string } = { + email: 't***@example.invalid', mobile: '138****5678', + }, + requestHeaders: Record = {}, + rewriteBrowserOrigin = false, + accountRequestHeaders: Record = {}, + inferenceOrigin = 'https://api.deepseek.com', + beforeAccount?: (ctx: Context, origin: string) => Promise, + embeddedPageDist = '', + desktopPlatform: 'darwin' | 'win32' | null = null, +) { + const home = await mkdtemp(join(tmpdir(), 'dsh-account-')) + cleanups.push(() => rm(home, { recursive: true, force: true })) + let init: Record = {} + const cancellations: Array> = [] + const cancellationReceived = Promise.withResolvers() + let holdCancel = false + const exchanged = Promise.withResolvers() + const release = Promise.withResolvers() + let redirect = false + let hold = false + let count = 0 + let businessCode = 0 + let exchangeOverride: Record = {} + let initOverride: Record = {} + let onInit = () => {} + let origin = '' + let detailsHold = false + let balanceHold = false + let profileFailed = false + let summaryFailed = false + let logoutFailed = false + let logoutHold = false + let logoutCount = 0 + const logoutHeaders: Array = [] + let bonusWallets: unknown = [{ currency: 'CNY', balance: '10.00' }] + let invalidSummary = false + const detailsStarted = Promise.withResolvers() + const detailRequests: Array<{ path: string; authorization: string | undefined }> = [] + const receivedHeaders: Array<{ + clientPlatform: string | undefined + path: string | undefined + cookie: string | undefined + authorization: string | undefined + }> = [] + const handle = async (req: IncomingMessage, res: ServerResponse) => { + expect(req.headers.authorization).toBeUndefined() + receivedHeaders.push({ clientPlatform: req.headers['x-client-platform'] as string | undefined, path: req.url, cookie: req.headers.cookie, authorization: req.headers['x-dsh-auth-token'] as string | undefined }) + if (redirect) { res.writeHead(302, { location: `${origin}/redirect-target` }).end(); return } + if (req.url === '/auth-api/v0/users/logout') { + logoutCount++ + logoutHeaders.push(req.headers['x-dsh-auth-token'] as string | undefined) + if (logoutHold) await release.promise + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ code: logoutFailed ? 50000 : 0, data: { biz_code: 0, biz_data: null } })) + return + } + if (req.method === 'GET') { + detailRequests.push({ path: req.url!, authorization: req.headers['x-dsh-auth-token'] as string | undefined }) + detailsStarted.resolve(undefined) + if (detailsHold || (balanceHold && req.url === '/api/v0/users/get_user_summary')) await release.promise + const value = req.url === '/auth-api/v0/users/current' + ? { id: 'test-user', token: 'never-copy-response-token', ...contact, + id_profile: { name: 'Test Account', picture: null } } + : { normal_wallets: [{ currency: 'CNY', balance: invalidSummary ? 'not-a-decimal' : '123.45', token_estimation: '0' }, + { currency: 'USD', balance: '6.78', token_estimation: '0' }], + bonus_wallets: bonusWallets } + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ code: 0, data: { + biz_code: (profileFailed && req.url === '/auth-api/v0/users/current') + || (summaryFailed && req.url === '/api/v0/users/get_user_summary') ? 17 : 0, biz_data: value, + } })) + return + } + req.setEncoding('utf8') + let text = '' + for await (const chunk of req) { + if (typeof chunk !== 'string') throw new Error('expected UTF-8 chunk') + text += chunk + } + const body = JSON.parse(text) as Record + let value: unknown + if (req.url?.endsWith('auth_cancel')) { + cancellations.push(body) + cancellationReceived.resolve(undefined) + if (holdCancel) await release.promise + value = null + } else if (req.url?.endsWith('auth_init')) { + init = body + onInit() + value = { authorize_url: `${rewriteBrowserOrigin ? 'https://platform.deepseek.com' : origin}/dsh/authorize?authorize_id=test`, expires_in: 600, authorize_id: 'test', ...initOverride } + } else { + count++ + exchanged.resolve(undefined) + if (hold) await release.promise + value = { user: null, token: 'dsh_mock_test', authorized_url: `${origin}/dsh/authorized?result=test&locale=zh_CN`, ...exchangeOverride } + } + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ code: 0, data: { biz_code: businessCode, biz_msg: 'sensitive diagnostic', biz_data: value } })) + } + const server = createServer((req, res) => { void handle(req, res).catch(() => { res.writeHead(500).end() }) }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('missing listener') + origin = `http://127.0.0.1:${address.port}` + cleanups.push(async () => { + release.resolve(undefined) + await new Promise((resolve) => { server.close(() => { resolve() }); server.closeAllConnections() }) + }) + const ctx = new Context() + const web = ctx.plugin(WebServer, { host: '127.0.0.1', port: 0 }) + await web + const callbackOrigin = `http://127.0.0.1:${ctx.webServer.port}` + const credentials = ctx.plugin(LocalCredentialProvider, { path: join(home, 'credentials.yaml'), watch: false }) + await credentials + const authorization = ctx.plugin(AuthorizationService) + await authorization + await beforeAccount?.(ctx, origin) + const provider = ctx.plugin(PlatformAccount, { + platformOrigin: origin, inferenceOrigin, embeddedPageDist, desktopPlatform, + allowLoopbackHttp: true, requestHeaders, accountRequestHeaders, + rewriteBrowserOrigin, logoutRetryDelayMs: 1, + }) + await provider + cleanups.push(async () => { await provider.dispose(); await authorization.dispose(); await credentials.dispose(); await web.dispose() }) + const account = ctx.deepseekAccount as PlatformAccount + const states = new AbortController() + cleanups.push(async () => { states.abort() }) + async function wait(phase: string) { + for await (const state of account.watch(states.signal)) if (state.attempt?.phase === phase) return state + throw new Error(`missing phase ${phase}`) + } + return { cancellations, cancellationReceived, holdCancel: () => { holdCancel = true }, + ctx, account, home, origin, callbackOrigin, wait, receivedHeaders, logoutHeaders, logoutCount: () => logoutCount, + holdLogout: () => { logoutHold = true }, + failLogout: (failed: boolean) => { logoutFailed = failed }, detailRequests, detailsStarted, + failProfile: (failed: boolean) => { profileFailed = failed }, + holdBalance: () => { balanceHold = true }, + holdDetails: () => { detailsHold = true }, failSummary: () => { summaryFailed = true }, + invalidateSummary: () => { invalidSummary = true }, dispose: () => provider.dispose(), exchanged, release, + redirect: () => { redirect = true }, + hold: () => { hold = true }, + initResponse: (value: Record) => { initOverride = value }, + onInit: (callback: () => void) => { onInit = callback }, + bonusWallets: (value: unknown) => { bonusWallets = value }, + exchangeResponse: (value: Record) => { exchangeOverride = value }, + fail: (value: number) => { businessCode = value }, + init: () => init, count: () => count, callback: (state = init.state) => `${init.redirect_uri}?code=test&state=${state}` } +} + +it.each([ + { serverTtl: 600, expectedRemaining: 480_000 }, + { serverTtl: 60, expectedRemaining: 60_000 }, +])('preserves the total sign-in deadline with a $serverTtl second server TTL', async ({ serverTtl, expectedRemaining }) => { + const f = await fixture() + const startedAt = 1_000_000 + // Only the wall clock is fake; loopback transport and resource teardown retain real timers. + vi.useFakeTimers({ toFake: ['Date'] }) + try { + vi.setSystemTime(startedAt) + f.onInit(() => { vi.setSystemTime(startedAt + 120_000) }) + f.initResponse({ expires_in: serverTtl }) + await f.account.startSignIn('en', f.callbackOrigin, 'desktop') + const state = await f.wait('waiting-browser') + expect(state.attempt?.expiresAt).toBe(startedAt + 120_000 + expectedRemaining) + await f.account.cancelSignIn(state.attempt!.id) + } finally { vi.useRealTimers() } +}) + +it('rejects initialization that completes after the total sign-in deadline', async () => { + const f = await fixture() + const startedAt = 1_000_000 + vi.useFakeTimers({ toFake: ['Date'] }) + try { + vi.setSystemTime(startedAt) + f.onInit(() => { vi.setSystemTime(startedAt + 600_000) }) + await f.account.startSignIn('en', f.callbackOrigin, 'desktop') + const state = await f.wait('expired') + expect(state.status).toBe('signed-out') + expect(state.attempt?.errorCode).toBe('expired') + expect(state.attempt?.authorizeUrl).toBeUndefined() + expect(f.count()).toBe(0) + await f.cancellationReceived.promise + expect(f.cancellations).toHaveLength(1) + } finally { vi.useRealTimers() } +}) + +it('does not store an exchange result received after the sign-in deadline', async () => { + const f = await fixture() + const startedAt = 1_000_000 + f.hold() + vi.useFakeTimers({ toFake: ['Date'] }) + try { + vi.setSystemTime(startedAt) + await f.account.startSignIn('en', f.callbackOrigin, 'desktop') + await f.wait('waiting-browser') + const callback = fetch(f.callback(), { redirect: 'manual' }) + await f.exchanged.promise + vi.setSystemTime(startedAt + 600_000) + f.release.resolve(undefined) + await callback + const state = await f.wait('expired') + expect(state.status).toBe('signed-out') + expect(await f.account.getPlatformSession()).toBeNull() + } finally { vi.useRealTimers() } +}) + +it('stores a grant before redirecting, restores account presence, and signs out without deleting device identity', async () => { + const f = await fixture() + await f.account.startSignIn('en', f.callbackOrigin, 'desktop') + await f.wait('waiting-browser') + expect((await fetch(f.callback('wrong'))).status).toBe(400) + const response = await fetch(f.callback(), { redirect: 'manual' }) + expect(response.status).toBe(302) + expect(f.init().login_source).toBe('desktop') + expect(f.init()).not.toHaveProperty('client_type') + expect(response.headers.get('location')).toBe(`${f.origin}/dsh/authorized?result=test&locale=zh_CN&login_source=desktop`) + expect((await f.account.getState()).status).toBe('credential-stored') + expect(await readFile(join(f.home, 'credentials.yaml'), 'utf8')).toContain('dsh_mock_test') + expect(await f.account.getPlatformSession()).toEqual({ origin: f.origin, token: 'dsh_mock_test' }) + expect(await f.account.resolveToken('https://api.deepseek.com')).toBeUndefined() + await f.account.signOut() + expect((await f.account.getState()).status).toBe('signed-out') + expect(await f.account.getPlatformSession()).toBeNull() + expect((await f.ctx.credentials.describeRecord(credentialKey('deepseek-account-platform', 'device'))).configured).toBe(true) +}) + +it('does not persist a delayed exchange after cancellation or replace the next attempt', async () => { + const f = await fixture() + f.exchangeResponse({ user: { email: 'c***@example.invalid', id_profile: { name: 'Cancelled User' } } }) + f.hold() + await f.account.startSignIn('en', f.callbackOrigin, 'desktop') + const waiting = await f.wait('waiting-browser') + const callback = fetch(f.callback(), { redirect: 'manual' }).catch(() => undefined) + await f.exchanged.promise + const cancelled = await f.account.cancelSignIn(waiting.attempt!.id) + expect(cancelled.attempt?.phase).toBe('cancelled') + await f.account.startSignIn('en', f.callbackOrigin, 'desktop') + const next = await f.wait('waiting-browser') + f.release.resolve(undefined) + await callback + expect((await f.account.getState()).attempt?.id).toBe(next.attempt?.id) + expect((await f.account.getState()).status).toBe('signed-out') + expect(f.count()).toBe(1) + expect(await f.account.getProfile()).toBeNull() + await f.account.cancelSignIn(next.attempt!.id) +}) + +it('allows real account credentials only on the exact official HTTPS origin', async () => { + const f = await fixture() + await f.ctx.credentials.modifyRecord(credentialKey('deepseek-account-platform', 'default'), () => Promise.resolve({ + kind: 'grant', payload: { version: 1, issuer: 'https://platform.deepseek.com', token: 'test-account-token' }, + })) + expect(await f.account.resolveToken('https://api.deepseek.com/v1')).toBe('test-account-token') + for (const url of ['http://api.deepseek.com', 'https://api.deepseek.com.evil.test', 'https://api.deepseek.com:8443', 'https://user@api.deepseek.com']) { + expect(await f.account.resolveToken(url)).toBeUndefined() + } +}) + +it('restricts platform destinations to the configured origin and route', () => { + expect(() => platformOrigin('http://localhost:8081', false)).toThrow() + expect(platformOrigin('http://localhost:8081', true)).toBe('http://localhost:8081') + expect(() => platformOrigin('http://example.com', true)).toThrow() + expect(() => browserUrl('https://evil.test/dsh/authorized', 'https://platform.deepseek.com', '/dsh/authorized')).toThrow() + expect(() => browserUrl('https://platform.deepseek.com/other', 'https://platform.deepseek.com', '/dsh/authorized')).toThrow() +}) + +it('maps both returned browser pages to the development origin across the login flow', async () => { + const f = await fixture(undefined, {}, true) + f.exchangeResponse({ authorized_url: 'https://platform.deepseek.com/dsh/authorized?result=a%2Fb&locale=zh_CN' }) + await f.account.startSignIn('en', f.callbackOrigin, 'desktop') + expect((await f.wait('waiting-browser')).attempt?.authorizeUrl).toBe(`${f.origin}/dsh/authorize?authorize_id=test`) + const response = await fetch(f.callback(), { redirect: 'manual' }) + expect(response.status).toBe(302) + expect(response.headers.get('location')).toBe(`${f.origin}/dsh/authorized?result=a%2Fb&locale=zh_CN&login_source=desktop`) + expect((await f.account.getState()).status).toBe('credential-stored') +}) + +it.each([ + ['web', ''], + ['desktop', ''], + ['web', '&login_source=desktop&login_source=desktop'], + ['desktop', '&login_source=web&login_source=web'], +] as const)('redirects the %s login completion with its login source when Platform returns %s', async (client, query) => { + const f = await fixture() + f.exchangeResponse({ authorized_url: `${f.origin}/dsh/authorized?result=a%2Fb&locale=zh_CN${query}` }) + await f.account.startSignIn('en', f.callbackOrigin, client) + await f.wait('waiting-browser') + const response = await fetch(f.callback(), { redirect: 'manual' }) + expect(response.status).toBe(302) + expect(response.headers.get('location')).toBe(`${f.origin}/dsh/authorized?result=a%2Fb&locale=zh_CN&login_source=${client}`) +}) + +it('preserves Platform business client_type in the completion URL', async () => { + const f = await fixture() + f.exchangeResponse({ authorized_url: `${f.origin}/dsh/authorized?client_type=DSH` }) + await f.account.startSignIn('en', f.callbackOrigin, 'web') + await f.wait('waiting-browser') + const response = await fetch(f.callback(), { redirect: 'manual' }) + expect(response.headers.get('location')).toBe(`${f.origin}/dsh/authorized?client_type=DSH&login_source=web`) +}) + +it.each([undefined, 'https://other.example/dsh/authorized', '/dsh/authorized'])('rejects an invalid exchange completion URL %s before storing a token', async (authorizedUrl) => { + const f = await fixture() + f.exchangeResponse({ authorized_url: authorizedUrl }) + await f.account.startSignIn('en', f.callbackOrigin, 'desktop') + await f.wait('waiting-browser') + const response = await fetch(f.callback(), { redirect: 'manual' }) + expect(response.status).toBe(204) + expect((await f.wait('failed')).status).toBe('signed-out') + expect((await f.ctx.credentials.describeRecord(credentialKey('deepseek-account-platform', 'default'))).configured).toBe(false) +}) + +it.each([2, 17])('uses the generic failure for business code %s without guessing product behavior or exposing backend text', async (code) => { + const f = await fixture() + f.fail(code) + await f.account.startSignIn('en', f.callbackOrigin, 'desktop') + const state = await f.wait('failed') + expect(state).toMatchObject({ status: 'signed-out', attempt: { phase: 'failed', errorCode: 'protocol' } }) + expect(JSON.stringify(state)).not.toContain('sensitive diagnostic') +}) + + +it('removes its callback route without closing the shared server on disposal', async () => { + const f = await fixture() + await f.account.startSignIn('en', f.callbackOrigin, 'desktop') + await f.wait('waiting-browser') + const callback = f.callback() + await f.dispose() + expect((await fetch(callback)).status).toBe(404) + await expect(f.account.startSignIn('en', f.callbackOrigin, 'desktop')).rejects.toThrow() + expect(await f.ctx.credentials.readRecord(credentialKey('deepseek-account-platform', 'default'))).toBeUndefined() +}) + + +it('derives every portal link from the private platform origin', async () => { + const f = await fixture() + expect((await f.account.getState()).links).toEqual({ usageUrl: `${f.origin}/usage`, topUpUrl: `${f.origin}/top_up` }) + await f.account.startSignIn('en', f.callbackOrigin, 'desktop') + expect((await f.wait('waiting-browser')).attempt?.authorizeUrl).toBe(`${f.origin}/dsh/authorize?authorize_id=test`) +}) + +async function storeAccount(f: Awaited>) { + await f.ctx.credentials.modifyRecord(credentialKey('deepseek-account-platform', 'default'), () => Promise.resolve({ + kind: 'grant', payload: { version: 1, issuer: f.origin, token: 'test-platform-grant' }, + })) +} + +it('queries Platform Web endpoints with the stored grant and projects only masked profile and separate recharge and bonus balances', async () => { + const f = await fixture() + expect(await readDetails(f.account)).toBeNull() + await storeAccount(f) + expect(await readDetails(f.account)).toEqual({ + profile: { status: 'ready', value: { id: 'test-user', name: 'Test Account', avatarUrl: null, contact: '138****5678' } }, + balance: { status: 'ready', bonusWallets: [{ currency: 'CNY', balance: '10.00' }], value: [{ currency: 'CNY', balance: '123.45' }, { currency: 'USD', balance: '6.78' }] }, + }) + expect(f.detailRequests).toEqual(expect.arrayContaining([ + { path: '/auth-api/v0/users/current', authorization: 'test-platform-grant' }, + { path: '/api/v0/users/get_user_summary', authorization: 'test-platform-grant' }, + ])) + expect(await f.ctx.credentials.readRecord(credentialKey('deepseek-account-platform', 'default'))).toMatchObject({ + payload: { token: 'test-platform-grant' }, + }) +}) + +it('retains profile data when balance fails instead of reporting a zero balance', async () => { + const f = await fixture() + await storeAccount(f) + f.failSummary() + expect(await readDetails(f.account)).toMatchObject({ profile: { status: 'ready' }, balance: { status: 'failed' } }) +}) + +it('discards account details when sign-out races the Platform response', async () => { + const f = await fixture() + await storeAccount(f) + f.holdDetails() + const pending = readDetails(f.account) + await f.detailsStarted.promise + await f.account.signOut() + expect(await pending).toBeNull() + f.release.resolve(undefined) + expect(await readDetails(f.account)).toBeNull() +}) + +it('does not send an account grant to a different configured Platform environment', async () => { + const f = await fixture() + await f.ctx.credentials.modifyRecord(credentialKey('deepseek-account-platform', 'default'), () => Promise.resolve({ + kind: 'grant', payload: { version: 1, issuer: 'https://platform.deepseek.com', token: 'test-platform-grant' }, + })) + await expect(readDetails(f.account)).rejects.toThrow('account: protocol') + expect(f.detailRequests).toEqual([]) +}) + +it('rejects malformed wallet data independently of the profile response', async () => { + const f = await fixture() + await storeAccount(f) + f.invalidateSummary() + expect(await readDetails(f.account)).toMatchObject({ profile: { status: 'ready' }, balance: { status: 'failed' } }) +}) + +it.each([ + { input: { email: '', mobile: '138***78' }, expected: '138***78' }, + { input: { email: '', mobile_number: '+86 138••••5678' }, expected: '+86 138••••5678' }, + { input: { email: 'te***@example.invalid' }, expected: 'te***@example.invalid' }, +])('preserves Platform contact masking without rewriting it: $expected', async ({ input, expected }) => { + const f = await fixture(input) + await storeAccount(f) + expect((await readDetails(f.account))?.profile).toEqual({ + status: 'ready', value: { id: 'test-user', name: 'Test Account', avatarUrl: null, contact: expected }, + }) +}) + +it('removes the local grant while a shared concurrent logout request is still pending', async () => { + const f = await fixture() + await storeAccount(f) + f.holdLogout() + await Promise.all([f.account.signOut(), f.account.signOut()]) + await expect.poll(f.logoutCount).toBe(1) + expect(f.logoutHeaders).toEqual(['test-platform-grant']) + expect((await f.account.getState()).status).toBe('signed-out') + await f.account.signOut() + expect(f.logoutCount()).toBe(1) +}) + +it('bounds failed logout retries without restoring the grant or deleting a new login', async () => { + const f = await fixture() + await storeAccount(f) + f.failLogout(true) + await expect(f.account.signOut()).resolves.toMatchObject({ status: 'signed-out' }) + expect(await readDetails(f.account)).toBeNull() + await f.account.startSignIn('en', f.callbackOrigin, 'desktop') + await f.wait('waiting-browser') + f.exchangeResponse({ token: 'new-account-token' }) + await fetch(f.callback(), { redirect: 'manual' }) + await expect.poll(f.logoutCount).toBe(6) + expect(f.logoutHeaders).toEqual(Array(6).fill('test-platform-grant')) + expect((await f.account.getState()).status).toBe('credential-stored') + expect(await f.ctx.credentials.readRecord(credentialKey('deepseek-account-platform', 'default'))) + .toMatchObject({ kind: 'grant', payload: { token: 'new-account-token' } }) +}) + +it.each([['en', 'en_US'], ['zh-CN', 'zh_CN']])('passes %s to Platform and keeps the active attempt language', async (locale, platformLocale) => { + const f = await fixture() + await f.account.startSignIn(locale, f.callbackOrigin, 'desktop') + const first = await f.wait('waiting-browser') + expect(f.init().locale).toBe(platformLocale) + expect((await f.account.startSignIn('zh', f.callbackOrigin, 'desktop')).attempt?.id).toBe(first.attempt?.id) + expect(f.init().locale).toBe(platformLocale) + await f.account.cancelSignIn(first.attempt!.id) + await f.account.startSignIn('zh', f.callbackOrigin, 'desktop') + await f.wait('waiting-browser') + expect(f.init().locale).toBe('zh_CN') +}) + +it('adds private deployment cookies to every Platform request without exposing them in account state', async () => { + const f = await fixture(undefined, { Cookie: 'test_gate=synthetic' }) + await f.account.startSignIn('en', f.callbackOrigin, 'desktop') + await f.wait('waiting-browser') + await fetch(f.callback(), { redirect: 'manual' }) + await readDetails(f.account) + expect(JSON.stringify(await f.account.getState())).not.toContain('test_gate') + await f.account.signOut() + await expect.poll(f.logoutCount).toBe(1) + expect(f.receivedHeaders.map(item => item.path)).toEqual([ + '/auth-api/v0/dsh/auth_init', '/auth-api/v0/dsh/auth_exchange', + '/auth-api/v0/users/current', '/api/v0/users/get_user_summary', '/auth-api/v0/users/logout', + ]) + expect(f.receivedHeaders.every(item => item.cookie === 'test_gate=synthetic')).toBe(true) + expect(f.receivedHeaders.slice(2).every(item => item.authorization === 'dsh_mock_test')).toBe(true) +}) + +it('rejects reserved, duplicate and malformed deployment headers without disclosing values', () => { + for (const headers of [ + { Authorization: 'secret-value' }, { 'X-DSH-Auth-Token': 'secret-value' }, { HOST: 'secret-value' }, { 'Content-Length': '5' }, + { Cookie: 'secret-value', cookie: 'other' }, { 'bad name': 'secret-value' }, + { Cookie: 'secret-value\r\ninjected: x' }, + ]) { + expect(() => platformHeaders(headers)).toThrow(/^account: requestHeaders/) + } + expect(platformHeaders({ Cookie: 'test_gate=synthetic' })).toEqual({ cookie: 'test_gate=synthetic' }) +}) + +it('does not forward deployment cookies through a Platform redirect', async () => { + const f = await fixture(undefined, { Cookie: 'test_gate=synthetic' }) + f.redirect() + await f.account.startSignIn('en', f.callbackOrigin, 'desktop') + await f.wait('failed') + expect(f.receivedHeaders.map(item => item.path)).toEqual(['/auth-api/v0/dsh/auth_init']) +}) + +it('closes the failed Web authorization tab without redirecting and keeps the shared HTTP server alive', async () => { + const f = await fixture() + const dispose = f.ctx.webServer.register({ kind: 'exact', path: '/health', handler: (_req, res) => { res.end('alive') } }) + cleanups.push(async () => { dispose() }) + await f.account.startSignIn('en', f.callbackOrigin, 'web') + await f.wait('waiting-browser') + expect(new URL(f.init().redirect_uri!).origin).toBe(f.callbackOrigin) + f.fail(17) + const response = await fetch(f.callback(), { redirect: 'manual' }) + expect(response.status).toBe(200) + expect(response.headers.get('location')).toBeNull() + const page = await response.text() + expect(page).toContain('window.close()') + const nonce = /nonce="([^"]+)"/.exec(page)![1] + expect(response.headers.get('content-security-policy')).toContain(`script-src 'nonce-${nonce}'`) + await expect(`${page.match(/

(.*?)<\/p>/)![1]}\n`) + .toMatchFileSnapshot('./expected/web-exchange-failure.txt') + expect(f.init().login_source).toBe('web') + expect(await f.wait('failed')).toMatchObject({ status: 'signed-out', attempt: { errorCode: 'protocol' } }) + expect(f.count()).toBe(1) + expect(await (await fetch(`${f.callbackOrigin}/health`)).text()).toBe('alive') + f.fail(0) + await f.account.startSignIn('en', f.callbackOrigin, 'web') + await f.wait('waiting-browser') + const success = await fetch(f.callback(), { redirect: 'manual' }) + expect(success.headers.get('location')).toBe(`${f.origin}/dsh/authorized?result=test&locale=zh_CN&login_source=web`) + expect(f.count()).toBe(2) +}) + +it('completes login through a local TCP forward using the browser port rather than the Host port', async () => { + const f = await fixture() + const sockets = new Set() + const forward = createTcpServer((socket) => { + const upstream = connect(f.ctx.webServer.port, '127.0.0.1') + for (const connection of [socket, upstream]) { + sockets.add(connection) + connection.on('close', () => { sockets.delete(connection) }) + connection.on('error', () => { socket.destroy(); upstream.destroy() }) + } + socket.pipe(upstream).pipe(socket) + }) + cleanups.push(async () => { + for (const socket of sockets) socket.destroy() + await new Promise(resolve => forward.close(() => { resolve() })) + }) + await new Promise(resolve => forward.listen(0, '127.0.0.1', resolve)) + const address = forward.address() + if (address === null || typeof address === 'string') throw new Error('missing forward listener') + const forwardedOrigin = `http://127.0.0.1:${address.port}` + expect(forwardedOrigin).not.toBe(f.callbackOrigin) + await f.account.startSignIn('en', forwardedOrigin, 'web') + await f.wait('waiting-browser') + expect(f.init().redirect_uri).toBe(`${forwardedOrigin}/oauth/callback`) + const response = await fetch(f.callback(), { redirect: 'manual' }) + expect(response.headers.get('location')).toBe(`${f.origin}/dsh/authorized?result=test&locale=zh_CN&login_source=web`) + expect((await f.account.getState()).status).toBe('credential-stored') +}) + +it('keeps cancellation authoritative without reopening WebUI after a delayed exchange', async () => { + const f = await fixture() + f.hold() + await f.account.startSignIn('en', f.callbackOrigin, 'web') + const state = await f.wait('waiting-browser') + const response = fetch(f.callback(), { redirect: 'manual' }) + await f.exchanged.promise + await f.account.cancelSignIn(state.attempt!.id) + expect((await response).status).toBe(204) + f.release.resolve(undefined) + expect(await f.account.getState()).toMatchObject({ status: 'signed-out', attempt: { phase: 'cancelled' } }) +}) + +it.each(['https://example.com', 'http://example.com', 'http://127.0.0.1.evil.test', + 'http://localhost', 'http://127.0.0.1', 'http://[::1]', 'http://localhost:0', 'http://localhost:65536', + 'http://user@localhost', 'http://localhost/path', 'http://localhost/?x=1', 'http://localhost/#x', 'invalid']) +('rejects unsupported callback origin %s before starting authorization', async (origin) => { + const f = await fixture() + await expect(f.account.startSignIn('en', origin, 'web')).rejects.toThrow('account: protocol') + expect(f.init()).toEqual({}) +}) + +it('normalizes supported loopback callback origins', () => { + expect(loginOrigin('http://localhost:8080/')).toBe('http://localhost:8080') + expect(loginOrigin('http://127.0.0.1:8080')).toBe('http://127.0.0.1:8080') + expect(loginOrigin('http://[::1]:8080/')).toBe('http://[::1]:8080') + expect(loginOrigin('http://localhost:80')).toBe('http://localhost:80') +}) + +it('cancels remotely with the original PKCE verifier without waiting for acknowledgment', async () => { + const f = await fixture() + f.holdCancel() + await f.account.startSignIn('en', f.callbackOrigin, 'web') + const state = await f.wait('waiting-browser') + expect((await f.account.cancelSignIn(state.attempt!.id)).attempt?.phase).toBe('cancelled') + await f.cancellationReceived.promise + expect(f.cancellations).toHaveLength(1) + const body = f.cancellations[0]! + expect(Object.keys(body).sort()).toEqual(['authorize_id', 'code_verifier']) + expect(body.authorize_id).toBe('test') + expect(createHash('sha256').update(body.code_verifier!).digest('base64url')).toBe(f.init().code_challenge) + expect(f.receivedHeaders.find(row => row.path?.endsWith('auth_cancel'))?.authorization).toBeUndefined() + await f.account.cancelSignIn(state.attempt!.id) + expect(f.cancellations).toHaveLength(1) + f.fail(1) + f.release.resolve(undefined) + expect(await f.account.getState()).toMatchObject({ status: 'signed-out', attempt: { phase: 'cancelled' } }) +}) + +it('preserves localhost and the browser-visible port in authorization initialization', async () => { + const f = await fixture() + const origin = f.callbackOrigin.replace('127.0.0.1', 'localhost') + await f.account.startSignIn('en', origin, 'web') + const state = await f.wait('waiting-browser') + expect(f.init().redirect_uri).toBe(`${origin}/oauth/callback`) + await f.account.cancelSignIn(state.attempt!.id) +}) + +it('never exports an embedded Platform token to a different configured issuer', async () => { + const f = await fixture() + await f.ctx.credentials.modifyRecord(credentialKey('deepseek-account-platform', 'default'), () => Promise.resolve({ + kind: 'grant', payload: { version: 1, token: 'fixture-secret', issuer: 'https://platform.deepseek.com' }, + })) + await expect(f.account.getPlatformSession()).rejects.toThrow() +}) + +async function readDetails(account: Pick) { + const [profile, balance] = await Promise.all([account.getProfile(), account.getBalance()]) + return profile === null || balance === null ? null : { profile, balance } +} + +it('returns the profile while the balance request is still pending', async () => { + const f = await fixture() + await storeAccount(f) + f.holdBalance() + let balanceSettled = false + const balance = f.account.getBalance().then((value) => { balanceSettled = true; return value }) + try { + await f.detailsStarted.promise + expect(await f.account.getProfile()).toMatchObject({ status: 'ready', value: { name: 'Test Account' } }) + expect(balanceSettled).toBe(false) + } finally { f.release.resolve(undefined) } + expect(await balance).toMatchObject({ status: 'ready' }) +}) + + +it('uses exchange user for the first profile read and fetches current on refresh', async () => { + const f = await fixture() + f.exchangeResponse({ user: { id: 'exchange-user', email: 'e***@example.invalid', id_profile: { name: 'Exchange User' }, token: 'discard-me' } }) + await f.account.startSignIn('en', f.callbackOrigin, 'desktop') + await f.wait('waiting-browser') + await fetch(f.callback(), { redirect: 'manual' }) + expect(await f.account.getProfile()).toMatchInlineSnapshot(` + { + "status": "ready", + "value": { + "avatarUrl": null, + "contact": "e***@example.invalid", + "id": "exchange-user", + "name": "Exchange User", + }, + } + `) + expect(f.detailRequests).toEqual([]) + expect((await f.account.getProfile())).toMatchObject({ status: 'ready', value: { name: 'Test Account' } }) + expect(f.detailRequests).toHaveLength(1) + expect(await readFile(join(f.home, 'credentials.yaml'), 'utf8')).not.toContain('discard-me') +}) + +it.each([undefined, null, { email: 123 }])('fetches current when exchange user is unavailable: %j', async (user) => { + const f = await fixture() + f.exchangeResponse({ user }) + await f.account.startSignIn('en', f.callbackOrigin, 'desktop') + await f.wait('waiting-browser') + await fetch(f.callback(), { redirect: 'manual' }) + expect(await f.account.getProfile()).toMatchObject({ status: 'ready', value: { name: 'Test Account' } }) + expect(f.detailRequests).toHaveLength(1) +}) + + +it('uses the initialization payload ID for cancellation without extracting it from the browser URL', async () => { + const f = await fixture() + f.initResponse({ authorize_id: 'payload-id', authorize_url: `${f.origin}/dsh/authorize?opaque=value` }) + await f.account.startSignIn('en', f.callbackOrigin, 'desktop') + const state = await f.wait('waiting-browser') + await f.account.cancelSignIn(state.attempt!.id) + await f.cancellationReceived.promise + expect(f.cancellations[0]?.authorize_id).toBe('payload-id') +}) + +it.each([undefined, '', 123])('rejects an invalid initialization authorize_id: %j', async (authorizeId) => { + const f = await fixture() + f.initResponse({ authorize_id: authorizeId }) + await f.account.startSignIn('en', f.callbackOrigin, 'desktop') + expect(await f.wait('failed')).toMatchObject({ status: 'signed-out', attempt: { errorCode: 'protocol' } }) + expect(f.count()).toBe(0) +}) + + +it('overlays account cookies without changing authorization or logout routing', async () => { + const f = await fixture(undefined, { Cookie: 'gate=private; route=auth', 'x-private': 'keep' }, false, { Cookie: 'route=account' }) + await f.account.startSignIn('en', f.callbackOrigin, 'desktop') + await f.wait('waiting-browser') + await fetch(f.callback(), { redirect: 'manual' }) + await readDetails(f.account) + expect(await f.account.getPlatformSession()).toMatchObject({ requestHeaders: { cookie: 'gate=private; route=account', 'x-private': 'keep' } }) + await f.account.signOut() + await expect.poll(f.logoutCount).toBe(1) + for (const row of f.receivedHeaders) { + const detail = ['/auth-api/v0/users/current', '/api/v0/users/get_user_summary'].includes(row.path ?? '') + expect(row.cookie).toBe(detail ? 'gate=private; route=account' : 'gate=private; route=auth') + } + expect(JSON.stringify(await f.account.getState())).not.toContain('private') +}) + + +it('sends a development grant only to its configured inference origin', async () => { + const f = await fixture(undefined, {}, false, {}, 'http://inference.example.test:8094') + f.exchangeResponse({ token: 'test-account-token' }) + await f.account.startSignIn('en', f.callbackOrigin, 'desktop') + await f.wait('waiting-browser') + await fetch(f.callback(), { redirect: 'manual' }) + await f.wait('succeeded') + expect(await f.account.resolveToken('http://inference.example.test:8094/api')).toBe('test-account-token') + for (const url of ['https://api.deepseek.com', 'http://inference.example.test:8095/api', + 'https://inference.example.test:8094/api', 'http://user@inference.example.test:8094/api']) { + expect(await f.account.resolveToken(url)).toBeUndefined() + } +}) + + +it('starts signed out after discarding another Platform issuer without remote logout or unrelated credential loss', async () => { + const accountKey = credentialKey('deepseek-account-platform', 'default') + const deviceKey = credentialKey('deepseek-account-platform', 'device') + const apiKey = credentialRef('TEST_PLATFORM_SWITCH_API_KEY') + const f = await fixture(undefined, {}, false, {}, undefined, async (ctx) => { + await ctx.credentials.modifyRecord(accountKey, () => Promise.resolve({ + kind: 'grant', payload: { version: 1, issuer: 'https://old-platform.example.test', token: 'old-token' }, + })) + await ctx.credentials.modifyRecord(deviceKey, () => Promise.resolve({ kind: 'grant', payload: { id: 'stable-device' } })) + await ctx.credentials.set(apiKey, 'retained-api-key') + }) + expect((await f.account.getState()).status).toBe('signed-out') + expect(await f.account.getPlatformSession()).toBeNull() + expect(await f.account.getProfile()).toBeNull() + expect(await f.account.getBalance()).toBeNull() + expect(await f.ctx.credentials.readRecord(accountKey)).toBeUndefined() + expect(await f.ctx.credentials.readRecord(deviceKey)).toEqual({ kind: 'grant', payload: { id: 'stable-device' } }) + expect((await f.ctx.credentials.resolve(apiKey))?.value).toBe('retained-api-key') + expect(f.receivedHeaders).toEqual([]) +}) + +it('preserves a matching issuer and its grant when a balance request fails', async () => { + const accountKey = credentialKey('deepseek-account-platform', 'default') + const f = await fixture(undefined, {}, false, {}, undefined, async (ctx, issuer) => { + await ctx.credentials.modifyRecord(accountKey, () => Promise.resolve({ + kind: 'grant', payload: { version: 1, issuer, token: 'retained-token' }, + })) + }) + f.failSummary() + expect(await f.account.getBalance()).toEqual({ status: 'failed' }) + expect((await f.account.getState()).status).toBe('credential-stored') + expect(await f.ctx.credentials.readRecord(accountKey)).toMatchObject({ kind: 'grant', payload: { token: 'retained-token' } }) + expect(f.logoutCount()).toBe(0) +}) + +it('carries the configured embedded frontend selector in the private Platform session', async () => { + const f = await fixture(undefined, {}, false, {}, undefined, undefined, 'feat/test') + await f.account.startSignIn('en', f.callbackOrigin, 'desktop') + await f.wait('waiting-browser') + await fetch(f.callback(), { redirect: 'manual' }) + expect(await f.account.getPlatformSession()).toEqual({ origin: f.origin, token: 'dsh_mock_test', embeddedPageDist: 'feat/test' }) +}) + +it.each([ + ['darwin', 'desktop-mac'], ['win32', 'desktop-win'], [null, undefined], +] as const)('identifies %s Host API and embedded Platform requests', async (desktopPlatform, expected) => { + const f = await fixture(undefined, { Cookie: 'test_gate=synthetic', ...(desktopPlatform === null ? {} : { 'X-Client-Platform': 'web' }) }, + false, {}, undefined, undefined, '', desktopPlatform) + await f.account.startSignIn('en', f.callbackOrigin, 'desktop') + await f.wait('waiting-browser') + const attempt = (await f.account.getState()).attempt! + await f.account.cancelSignIn(attempt.id) + await f.cancellationReceived.promise + await f.account.startSignIn('en', f.callbackOrigin, 'desktop') + await f.wait('waiting-browser') + await fetch(f.callback(), { redirect: 'manual' }) + await readDetails(f.account) + expect(await f.account.getPlatformSession()).toMatchObject({ requestHeaders: { cookie: 'test_gate=synthetic' } }) + expect((await f.account.getPlatformSession())?.requestHeaders?.['x-client-platform']).toBe(expected) + await f.account.signOut() + await expect.poll(f.logoutCount).toBe(1) + expect(f.receivedHeaders.map(item => item.path)).toEqual([ + '/auth-api/v0/dsh/auth_init', '/auth-api/v0/dsh/auth_cancel', + '/auth-api/v0/dsh/auth_init', '/auth-api/v0/dsh/auth_exchange', + '/auth-api/v0/users/current', '/api/v0/users/get_user_summary', '/auth-api/v0/users/logout', + ]) + expect(f.receivedHeaders.map(item => item.clientPlatform)).toEqual(f.receivedHeaders.map(() => expected)) +}) + +it('rejects unsupported native desktop platforms in configuration', () => { + // @ts-expect-error Configuration files can name unsupported operating systems. + expect(() => Config({ desktopPlatform: 'linux' })).toThrow() +}) + +it('retains successful profile data on current failure only for the same credential', async () => { + const f = await fixture() + f.exchangeResponse({ user: { email: 'e***@example.invalid', id_profile: { name: 'Exchange User' } } }) + await f.account.startSignIn('en', f.callbackOrigin, 'desktop') + await f.wait('waiting-browser') + await fetch(f.callback(), { redirect: 'manual' }) + const initial = await f.account.getProfile() + expect(initial).toMatchObject({ status: 'ready', value: { name: 'Exchange User' } }) + f.failProfile(true) + expect(await f.account.getProfile()).toEqual(initial) + f.failProfile(false) + const refreshed = await f.account.getProfile() + expect(refreshed).toMatchObject({ status: 'ready', value: { name: 'Test Account' } }) + f.failProfile(true) + expect(await f.account.getProfile()).toEqual(refreshed) + const key = credentialKey('deepseek-account-platform', 'default') + await f.ctx.credentials.modifyRecord(key, () => Promise.resolve({ + kind: 'grant', payload: { version: 1, issuer: f.origin, token: 'replacement-token' }, + })) + expect(await f.account.getProfile()).toEqual({ status: 'failed' }) + await f.account.signOut() + expect(await f.account.getProfile()).toBeNull() +}) + +it.each([[], [{ currency: 'CNY', balance: '0.00' }]].map(wallets => ({ wallets })))('preserves empty or zero bonus wallets', async ({ wallets }) => { + const f = await fixture() + await storeAccount(f) + f.bonusWallets(wallets) + expect(await f.account.getBalance()).toMatchObject({ status: 'ready', bonusWallets: wallets }) +}) + +it.each([undefined, [{ currency: 'EUR', balance: '1' }], [{ currency: 'CNY', balance: 'invalid' }]].map(wallets => ({ wallets })))( + 'rejects malformed bonus wallets without reporting zero credit', async ({ wallets }) => { + const f = await fixture() + await storeAccount(f) + f.bonusWallets(wallets) + expect(await f.account.getBalance()).toEqual({ status: 'failed' }) + }, +) + +it.each([{ kind: 'api-key' as const, key: 'wrong-kind' }, { kind: 'grant' as const, payload: { version: 0 } }])( + 'rejects invalid stored account records across account consumers: $kind', async (record) => { + const f = await fixture() + const key = credentialKey('deepseek-account-platform', 'default') + await f.ctx.credentials.modifyRecord(key, () => Promise.resolve(record)) + await expect(f.account.getState()).rejects.toThrow('account: storage') + await expect(f.account.getProfile()).rejects.toThrow('account: storage') + await expect(f.account.getPlatformSession()).rejects.toThrow('account: storage') + await expect(f.account.resolveToken('https://api.deepseek.com')).rejects.toThrow('account: storage') + await expect(f.account.signOut()).rejects.toThrow('account: storage') + }, +) + +it('returns no credentials when signed out or disposed', async () => { + const f = await fixture() + expect(await f.account.resolveToken('https://api.deepseek.com')).toBeUndefined() + expect(await f.account.getPlatformSession()).toBeNull() + await f.account.cancelSignIn('missing' as import('@deepseek-ai/dsh-deepseek-account').SignInAttemptId) + await f.dispose() + expect(await f.account.getProfile()).toBeNull() + expect(await f.account.getPlatformSession()).toBeNull() + await expect(f.account.startSignIn('en', f.callbackOrigin, 'web')).rejects.toThrow('account: protocol') + await expect(f.account.signOut()).rejects.toThrow('account: protocol') +}) + +it('rejects sign-out and inference grants issued by another environment', async () => { + const f = await fixture(undefined, {}, false, {}, 'https://private.example') + await f.ctx.credentials.modifyRecord(credentialKey('deepseek-account-platform', 'default'), () => Promise.resolve({ + kind: 'grant', payload: { version: 1, token: 'real-token', issuer: 'https://other.example' }, + })) + expect(await f.account.resolveToken('https://private.example')).toBeUndefined() + await expect(f.account.signOut()).rejects.toThrow('account: protocol') +}) + +it('never sends a development issuer grant to official inference', async () => { + const f = await fixture() + await f.ctx.credentials.modifyRecord(credentialKey('deepseek-account-platform', 'default'), () => Promise.resolve({ + kind: 'grant', payload: { version: 1, token: 'real-token', issuer: f.origin }, + })) + expect(await f.account.resolveToken('https://api.deepseek.com')).toBeUndefined() +}) + +it('merges account cookies when there are no base cookies', async () => { + const f = await fixture(undefined, {}, false, { cookie: 'account=value' }) + await storeAccount(f) + expect(await f.account.getPlatformSession()).toMatchObject({ requestHeaders: { cookie: 'account=value' } }) +}) + +it.each([null, []])('rejects invalid initialization and exchange payload fields: %j', async (value) => { + const f = await fixture() + f.initResponse({ authorize_id: value }) + await f.account.startSignIn('en', f.callbackOrigin, 'web') + await f.wait('failed') + f.initResponse({}) + f.exchangeResponse({ token: value }) + await f.account.startSignIn('zh', f.callbackOrigin, 'web') + await f.wait('waiting-browser') + const callback = await fetch(f.callback(), { redirect: 'manual' }) + expect(callback.status).toBe(200) + expect(await callback.text()).toContain('登录失败') + expect((await f.wait('failed')).status).toBe('signed-out') +}) + +it.each([{ kind: 'api-key' as const, key: 'wrong-kind' }, { kind: 'grant' as const, payload: null }])( + 'rejects invalid durable grants during service initialization: $kind', async (record) => { + const f = await fixture() + await f.ctx.credentials.modifyRecord(credentialKey('deepseek-account-platform', 'default'), () => Promise.resolve(record)) + await expect(f.account[Service.init]()).rejects.toThrow('account: storage') + }, +) + +it.each(['ftp://api.example', 'https://user:password@api.example', 'https://api.example/path', + 'https://api.example/?query=1', 'https://api.example/#fragment'])('rejects invalid inference origin %s', async (inferenceOrigin) => { + expect(() => new PlatformAccount(new Context(), { inferenceOrigin })).toThrow('account: inferenceOrigin') +}) + +it('rejects authorization begun without a local account attempt', async () => { + const f = await fixture() + await expect(f.ctx.authorization.begin({ key: credentialKey('deepseek-account-platform', 'default'), + interaction: { notify: () => undefined, prompt: () => Promise.resolve('') }, + })).rejects.toThrow('account: protocol') +}) + +it('fails sign-in when its shared callback server is absent', async () => { + const f = await fixture() + const get = vi.spyOn(f.ctx, 'get').mockImplementation(() => undefined) + try { + await f.account.startSignIn('en', f.callbackOrigin, 'web') + expect((await f.wait('failed')).attempt?.errorCode).toBe('protocol') + } finally { get.mockRestore() } +}) + +it('reports a credential commit failure without redirecting to success', async () => { + const f = await fixture() + await f.account.startSignIn('en', f.callbackOrigin, 'desktop') + await f.wait('waiting-browser') + const original = f.ctx.credentials.modifyRecord.bind(f.ctx.credentials) + const modify = vi.spyOn(f.ctx.credentials, 'modifyRecord').mockImplementation((key, mutate) => + key === credentialKey('deepseek-account-platform', 'default') ? Promise.reject(new Error('store unavailable')) : original(key, mutate)) + try { + const callback = await fetch(f.callback(), { redirect: 'manual' }) + expect(callback.status).toBe(204) + expect((await f.wait('failed')).attempt?.errorCode).toBe('storage') + } finally { modify.mockRestore() } +}) + +it('rejects a device record of the wrong kind before exchanging', async () => { + const f = await fixture() + await f.ctx.credentials.modifyRecord(credentialKey('deepseek-account-platform', 'device'), () => Promise.resolve({ kind: 'api-key', key: 'wrong' })) + await f.account.startSignIn('en', f.callbackOrigin, 'desktop') + await f.wait('waiting-browser') + expect((await fetch(f.callback(), { redirect: 'manual' })).status).toBe(204) + expect((await f.wait('failed')).attempt?.errorCode).toBe('storage') + expect(f.count()).toBe(0) +}) + +it('rejects callbacks with missing state and callbacks arriving during exchange', async () => { + const f = await fixture() + f.hold() + await f.account.startSignIn('en', f.callbackOrigin, 'web') + await f.wait('waiting-browser') + expect((await fetch(`${f.init().redirect_uri}?code=test`)).status).toBe(400) + const callback = fetch(f.callback(), { redirect: 'manual' }) + await f.exchanged.promise + expect((await fetch(f.callback())).status).toBe(410) + f.release.resolve(undefined) + expect((await callback).status).toBe(302) +}) + +it.each(['initializing', 'waiting-browser'])('expires the attempt while %s', async (phase) => { + const f = await fixture() + const timeout = vi.spyOn(globalThis, 'setTimeout') + if (phase === 'initializing') f.onInit(() => { + const timer = timeout.mock.calls.find(([, delay]) => delay === 600_000)?.[0] + if (typeof timer !== 'function') throw new Error('missing initial deadline') + timer() + }) + try { + await f.account.startSignIn('en', f.callbackOrigin, 'web') + if (phase === 'waiting-browser') { + await f.wait('waiting-browser') + const timer = timeout.mock.calls.filter(([, delay]) => typeof delay === 'number' && delay > 590_000).at(-1)?.[0] + if (typeof timer !== 'function') throw new Error('missing browser deadline') + timer() + } + expect((await f.wait('expired')).status).toBe('signed-out') + } finally { timeout.mockRestore() } +}) + +it('holds cancellation and disposal until an admitted account write completes', async () => { + const f = await fixture() + const admitted = Promise.withResolvers() + const release = Promise.withResolvers() + const original = f.ctx.credentials.modifyRecord.bind(f.ctx.credentials) + const modify = vi.spyOn(f.ctx.credentials, 'modifyRecord').mockImplementation(async (key, mutate) => { + if (key === credentialKey('deepseek-account-platform', 'default')) { + admitted.resolve(undefined) + await release.promise + } + return original(key, mutate) + }) + let callback: Promise | undefined + try { + await f.account.startSignIn('en', f.callbackOrigin, 'web') + const state = await f.wait('waiting-browser') + callback = fetch(f.callback(), { redirect: 'manual' }) + await admitted.promise + const cancelled = f.account.cancelSignIn(state.attempt!.id) + const disposed = f.dispose() + release.resolve(undefined) + expect((await callback).status).toBe(302) + expect((await cancelled).status).toBe('credential-stored') + await disposed + } finally { + release.resolve(undefined) + await callback + modify.mockRestore() + } +}) + +it('waits for local sign-out before beginning a new authorization', async () => { + const f = await fixture() + await storeAccount(f) + const admitted = Promise.withResolvers() + const release = Promise.withResolvers() + const original = f.ctx.credentials.deleteRecord.bind(f.ctx.credentials) + const remove = vi.spyOn(f.ctx.credentials, 'deleteRecord').mockImplementation(async (key) => { + admitted.resolve(undefined) + await release.promise + return original(key) + }) + try { + const out = f.account.signOut() + await admitted.promise + const login = f.account.startSignIn('en', f.callbackOrigin, 'web') + release.resolve(undefined) + expect((await out).status).toBe('signed-out') + expect((await login).attempt?.phase).toBe('initializing') + await f.wait('waiting-browser') + } finally { release.resolve(undefined); remove.mockRestore() } +}) + +it('does not begin a remote logout after disposal starts during local removal', async () => { + const f = await fixture() + await storeAccount(f) + const admitted = Promise.withResolvers() + const release = Promise.withResolvers() + const original = f.ctx.credentials.deleteRecord.bind(f.ctx.credentials) + const remove = vi.spyOn(f.ctx.credentials, 'deleteRecord').mockImplementation(async (key) => { + admitted.resolve(undefined) + await release.promise + return original(key) + }) + try { + const out = f.account.signOut() + await admitted.promise + const disposed = f.dispose() + release.resolve(undefined) + expect((await out).status).toBe('signed-out') + await disposed + expect(f.logoutCount()).toBe(0) + } finally { release.resolve(undefined); remove.mockRestore() } +}) + +it.each([false, true])('settles a previous attempt before concurrent restart or disposal: %s', async (dispose) => { + const f = await fixture() + f.hold() + await f.account.startSignIn('en', f.callbackOrigin, 'web') + await f.wait('waiting-browser') + const callback = fetch(f.callback(), { redirect: 'manual' }) + await f.exchanged.promise + const cancel = f.account.cancelSignIn((await f.account.getState()).attempt!.id) + await f.wait('cancelled') + const first = f.account.startSignIn('en', f.callbackOrigin, 'web') + if (dispose) { + const rejected = expect(first).rejects.toThrow('account: protocol') + const disposed = f.dispose() + f.release.resolve(undefined) + await rejected + await disposed + } else { + const second = f.account.startSignIn('zh', f.callbackOrigin, 'web') + f.release.resolve(undefined) + expect((await first).attempt?.id).toBe((await second).attempt?.id) + await f.wait('waiting-browser') + } + await cancel + expect((await callback).status).toBe(204) +}) + +it('rejects unsupported interaction and projects unexpected authorization failures safely', async () => { + const f = await fixture() + const begin = vi.spyOn(f.ctx.authorization, 'begin').mockImplementation(async (request) => { + request.interaction.notify({ message: 'ignored' }) + await expect(request.interaction.prompt({ kind: 'text', message: 'unsupported' })).rejects.toThrow('account: protocol') + throw new Error('private implementation failure') + }) + try { + await f.account.startSignIn('en', f.callbackOrigin, 'web') + expect((await f.wait('failed')).attempt?.errorCode).toBe('protocol') + } finally { begin.mockRestore() } +}) + +it('rejects callback requests whose raw URL is absent or malformed', async () => { + const f = await fixture() + const register = vi.spyOn(f.ctx.webServer, 'register') + try { + await f.account.startSignIn('en', f.callbackOrigin, 'web') + await f.wait('waiting-browser') + const route = register.mock.calls.find(([route]) => route.path === '/oauth/callback')?.[0] + if (route === undefined) throw new Error('missing callback route') + for (const url of [undefined, 'http://[']) { + const socket = new Socket() + const req = new IncomingMessage(socket) + req.method = 'GET' + req.url = url + const res = new ServerResponse(req) + try { + await route.handler(req, res) + expect(res.statusCode).toBe(400) + expect(res.writableEnded).toBe(true) + } finally { res.destroy(); socket.destroy() } + } + } finally { register.mockRestore() } +}) + +it('settles an attempt after its browser callback connection closes during exchange', async () => { + const f = await fixture() + f.hold() + const response = Promise.withResolvers() + const serverClosed = Promise.withResolvers() + const original = f.ctx.webServer.register.bind(f.ctx.webServer) + const register = vi.spyOn(f.ctx.webServer, 'register').mockImplementation(route => original({ + ...route, handler: (req, res) => { + response.resolve(res) + res.once('error', () => undefined) + res.once('close', () => { serverClosed.resolve(undefined) }) + return route.handler(req, res) + }, + })) + await f.account.startSignIn('en', f.callbackOrigin, 'web') + await f.wait('waiting-browser') + const socket = connect(Number(new URL(f.callbackOrigin).port), '127.0.0.1') + const closed = new Promise(resolve => socket.once('close', () => { resolve() })) + try { + await new Promise(resolve => socket.once('connect', resolve)) + socket.write(`GET ${new URL(f.callback()).pathname}${new URL(f.callback()).search} HTTP/1.1\r\nHost: localhost\r\n\r\n`) + await f.exchanged.promise + const callbackResponse = await response.promise + vi.spyOn(callbackResponse, 'end').mockImplementation(() => callbackResponse) + callbackResponse.destroy(new Error('callback transport failed')) + socket.destroy() + await closed + await serverClosed.promise + f.release.resolve(undefined) + await f.wait('succeeded') + expect((await f.account.signOut()).status).toBe('signed-out') + } finally { socket.destroy(); f.release.resolve(undefined); await closed; register.mockRestore() } +}) + +it('invalidates account reads before the shared grant lookup returns to its caller', async () => { + const f = await fixture() + await storeAccount(f) + const key = credentialKey('deepseek-account-platform', 'default') + const original = f.ctx.credentials.readRecord.bind(f.ctx.credentials) + const read = vi.spyOn(f.ctx.credentials, 'readRecord').mockImplementation(async (requested) => { + const record = await original(requested) + // The first checkpoint lets grant validation finish; the second invalidates its consumer. + queueMicrotask(() => { queueMicrotask(() => { f.ctx.emit('credentials/record-updated', key) }) }) + return record + }) + try { + expect(await f.account.getPlatformSession()).toBeNull() + expect(await f.account.getProfile()).toBeNull() + expect(f.detailRequests).toEqual([]) + } finally { read.mockRestore() } +}) diff --git a/packages/credentials/deepseek-account-platform/tests/details.spec.ts b/packages/credentials/deepseek-account-platform/tests/details.spec.ts new file mode 100644 index 0000000000..0fb134cbe4 --- /dev/null +++ b/packages/credentials/deepseek-account-platform/tests/details.spec.ts @@ -0,0 +1,12 @@ +import { expect, it } from 'vitest' +import { profile } from '../src/details.ts' + +it.each([undefined, null, '', 'https://example.test/avatar.png'])('projects Platform picture %s', (picture) => { + expect(profile({ id: 'user', email: 'masked@example.test', id_profile: { name: 'User', picture } }).avatarUrl) + .toBe(picture || null) +}) + +it('projects missing display fields and empty contact values as null', () => { + expect(profile({ email: '' })).toEqual({ id: null, avatarUrl: null, name: null, contact: null }) + expect(profile({ email: '', id_profile: { name: '' } }).name).toBeNull() +}) diff --git a/packages/credentials/deepseek-account-platform/tests/diagnostics.spec.ts b/packages/credentials/deepseek-account-platform/tests/diagnostics.spec.ts new file mode 100644 index 0000000000..be3203bc08 --- /dev/null +++ b/packages/credentials/deepseek-account-platform/tests/diagnostics.spec.ts @@ -0,0 +1,115 @@ +import { expect, it, vi } from 'vitest' +import { browserUrl, requestPlatform } from '../src/protocol.ts' + +it.each(['/dsh/authorize', '/dsh/authorized'])('maps %s to the configured development origin and preserves query bytes', (path) => { + const origin = 'http://localhost:8081' + const query = '?authorize_id=fixture&value=a%2Fb&value=two+words&empty=' + const url = `https://platform.deepseek.com${path}${query}` + expect(() => browserUrl(url, origin, path)).toThrow() + expect(browserUrl(url, origin, path, true)).toBe(`${origin}${path}${query}`) + expect(browserUrl(`${origin}${path}${query}`, origin, path, true)).toBe(`${origin}${path}${query}`) + for (const invalid of [ + `http://other.example${path}`, + 'javascript:alert(1)', + 'https://platform.deepseek.com/other', + `https://user:password@platform.deepseek.com${path}`, + `https://platform.deepseek.com${path}#fragment`, + ]) expect(() => browserUrl(invalid, origin, path, true)).toThrow() +}) + +it('reports business failure codes without credentials or response messages', async () => { + const output = vi.spyOn(console, 'info').mockImplementation(() => undefined) + const fetcher = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ + code: 0, data: { biz_code: 40123, biz_data: { token: 'response-secret' } }, message: 'private-message', + }))) + try { + await expect(requestPlatform('https://platform.deepseek.com', 'auth_init', + { code_verifier: 'request-secret' }, new AbortController().signal, + { Cookie: 'cookie-secret' })).rejects.toThrow('account: protocol') + const logged = JSON.stringify(output.mock.calls) + expect(logged).toContain('/auth-api/v0/dsh/auth_init') + expect(logged).toContain('40123') + expect(logged).toContain('200') + for (const secret of ['response-secret', 'private-message', 'request-secret', 'cookie-secret']) { + expect(logged).not.toContain(secret) + } + } finally { + fetcher.mockRestore() + output.mockRestore() + } +}) + +it('reports transport failure without exposing the thrown error', async () => { + const output = vi.spyOn(console, 'info').mockImplementation(() => undefined) + const fetcher = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('private-network-detail')) + try { + await expect(requestPlatform('https://platform.deepseek.com', 'auth_init', {}, + new AbortController().signal, {})).rejects.toThrow('account: network') + const logged = JSON.stringify(output.mock.calls) + expect(logged).toContain('network') + expect(logged).not.toContain('private-network-detail') + } finally { + fetcher.mockRestore() + output.mockRestore() + } +}) + + +it('identifies invalid envelope fields without logging response values', async () => { + const output = vi.spyOn(console, 'info').mockImplementation(() => undefined) + const fetcher = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ + code: 0, data: { biz_code: 'private-code', biz_data: { token: 'private-token' } }, + }))) + try { + await expect(requestPlatform('https://platform.deepseek.com', 'auth_init', {}, + new AbortController().signal, {})).rejects.toThrow('account: protocol') + const logged = JSON.stringify(output.mock.calls) + expect(logged).toContain('envelope') + expect(logged).toContain('biz_code') + expect(logged).toContain('invalid_type') + expect(logged).not.toContain('private-code') + expect(logged).not.toContain('private-token') + } finally { + fetcher.mockRestore() + output.mockRestore() + } +}) + +it('reports browser URL rejection rules without exposing the destination', () => { + const output = vi.spyOn(console, 'info').mockImplementation(() => undefined) + try { + expect(() => browserUrl('https://private.example/wrong?code=private-code', + 'https://platform.deepseek.com', '/dsh/authorize')).toThrow('account: protocol') + expect(output).toHaveBeenCalledWith('[deepseek-account] browser URL rejected', { + path: '/dsh/authorize', originMismatch: true, pathMismatch: true, + hasCredentials: false, hasFragment: false, + }) + expect(() => browserUrl('private-invalid-url', 'https://platform.deepseek.com', + '/dsh/authorize')).toThrow('account: protocol') + expect(JSON.stringify(output.mock.calls)).not.toContain('private-') + } finally { + output.mockRestore() + } +}) + +it.each([ + { name: 'HTTP failure', response: () => new Response('private-error', { status: 503 }), code: 'network' }, + { name: 'missing body', response: () => new Response(null), code: 'network' }, + { name: 'oversized body', response: () => new Response('x'.repeat(65_537)), code: 'protocol' }, + { name: 'invalid JSON', response: () => new Response('{private-invalid'), code: 'protocol' }, + { name: 'missing envelope data', response: () => new Response('{"code":0}'), code: 'protocol' }, + { name: 'broken response stream', response: () => new Response(new ReadableStream({ + start(controller) { controller.error(new Error('private-stream-error')) }, + })), code: 'protocol' }, +])('rejects $name without exposing response data', async ({ response, code }) => { + const output = vi.spyOn(console, 'info').mockImplementation(() => undefined) + const fetcher = vi.spyOn(globalThis, 'fetch').mockResolvedValue(response()) + try { + await expect(requestPlatform('https://platform.deepseek.com', 'auth_init', {}, + new AbortController().signal, {})).rejects.toThrow(`account: ${code}`) + expect(JSON.stringify(output.mock.calls)).not.toContain('private-') + } finally { + fetcher.mockRestore() + output.mockRestore() + } +}) diff --git a/packages/credentials/deepseek-account-platform/tests/expected/web-exchange-failure.txt b/packages/credentials/deepseek-account-platform/tests/expected/web-exchange-failure.txt new file mode 100644 index 0000000000..49037a1dfd --- /dev/null +++ b/packages/credentials/deepseek-account-platform/tests/expected/web-exchange-failure.txt @@ -0,0 +1 @@ +Sign-in failed. Close this tab and try again in the original tab. diff --git a/packages/credentials/deepseek-account-platform/tests/logout.spec.ts b/packages/credentials/deepseek-account-platform/tests/logout.spec.ts new file mode 100644 index 0000000000..e41d950e0b --- /dev/null +++ b/packages/credentials/deepseek-account-platform/tests/logout.spec.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { setTimeout } from 'node:timers/promises' +import { logoutAccount } from '../src/protocol.ts' +import { revokeAccount } from '../src/logout.ts' + +vi.mock('node:timers/promises', () => ({ setTimeout: vi.fn() })) +vi.mock('../src/protocol.ts', () => ({ logoutAccount: vi.fn() })) +beforeEach(() => { + vi.resetAllMocks() + vi.mocked(setTimeout).mockResolvedValue(undefined) +}) +afterEach(() => { vi.restoreAllMocks() }) + +const policy = { maxRetries: 5, delayMs: 1_000, requestTimeoutMs: 30_000 } + +it('stops after one successful request without waiting', async () => { + await revokeAccount('https://platform.deepseek.com', 'old-token', policy, new AbortController().signal, {}) + expect(logoutAccount).toHaveBeenCalledTimes(1) + expect(setTimeout).not.toHaveBeenCalled() +}) + +it('makes at most six requests with five exponentially increasing delays and the captured token', async () => { + vi.mocked(logoutAccount).mockRejectedValue(new Error('unavailable')) + const controller = new AbortController() + const headers = { cookie: 'test-gate' } + await expect(revokeAccount('https://platform.deepseek.com', 'old-token', policy, controller.signal, headers)) + .resolves.toBeUndefined() + expect(logoutAccount).toHaveBeenCalledTimes(6) + expect(vi.mocked(setTimeout).mock.calls.map(args => args[0])).toEqual([1_000, 2_000, 4_000, 8_000, 16_000]) + for (const args of vi.mocked(logoutAccount).mock.calls) { + expect(args).toEqual(['https://platform.deepseek.com', 'old-token', expect.any(AbortSignal), headers]) + } +}) + +it('stops retrying as soon as a request succeeds', async () => { + vi.mocked(logoutAccount).mockRejectedValueOnce(new Error('offline')).mockResolvedValue(undefined) + await revokeAccount('https://platform.deepseek.com', 'old-token', policy, new AbortController().signal, {}) + expect(logoutAccount).toHaveBeenCalledTimes(2) + expect(setTimeout).toHaveBeenCalledTimes(1) +}) + +it('honors a disabled retry policy', async () => { + vi.mocked(logoutAccount).mockRejectedValue(new Error('offline')) + await revokeAccount('https://platform.deepseek.com', 'old-token', { ...policy, maxRetries: 0 }, new AbortController().signal, {}) + expect(logoutAccount).toHaveBeenCalledTimes(1) + expect(setTimeout).not.toHaveBeenCalled() +}) + +it('cancels an in-flight request when the provider closes', async () => { + const controller = new AbortController() + vi.mocked(logoutAccount).mockImplementation((_origin, _token, signal) => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('cancelled')) }, { once: true }) + controller.abort() + })) + await revokeAccount('https://platform.deepseek.com', 'old-token', policy, controller.signal, {}) + expect(logoutAccount).toHaveBeenCalledTimes(1) + expect(setTimeout).not.toHaveBeenCalled() +}) + +it('cancels a pending delay and never starts another request', async () => { + const controller = new AbortController() + vi.mocked(logoutAccount).mockRejectedValue(new Error('offline')) + vi.mocked(setTimeout).mockImplementation(async (_delay, _value, options) => { + expect(options?.signal).toBe(controller.signal) + controller.abort() + throw controller.signal.reason + }) + await revokeAccount('https://platform.deepseek.com', 'old-token', policy, controller.signal, {}) + expect(logoutAccount).toHaveBeenCalledTimes(1) +}) + +it('does not start work for an already closed provider', async () => { + await revokeAccount('https://platform.deepseek.com', 'old-token', policy, AbortSignal.abort(), {}) + expect(logoutAccount).not.toHaveBeenCalled() +}) diff --git a/packages/settings/settings-file/tsconfig.json b/packages/credentials/deepseek-account-platform/tsconfig.json similarity index 71% rename from packages/settings/settings-file/tsconfig.json rename to packages/credentials/deepseek-account-platform/tsconfig.json index 25bb9d13e2..63c81daaea 100644 --- a/packages/settings/settings-file/tsconfig.json +++ b/packages/credentials/deepseek-account-platform/tsconfig.json @@ -14,17 +14,20 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../deepseek-account" + }, + { + "path": "../authorization" + }, + { + "path": "../credentials" + }, { "path": "../../../vendor/schemastery" }, { - "path": "../../util/atomic-write" - }, - { - "path": "../../util/home-paths" - }, - { - "path": "../settings" + "path": "../../host/webserver" } ] } diff --git a/packages/credentials/deepseek-account/README.i18n.yaml b/packages/credentials/deepseek-account/README.i18n.yaml new file mode 100644 index 0000000000..7d8ddfe1f3 --- /dev/null +++ b/packages/credentials/deepseek-account/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/credentials/deepseek-account/README.md +README.md: 72b42ee996b809992e48ea4c124f0793d756526a +README.zh.md: b30813e24adc2afc9e8245b839432dd195093c63 diff --git a/packages/credentials/deepseek-account/README.md b/packages/credentials/deepseek-account/README.md new file mode 100644 index 0000000000..72b42ee996 --- /dev/null +++ b/packages/credentials/deepseek-account/README.md @@ -0,0 +1,63 @@ +--- +description: "Account consumers read stored login state, start or cancel a browser login, and sign out without editing API keys. Host model consumers resolve account credentials only for the provider-configured inference origin." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-deepseek-account + +English | [中文](README.zh.md) + +getPlatformSession returns a Host-only origin/token snapshot for native Platform embedding, or null when signed out. It is absent from account-controller RPC and Client state. Consumers destroy documents holding a snapshot when the account changes. + +`desktopClientHeaders` maps the native `darwin` and `win32` platforms to the shared Desktop account and update-policy request header; `null` adds no header. + +## Summary + +Account consumers read stored login state, start or cancel a browser login, and sign out without editing API keys. Host model consumers resolve account credentials only for the provider-configured inference origin. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) + + +## Use this package + +`AccountProfile.avatarUrl` is an optional profile image URL; null or absence means no avatar. + +The service defines account operations and reconnectable state snapshots. The platform provider owns the protocol and stored grant. Credentials are Host-only; the API controller exports state and commands without resolveToken. + + +## Understand the implementation + +The service defines account operations without maintaining a second credential index; no invariant companion is published. The provider owns persistence and login lifecycle checks. + + +## Further Exploration + +The [credentials subsystem](../../../docs/subsystems/credentials.md) owns storage APIs; the [architecture](../../../docs/architecture.md) explains application composition. + +A ready AccountDetails.balance keeps recharge wallets in value and bonus wallets in bonusWallets; both arrays preserve decimal strings and currency. Query failure supplies neither amount. + + +## Model Experience + +None, as account credentials affect HTTP authentication and never enter model prompts, Session logs, or tool results. + +#### KV Cache effect + +No model request prefix changes. + +## Known Limitations and Deferred Work + + + +- Account tokens have no expiry or refresh flow. Sign-out removes the local grant before background provider revocation; remote logout failures never restore local login. Profile and balance query failures retain the stored grant. getProfile / getBalance reports profile and balance query outcomes independently. + + +### Dev Note + +The [desktop login decision](../../../.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md) records cancellation and storage ownership. + +PlatformSession may carry private deployment requestHeaders from Host to Electron main. Consumers must exclude those headers from renderer bootstrap and restrict them to the configured origin. mergePlatformCookies preserves unrelated cookie pairs while replacing matching names. diff --git a/packages/credentials/deepseek-account/README.zh.md b/packages/credentials/deepseek-account/README.zh.md new file mode 100644 index 0000000000..b30813e24a --- /dev/null +++ b/packages/credentials/deepseek-account/README.zh.md @@ -0,0 +1,63 @@ +--- +description: "账号使用方可读取本地登录状态、发起或取消浏览器登录,并在保留 API Key 的情况下退出。Host 模型使用方仅能为提供者配置的推理来源解析账号凭证。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-deepseek-account + +[English](README.md) | 中文 + +getPlatformSession 为原生 Platform 内嵌提供仅限 Host 的 origin/token 快照,退登时返回 null。账号控制器 RPC 和 Client 状态不包含此方法及快照。账号变化时,使用方销毁持有旧快照的文档。 + +`desktopClientHeaders` 将原生平台 `darwin` 和 `win32` 映射为 Desktop 账号与更新策略请求共用的请求头;`null` 不添加请求头。 + +## 概述 + +账号使用方可读取本地登录状态、发起或取消浏览器登录,并在保留 API Key 的情况下退出。Host 模型使用方仅能为提供者配置的推理来源解析账号凭证。 + +## 目录 + +- [使用此包](#use-this-package) +- [模型体验](#model-experience) +- [已知限制与后续工作](#known-limitations-and-deferred-work) + + +## 使用此包 + +`AccountProfile.avatarUrl` 是可选的账号头像 URL;为空或缺失时表示没有头像。 + +该服务定义账号操作和可重连的状态快照。平台提供者负责协议和授权记录。凭证仅限 Host;API 控制器只导出状态与操作,不导出 resolveToken。 + + +## 理解实现 + +服务只定义账号操作,不维护第二份凭证索引,因此不发布 invariant。提供者负责持久化和登录生命周期检查。 + + +## 深入探索 + +[凭证子系统](../../../docs/subsystems/credentials.zh.md)定义存储接口;[架构](../../../docs/architecture.zh.md)说明应用组合。 + +AccountDetails.balance 就绪时,value 保存充值钱包,bonusWallets 保存赠送钱包;两个数组均保留十进制金额字符串和币种。查询失败不提供金额。 + + +## 模型体验 + +无,因为账号凭证只影响 HTTP 认证,不进入模型提示、Session 日志或工具结果。 + +#### KV Cache effect + +不改变模型请求前缀。 + +## 已知限制与后续工作 + + + +- 账号 token 没有过期或刷新流程。退出时先移除本地授权,再由提供者在后台撤销;远程退登失败不会恢复本地登录态。资料和余额查询失败保留已存储授权。getProfile / getBalance 分别报告资料和余额的查询结果。 + + +### 开发备注 + +[桌面登录决策](../../../.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md)记录取消和存储的职责。 + +PlatformSession 可将私有部署 requestHeaders 从 Host 传至 Electron 主进程。消费者必须从渲染层 bootstrap 排除这些请求头,并将其限定于配置来源。mergePlatformCookies 替换同名 Cookie,同时保留其他 Cookie。 diff --git a/packages/settings/settings-file/package.json b/packages/credentials/deepseek-account/package.json similarity index 50% rename from packages/settings/settings-file/package.json rename to packages/credentials/deepseek-account/package.json index 60b026c00d..63cc95546d 100644 --- a/packages/settings/settings-file/package.json +++ b/packages/credentials/deepseek-account/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-settings-file", - "description": "File-backed settings provider (settings.yaml) for the DeepSeek Harness", + "name": "@deepseek-ai/dsh-deepseek-account", + "description": "Read account state and resolve official API credentials", "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" @@ -8,7 +8,7 @@ "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/settings/settings-file" + "directory": "packages/credentials/deepseek-account" }, "type": "module", "main": "lib/index.js", @@ -18,30 +18,25 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "MIT", "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-atomic-write": "workspace:^", - "@deepseek-ai/dsh-home-paths": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" - }, - "dependencies": { - "@deepseek-ai/dsh-util-values": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^", - "chokidar": "^4.0.3", - "yaml": "^2.9.0" + "@deepseek-ai/dsh-brand": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-atomic-write": "workspace:^", - "@deepseek-ai/dsh-home-paths": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-brand": "workspace:^" } } diff --git a/packages/credentials/deepseek-account/src/index.ts b/packages/credentials/deepseek-account/src/index.ts new file mode 100644 index 0000000000..465bbedf03 --- /dev/null +++ b/packages/credentials/deepseek-account/src/index.ts @@ -0,0 +1,106 @@ +/** Account Service Definition shared by platform, API, and model consumers. */ +import { Context, Service } from '@deepseek-ai/cordis' +import type { AccountDetails, AccountView, SignInAttemptId } from './types.ts' +export type { AccountDetails, AccountProfile, AccountWallet, AccountLinks, AccountView, SignInAttemptId, SignInAttemptView, SignInErrorCode } from './types.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + deepseekAccount: DeepSeekAccount + } +} + +/** Host-only credentials for an embedded Platform document; never expose through account RPC. */ +export interface PlatformSession { + readonly origin: string + readonly token: string + /** Optional dist query value selecting the embedded frontend deployment. */ + readonly embeddedPageDist?: string + /** Private deployment headers for native requests; excluded from renderer bootstrap. */ + readonly requestHeaders?: Readonly> +} + +/** Account operations; only Host consumers can obtain a request credential. */ +export abstract class DeepSeekAccount extends Service { + /** @param ctx - context owning this account implementation. */ + constructor(ctx: Context) { super(ctx, 'deepseekAccount') } + /** + * Read stored-account presence and the latest login attempt. + * @returns a snapshot without credentials or PKCE secrets. + */ + abstract getState(): Promise + /** + * Query Platform profile independently of wallet balances. + * @returns profile outcome, or null if signed out or the grant changed during the query. + */ + abstract getProfile(): Promise + /** + * Query Platform recharge and bonus wallet balances independently of profile data. + * @returns balance outcome, or null if signed out or the grant changed during the query. + */ + abstract getBalance(): Promise + /** + * Join an active attempt or start browser authorization. + * @param locale - active UI language for a new attempt; joining retains its original language. + * @param callbackOrigin - browser-accessible loopback HTTP origin, including any SSH local port. + * @param loginSource - initiating UI, used to return from a failed exchange. + * @returns the initial snapshot without waiting for browser approval. + */ + abstract startSignIn(locale: string, callbackOrigin: string, loginSource: 'web' | 'desktop'): Promise + /** + * Cancel only the named attempt; committing attempts settle before returning. + * @param id - attempt identity from this Host. + * @returns state after cancellation or an already-started commit. + */ + abstract cancelSignIn(id: SignInAttemptId): Promise + /** + * Remove the local grant while retaining API keys and tasks; the provider revokes it in the background. + * @returns the signed-out state after local removal; remote failures never restore the grant. + */ + abstract signOut(): Promise + /** + * Subscribe to snapshots including a complete initial state. + * @param signal - subscription lifetime; ending it never cancels login. + * @returns complete snapshots as account state changes. + */ + abstract watch(signal: AbortSignal): AsyncIterable + /** + * Resolve a credential only for the inference origin allowed by the provider. + * @param url - actual request destination or API base URL. + * @returns stored token, or undefined for other origins or a signed-out account. + */ + abstract resolveToken(url: string): Promise + /** + * Read credentials for the configured Platform origin, bound to their issuing environment. + * @returns a Host-only snapshot, or null while signed out. + */ + abstract getPlatformSession(): Promise +} +export default DeepSeekAccount + + +/** Merge Cookie header pairs by case-sensitive name, retaining unrelated cookies. + * @param base - existing request cookies. + * @param override - deployment cookies whose values take precedence. + * @returns one Cookie header with at most one pair per name. + */ +export function mergePlatformCookies(base: string, override: string): string { + const cookies = new Map() + for (const header of [base, override]) { + for (const pair of header.split(';')) { + const separator = pair.indexOf('=') + if (separator < 1) continue + cookies.set(pair.slice(0, separator).trim(), pair.slice(separator + 1).trim()) + } + } + return [...cookies].map(([name, value]) => `${name}=${value}`).join('; ') +} + +/** + * Identify native desktop API requests; null leaves non-desktop requests unchanged. + * @param platform - Operating system supplied by the desktop composition. + * @returns Platform request headers shared by account and update-policy clients. + */ +export function desktopClientHeaders(platform: 'darwin' | 'win32' | null): Record { + if (platform === null) return {} + return { 'x-client-platform': platform === 'win32' ? 'desktop-win' : 'desktop-mac' } +} diff --git a/packages/credentials/deepseek-account/src/types.ts b/packages/credentials/deepseek-account/src/types.ts new file mode 100644 index 0000000000..f59c959567 --- /dev/null +++ b/packages/credentials/deepseek-account/src/types.ts @@ -0,0 +1,47 @@ +/** Client-safe account state; credentials never cross this projection. */ +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Identity of one local login attempt, unrelated to the platform request ID. */ +export type SignInAttemptId = Branded<'SignInAttemptId'> +/** Safe failure codes rendered through the caller's locale dictionary. */ +export type SignInErrorCode = 'network' | 'protocol' | 'expired' | 'storage' +/** Latest login attempt, including terminal outcomes until the next attempt. */ +export interface SignInAttemptView { + readonly id: SignInAttemptId + readonly phase: 'initializing' | 'waiting-browser' | 'exchanging' | 'committing' | 'succeeded' | 'cancelled' | 'expired' | 'failed' + readonly authorizeUrl?: string + readonly expiresAt?: number + readonly errorCode?: SignInErrorCode +} +/** Browser destinations derived from the Host's platform configuration; never carry tokens. */ +export interface AccountLinks { + readonly usageUrl: string + readonly topUpUrl: string +} +/** Stored-account presence is not a claim that the server has validated its token. */ +export interface AccountView { + readonly status: 'signed-out' | 'credential-stored' + readonly links: AccountLinks + readonly attempt: SignInAttemptView | null +} + +/** Platform account identifier supplied by the current-user endpoint. */ +export type AccountUserId = Branded<'AccountUserId'> +/** Display identity and support UID; contacts retain Platform masking. */ +export interface AccountProfile { + readonly id: AccountUserId | null + readonly name: string | null + readonly contact: string | null + /** Profile image URL supplied by Platform, absent when no picture is configured. */ + readonly avatarUrl?: string | null +} +/** Platform recharge or bonus wallet balance; decimal strings retain server precision. */ +export interface AccountWallet { + readonly currency: 'CNY' | 'USD' + readonly balance: string +} +/** Independent query outcomes allow profile and balance failures to render separately. */ +export interface AccountDetails { + readonly profile: { readonly status: 'ready'; readonly value: AccountProfile } | { readonly status: 'failed' } + readonly balance: { readonly status: 'ready'; readonly value: readonly AccountWallet[]; readonly bonusWallets: readonly AccountWallet[] } | { readonly status: 'failed' } +} diff --git a/packages/credentials/deepseek-account/tsconfig.json b/packages/credentials/deepseek-account/tsconfig.json new file mode 100644 index 0000000000..342f636170 --- /dev/null +++ b/packages/credentials/deepseek-account/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + } + ] +} diff --git a/packages/document/office-to-pdf/README.i18n.yaml b/packages/document/office-to-pdf/README.i18n.yaml index 482d1cb2a2..b298d219f5 100644 --- a/packages/document/office-to-pdf/README.i18n.yaml +++ b/packages/document/office-to-pdf/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/document/office-to-pdf/README.md -README.md: e6017e121566b248026e9c8f9573afa1d402fd75 -README.zh.md: b00f9c7dbacf72bd9beb71c644970fcc0bcf0528 +README.md: 6655ccff204e33848589468326fc8df7e4d1a07b +README.zh.md: e3aa43a42780378439812874a8eaf178e9c153b9 diff --git a/packages/document/office-to-pdf/README.md b/packages/document/office-to-pdf/README.md index e6017e1215..6655ccff20 100644 --- a/packages/document/office-to-pdf/README.md +++ b/packages/document/office-to-pdf/README.md @@ -31,7 +31,7 @@ Callers submit authorized source identity, version, optional byte size, a deferr The provider depends on the independently published [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API at kit version `0.0.1`. Application packaging selects the matching native package declared in the kit’s `optionalDependencies`, or WASM when no native package is declared for that target. A missing declared native engine rejects packaging without selecting WASM. The [platform engine decision](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md) defines installation and packaging; the [release ownership decision](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) defines the independent kit and Harness responsibilities. -Browsers request PDFs through the `officeToPdf.render` Remote method with a Session identity, Office path, and priority. This entry uses `workspaceFiles` for authorization and source versions, then reads raw bytes through `fs.readBytes` within the conversion reservation. In-process `convert()` does not require those services. Responses retain the source path and version and carry base64 PDF bytes, missing fonts, and conversion generation. The `officeToPdf.generation` Remote method returns the current provider generation; `api/remotes` mounts the generated Client descriptor. +Browsers request PDFs through the `officeToPdf.render` Remote method with a Session identity, Office path, and priority. This entry uses `workspaceFiles` for authorization and source versions, then reads raw bytes through `fs.readBytes` within the conversion reservation. In-process `convert()` does not require those services. Responses retain the source path and version and carry native PDF bytes through the binary Remote multipart transport, plus missing fonts and conversion generation. The `officeToPdf.generation` Remote method returns the current provider generation; `api/remotes` mounts the generated Client descriptor. | Field | Default | Meaning | |---|---|---| @@ -50,7 +50,7 @@ The provider retains successful PDFs by converter generation, Office extension, Admission bounds queued metadata, outstanding readers, active source-byte reservations, and conversions before invoking a source read. Unknown source sizes reserve `maxInputBytes`; known sizes reserve their stat size. Reads receive that capacity and may read one overflow sentinel byte. `maxSourceBytes` must cover `maxInputBytes`. The final reader allowance is reserved for foreground work. Setting `maxBackgroundConversions` to zero rejects background joins to queued and running work; completed alias hits remain available. Background jobs wait while any foreground job is queued, including when it awaits source capacity. Foreground joins promote queued prewarming; when its last foreground reader leaves, the queued job returns to background priority and eligible work can start immediately. Foreground admission can evict queued speculation. Background concurrency leaves a foreground slot when total concurrency exceeds one. A running prewarm keeps its background admission slot until settlement, even after promotion. The final reader cancels shared work. Removing a queued foreground blocker immediately admits other eligible work; active reservations remain held until actual read/conversion cleanup settles. -The defaults retain 8 PDFs/128 MiB and 64 source aliases, admit 32 readers and 8 queued jobs, reserve at most 100 MiB of source bytes, and allow one background conversion. These limits bound owned requests and binary payloads, not engine RSS, base64 transport expansion, caller-retained results, or PDF.js page rendering. +The defaults retain 8 PDFs/128 MiB and 64 source aliases, admit 32 readers and 8 queued jobs, reserve at most 100 MiB of source bytes, and allow one background conversion. These limits bound owned requests and binary payloads, not engine RSS, multipart framing, caller-retained results, or PDF.js page rendering. ----- diff --git a/packages/document/office-to-pdf/README.zh.md b/packages/document/office-to-pdf/README.zh.md index b00f9c7dba..e3aa43a427 100644 --- a/packages/document/office-to-pdf/README.zh.md +++ b/packages/document/office-to-pdf/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" 此 provider 依赖独立发布的 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API,kit 版本为 `0.0.1`。应用打包选择 kit 的 `optionalDependencies` 中声明的匹配原生包;目标没有声明原生包时选择 WASM。已声明的原生引擎缺失时拒绝打包,不会选择 WASM。[平台引擎决策](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装与打包策略;[发布归属决策](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义独立 kit 与 Harness 各自的职责。 -浏览器通过 `officeToPdf.render` Remote 方法请求 PDF,参数为 Session 标识、Office 路径和优先级。此入口使用 `workspaceFiles` 完成授权和源版本检查,再通过 `fs.readBytes` 在转换预留容量内读取原始字节。进程内 `convert()` 不要求这些服务。响应保留源文件路径与版本,携带 base64 PDF、缺失字体和转换 generation。`officeToPdf.generation` Remote 方法返回当前提供方 generation;`api/remotes` 负责挂载生成的 Client 描述符。 +浏览器通过 `officeToPdf.render` Remote 方法请求 PDF,参数为 Session 标识、Office 路径和优先级。此入口使用 `workspaceFiles` 完成授权和源版本检查,再通过 `fs.readBytes` 在转换预留容量内读取原始字节。进程内 `convert()` 不要求这些服务。响应保留源文件路径与版本,通过二进制 Remote 的 multipart 传输携带原生 PDF 字节,并携带缺失字体和转换 generation。`officeToPdf.generation` Remote 方法返回当前提供方 generation;`api/remotes` 负责挂载生成的 Client 描述符。 | 字段 | 默认值 | 含义 | |---|---|---| @@ -50,7 +50,7 @@ kind: "package-reference" 准入在调用源读取前限制排队元数据、未完成读取方、活动源字节预留和转换。未知源大小预留 `maxInputBytes`;已知大小预留 stat 字节数。读取收到该容量,最多额外读取一个超限哨兵字节。`maxSourceBytes` 必须覆盖 `maxInputBytes`。最后一个读取方额度预留给前台。`maxBackgroundConversions` 设为零时,拒绝后台读取方加入排队或运行中的工作;仍可命中已完成的别名缓存。只要仍有前台任务排队,后台任务就继续等待,包括前台正在等待源容量的情况。前台加入会提升排队预热;最后一个前台读取方离开后,排队任务恢复后台优先级,符合条件的工作可立即开始。前台准入也可移除排队推测工作。总并发大于一时,后台并发为前台保留一个槽位。正在运行的预热即使被提权,也保留后台准入槽位直至结束。最后一个读取方取消共享工作。移除排队的前台阻塞任务后,其他符合条件的工作立即准入;实际读取、转换和清理完成前仍保留活动预留容量。 -默认保留 8 个 PDF、128 MiB 和 64 个源别名,准入 32 个读取方与 8 个排队任务,最多预留 100 MiB 源字节,并允许一个后台转换。这些限制约束拥有的请求和二进制载荷,不约束引擎 RSS、base64 传输膨胀、调用方保留的结果或 PDF.js 页面渲染。 +默认保留 8 个 PDF、128 MiB 和 64 个源别名,准入 32 个读取方与 8 个排队任务,最多预留 100 MiB 源字节,并允许一个后台转换。这些限制约束拥有的请求和二进制载荷,不约束引擎 RSS、multipart 封装、调用方保留的结果或 PDF.js 页面渲染。 ----- diff --git a/packages/document/office-to-pdf/src/index.ts b/packages/document/office-to-pdf/src/index.ts index f8d3b993c9..8131f08fcf 100644 --- a/packages/document/office-to-pdf/src/index.ts +++ b/packages/document/office-to-pdf/src/index.ts @@ -154,7 +154,7 @@ export class OfficeToPdf extends TypertRemoteService { * @param path - absolute or workspace-relative Office path. * @param priority - foreground preview or speculative background work. * @param signal - Remote cancellation; disposal also cancels outstanding reads and conversions. - * @returns complete base64 PDF with original source identity and missing font families. + * @returns complete PDF bytes with original source identity and missing font families. */ @Remote async render( @@ -219,7 +219,8 @@ export class OfficeToPdf extends TypertRemoteService { } }, signal) signal.throwIfAborted() return { absolutePath: source.absolutePath, version: source.version, - offset: 0, eof: true, bytes: result.pdf.byteLength, data: Buffer.from(result.pdf).toString('base64'), missingFonts: result.missingFonts, generation: result.generation } + offset: 0, eof: true, bytes: result.pdf.byteLength, data: result.pdf, + missingFonts: result.missingFonts, generation: result.generation } } catch (cause) { if (signal.aborted) throw new RemoteError('gateway/cancelled', 'The document preview was cancelled.', {}, { cause }) if (cause instanceof OfficeToPdfError) { diff --git a/packages/document/office-to-pdf/src/types.ts b/packages/document/office-to-pdf/src/types.ts index 5138dad52c..d5f6f438db 100644 --- a/packages/document/office-to-pdf/src/types.ts +++ b/packages/document/office-to-pdf/src/types.ts @@ -44,10 +44,8 @@ export type OfficeToPdfErrorCode = | 'input-too-large' | 'output-too-large' | 'invalid-document' | 'unsupported-format' | 'invalid-output' | 'timeout' | 'unavailable' | 'failed' | 'busy' | 'source-changed' -/** PDF content carries the original Office file's absolute path and version. */ -export interface RenderedDocumentBytes extends Omit { - /** Base64 PDF contents carried by the JSON Remote response. */ - readonly data: string +/** PDF content carries the original Office file's identity and multipart-transferred bytes. */ +export interface RenderedDocumentBytes extends WorkspaceFileBytes { readonly missingFonts: string[] readonly generation: OfficeToPdfGeneration } diff --git a/packages/document/office-to-pdf/tests/remote.spec.ts b/packages/document/office-to-pdf/tests/remote.spec.ts index 4029f3ea74..2f12444248 100644 --- a/packages/document/office-to-pdf/tests/remote.spec.ts +++ b/packages/document/office-to-pdf/tests/remote.spec.ts @@ -11,7 +11,7 @@ import OfficeToPdf from '../src/index.ts' const scope = { sessionId: SessionId('document-test'), workspaceRoot: '/workspace' } const source = { absolutePath: '/workspace/report.DOCX', version: 'source-v1', bytes: 4 } const rawSource = { ...source, data: new Uint8Array([80, 75, 3, 4]) } -const wireSource = { ...source, offset: 0, eof: true, data: 'UEsDBA==' } +const wireSource = { ...source, offset: 0, eof: true, data: rawSource.data } let generation: OfficeToPdf['generation'] const cacheKey = OfficeToPdfKey('test-result') const pdf = new Uint8Array([37, 80, 68, 70, 45]) @@ -51,7 +51,7 @@ it.each(['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'])('converts authorized %s b expect(authorize).toHaveBeenCalledExactlyOnceWith(scope, path, { range: { offset: 0, length: 1 } }, expect.any(AbortSignal)) expect(render).toHaveBeenCalledOnce() expect(render.mock.calls[0]?.[0]).toMatchObject({ extension, priority: 'foreground', source: { version: source.version, bytes: 4 } }) - expect(result).toEqual({ ...wireSource, data: Buffer.from(pdf).toString('base64'), bytes: pdf.length, missingFonts: ['Missing Serif'], generation }) + expect(result).toEqual({ ...wireSource, data: pdf, bytes: pdf.length, missingFonts: ['Missing Serif'], generation }) expect(ctx.get('agents')).toBeUndefined() }) diff --git a/packages/experimental/api-speech-to-text/tests/controller.spec.ts b/packages/experimental/api-speech-to-text/tests/controller.spec.ts index 6853c38cc6..0cf59f5449 100644 --- a/packages/experimental/api-speech-to-text/tests/controller.spec.ts +++ b/packages/experimental/api-speech-to-text/tests/controller.spec.ts @@ -10,7 +10,7 @@ afterEach(async () => { await Promise.all(roots.splice(0).map(root => root.fiber const id = 'sensevoice-local' as SpeechProviderId function fixture(maxAudioBytes = 32044, maxDurationSeconds = 1) { const ctx = new Context(); roots.push(ctx) - const speech = new SpeechToText(ctx, { defaultProvider: id, language: 'auto' }) + const speech = new SpeechToText(ctx, SpeechToText.Config({ defaultProvider: id, language: 'auto' })) const recognize = vi.fn(async () => ({ text: '你好', audioSeconds: 1, inferenceSeconds: 0.1 })) speech.register({ info: { id, name: 'test', location: 'host-local', languages: ['auto', 'zh', 'en', 'ja'] }, transcribe: recognize }) return { api: new SpeechController(ctx, { maxAudioBytes, maxDurationSeconds }), recognize } @@ -53,7 +53,7 @@ it('rejects oversized, noncanonical, malformed and overlong audio without infere it('preserves cancellation and reports recognizer failures', async () => { const { api, recognize } = fixture() - await expect(api.configure({ language: 'zh' })).rejects.toThrow('user-settings') + await expect(api.configure({ language: 'zh' })).rejects.toThrow('settings service') await expect(api.transcribe({ audioBase64: recording() }, AbortSignal.abort(new Error('cancel')))).rejects.toThrow('cancel') recognize.mockRejectedValueOnce(new Error('offline')) await expect(api.transcribe({ audioBase64: recording() }, new AbortController().signal)).rejects.toMatchObject({ code: 'speech/transcription-failed', message: 'offline' }) diff --git a/packages/experimental/auto-review/tests/auto-review.spec.ts b/packages/experimental/auto-review/tests/auto-review.spec.ts index 6007f789b3..47f32742b3 100644 --- a/packages/experimental/auto-review/tests/auto-review.spec.ts +++ b/packages/experimental/auto-review/tests/auto-review.spec.ts @@ -128,7 +128,7 @@ function reasoningDecisionChunks(text: string): StreamChunk[] { async function harness( script: ReviewScript[], - permissionConfig: PermissionConfig = { presets: PRESETS, defaultPreset: 'workspace-write' }, + permissionConfig: NonNullable[0]> = { presets: PRESETS, defaultPreset: 'workspace-write' }, ): Promise<{ ctx: Context; adapter: RecordingAdapter; auto: PluginFiber }> { const ctx = new Context() contexts.push(ctx) diff --git a/packages/experimental/client-ui-agent-team/tsconfig.json b/packages/experimental/client-ui-agent-team/tsconfig.json index c26bb4b7b6..188f578e84 100644 --- a/packages/experimental/client-ui-agent-team/tsconfig.json +++ b/packages/experimental/client-ui-agent-team/tsconfig.json @@ -10,8 +10,8 @@ { "path": "../agent-team" }, { "path": "../../api/remotes/tsconfig.client.json" }, { "path": "../../api/session-controller/tsconfig.client.json" }, - { "path": "../../client/locale" }, - { "path": "../../client/ui-conversation" }, + { "path": "../../client/locale/tsconfig.client.json" }, + { "path": "../../client/ui-conversation/tsconfig.client.json" }, { "path": "../../client/ui-primitives" }, { "path": "../../client/ui-renderer" }, { "path": "../../client/ui-session" }, diff --git a/packages/experimental/client-ui-voice-input/tsconfig.json b/packages/experimental/client-ui-voice-input/tsconfig.json index e95353edd9..5278c5d429 100644 --- a/packages/experimental/client-ui-voice-input/tsconfig.json +++ b/packages/experimental/client-ui-voice-input/tsconfig.json @@ -18,10 +18,10 @@ "path": "../../api/session-controller/tsconfig.client.json" }, { - "path": "../../client/locale" + "path": "../../client/locale/tsconfig.client.json" }, { - "path": "../../client/ui-conversation" + "path": "../../client/ui-conversation/tsconfig.client.json" }, { "path": "../../client/ui-primitives" diff --git a/packages/experimental/speech-to-text/README.i18n.yaml b/packages/experimental/speech-to-text/README.i18n.yaml index 6d5068c1ff..2aa9341cd0 100644 --- a/packages/experimental/speech-to-text/README.i18n.yaml +++ b/packages/experimental/speech-to-text/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/speech-to-text/README.md -README.md: 6b2481590c2e1ee7c02401ca0838ab62d4a64b86 -README.zh.md: 46436194dd2350f85fc652b31377fad2f0d79348 +README.md: 64202aba4ae7e0ffc47c86243d818caa8f85a9e4 +README.zh.md: 38466f18a0670a252958c6b51c17457fcf80f14c diff --git a/packages/experimental/speech-to-text/README.md b/packages/experimental/speech-to-text/README.md index 6b2481590c..64202aba4a 100644 --- a/packages/experimental/speech-to-text/README.md +++ b/packages/experimental/speech-to-text/README.md @@ -37,7 +37,7 @@ Load through the [voice input bundle](../voice-input-bundle/README.md), or compo `resolve()` captures the provider instance, recording and language. `transcribe()` rejects a withdrawn or replaced registration. A registration disposer closes admission, aborts accepted requests and joins provider settlement; providers must honor cancellation. No fallback selects a different recognizer or uploads audio. No runtime invariant companion is published because the registry is the sole source of provider and preparation observations. -When `settings` is present, the `voice-input` namespace persists provider and language choices; composition defaults apply before an override is saved. `configure()` updates only supplied fields and rejects a language unsupported by the selected provider before saving. Providers advertise accepted language hints through `languages`; `resolve()` validates the selected hint before transcription. Provider-owned preparation is observed through complete `SpeechSnapshot` values; closing an observer never cancels preparation. +`defaultProvider` and `language` are volatile Config fields: `configure()` writes the supplied fields into this plugin's profile entry through the `settings` service, and the running instance reads the updated values without remounting; composition defaults apply until an override is saved. `configure()` fails without `settings` or a profile entry, and rejects a language unsupported by the selected provider before saving. Providers advertise accepted language hints through `languages`; `resolve()` validates the selected hint before transcription. Provider-owned preparation is observed through complete `SpeechSnapshot` values; closing an observer never cancels preparation. The `./wave` helper validates canonical 16 kHz mono PCM16 WAV for the Remote consumer and the native recognition process. Both reject inconsistent headers and lengths before decoding samples. diff --git a/packages/experimental/speech-to-text/README.zh.md b/packages/experimental/speech-to-text/README.zh.md index 46436194dd..38466f18a0 100644 --- a/packages/experimental/speech-to-text/README.zh.md +++ b/packages/experimental/speech-to-text/README.zh.md @@ -37,7 +37,7 @@ kind: "package-reference" `resolve()` 捕获 Provider 实例、录音和语言。`transcribe()` 拒绝已撤销或替换的注册。注册的清理函数先拒绝新请求,再取消已接收的请求并等待 Provider 完成;Provider 必须响应取消。服务不会回退到其他识别器或上传音频。不发布运行时不变量伴随模块,因为注册表是 Provider 与准备状态观测的唯一来源。 -存在 `settings` 时,`voice-input` 命名空间持久化 Provider 与语言选择;保存覆盖值之前使用组合配置默认值。`configure()` 只更新传入的字段,并在保存前拒绝所选 Provider 不支持的语言。Provider 通过 `languages` 公布支持的语言提示;`resolve()` 在转写前校验所选提示。观察者通过完整 `SpeechSnapshot` 接收 Provider 拥有的准备状态;关闭观察者不会取消准备。 +`defaultProvider` 与 `language` 是 volatile Config 字段:`configure()` 通过 `settings` 服务把传入的字段写入本插件的 profile 条目,运行中的实例无需重挂载即可读到更新值;保存覆盖值之前使用组合配置默认值。没有 `settings` 或 profile 条目时 `configure()` 失败,并在保存前拒绝所选 Provider 不支持的语言。Provider 通过 `languages` 公布支持的语言提示;`resolve()` 在转写前校验所选提示。观察者通过完整 `SpeechSnapshot` 接收 Provider 拥有的准备状态;关闭观察者不会取消准备。 `./wave` 辅助函数为 Remote 消费者与原生识别进程校验规范的 16 kHz 单声道 PCM16 WAV。两者均在解码采样前拒绝不一致的头部和长度。 diff --git a/packages/experimental/speech-to-text/package.json b/packages/experimental/speech-to-text/package.json index b3a54a60bf..19cc4b0bde 100644 --- a/packages/experimental/speech-to-text/package.json +++ b/packages/experimental/speech-to-text/package.json @@ -41,10 +41,17 @@ }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^" + }, + "peerDependenciesMeta": { + "@deepseek-ai/cordis-plugin-loader": { + "optional": true + } } } diff --git a/packages/experimental/speech-to-text/src/index.ts b/packages/experimental/speech-to-text/src/index.ts index d265d73b2d..b0db14b83a 100644 --- a/packages/experimental/speech-to-text/src/index.ts +++ b/packages/experimental/speech-to-text/src/index.ts @@ -1,8 +1,10 @@ /** Named transcription providers with disposable registration and explicit routing. */ -import { Context, Service } from '@deepseek-ai/cordis' +import { Context, Service, type Volatile } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import type { SettingsScope } from '@deepseek-ai/dsh-settings' +// Type-only: the `settings` service that persists `configure()` into this plugin's profile entry, and the Loader's +// entry and `loader/volatile-update` merges. import type {} from '@deepseek-ai/dsh-settings' +import type {} from '@deepseek-ai/cordis-plugin-loader' import type { SpeechProvider, SpeechProviderId, SpeechProviderInfo, SpeechSnapshot, SpeechSelectionPatch, SpeechRequest, SpeechSpec, Transcript } from './types.ts' export type * from './types.ts' @@ -14,12 +16,12 @@ declare module '@deepseek-ai/cordis' { } } -/** Composition defaults resolved before a transcription starts. */ +/** Live selection read before a transcription starts; `configure()` writes it through the profile. */ export interface Config { /** Registered provider selected when the caller omits an id. */ - defaultProvider: string + defaultProvider: Volatile /** Provider language hint selected when the caller omits one. */ - language: string + language: Volatile } interface Registration { @@ -31,25 +33,21 @@ interface Registration { /** Registry shared by all transcription consumers in one Host composition. */ export default class SpeechToText extends Service { - static Config: z = z.object({ - defaultProvider: z.string().min(1).required(), - language: z.string().min(1).default('auto'), + static Config = z.object({ + defaultProvider: z.string().min(1).required().volatile(), + language: z.string().min(1).default('auto').volatile(), }) private readonly providers = new Map() private readonly listeners = new Set<() => void>() private readonly lifetime = new AbortController() - private preferences: SettingsScope | undefined + /** Profile entry `configure()` writes to; absent when the plugin was mounted without Loader. */ + private readonly entryId: string | undefined constructor(ctx: Context, private readonly config: Config) { super(ctx, 'speechToText') - ctx.inject(['settings'], (settingsCtx) => { - const preferences = settingsCtx.settings.register('voice-input', SpeechToText.Config, { base: config }) - this.preferences = preferences - settingsCtx.effect(() => preferences.watch(() => { this.changed() })) - settingsCtx.effect(() => () => { this.preferences = undefined; this.changed() }) - this.changed() - }) + this.entryId = ctx.fiber.entry?.id + ctx.on('loader/volatile-update', () => { this.changed() }) ctx.effect(() => async () => { this.lifetime.abort(new Error('Speech service disposed')) await Promise.all([...this.providers.values()].map(registration => this.remove(registration))) @@ -123,23 +121,23 @@ export default class SpeechToText extends Service { * @returns one detached complete observation. */ snapshot(): SpeechSnapshot { - const config = this.preferences?.get() ?? this.config return { providers: [...this.providers.values()].map(({ provider }) => ({ ...provider.info, preparation: provider.preparation?.snapshot() ?? { phase: 'ready' }, - })), selection: { providerId: config.defaultProvider as SpeechProviderId, language: config.language } } + })), selection: { providerId: this.config.defaultProvider.get() as SpeechProviderId, language: this.config.language.get() } } } /** - * Persist changed preference fields; the resulting language must be accepted by the selected provider. + * Persist changed selection fields into this plugin's profile entry; the resulting language must be accepted by the selected provider. * @param patch - explicit provider or language changes. - * @returns after persistence and the resolved preference update. + * @returns after the profile write and the live update it applies. */ async configure(patch: SpeechSelectionPatch): Promise { - if (!this.preferences) throw new Error('Speech preferences require the user-settings service') - const current = this.preferences.get() - const id = patch.providerId ?? current.defaultProvider as SpeechProviderId - this.selectedProvider(id, patch.language ?? current.language) - await this.preferences.update({ ...patch.providerId === undefined ? {} : { defaultProvider: patch.providerId }, + const settings = this.ctx.get('settings') + const entry = this.entryId + if (settings === undefined || entry === undefined) throw new Error('Speech selection requires the settings service and a profile entry') + const id = patch.providerId ?? this.config.defaultProvider.get() as SpeechProviderId + this.selectedProvider(id, patch.language ?? this.config.language.get()) + await settings.update(entry, { ...patch.providerId === undefined ? {} : { defaultProvider: patch.providerId }, ...patch.language === undefined ? {} : { language: patch.language } }) } @@ -179,9 +177,8 @@ export default class SpeechToText extends Service { * @returns provider-pinned input for transcribe(). */ resolve(request: SpeechRequest): SpeechSpec { - const config = this.preferences?.get() ?? this.config - const id = request.providerId ?? config.defaultProvider as SpeechProviderId - const language = request.language ?? config.language + const id = request.providerId ?? this.config.defaultProvider.get() as SpeechProviderId + const language = request.language ?? this.config.language.get() return { provider: this.selectedProvider(id, language), audio: request.audio, language } } diff --git a/packages/experimental/speech-to-text/tests/service.spec.ts b/packages/experimental/speech-to-text/tests/service.spec.ts index 6ab724a9a5..60dfe50bd1 100644 --- a/packages/experimental/speech-to-text/tests/service.spec.ts +++ b/packages/experimental/speech-to-text/tests/service.spec.ts @@ -4,7 +4,7 @@ import z from '@deepseek-ai/schemastery' import { describe, expect, it, vi } from 'vitest' import SpeechToText from '../src/index.ts' import type { SpeechPreparationState, SpeechProvider, SpeechProviderId, Transcript } from '../src/types.ts' -import { MemorySettings } from '../../../settings/settings/tests/memory.ts' +import { liveConfig } from '../../../settings/settings/tests/live-config.ts' const result: Transcript = { text: 'hello', audioSeconds: 1, inferenceSeconds: 0.1 } const audio = new Uint8Array([1, 2]) @@ -13,6 +13,14 @@ function provider(id: string, transcribe: SpeechProvider['transcribe'] = async ( return { info: { id: id as SpeechProviderId, name: id, location: 'host-local', languages: ['auto', 'zh', 'en', 'ja'] }, transcribe } } +/** Mount the service behind Loader with a settings stub that writes patches back into its live entry config. */ +async function mounted(ctx: Context, config: object) { + const live = await liveConfig(ctx, SpeechToText, config) + const writes: object[] = [] + ctx.provide('settings', { update: async (_entry: string, patch: Record) => { writes.push(patch); await live.update(patch) } } as never) + return { speech: ctx.get('speechToText')!, writes, live } +} + it('shares Host preparation across observers and does not cancel work when observation ends', async () => { const ctx = new Context(), base = ctx.plugin(SpeechToText, { defaultProvider: 'sensevoice-local', language: 'auto' }) await base @@ -43,25 +51,36 @@ it('shares Host preparation across observers and does not cancel work when obser } finally { await base.dispose() } }) -it('persists explicit provider and language changes and publishes them with readiness', async () => { +it('persists explicit provider and language changes through the profile and publishes them with readiness', async () => { const ctx = new Context() - const settings = ctx.plugin(MemorySettings) - await settings - const base = ctx.plugin(SpeechToText, { defaultProvider: 'sensevoice-local', language: 'auto' }) - await base try { - const speech = ctx.get('speechToText')! + const { speech, writes } = await mounted(ctx, { defaultProvider: 'sensevoice-local', language: 'auto' }) speech.register(provider('sensevoice-local')); speech.register(provider('cloud')) + const stream = speech.follow(new AbortController().signal)[Symbol.asyncIterator]() + expect((await stream.next()).value).toMatchObject({ selection: { providerId: 'sensevoice-local', language: 'auto' } }) + const next = stream.next() await speech.configure({ providerId: 'cloud' as SpeechProviderId }) + expect((await next).value).toMatchObject({ selection: { providerId: 'cloud', language: 'auto' } }) await speech.configure({ language: 'en' }) expect(speech.snapshot().selection).toEqual({ providerId: 'cloud', language: 'en' }) expect(speech.resolve({ audio })).toMatchObject({ provider: { info: { id: 'cloud' } }, language: 'en' }) - expect((ctx.get('settings') as MemorySettings).doc['voice-input']).toEqual({ defaultProvider: 'cloud', language: 'en' }) + expect(writes).toEqual([{ defaultProvider: 'cloud' }, { language: 'en' }]) await expect(speech.configure({ providerId: 'missing' as SpeechProviderId })).rejects.toThrow('unavailable') await expect(speech.configure({ language: '' })).rejects.toThrow() - await settings.dispose() - expect(speech.snapshot().selection).toEqual({ providerId: 'sensevoice-local', language: 'auto' }) - await expect(speech.configure({ language: 'zh' })).rejects.toThrow('user-settings') + expect(speech.snapshot().selection).toEqual({ providerId: 'cloud', language: 'en' }) + } finally { await ctx.fiber.dispose() } +}) + +it('refuses to configure without the settings service or a profile entry', async () => { + const ctx = new Context() + try { + await ctx.plugin(SpeechToText, { defaultProvider: 'local', language: 'auto' }) + const speech = ctx.get('speechToText')! + speech.register(provider('local')) + await expect(speech.configure({ language: 'zh' })).rejects.toThrow('settings service') + ctx.provide('settings', { update: vi.fn() } as never) + await expect(speech.configure({ language: 'zh' })).rejects.toThrow('profile entry') + expect(speech.snapshot().selection).toEqual({ providerId: 'local', language: 'auto' }) } finally { await ctx.fiber.dispose() } }) @@ -155,15 +174,14 @@ describe('speech providers', () => { it('rejects unsupported language selections before persistence or transcription', async () => { const ctx = new Context() try { - await ctx.plugin(MemorySettings) - await ctx.plugin(SpeechToText, { defaultProvider: 'local', language: 'auto' }) - const speech = ctx.get('speechToText')!, transcribe = vi.fn(async () => result) + const { speech, writes } = await mounted(ctx, { defaultProvider: 'local', language: 'auto' }) + const transcribe = vi.fn(async () => result) speech.register(provider('local', transcribe)) const cloud = provider('cloud', transcribe) speech.register({ ...cloud, info: { ...cloud.info, languages: ['fr'] } }) await expect(speech.configure({ language: 'fr' })).rejects.toThrow('does not support language') await expect(speech.configure({ providerId: cloud.info.id })).rejects.toThrow('does not support language') - expect((ctx.get('settings') as MemorySettings).doc['voice-input']).toBeUndefined() + expect(writes).toEqual([]) expect(() => speech.resolve({ audio, language: 'fr' })).toThrow('does not support language') expect(transcribe).not.toHaveBeenCalled() await speech.configure({ providerId: cloud.info.id, language: 'fr' }) @@ -173,22 +191,20 @@ it('rejects unsupported language selections before persistence or transcription' it('requires the composition to choose its default provider', () => { expect(() => z.resolve({}, SpeechToText.Config, {})).toThrow() - expect(SpeechToText.Config({ defaultProvider: 'custom', language: 'auto' })).toEqual({ defaultProvider: 'custom', language: 'auto' }) + const resolved = SpeechToText.Config({ defaultProvider: 'custom', language: 'auto' }) + expect([resolved.defaultProvider.get(), resolved.language.get()]).toEqual(['custom', 'auto']) }) -it('ends observers without fallback settings when the service unloads with Settings mounted', async () => { +it('ends observers when the service unloads after a persisted selection', async () => { const ctx = new Context() try { - await ctx.plugin(MemorySettings) - const base = ctx.plugin(SpeechToText, { defaultProvider: 'local', language: 'auto' }) - await base - const speech = ctx.get('speechToText')! + const { speech, live } = await mounted(ctx, { defaultProvider: 'local', language: 'auto' }) speech.register(provider('local')) await speech.configure({ language: 'zh' }) const stream = speech.follow(new AbortController().signal)[Symbol.asyncIterator]() expect((await stream.next()).value).toMatchObject({ selection: { language: 'zh' } }) const waiting = stream.next() - await base.dispose() + await live.fiber.dispose() expect(await waiting).toEqual({ done: true, value: undefined }) } finally { await ctx.fiber.dispose() } }) diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml index 6772e504c2..2e7f6e39ae 100644 --- a/packages/experimental/webworker-runtime/README.i18n.yaml +++ b/packages/experimental/webworker-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/webworker-runtime/README.md -README.md: b3a474a3cd700289497283d617dc4f502c0be662 -README.zh.md: 94f3f82f204058084ac27b7f9fdc350d0edb519c +README.md: 3a5d702c2163c19daa3230ffd14270b4f236f2d9 +README.zh.md: a987fed1db5f607aee4e95e2faf96575c1d0c904 diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md index b3a474a3cd..3a5d702c21 100644 --- a/packages/experimental/webworker-runtime/README.md +++ b/packages/experimental/webworker-runtime/README.md @@ -27,8 +27,8 @@ The public npm package exposes the runtime library at its root, the self-contain Three build artifacts and one source-owned process layer: -- **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the base image and any ordered data overlays (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. Overlays may replace files only under `home/` and `workspace/`; they cannot replace the base manifest, configuration, or modules. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches select plaintext JSONL session logs. Preset declarations are part of the composed configuration. -- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. The `node:util` JSON-data comparator compares own enumerable properties, preserving Node admission decisions for recorded fields such as `__proto__`. The global `process` shim carries Node detection fields including `title`, preventing Worker execution from entering DOM-only branches. The pack-time parser reports statically named module requests, including module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named `node:module` or `module` import, to the packer's reachability walk. Stored, CommonJS-obtained, and rebased `createRequire` calls require image entry seeds. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink, `FileHandle.stat({ bigint: true })` reports the same device and inode identity as a path stat while the name still refers to that file, and `FileHandle.chmod()` updates the opened file identity; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount. The callback `fs.realpath.native` alias resolves within the mounted VFS and supports `util.promisify`. +- **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the base image and any ordered data overlays (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. Overlays may replace files only under `home/` and `workspace/`; they cannot replace the base manifest, configuration, or modules. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. The Worker supplies `profileContext` for an editable profile at `home/profiles/preview/`: the packed rows seed its insertion patch, and ConfigEditor persists overrides in that VFS patch. ConfigEditor reconciles each write and Loader commits live values; package management and Node module HMR stay disabled. Deployment overlays keep JSONL session logs plaintext and preset roots at the image's `config/agent-presets`. Configuration edits last for the current Worker lifetime. +- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. The `node:util` JSON-data comparator compares own enumerable properties, preserving Node admission decisions for recorded fields such as `__proto__`. The global `process` shim carries Node detection fields including `title`, preventing Worker execution from entering DOM-only branches. The pack-time parser reports statically named module requests, including module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named `node:module` or `module` import, to the packer's reachability walk. Stored, CommonJS-obtained, and rebased `createRequire` calls require image entry seeds. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink, `FileHandle.stat({ bigint: true })` reports the same device and inode identity as a path stat while the name still refers to that file, and `FileHandle.chmod()` updates the opened file identity; `readable-stream` supplies `node:stream/promises` completion and pipeline helpers and the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount. The callback `fs.realpath.native` alias resolves within the mounted VFS and supports `util.promisify`. - **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process. - **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` fetches each combo on first demand, embeds its tunnel-only source map as a Base64 data URL, and executes the script as a Blob. The tunnel also exposes fetch-shaped transport, the independent file-upload carrier, and the API client. Request frames preserve Blob bodies through structured clone and transfer `ReadableStream` ownership. The Host Worker streams both forms into the route, so neither browser thread creates a complete byte array for a generic-file upload. A Remote stream's uplink crosses as `stream-uplink-item` / `stream-uplink-end` frames into an unbounded `TunnelUplink` that drops items arriving after `end`: the page and its worker are one trusted same-origin boundary, so the Host inbox cap does not apply. diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md index 94f3f82f20..a987fed1db 100644 --- a/packages/experimental/webworker-runtime/README.zh.md +++ b/packages/experimental/webworker-runtime/README.zh.md @@ -27,8 +27,8 @@ kind: "package-library" 一条 tsdown 管线产出三个构建产物;另有一层由源码维护的进程实现: -- **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务 seam 交给隧道。Overlay 只能替换 `home/` 与 `workspace/` 下的文件,不能替换基础 manifest、配置或模块。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 选择明文 JSONL 会话日志。预设声明包含在组合后的配置中。 -- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。`node:util` 的 JSON 数据比较器检查可枚举的自有属性,使 `__proto__` 等记录字段的准入判定与 Node 一致。全局 `process` shim 带有包括 `title` 在内的 Node 环境识别字段,避免 Worker 执行误入仅适用于 DOM 的分支。pack 期解析器会把名称静态可知的模块请求报告给 packer 的可达性遍历,其中包括通过 `node:module` 或 `module` 具名导入在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用。保存、经 CommonJS 获取或另设基准的 `createRequire` 调用需要镜像入口种子。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式,只要文件名仍指向该文件,`FileHandle.stat({ bigint: true })` 报告的 device 与 inode 身份就与路径 stat 相同,`FileHandle.chmod()` 则更新打开文件身份的权限;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒。回调式 `fs.realpath.native` 别名在已挂载的 VFS 内解析路径,并支持 `util.promisify`。 +- **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务 seam 交给隧道。Overlay 只能替换 `home/` 与 `workspace/` 下的文件,不能替换基础 manifest、配置或模块。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。Worker 为 `home/profiles/preview/` 下可编辑的 profile 提供 `profileContext`:打包行初始化其插入 patch,ConfigEditor 将覆盖值持久化到该 VFS patch。ConfigEditor 协调每次写入,Loader 提交实时值;包管理和 Node 模块 HMR 保持禁用。部署 overlay 保持 JSONL 会话日志为明文,preset 根指向镜像内 `config/agent-presets`。配置修改在当前 Worker 生命周期内有效。预设声明包含在组合后的配置中。 +- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。`node:util` 的 JSON 数据比较器检查可枚举的自有属性,使 `__proto__` 等记录字段的准入判定与 Node 一致。全局 `process` shim 带有包括 `title` 在内的 Node 环境识别字段,避免 Worker 执行误入仅适用于 DOM 的分支。pack 期解析器会把名称静态可知的模块请求报告给 packer 的可达性遍历,其中包括通过 `node:module` 或 `module` 具名导入在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用。保存、经 CommonJS 获取或另设基准的 `createRequire` 调用需要镜像入口种子。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式,只要文件名仍指向该文件,`FileHandle.stat({ bigint: true })` 报告的 device 与 inode 身份就与路径 stat 相同,`FileHandle.chmod()` 则更新打开文件身份的权限;`readable-stream` 提供 `node:stream/promises` 的完成等待与管道方法,以及文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒。回调式 `fs.realpath.native` 别名在已挂载的 VFS 内解析路径,并支持 `util.promisify`。 - **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。 - **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时获取 combo、把仅 tunnel 可达的 sourcemap 内嵌为 Base64 data URL,再以 Blob 执行脚本。Tunnel 还暴露 fetch 形式的传输、独立文件上传载体和 API 客户端。请求帧通过结构化克隆保留 Blob 请求体,并转移 `ReadableStream` 的所有权。Host Worker 将两种请求体都逐块送入路由,因此通用文件上传不会在任何浏览器线程创建完整字节数组。Remote 流的上行以 `stream-uplink-item` / `stream-uplink-end` 帧进入没有上限的 `TunnelUplink`,`end` 之后到达的项被丢弃:页面与其 worker 是同一个可信的同源边界,Host 的 inbox 上限在此不适用。 diff --git a/packages/experimental/webworker-runtime/package.json b/packages/experimental/webworker-runtime/package.json index c8fa0e5582..fde92fb05f 100644 --- a/packages/experimental/webworker-runtime/package.json +++ b/packages/experimental/webworker-runtime/package.json @@ -48,6 +48,7 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-file-upload": "workspace:^", diff --git a/packages/experimental/webworker-runtime/src/module-proxies.ts b/packages/experimental/webworker-runtime/src/module-proxies.ts index 67ebb786cf..8e4dcc6cd7 100644 --- a/packages/experimental/webworker-runtime/src/module-proxies.ts +++ b/packages/experimental/webworker-runtime/src/module-proxies.ts @@ -61,6 +61,8 @@ export const MODULE_PROXIES: Record = { 'dns/promises': './node/builtin_modules/mock/dns/promises.ts', 'node:net': './node/builtin_modules/mock/net.ts', 'node:stream': './node/builtin_modules/implemented/stream.ts', + 'node:stream/promises': './node/builtin_modules/implemented/stream/promises.ts', + 'stream/promises': './node/builtin_modules/implemented/stream/promises.ts', 'node:vm': './node/builtin_modules/mock/vm.ts', 'node:worker_threads': './node/builtin_modules/mock/worker_threads.ts', 'node:sqlite': './node/builtin_modules/mock/sqlite.ts', diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/stream/promises.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/stream/promises.ts new file mode 100644 index 0000000000..9d6b651396 --- /dev/null +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/stream/promises.ts @@ -0,0 +1,14 @@ +/** Promise-based stream completion and pipelines from the shared readable-stream implementation. */ +import { promises } from '../stream.ts' + +/** Shared stream completion promise, including abort and listener cleanup options. */ +export const finished = promises.finished + +/** Shared stream pipeline promise, including backpressure and error propagation. */ +export const pipeline = promises.pipeline + +/** CommonJS interop marker consumed by the worker module loader. */ +export const __esModule = true + +/** CommonJS-compatible promise namespace. */ +export default promises diff --git a/packages/experimental/webworker-runtime/src/node/builtins.ts b/packages/experimental/webworker-runtime/src/node/builtins.ts index aa21754b4e..a95b28b681 100644 --- a/packages/experimental/webworker-runtime/src/node/builtins.ts +++ b/packages/experimental/webworker-runtime/src/node/builtins.ts @@ -34,6 +34,7 @@ import * as nodeOs from './builtin_modules/implemented/os.ts' import * as nodePath from './builtin_modules/implemented/path.ts' import * as nodePerfHooks from './builtin_modules/implemented/perf_hooks.ts' import * as nodeStream from './builtin_modules/implemented/stream.ts' +import * as nodeStreamPromises from './builtin_modules/implemented/stream/promises.ts' import * as nodeTimersPromises from './builtin_modules/implemented/timers/promises.ts' import * as nodeTty from './builtin_modules/implemented/tty.ts' import * as nodeUrl from './builtin_modules/implemented/url.ts' @@ -76,6 +77,7 @@ const BUILTINS: Record = { perf_hooks: () => nodePerfHooks, sqlite: () => nodeSqlite, stream: () => nodeStream, + 'stream/promises': () => nodeStreamPromises, 'timers/promises': () => nodeTimersPromises, tty: () => nodeTty, url: () => nodeUrl, diff --git a/packages/experimental/webworker-runtime/src/worker-host.ts b/packages/experimental/webworker-runtime/src/worker-host.ts index 1d0853f9f7..6dfa7097b0 100644 --- a/packages/experimental/webworker-runtime/src/worker-host.ts +++ b/packages/experimental/webworker-runtime/src/worker-host.ts @@ -17,11 +17,12 @@ * * The tree itself boots through the host's own `boot()` glue loaded from the * image, so entry mounting, the activation audit, and its diagnostics are the - * same code the Node deployment runs. Only the module seam and the command line - * are supplied from here. + * same code the Node deployment runs. The Worker supplies module loading, + * profile locations, and the command line. * @module @deepseek-ai/dsh-experimental-webworker-runtime/src/worker-host */ import { setActiveModuleLoader, WorkerModuleLoader, type StaticModuleFactory } from './module-system/module-loader.ts' +import type { ProfileContext } from '@deepseek-ai/dsh-app-boot' import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway' import type { HostConnectionHandle } from '@deepseek-ai/dsh-client-connection' import type { AlsCausality } from './polyfill/async-context/als-runtime.ts' @@ -230,8 +231,10 @@ export function createWorkerHost(options: WorkerHostOptions): WorkerHost { provideCmdline(ctx: unknown, host: { args: readonly string[]; exit: (code: number) => void }): void } - const { patches } = bootPatches(loader, mounted, configPath, root) - const ctx = await appBoot.boot('dsh-webworker', configPath, patches, (hostCtx) => { + const { patches, profile } = bootPatches(loader, mounted, configPath, root) + const profileConfig = join(profile.dir, 'cordis.yml') + const ctx = await appBoot.boot('dsh-webworker', profileConfig, patches, (hostCtx) => { + hostCtx.provide('profileContext', profile) // Before any entry mounts: the Loader would otherwise fall back to the // runtime's own dynamic import for every row. hostCtx.loader.internal = loader.internal @@ -348,27 +351,33 @@ function requireLoweredImage(vfs: MemoryVfs, path: string): void { } } -/** Select plaintext session persistence for the in-memory Worker filesystem. - * @param loader Module loader for the image's YAML reader. - * @param vfs Filesystem holding the composed configuration. - * @param configPath Composed configuration path. - * @param root Virtual root used to resolve YAML packages. - * @returns Boot patches for the configured JSONL provider. +/** + * Prepare an editable VFS profile from the packed rows and deployment overlays. + * ConfigEditor writes overrides beside the insertion rows; reconciliation starts + * at the empty profile root and retains the Worker deployment overlays. + * @param loader - Module loader, for the image's YAML reader. + * @param vfs - Filesystem holding the composed configuration. + * @param configPath - Composed configuration path. + * @param root - Virtual root. + * @returns Profile locations and effective boot patches. */ function bootPatches( loader: WorkerModuleLoader, vfs: MemoryVfs, configPath: string, root: string, -): { patches: unknown[] } { +): { patches: unknown[]; profile: ProfileContext } { const text = vfs.readFileSync(configPath, 'utf8') as string + const include = loader.load(loader.resolve('@deepseek-ai/cordis-plugin-include', root)) as { entryListSchema: unknown } + const yaml = loader.load(loader.resolve('js-yaml', root)) as { + load(source: string, options: { schema: unknown }): unknown + dump(value: unknown, options: { schema: unknown }): string + } let rows: unknown if (configPath.endsWith('.json')) { rows = JSON.parse(text) } else { // The roster's `!!js` scalars need Include's own YAML dialect. - const include = loader.load(loader.resolve('@deepseek-ai/cordis-plugin-include', root)) as { entryListSchema: unknown } - const yaml = loader.load(loader.resolve('js-yaml', root)) as { load(source: string, options: { schema: unknown }): unknown } rows = yaml.load(text, { schema: include.entryListSchema }) } const find = (entries: unknown, id: string): Record | undefined => { @@ -385,14 +394,34 @@ function bootPatches( ? row.config : {}) as Record - const patches: unknown[] = [] + const patches: Array = [] + // The image has no package manager or Node module-reload cache. ConfigEditor + // reconciles writes directly; Loader commits accepted live values. + for (const id of ['hmr', 'plugin-manager']) { + if (find(rows, id) !== undefined) patches.push({ id, disabled: true }) + } // The worker carries no compression codec, and the VFS is in-memory anyway: // the JSONL backend's plaintext path is the composition's one legal encoding. const jsonl = find(rows, 'session-persistence-jsonl') if (jsonl !== undefined) { patches.push({ id: 'session-persistence-jsonl', config: { ...configOf(jsonl), compression: 'none' } }) } - return { patches } + const dir = join(root, IMAGE_HOME_DIRECTORY, 'profiles/preview') + const profile: ProfileContext = { + name: 'preview', dir, patchPath: join(dir, 'cordis.patch.yml'), + installAnchor: join(dir, 'package.json'), cwd: root, + home: join(root, IMAGE_HOME_DIRECTORY), startedBundles: [], + overlays: patches, telemetryDisabledEnv: undefined, + } + vfs.seed(join(dir, 'package.json'), '{"private":true,"dsh":{"profile":{"bundles":[]}}}\n') + vfs.seed(join(dir, 'cordis.yml'), '[]\n') + if (!vfs.existsSync(profile.patchPath)) { + vfs.seed(profile.patchPath, yaml.dump([{ insert: rows }], { schema: include.entryListSchema })) + } + const appBoot = loader.load(loader.resolve('@deepseek-ai/dsh-app-boot', root)) as { + readProfilePatches(binName: string, profile: ProfileContext): unknown[] + } + return { patches: appBoot.readProfilePatches('dsh-webworker', profile), profile } } /** diff --git a/packages/experimental/webworker-runtime/tests/node/builtins-table.spec.ts b/packages/experimental/webworker-runtime/tests/node/builtins-table.spec.ts index a094ff5f0a..1260c0882a 100644 --- a/packages/experimental/webworker-runtime/tests/node/builtins-table.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/builtins-table.spec.ts @@ -89,6 +89,28 @@ describe('module identity through the loader', () => { expect(require('tty')).toBe(require('node:tty')) }) + it('shares promise stream helpers and observes completion and errors through the loader', async () => { + const require = loaderRequire() + const stream = require('node:stream') as typeof import('node:stream') + const promises = require('node:stream/promises') as typeof import('node:stream/promises') + expect(require('stream/promises')).toBe(promises) + expect(promises.finished).toBe(stream.promises.finished) + expect(promises.pipeline).toBe(stream.promises.pipeline) + + const output = new stream.Writable({ write(_chunk, _encoding, callback) { callback() } }) + const baseline = output.listenerCount('error') + const completed = promises.finished(output, { cleanup: true }) + output.end('done') + await completed + expect(output.listenerCount('error')).toBe(baseline) + + const failed = new stream.Writable({ write(_chunk, _encoding, callback) { callback(new Error('write failed')) } }) + const rejected = expect(promises.finished(failed, { cleanup: true })).rejects.toThrow('write failed') + failed.end('broken') + await rejected + expect(failed.listenerCount('error')).toBe(0) + }) + it('reports that worker file descriptors are not terminals', () => { const tty = loaderRequire()('tty') as { isatty(fd: number): boolean } expect(tty.isatty(2)).toBe(false) diff --git a/packages/experimental/webworker-runtime/tests/node/chokidar.spec.ts b/packages/experimental/webworker-runtime/tests/node/chokidar.spec.ts index a1c7d1da12..9b28a5e706 100644 --- a/packages/experimental/webworker-runtime/tests/node/chokidar.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/chokidar.spec.ts @@ -23,8 +23,8 @@ interface ChokidarFixture { const CHOKIDAR_FIXTURES: readonly ChokidarFixture[] = [ { - label: 'Chokidar 4 from settings and credentials', - consumerManifest: 'packages/settings/settings-file/package.json', + label: 'Chokidar 4 from credentials', + consumerManifest: 'packages/credentials/credentials-local/package.json', chokidarFiles: ['package.json', 'esm/package.json', 'esm/index.js', 'esm/handler.js'], readdirpFiles: ['package.json', 'esm/package.json', 'esm/index.js'], }, diff --git a/packages/experimental/webworker-runtime/tsconfig.json b/packages/experimental/webworker-runtime/tsconfig.json index 50cbd74404..2a9818f8a4 100644 --- a/packages/experimental/webworker-runtime/tsconfig.json +++ b/packages/experimental/webworker-runtime/tsconfig.json @@ -40,6 +40,9 @@ }, { "path": "../../client/web" + }, + { + "path": "../../boot/app-boot" } ] } diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 6fe1e49f41..8217db928c 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -365,9 +365,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'workspaceId', description: 'explicit target; absent inherits the current or most recent Workspace.' }], }, { - signature: 'archiveSession(sessionId: SessionId): Promise', + signature: 'archiveSession(sessionId: SessionId, options?: { readonly stopActivity?: boolean }): Promise', description: 'Archive a Session and clear it when it is the current selection.', - parameters: [{ name: 'sessionId', description: 'Session to archive.' }], + parameters: [{ name: 'sessionId', description: 'Session to archive.' }, { name: 'options', description: '`stopActivity` asks the Host to stop the Session\'s running work instead of refusing.' }], }, { signature: 'unarchiveSession(sessionId: SessionId): Promise', @@ -417,9 +417,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'workspaceId', description: 'target Workspace.' }], }, { - signature: 'archiveSession(sessionId: SessionId): Promise', + signature: 'archiveSession(sessionId: SessionId, options?: { readonly stopActivity?: boolean }): Promise', description: 'Archive a Session from Workspace grouping surfaces.', - parameters: [{ name: 'sessionId', description: 'Session to archive.' }], + parameters: [{ name: 'sessionId', description: 'Session to archive.' }, { name: 'options', description: '`stopActivity` asks the Host to stop the Session\'s running work instead of refusing.' }], + throws: ['{WorkspaceArchiveError} when the Host refuses; without `stopActivity` a Session with running work fails as `workspace/session-active`, its details naming what runs.'], }, { signature: 'unarchiveSession(sessionId: SessionId): Promise', diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index a2d6a92eeb..5100c8215b 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -176,7 +176,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:248', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:271', }, { key: 'conversation.chat.commandview', @@ -224,7 +224,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:236', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:259', }, { key: 'conversation.chat.node', @@ -268,14 +268,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useProjection: UseProjection', 'useTrajectory: UseTrajectory', ], - keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, steering, system-prompt, tool-call, turn-error, turn-max-tokens, turn-process, turn-tail, unknown, user, workflow-run', - hookContext: 'ConversationLocationDataStore | undefined', - slotInject: 'ChatNodeTurnDataInjected', + keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, steering, system-prompt, tool-call, turn-error, turn-max-tokens, turn-process, turn-tail, turn-trigger, unknown, user, workflow-run', + hookContext: 'ChatNodeHookContext', + slotInject: 'ChatNodeInjected', declaredBy: 'an entry in \'conversation.view\' (client-ui-chat), so it exists while that entry is mounted', occupants: [ 'client-ui-chat UserMessageNodeView key \'user\'', 'client-ui-chat UserMessageNodeView key \'steering\'', 'client-ui-chat ContextMessageNodeView key \'context\'', + 'client-ui-chat TurnTriggerNodeView key \'turn-trigger\'', 'client-ui-chat SystemPromptNodeView key \'system-prompt\'', 'client-ui-chat AssistantNodeView key \'assistant-step\'', 'client-ui-chat CommandNodeView key \'command\'', @@ -293,7 +294,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:217', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:240', }, { key: 'conversation.chat.turnTail', @@ -354,7 +355,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:242', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:265', }, { key: 'conversation.composer', @@ -696,7 +697,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace.directoryFlow\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace.directoryFlow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:99', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:101', }, { key: 'conversation.input.activity', @@ -1167,7 +1168,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:230', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:253', }, { key: 'conversation.plan-review.actions', @@ -1760,7 +1761,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'main\', () => ctx.slots.register(\n { name: \'main\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-layout/src/client/index.ts:66', + source: 'packages/client/ui-layout/src/client/index.ts:73', }, { key: 'main.conversation', @@ -1833,7 +1834,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.activation\', () => ctx.slots.register(\n { name: \'plugins.bundle.activation\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:34', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:78', }, { key: 'plugins.bundle.config', @@ -1850,9 +1851,11 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** The view the page asks a configuration entry for. */\nexport interface PluginConfigViewProps {\n /** `summary` renders the one-liner alone, as text or inline nodes; `page` renders the form with its save control. */\n readonly view: \'summary\' | \'page\'\n}', + '/** The view the page asks a configuration entry for. */\nexport interface PluginConfigViewProps {\n /** `summary` renders the one-liner alone, as text or inline nodes; `page` renders the form with its save control. */\n readonly view: \'summary\' | \'page\'\n /** Host-owned configuration values and write actions for this page\'s entry. */\n readonly form?: ConfigPageForm | undefined\n}', + ], + ownerPropsReferences: [ + 'ConfigPageForm', ], - ownerPropsReferences: [], standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', @@ -1871,14 +1874,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.config\', () => ctx.slots.register(\n { name: \'plugins.bundle.config\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:50', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:94', }, { - key: 'plugins.item', + key: 'plugins.detail.actions', kind: 'list', scope: 'root', - summary: 'One official plugin the Plugins page lists in its Official group after the official bundles: `label` is the card\'s title and `order` its place.', - doc: 'One official plugin the Plugins page lists in its Official group after\nthe official bundles: `label` is the card\'s title and `order` its place.\nThe page renders the entry as the card\'s one-liner (`view: \'summary\'`)\nand, once the card is opened, as the body of the plugin\'s own page\n(`view: \'page\'`). OCCUPIED by the host-plane configuration pages\n`ui-settings-plugins` ships; a bundle\'s configuration belongs in\n`plugins.bundle.config` or `plugins.row.config` instead.', + summary: 'Controls at the head of a detail page, before the page\'s own switch and uninstall, rendered with the page\'s subject.', + doc: 'Controls at the head of a detail page, before the page\'s own switch and\nuninstall, rendered with the page\'s subject. An entry renders null for a\nsubject it has no control for.', registerOptions: [ { name: 'id', @@ -1900,9 +1903,161 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** The view the page asks a configuration entry for. */\nexport interface PluginConfigViewProps {\n /** `summary` renders the one-liner alone, as text or inline nodes; `page` renders the form with its save control. */\n readonly view: \'summary\' | \'page\'\n}', + '/** The owner props every detail contribution is rendered with. */\nexport interface PluginDetailProps {\n /** The subject of the open page; an entry renders null for a subject it has nothing for. */\n readonly subject: PluginsSubject\n}', + ], + ownerPropsReferences: [ + 'PluginsSubject', + ], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'main\' (client-ui-plugin-manager), so it exists while that entry is mounted', + occupants: [], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.detail.actions\', () => ctx.slots.register(\n { name: \'plugins.detail.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:108', + }, + { + key: 'plugins.detail.badge', + kind: 'list', + scope: 'root', + summary: 'Tags beside a detail page\'s title, after the version, beta, and problem tags the page draws itself, rendered with the page\'s subject.', + doc: 'Tags beside a detail page\'s title, after the version, beta, and problem\ntags the page draws itself, rendered with the page\'s subject.', + registerOptions: [ + { + name: 'id', + requirement: 'required', + type: 'string', + doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.', + }, + { + name: 'order', + requirement: 'optional', + type: 'number', + doc: 'Position among the entries, ascending (default 0).', + }, + { + name: 'label', + requirement: 'optional', + type: 'string | (() => string)', + doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.', + }, + ], + ownerProps: [ + '/** The owner props every detail contribution is rendered with. */\nexport interface PluginDetailProps {\n /** The subject of the open page; an entry renders null for a subject it has nothing for. */\n readonly subject: PluginsSubject\n}', + ], + ownerPropsReferences: [ + 'PluginsSubject', + ], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'main\' (client-ui-plugin-manager), so it exists while that entry is mounted', + occupants: [], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.detail.badge\', () => ctx.slots.register(\n { name: \'plugins.detail.badge\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:113', + }, + { + key: 'plugins.detail.section', + kind: 'list', + scope: 'root', + summary: 'Sections under a detail page\'s own content: after the rows on a bundle\'s page, after the configuration on a row\'s or an official plugin\'s page.', + doc: 'Sections under a detail page\'s own content: after the rows on a bundle\'s\npage, after the configuration on a row\'s or an official plugin\'s page.\nAn entry draws its own section chrome and renders null for a subject it\nhas nothing for.', + registerOptions: [ + { + name: 'id', + requirement: 'required', + type: 'string', + doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.', + }, + { + name: 'order', + requirement: 'optional', + type: 'number', + doc: 'Position among the entries, ascending (default 0).', + }, + { + name: 'label', + requirement: 'optional', + type: 'string | (() => string)', + doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.', + }, + ], + ownerProps: [ + '/** The owner props every detail contribution is rendered with. */\nexport interface PluginDetailProps {\n /** The subject of the open page; an entry renders null for a subject it has nothing for. */\n readonly subject: PluginsSubject\n}', + ], + ownerPropsReferences: [ + 'PluginsSubject', + ], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'main\' (client-ui-plugin-manager), so it exists while that entry is mounted', + occupants: [], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.detail.section\', () => ctx.slots.register(\n { name: \'plugins.detail.section\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:120', + }, + { + key: 'plugins.item', + kind: 'list', + scope: 'root', + summary: 'One official plugin the Plugins page lists in its Official group after the official bundles: `label` is the card\'s title and `order` its place.', + doc: 'One official plugin the Plugins page lists in its Official group after\nthe official bundles: `label` is the card\'s title and `order` its place.\nThe page renders the entry as the card\'s one-liner (`view: \'summary\'`)\nand, once the card is opened, as the body of the plugin\'s own page\n(`view: \'page\'`). OCCUPIED by the official settings pages, one companion\npackage per host-plane namespace; a bundle\'s configuration belongs in\n`plugins.bundle.config` or `plugins.row.config` instead.', + registerOptions: [ + { + name: 'id', + requirement: 'required', + type: 'string', + doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.', + }, + { + name: 'order', + requirement: 'optional', + type: 'number', + doc: 'Position among the entries, ascending (default 0).', + }, + { + name: 'label', + requirement: 'optional', + type: 'string | (() => string)', + doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.', + }, + ], + ownerProps: [ + '/** The view the page asks a configuration entry for. */\nexport interface PluginConfigViewProps {\n /** `summary` renders the one-liner alone, as text or inline nodes; `page` renders the form with its save control. */\n readonly view: \'summary\' | \'page\'\n /** Host-owned configuration values and write actions for this page\'s entry. */\n readonly form?: ConfigPageForm | undefined\n}', + ], + ownerPropsReferences: [ + 'ConfigPageForm', ], - ownerPropsReferences: [], standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', @@ -1917,14 +2072,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ slotInject: '', declaredBy: 'an entry in \'main\' (client-ui-plugin-manager), so it exists while that entry is mounted', occupants: [ - 'client-ui-settings-plugins BashCard id \'bash\'', - 'client-ui-settings-plugins AgentLoopCard id \'agent-loop\'', - 'client-ui-settings-plugins SubagentCard id \'subagent\'', - 'client-ui-settings-plugins WebSearchCard id \'web-search\'', + 'client-ui-settings-agent-loop AgentLoopCard id \'agent-loop\'', + 'client-ui-settings-shell ShellCard id \'shell\'', + 'client-ui-settings-subagent SubagentCard id \'subagent\'', + 'client-ui-settings-web-search WebSearchCard id \'web-search\'', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.item\', () => ctx.slots.register(\n { name: \'plugins.item\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:44', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:88', }, { key: 'plugins.row.config', @@ -1941,9 +2096,11 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** The view the page asks a configuration entry for. */\nexport interface PluginConfigViewProps {\n /** `summary` renders the one-liner alone, as text or inline nodes; `page` renders the form with its save control. */\n readonly view: \'summary\' | \'page\'\n}', + '/** The view the page asks a configuration entry for. */\nexport interface PluginConfigViewProps {\n /** `summary` renders the one-liner alone, as text or inline nodes; `page` renders the form with its save control. */\n readonly view: \'summary\' | \'page\'\n /** Host-owned configuration values and write actions for this page\'s entry. */\n readonly form?: ConfigPageForm | undefined\n}', + ], + ownerPropsReferences: [ + 'ConfigPageForm', ], - ownerPropsReferences: [], standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', @@ -1960,7 +2117,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.row.config\', () => ctx.slots.register(\n { name: \'plugins.row.config\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:58', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:102', }, { key: 'rightbar', @@ -1991,7 +2148,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'rightbar\', () => ctx.slots.register(\n { name: \'rightbar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-layout/src/client/index.ts:80', + source: 'packages/client/ui-layout/src/client/index.ts:87', }, { key: 'rightbar.session', @@ -2033,7 +2190,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'rightbar.session\', () => ctx.slots.register(\n { name: \'rightbar.session\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:42', + source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:43', }, { key: 'root', @@ -2114,7 +2271,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.action\', () => ctx.slots.register(\n { name: \'settings.action\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-settings/src/client/contract/slots.ts:36', + source: 'packages/client/ui-settings/src/client/contract/slots.ts:39', }, { key: 'settings.close', @@ -2145,14 +2302,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.close\', () => ctx.slots.register(\n { name: \'settings.close\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-settings/src/client/contract/slots.ts:42', + source: 'packages/client/ui-settings/src/client/contract/slots.ts:45', }, { key: 'settings.general.item', kind: 'list', scope: 'root', summary: 'One preference row inside the General section — the additive seat for a single setting that needs no page of its own (a whole page is `settings.section`), contributed by the feature plugin that owns the preference (locale → Language, ui-theme → Appearance, ui-conversation → Composer Enter).', - doc: 'One preference row inside the General section — the additive seat for a\nsingle setting that needs no page of its own (a whole page is\n`settings.section`), contributed by the feature plugin that owns the\npreference (locale → Language, ui-theme → Appearance, ui-conversation →\nComposer Enter). Options: `id` (row key), `order` (row position). The\nsection column only stacks rows, so a row draws its own internals,\nincluding its label: nothing projects a `label` here and the owner passes\nno props at all — copy, current value, and the write path are all yours,\nthrough your own inject face and `host.call`. Declared at runtime by\nui-settings-general\'s General entry; the type lives here with every other\nsettings slot type, because this package is the settings domain\'s base\nlayer and every registrant already depends on it for `ctx.settingsScope`.', + doc: 'One preference row inside the General section — the additive seat for a\nsingle setting that needs no page of its own (a whole page is\n`settings.section`), contributed by the feature plugin that owns the\npreference (locale → Language, ui-theme → Appearance, ui-conversation →\nComposer Enter). Options: `id` (row key), `order` (row position). The\nsection column only stacks rows, so a row draws its own internals,\nincluding its label: nothing projects a `label` here and the owner passes\nno props at all — copy, current value, and the write path are all yours,\nthrough your own inject face and `host.call`. Declared at runtime by\nui-settings-general\'s General entry; the type lives here with every other\nsettings slot type, because this package is the settings domain\'s base\nlayer and every registrant already depends on it for `ctx.configForms`.', registerOptions: [ { name: 'id', @@ -2199,12 +2356,13 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-permission-presets PermissionRow id \'permission\'', 'client-ui-settings-general DeveloperToolsRow id \'developer-tools\'', 'client-ui-settings-general CurrentVersionRow id \'current-version\'', + 'client-ui-shortcuts ShortcutsRow id \'shortcuts\'', 'client-ui-theme AppearanceRow id \'appearance\'', 'client-ui-theme FontSizeRow id \'font-size\'', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.general.item\', () => ctx.slots.register(\n { name: \'settings.general.item\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-settings/src/client/contract/slots.ts:89', + source: 'packages/client/ui-settings/src/client/contract/slots.ts:92', }, { key: 'settings.header', @@ -2235,7 +2393,38 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.header\', () => ctx.slots.register(\n { name: \'settings.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-settings/src/client/contract/slots.ts:30', + source: 'packages/client/ui-settings/src/client/contract/slots.ts:33', + }, + { + key: 'settings.launcher', + kind: 'single', + scope: 'root', + summary: 'Optional sidebar account launcher; opens the shell-owned settings panel.', + doc: 'Optional sidebar account launcher; opens the shell-owned settings panel.', + registerOptions: [], + ownerProps: [ + '/** Sidebar launcher geometry and settings navigation. */\nexport interface SettingsLauncherOwnerProps {\n /** Whether the sidebar shows labels. */\n wide: boolean\n /** Effective Settings key labels and accessible combination; omitted when unbound. */\n settingsShortcut?: { readonly keys: readonly string[]; readonly aria?: string | undefined }\n /** Open the settings panel. */\n openSettings: () => void\n /** @param id - registered onboarding editor to open explicitly. */\n openOnboarding: (id: string) => void\n}', + ], + ownerPropsReferences: [], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'sidebar.settings\' (client-ui-settings-general), so it exists while that entry is mounted', + occupants: [ + 'client-ui-settings-account AccountMenu', + ], + replaceRisk: 'shadows-shipped-ui', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.launcher\', () => ctx.slots.register(\n { name: \'settings.launcher\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-settings/src/client/contract/slots.ts:17', }, { key: 'settings.models.footer', @@ -2283,7 +2472,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.models.footer\', () => ctx.slots.register(\n { name: \'settings.models.footer\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-settings-models/src/client/slot-contract.ts:38', + source: 'packages/client/ui-settings-models/src/client/slot-contract.ts:40', }, { key: 'settings.models.provider-card', @@ -2323,6 +2512,35 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.models.provider-card\', () => ctx.slots.register(\n { name: \'settings.models.provider-card\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', source: 'packages/client/ui-settings-models/src/client/slot-contract.ts:33', }, + { + key: 'settings.models.sign-in', + kind: 'single', + scope: 'root', + summary: 'Optional account login choice before the credential editor.', + doc: 'Optional account login choice before the credential editor.', + registerOptions: [], + ownerProps: [], + ownerPropsReferences: [], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'settings.onboarding\' (client-ui-settings-models), so it exists while that entry is mounted', + occupants: [ + 'client-ui-settings-account AccountOnboarding', + ], + replaceRisk: 'shadows-shipped-ui', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.models.sign-in\', () => ctx.slots.register(\n { name: \'settings.models.sign-in\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-settings-models/src/client/slot-contract.ts:35', + }, { key: 'settings.onboarding', kind: 'list', @@ -2350,7 +2568,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Owner share of the currently active settings-backed onboarding step. */\nexport interface SettingsOnboardingOwnerProps {\n /** Stable id of the step currently selected by the coordinator. */\n stepId: string\n /** Complete or skip this step and transfer ownership to the next entry. */\n complete: () => void\n /** Open the settings panel directly on one registered section. */\n openSection: (id: string) => void\n}', + '/** Owner share of the currently active settings-backed onboarding step. */\nexport interface SettingsOnboardingOwnerProps {\n /** Stable id of the step currently selected by the coordinator. */\n stepId: string\n /** User explicitly reopened this step outside first-run onboarding. */\n explicit?: boolean\n /** Complete or skip this step and transfer ownership to the next entry. */\n complete: () => void\n /** Open the settings panel directly on one registered section. */\n openSection: (id: string) => void\n}', ], ownerPropsReferences: [], standardProps: [ @@ -2372,7 +2590,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.onboarding\', () => ctx.slots.register(\n { name: \'settings.onboarding\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-settings/src/client/contract/slots.ts:74', + source: 'packages/client/ui-settings/src/client/contract/slots.ts:77', }, { key: 'settings.plugins.tab', @@ -2422,7 +2640,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.plugins.tab\', () => ctx.slots.register(\n { name: \'settings.plugins.tab\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-settings/src/client/contract/slots.ts:63', + source: 'packages/client/ui-settings/src/client/contract/slots.ts:66', }, { key: 'settings.section', @@ -2469,13 +2687,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ declaredBy: 'an entry in \'sidebar.settings\' (client-ui-settings-general), so it exists while that entry is mounted', occupants: [ 'client-ui-agent-preset AgentPresetSection id \'agent-presets\'', + 'client-ui-settings-account AccountSection id \'account\'', 'client-ui-settings-general GeneralSection id \'general\'', 'client-ui-settings-models ModelsSection id \'models\'', 'client-ui-settings-plugins PluginsSettingsSection id \'plugins\'', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.section\', () => ctx.slots.register(\n { name: \'settings.section\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-settings/src/client/contract/slots.ts:54', + source: 'packages/client/ui-settings/src/client/contract/slots.ts:57', }, { key: 'settings.trigger', @@ -2506,7 +2725,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.trigger\', () => ctx.slots.register(\n { name: \'settings.trigger\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-settings/src/client/contract/slots.ts:24', + source: 'packages/client/ui-settings/src/client/contract/slots.ts:27', }, { key: 'shell.leading', @@ -2535,7 +2754,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.leading\', () => ctx.slots.register(\n { name: \'shell.leading\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-layout/src/client/index.ts:105', + source: 'packages/client/ui-layout/src/client/index.ts:112', }, { key: 'shell.overlay', @@ -2579,12 +2798,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ slotInject: '', declaredBy: 'an entry in \'root\' (client-ui-layout), so it exists while that entry is mounted', occupants: [ + 'client-ui-shortcuts ShortcutReference id \'shortcuts\'', 'client-ui-workspace SessionRenameDialog id \'workspace.session-rename\'', + 'client-ui-workspace SessionArchiveConfirmDialog id \'workspace.session-archive\'', 'client-ui-workspace RowActionToast id \'workspace.row-toast\'', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.overlay\', () => ctx.slots.register(\n { name: \'shell.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-layout/src/client/index.ts:91', + source: 'packages/client/ui-layout/src/client/index.ts:98', }, { key: 'sidebar', @@ -2615,7 +2836,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar\', () => ctx.slots.register(\n { name: \'sidebar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-layout/src/client/index.ts:61', + source: 'packages/client/ui-layout/src/client/index.ts:68', }, { key: 'sidebar.brand.mark', @@ -2646,7 +2867,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.brand.mark\', () => ctx.slots.register(\n { name: \'sidebar.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:24', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:25', }, { key: 'sidebar.brand.name', @@ -2677,7 +2898,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.brand.name\', () => ctx.slots.register(\n { name: \'sidebar.brand.name\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:29', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:30', }, { key: 'sidebar.chat.conversation', @@ -2764,7 +2985,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.footer.action\', () => ctx.slots.register(\n { name: \'sidebar.footer.action\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:52', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:53', }, { key: 'sidebar.panellist', @@ -2814,7 +3035,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.panellist\', () => ctx.slots.register(\n { name: \'sidebar.panellist\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:34', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:35', }, { key: 'sidebar.right.pane.tab', @@ -2865,7 +3086,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.pane.tab\', () => ctx.slots.register(\n { name: \'sidebar.right.pane.tab\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:58', + source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:59', }, { key: 'sidebar.right.pane.tab.title', @@ -2914,7 +3135,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.pane.tab.title\', () => ctx.slots.register(\n { name: \'sidebar.right.pane.tab.title\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:72', + source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:73', }, { key: 'sidebar.right.tab.document', @@ -3219,7 +3440,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.guide\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.guide\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:83', + source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:84', }, { key: 'sidebar.right.tab.guide.entry', @@ -3265,7 +3486,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.guide.entry\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.guide.entry\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:90', + source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:91', }, { key: 'sidebar.right.tab.menu.item', @@ -3323,7 +3544,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.menu.item\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.menu.item\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:102', + source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:103', }, { key: 'sidebar.settings', @@ -3354,7 +3575,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.settings\', () => ctx.slots.register(\n { name: \'sidebar.settings\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:47', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:48', }, { key: 'sidebar.toggle.badge', @@ -3383,7 +3604,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.toggle.badge\', () => ctx.slots.register(\n { name: \'sidebar.toggle.badge\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:18', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:19', }, { key: 'sidebar.workspaces', @@ -3414,7 +3635,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.workspaces\', () => ctx.slots.register(\n { name: \'sidebar.workspaces\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:41', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:42', }, { key: 'sidebar.workspaces.directoryFlow', @@ -3446,7 +3667,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.workspaces.directoryFlow\', () => ctx.slots.register(\n { name: \'sidebar.workspaces.directoryFlow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:101', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:103', }, { key: 'sidebar.workspaces.session.menu.item', @@ -3491,7 +3712,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], keyDomain: '', hookContext: 'MenuOpenState', - slotInject: '{ hooks: { menuOpenState: SlotHookFactory<\'sidebar.workspaces.session.menu.item\', UseMenuOpenState> } }', + slotInject: '{ hooks: { menuOpenState: SlotHookFactory<\'sidebar.workspaces.session.menu.item\', UseMenuOpenState> shortcuts: HostObservable } }', declaredBy: 'an entry in \'sidebar.workspaces\' (client-ui-workspace), so it exists while that entry is mounted', occupants: [ 'client-ui-workspace PinSessionMenuItem id \'pin\'', @@ -3501,7 +3722,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n const copyLabel = \'Copy Session ID\' // Localize in the contributing package.\n ctx.slots.inject(\'sidebar.workspaces.session.menu.item\', () => ctx.slots.register(\n { name: \'sidebar.workspaces.session.menu.item\', id: \'copy-session-id\', order: 500 },\n ({ sessionId, useMenuOpenState }) => {\n const [, setMenuOpen] = useMenuOpenState()\n return React.createElement(\n \'button\',\n { type: \'button\', role: \'menuitem\', onClick: () => { setMenuOpen(false); void navigator.clipboard.writeText(sessionId) } },\n copyLabel,\n )\n },\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:133', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:135', }, { key: 'sidebar.workspaces.session.row.action', @@ -3554,7 +3775,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.workspaces.session.row.action\', () => ctx.slots.register(\n { name: \'sidebar.workspaces.session.row.action\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:148', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:153', }, { key: 'tool.call.images', @@ -3613,12 +3834,13 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Standard owner currency supplied to every atomic Tool view. */\nexport interface ToolCallOwnerProps {\n /** Tool call identity, stable across running and settled forms. */\n callId: string\n /** Wire Tool name and keyed dispatch value. */\n toolName: string\n /** Frozen running call or settled result node. */\n block: ToolCallBlock\n /** Session workspace root for relative summaries. */\n cwd?: string | undefined\n /** Host account home; POSIX home-rooted summaries display as `~`. */\n home?: string | undefined\n /**\n * Open a Tool argument path. A view that knows which line the call was about\n * passes it, and the opened surface lands there.\n */\n openFile: (path: string, options?: OpenFileOptions) => void\n /**\n * Session-authorized image loader for the `tool.call.images` slot, supplied\n * by the chat node that owns this call. A composed chat node always\n * supplies it (`ChatNodeOwnerProps.loadImage` is required), so the tool\n * layer never imports an attachment implementation nor handles URL\n * authorization.\n */\n loadImage: MessageImageLoader\n /** Inspect this call in the trajectory view when available. */\n inspect?: (() => void) | undefined\n}', + '/** Standard owner currency supplied to every atomic Tool view. */\nexport interface ToolCallOwnerProps {\n /** Stable injected Hook; each invocation owns its open state and subscribes to enclosing-Turn resets. */\n useDisclosure: UseDisclosure\n /** Tool call identity, stable across running and settled forms. */\n callId: string\n /** Wire Tool name and keyed dispatch value. */\n toolName: string\n /** Frozen running call or settled result node. */\n block: ToolCallBlock\n /** Session workspace root for relative summaries. */\n cwd?: string | undefined\n /** Host account home; POSIX home-rooted summaries display as `~`. */\n home?: string | undefined\n /**\n * Open a Tool argument path. A view that knows which line the call was about\n * passes it, and the opened surface lands there.\n */\n openFile: (path: string, options?: OpenFileOptions) => void\n /**\n * Session-authorized image loader for the `tool.call.images` slot, supplied\n * by the chat node that owns this call. A composed chat node always\n * supplies it (`ChatNodeOwnerProps.loadImage` is required), so the tool\n * layer never imports an attachment implementation nor handles URL\n * authorization.\n */\n loadIm /* …truncated — full shape in source */', ], ownerPropsReferences: [ 'ChatNodeOwnerProps', 'MessageImageLoader', 'OpenFileOptions', + 'UseDisclosure', ], standardProps: [ 'useResource: UseResource', diff --git a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts index 4af6be17b9..a4fb6cfef2 100644 --- a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts @@ -16,7 +16,7 @@ describe('Client Cordis inspect catalog', () => { 'create(input: { path: string }): Promise', 'rename(workspaceId: WorkspaceId, title: string): Promise', 'delete(workspaceId: WorkspaceId): Promise', - 'archiveSession(sessionId: SessionId): Promise', + 'archiveSession(sessionId: SessionId, options?: { readonly stopActivity?: boolean }): Promise', 'unarchiveSession(sessionId: SessionId): Promise', 'insertSessionBefore( workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId, ): Promise', ]) @@ -27,7 +27,7 @@ describe('Client Cordis inspect catalog', () => { 'forkSession(sessionId: SessionId): Promise', 'connectWorkspace(workspaceId: WorkspaceId): Promise', 'startSession(workspaceId?: WorkspaceId): void', - 'archiveSession(sessionId: SessionId): Promise', + 'archiveSession(sessionId: SessionId, options?: { readonly stopActivity?: boolean }): Promise', 'unarchiveSession(sessionId: SessionId): Promise', 'pickDirectory(): Promise', 'listDirectory(path?: string, signal?: AbortSignal): Promise', diff --git a/packages/extensions/cordis-client-runner/tsconfig.json b/packages/extensions/cordis-client-runner/tsconfig.json index 5c8875b21a..cfeb3c7ac5 100644 --- a/packages/extensions/cordis-client-runner/tsconfig.json +++ b/packages/extensions/cordis-client-runner/tsconfig.json @@ -27,7 +27,7 @@ "path": "../../client/ui-slots" }, { - "path": "../../client/ui-theme" + "path": "../../client/ui-theme/tsconfig.client.json" } ] } diff --git a/packages/extensions/cordis-host-runner/src/index.ts b/packages/extensions/cordis-host-runner/src/index.ts index 3177924f88..67519f4d9f 100644 --- a/packages/extensions/cordis-host-runner/src/index.ts +++ b/packages/extensions/cordis-host-runner/src/index.ts @@ -45,7 +45,6 @@ export type { } from './registry.ts' export { CordisInspectRegistryService } from './inspect-registry.ts' export type { HostCordisInspectProviderRegistration } from './inspect-registry.ts' -export { HOST_BUILTIN_INSPECTION } from './sandbox.ts' /** * Brand a Host-minted Plugin ID. diff --git a/packages/extensions/cordis-host-runner/src/sandbox.ts b/packages/extensions/cordis-host-runner/src/sandbox.ts index fc0a771e4f..a9537288b1 100644 --- a/packages/extensions/cordis-host-runner/src/sandbox.ts +++ b/packages/extensions/cordis-host-runner/src/sandbox.ts @@ -14,34 +14,6 @@ import { createContext, runInContext, Script } from 'node:vm' import { sandboxDefineTool, sandboxRegisterTool } from './guard.ts' -/** Exact Host closure symbols exposed by the sandbox and guarded Context. */ -export const HOST_BUILTIN_INSPECTION = [ - { - name: 'ctx', - description: 'Restricted Cordis Context. Prefer ctx.get(name) with an undefined check; use inject for hard dependencies.', - signatures: [ - 'ctx.get(name: string): unknown | undefined', - 'ctx.on(name: string, listener: Function): () => void', - 'ctx.provide(name: string, value: unknown): () => void', - 'ctx.effect(callback: Function, label?: string): () => void', - ], - }, - { - name: 'harness', - description: 'Host helpers for Package-private Client RPC and model-visible dynamic Tools.', - signatures: [ - 'harness.handle(method: string, handler: (args: JsonValue) => JsonValue | Promise): () => void', - 'harness.defineTool(definition: ToolDefinition): ToolDefinition', - 'harness.registerTool(ctx: Context, tool: ToolDefinition): () => void', - ], - }, - { name: 'console', description: 'Package-tagged Host logging.', signatures: ['console.log(...values): void', 'console.error(...values): void'] }, - { name: 'btoa', description: 'Encode UTF-8 text as base64.', signatures: ['btoa(value: string): string'] }, - { name: 'atob', description: 'Decode base64 as UTF-8 text.', signatures: ['atob(value: string): string'] }, - { name: 'TextEncoder', description: 'Standard UTF-8 encoder constructor.', signatures: ['new TextEncoder()'] }, - { name: 'TextDecoder', description: 'Standard text decoder constructor.', signatures: ['new TextDecoder(label?: string)'] }, -] as const - /** * A write-through console for one package, tagging every line with the package * id. Write-through (host stdout/stderr), NOT buffered into the tool result: diff --git a/packages/extensions/tool-cordis/README.i18n.yaml b/packages/extensions/tool-cordis/README.i18n.yaml index 3f3feb821d..f75a8b6ecf 100644 --- a/packages/extensions/tool-cordis/README.i18n.yaml +++ b/packages/extensions/tool-cordis/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/extensions/tool-cordis/README.md -README.md: 4e7efefd21e3b7cf40e8ba5fe2291d220dfd796f -README.zh.md: 76331c25f0996c053b39e5b227a50afae84e6bb4 +README.md: 23e2d4a3d047c1442769e07be045f7da4a24a783 +README.zh.md: da855b7b1d4b4287a1f8d3a5ea7a1fb0c9407f2f diff --git a/packages/extensions/tool-cordis/README.md b/packages/extensions/tool-cordis/README.md index 4e7efefd21..23e2d4a3d0 100644 --- a/packages/extensions/tool-cordis/README.md +++ b/packages/extensions/tool-cordis/README.md @@ -25,7 +25,7 @@ Inspect Host and Client runtime APIs before writing plugin code. Creator mode pr ## Use this package -Creator mode includes this toolset. Other compositions mount `@deepseek-ai/dsh-tool-cordis/host` once in the host composition beside the host runner that provides `cordisInspect`, and `@deepseek-ai/dsh-tool-cordis` in each agent preset that exposes the tools; a preset row alone registers no Host providers. Call `cordis_inspect_list` to discover providers, then `cordis_inspect_query` for a provider's exact methods and types. Use [Plugin Manager](../../boot/plugin-manager/README.md) to install bundles containing plugin code or MCP configuration. +Creator mode includes this toolset. Other compositions mount `@deepseek-ai/dsh-tool-cordis/host` once in the host composition beside the host runner that provides `cordisInspect`, and `@deepseek-ai/dsh-tool-cordis` in each agent preset that exposes the tools; a preset row alone registers no Host providers. Call `cordis_inspect_list` to discover providers, then `cordis_inspect_query` for a provider's exact methods and types. The Host `Config` provider lists live Loader entries in pages (`offset`, `limit` up to 100, optional exact plugin `name`; `total` and `nextOffset` bound the walk) with each entry's Loader id, the tree-local id patches address, and its Config status (`schema`, `absent`, `unsupported`, `tree` for group and include carriers, `inactive` for disabled, never imported, or disposed entries), and projects one entry's native Config into a self-contained JSON Schema document beside the entry's `packageDir`, the resolved directory holding the package README and built `lib/`, when the profile package lookup resolves it. Use [Plugin Manager](../../boot/plugin-manager/README.md) to install bundles containing plugin code or MCP configuration. ----- @@ -35,7 +35,7 @@ Creator mode includes this toolset. Other compositions mount `@deepseek-ai/dsh-t

Implementation internals — click to expand -Host providers combine generated Service/Event catalogs and the requesting agent's tool registry. Client providers synchronize their manifests through the existing inspection registry and answer queries from a connected page. The host entry owns the Host provider registrations and the preset row owns the two tools, each through Cordis effects; the registry rejects a duplicate provider id, which is why the providers register once per process rather than per preset. No invariant companion is published because inspection reads its providers directly and maintains no independent runtime projection. +Host providers combine generated Service/Event catalogs, the live Loader tree projected through the app-boot Config projector, and the requesting agent's tool registry. Client providers synchronize their manifests through the existing inspection registry and answer queries from a connected page. The host entry owns the Host provider registrations and the preset row owns the two tools, each through Cordis effects; the registry rejects a duplicate provider id, which is why the providers register once per process rather than per preset. No invariant companion is published because inspection reads its providers directly and maintains no independent runtime projection.
@@ -54,7 +54,7 @@ Host providers combine generated Service/Event catalogs and the requesting agent #### What the model sees -The [tool catalog](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis) describes two read-only inspection tools. The plugin contributes no system prompt section: the tool descriptions state when to call each tool and that queries never invoke business methods. In the `cordis` preset, the first-turn skill catalog carries the descriptions of the two shipped skills, which route plugin, MCP, composition, and destination-less visual requests to the skill covering Plugin Manager, MCP setup, Client packaging, and slot registration. Query results contain the requested API declarations or live tool schemas. +The [tool catalog](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis) describes two read-only inspection tools. The plugin contributes no system prompt section: the tool descriptions state when to call each tool and that queries never invoke business methods. In the `cordis` preset, the first-turn skill catalog carries the descriptions of the two shipped skills, which route plugin, MCP, composition, and destination-less visual requests to the skill covering Plugin Manager, MCP setup, Client packaging, and slot registration. Query results contain the requested API declarations, live tool schemas, the live entry directory with Config status, or one entry's projected Config JSON Schema. #### Token effect @@ -69,6 +69,7 @@ Unchanged tool schemas remain prefix-stable. Query results append to history; en - Client queries wait for a responding page or cancellation. Inspection cannot invoke service methods, configure plugins, or execute generated code. +- `Config.listConfigs` walks the profile Loader tree only. Agent preset `plugins` lists mount in detached preset trees, so a plugin present only inside a preset declaration is not listed unless the profile tree also mounts it. ### Dev Note diff --git a/packages/extensions/tool-cordis/README.zh.md b/packages/extensions/tool-cordis/README.zh.md index 76331c25f0..da855b7b1d 100644 --- a/packages/extensions/tool-cordis/README.zh.md +++ b/packages/extensions/tool-cordis/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -创造模式包含这组工具。其他组合需要在宿主组合里、提供 `cordisInspect` 的 host runner 旁挂载一次 `@deepseek-ai/dsh-tool-cordis/host`,并在每个要暴露这些工具的 agent preset 里挂载 `@deepseek-ai/dsh-tool-cordis`;仅有 preset 行不会注册任何 Host provider。调用 `cordis_inspect_list` 发现 provider,再用 `cordis_inspect_query` 查询其具体方法和类型。通过 [Plugin Manager](../../boot/plugin-manager/README.zh.md) 安装包含插件代码或 MCP 配置的组合包。 +创造模式包含这组工具。其他组合需要在宿主组合里、提供 `cordisInspect` 的 host runner 旁挂载一次 `@deepseek-ai/dsh-tool-cordis/host`,并在每个要暴露这些工具的 agent preset 里挂载 `@deepseek-ai/dsh-tool-cordis`;仅有 preset 行不会注册任何 Host provider。调用 `cordis_inspect_list` 发现 provider,再用 `cordis_inspect_query` 查询其具体方法和类型。Host 的 `Config` provider 分页列出运行中的 Loader entry(`offset`、最多 100 的 `limit`、可选的精确插件 `name`;`total` 与 `nextOffset` 界定遍历),每个 entry 带 Loader id、patch 所寻址的树内 id 及其 Config 状态(`schema`、`absent`、`unsupported`、group 与 include 载体为 `tree`、禁用、未导入或已销毁的 entry 为 `inactive`),并把单个 entry 的原生 Config 投影为自包含的 JSON Schema 文档,同时在 profile 包查找能解析时给出该 entry 的 `packageDir`,即包 README 与构建产物 `lib/` 所在目录。通过 [Plugin Manager](../../boot/plugin-manager/README.zh.md) 安装包含插件代码或 MCP 配置的组合包。 ----- @@ -35,7 +35,7 @@ kind: "package-reference"
实现细节 — 点击展开 -Host provider 结合生成的 Service/Event 目录与请求 agent 的工具注册表。Client provider 通过现有检查注册表同步清单,并从已连接页面回答查询。宿主入口持有 Host provider 的注册,preset 行持有两个工具,都通过 Cordis effect;注册表拒绝重复的 provider id,所以 provider 按进程注册一次而不是按 preset 注册。检查直接读取 provider,不维护独立运行时投影,因此不发布不变式配套插件。 +Host provider 结合生成的 Service/Event 目录、经 app-boot Config 投影器投影的运行中 Loader 树,以及请求 agent 的工具注册表。Client provider 通过现有检查注册表同步清单,并从已连接页面回答查询。宿主入口持有 Host provider 的注册,preset 行持有两个工具,都通过 Cordis effect;注册表拒绝重复的 provider id,所以 provider 按进程注册一次而不是按 preset 注册。检查直接读取 provider,不维护独立运行时投影,因此不发布不变式配套插件。
@@ -54,7 +54,7 @@ Host provider 结合生成的 Service/Event 目录与请求 agent 的工具注 #### 模型所见 -[工具目录](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-cordis) 描述两个只读检查工具。插件不贡献 system prompt 段落:工具描述已说明何时调用每个工具以及查询不会调用业务方法。在 `cordis` preset 中,首轮 skill catalog 携带两个随附技能的描述,把插件、MCP、组合编辑和未指定去向的视觉请求路由到覆盖 Plugin Manager、MCP 设置、Client 打包和 slot 注册的技能。查询结果包含所请求的 API 声明或当前工具 schema。 +[工具目录](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-cordis) 描述两个只读检查工具。插件不贡献 system prompt 段落:工具描述已说明何时调用每个工具以及查询不会调用业务方法。在 `cordis` preset 中,首轮 skill catalog 携带两个随附技能的描述,把插件、MCP、组合编辑和未指定去向的视觉请求路由到覆盖 Plugin Manager、MCP 设置、Client 打包和 slot 注册的技能。查询结果包含所请求的 API 声明、当前工具 schema、带 Config 状态的运行中 entry 目录,或单个 entry 投影后的 Config JSON Schema。 #### Token 影响 @@ -69,6 +69,7 @@ Host provider 结合生成的 Service/Event 目录与请求 agent 的工具注 - Client 查询等待页面响应或取消。检查不能调用服务方法、配置插件或执行生成代码。 +- `Config.listConfigs` 只遍历 profile 的 Loader 树。Agent preset 的 `plugins` 列表挂载在独立的 preset 树中,所以只出现在 preset 声明里的插件不会被列出,除非 profile 树也挂载了它。 ### 开发备注 diff --git a/packages/extensions/tool-cordis/package.json b/packages/extensions/tool-cordis/package.json index b844de74e3..e7db558f31 100644 --- a/packages/extensions/tool-cordis/package.json +++ b/packages/extensions/tool-cordis/package.json @@ -32,17 +32,22 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" - }, - "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-group": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^" } } diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index c80afecf70..ef89008d91 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -84,7 +84,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'agentDefaultModel', summary: 'Owns the default model selection independently of any Host or transport.', - description: 'Owns the default model selection independently of any Host or transport. The composition entry remains usable without a settings provider; when one is mounted, its user layer is read live.', + description: 'Owns the default model selection independently of any Host or transport. Each operation reads the owning Config references.', methods: [ { signature: 'currentSelection(): ModelSelection', @@ -94,9 +94,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async saveSelection(next: ModelSelection): Promise', - description: 'Save the complete default model selection. A deployment without a settings provider keeps its composition entry.', + description: 'Save the complete default model selection. A deployment without a configuration editor keeps its composition entry.', parameters: [{ name: 'next', description: 'resolved selection accepted by an entry point.' }], - returns: 'fulfillment after the optional settings write settles.', + returns: 'fulfillment after the optional profile write settles.', }, ], }, @@ -106,7 +106,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Concrete agent factory and driver service.', methods: [ { - signature: 'readonly config: ResolvedConfig', + signature: 'readonly config: Config', description: 'Validated configuration owned by the agent-loop service.', parameters: [], }, @@ -586,13 +586,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'rebuilt(id: string): string | undefined', - description: 'Publish one completed bundle generation (the HMR watch\'s registration hook — the only entry point through which build changes reach the graph).', + description: 'Publish one completed bundle generation (the HMR watch\'s registration hook — the only entry point through which build changes reach the graph). Unchanged mtime, ctime and size preserve the graph without reading the bundle.', parameters: [{ name: 'id', description: 'entry id (package name).' }], - returns: 'the new rev, or undefined for an unknown id.', + returns: 'the current artifact rev, or undefined for an unknown id.', }, { signature: 'onRebuilt(listener: (id: string, rev: string) => void): () => void', - description: 'Subscribe to bundle rebuilds; fires only when the re-hash changed the rev.', + description: 'Subscribe to bundle rebuilds; fires only when artifact metadata changes the rev.', parameters: [{ name: 'listener', description: 'receives the entry id and its new bundle rev.' }], returns: 'the unsubscriber.', }, @@ -681,6 +681,31 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'configEditor', + summary: 'Persist complete raw configs and apply them through the normal Loader path.', + description: 'Persist complete raw configs and apply them through the normal Loader path.', + methods: [ + { + signature: 'entries(): Entry[]', + description: 'Addressable profile rows; nested Includes have independent configuration ownership.', + parameters: [], + returns: 'Active entries with unique profile patch ids.', + }, + { + signature: 'configuration(): Array<{ entry: Entry; inherited: Record; override: Record }>', + description: 'Read inherited and explicit profile values for the active entries.', + parameters: [], + returns: 'Detached layer values alongside their Loader entries.', + }, + { + signature: 'async edit( entry: Entry, change: (current: Record, inherited: Record) => Record, ): Promise', + description: 'Validate, persist, and reconcile a plugin\'s next config; ordinary fields keep normal lifecycle rules.', + parameters: [{ name: 'entry', description: 'Current Loader entry, also used to detect replacement during the write.' }, { name: 'change', description: 'Derive a raw config from the current entry and its inherited layer.' }], + returns: 'Fulfillment after Loader reconciliation completes.', + }, + ], + }, { key: 'connection', summary: 'Host `ctx.connection` members consumed by transport-independent adapters.', @@ -817,6 +842,67 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'deepseekAccount', + summary: 'Account operations; only Host consumers can obtain a request credential.', + description: 'Account operations; only Host consumers can obtain a request credential.', + methods: [ + { + signature: 'abstract getState(): Promise', + description: 'Read stored-account presence and the latest login attempt.', + parameters: [], + returns: 'a snapshot without credentials or PKCE secrets.', + }, + { + signature: 'abstract getProfile(): Promise', + description: 'Query Platform profile independently of wallet balances.', + parameters: [], + returns: 'profile outcome, or null if signed out or the grant changed during the query.', + }, + { + signature: 'abstract getBalance(): Promise', + description: 'Query Platform recharge and bonus wallet balances independently of profile data.', + parameters: [], + returns: 'balance outcome, or null if signed out or the grant changed during the query.', + }, + { + signature: 'abstract startSignIn(locale: string, callbackOrigin: string, loginSource: \'web\' | \'desktop\'): Promise', + description: 'Join an active attempt or start browser authorization.', + parameters: [{ name: 'locale', description: 'active UI language for a new attempt; joining retains its original language.' }, { name: 'callbackOrigin', description: 'browser-accessible loopback HTTP origin, including any SSH local port.' }, { name: 'loginSource', description: 'initiating UI, used to return from a failed exchange.' }], + returns: 'the initial snapshot without waiting for browser approval.', + }, + { + signature: 'abstract cancelSignIn(id: SignInAttemptId): Promise', + description: 'Cancel only the named attempt; committing attempts settle before returning.', + parameters: [{ name: 'id', description: 'attempt identity from this Host.' }], + returns: 'state after cancellation or an already-started commit.', + }, + { + signature: 'abstract signOut(): Promise', + description: 'Remove the local grant while retaining API keys and tasks; the provider revokes it in the background.', + parameters: [], + returns: 'the signed-out state after local removal; remote failures never restore the grant.', + }, + { + signature: 'abstract watch(signal: AbortSignal): AsyncIterable', + description: 'Subscribe to snapshots including a complete initial state.', + parameters: [{ name: 'signal', description: 'subscription lifetime; ending it never cancels login.' }], + returns: 'complete snapshots as account state changes.', + }, + { + signature: 'abstract resolveToken(url: string): Promise', + description: 'Resolve a credential only for the inference origin allowed by the provider.', + parameters: [{ name: 'url', description: 'actual request destination or API base URL.' }], + returns: 'stored token, or undefined for other origins or a signed-out account.', + }, + { + signature: 'abstract getPlatformSession(): Promise', + description: 'Read credentials for the configured Platform origin, bound to their issuing environment.', + parameters: [], + returns: 'a Host-only snapshot, or null while signed out.', + }, + ], + }, { key: 'deepseekLlmApiExtensions', summary: 'Registry of independently owned top-level fields for official DeepSeek requests.', @@ -1427,7 +1513,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: '@Remote async render( workspaceFileScope: WorkspaceFileScope, path: string, priority: OfficeToPdfPriority, signal: AbortSignal, ): Promise', description: 'Read and convert one Office file using the Session\'s ordinary filesystem authorization.', parameters: [{ name: 'workspaceFileScope', description: 'Session header lookup shared with workspaceFiles.' }, { name: 'path', description: 'absolute or workspace-relative Office path.' }, { name: 'priority', description: 'foreground preview or speculative background work.' }, { name: 'signal', description: 'Remote cancellation; disposal also cancels outstanding reads and conversions.' }], - returns: 'complete base64 PDF with original source identity and missing font families.', + returns: 'complete PDF bytes with original source identity and missing font families.', }, { signature: '@Remote(\'generation\') getGeneration(signal: AbortSignal): OfficeToPdfGeneration', @@ -1567,6 +1653,18 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'productTelemetry', + summary: 'Host analytics sender.', + description: 'Host analytics sender. Mounting alone sends nothing; the owning fiber drains it on unload.', + methods: [ + { + signature: 'emit(record: ProductTelemetryRecord): void', + description: 'Enqueue one selected product event without waiting for network delivery. Queue admission and shutdown completion are not collector or warehouse acknowledgements.', + parameters: [{ name: 'record', description: 'caller-owned event containing only approved analytics fields.' }], + }, + ], + }, { key: 'profileContext', summary: 'Current profile facts; scheduling and mutation belong to their callers.', @@ -2250,63 +2348,42 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'settings', - summary: 'Abstract settings service.', - description: 'Abstract settings service. Providers implement raw-document storage (`load`/`persist`) and push external changes through Settings.publish; the base class owns namespace registration, resolution, validation, change detection, and the `settings/updated` commit event.', + summary: 'Project Config schemas into forms and own optional instance-level UI policy.', + description: 'Project Config schemas into forms and own optional instance-level UI policy.', methods: [ { - signature: 'abstract readonly writable: boolean', - description: 'Whether update may persist through this provider.', + signature: 'configure(presentation: { auto?: boolean }, owner: Fiber = this.ctx.fiber): () => void', + description: 'Register the calling plugin instance\'s page policy without changing its Config.', + parameters: [{ name: 'presentation', description: 'Automatic-page policy for this instance; `auto` defaults to true.' }, { name: 'owner', description: 'Plugin instance the policy belongs to; defaults to the calling fiber.' }], + returns: 'Disposer; register it with the calling plugin\'s effects.', + throws: ['If this instance already has a registered policy.'], + }, + { + signature: 'prepareDocument(): Promise', + description: 'Locate the profile patch for native editing.', parameters: [], - }, - { - signature: 'prepareDocument(): Promise', - description: 'Prepare the provider\'s user-editable document for a native editor. File providers may materialize an absent document before returning its path; non-file providers return undefined.', - parameters: [], - returns: 'the absolute local document path, or undefined for non-file storage.', - }, - { - signature: 'register( ns: Namespace & SettingsNamespaceInput, schema: z, options?: SettingsRegisterOptions, ): SettingsScope', - description: 'Register a namespace schema and receive its owner scope. The registration is an effect on the calling plugin\'s fiber: disposing that fiber removes the namespace and its observers. An invalid stored section fails the registration itself — the earliest point where the schema can judge it.', - parameters: [{ name: 'ns', description: 'unique namespace; duplicate registration fails loud.' }, { name: 'schema', description: 'schemastery schema resolving this namespace\'s value.' }, { name: 'options', description: 'composition `base` layer and effect timing.' }], - returns: 'the owner scope for reads, observation, and updates.', - throws: ['{TypeError} when `ns` is not a lowercase hyphenated identifier.'], - }, - { - signature: 'installSection( owner: Context, ns: Namespace & SettingsNamespaceInput, schema: z, entry: T, hooks: SettingsSectionHooks, ): void', - description: 'Attach one optional-settings consumer to this provider. The consumer registers its composition entry as the base layer while this provider is present, then falls back to that entry if the provider detaches.', - parameters: [{ name: 'owner', description: 'consumer context whose unload suppresses fallback work.' }, { name: 'ns', description: 'consumer-owned settings namespace.' }, { name: 'schema', description: 'schema resolving the namespace.' }, { name: 'entry', description: 'composition entry used as the base and fallback value.' }, { name: 'hooks', description: 'source sink, change notification, and optional validation.' }], - throws: ['{TypeError} when `ns` is not a lowercase hyphenated identifier.'], + returns: 'The existing profile patch path.', }, { signature: 'describe(options?: SettingsDescribeOptions): SettingsDescriptor[]', - description: 'Describe every registered namespace for configuration surfaces, including the composition `base` and raw user layers so a form can mark which fields the user overrode (presence in `user`) and what a reset returns to.', - parameters: [{ name: 'options', description: 'redaction switch; wire surfaces must redact.' }], - returns: 'one descriptor per registered namespace, in registration order.', + description: 'Read active plugin schemas and their live values.', + parameters: [{ name: 'options', description: 'Redaction required for remote callers.' }], + returns: 'Forms keyed by unique profile entry ids.', }, { - signature: 'get(ns: Namespace & SettingsNamespaceInput): unknown', - description: 'Read one registered namespace\'s resolved value.', - parameters: [{ name: 'ns', description: 'the namespace to read.' }], - returns: 'the resolved value, or `undefined` while unregistered.', - throws: ['{TypeError} when `ns` is not a lowercase hyphenated identifier.'], + signature: 'async update(ns: string, patch: object, expectedRevision?: number): Promise', + description: 'Merge editable fields into an entry\'s config.', + parameters: [{ name: 'ns', description: 'Profile entry id.' }, { name: 'patch', description: 'Fields to merge.' }, { name: 'expectedRevision', description: 'Revision returned by describe.' }], }, { - signature: 'async update( ns: Namespace & SettingsNamespaceInput, patch: object, expectedRevision?: number, ): Promise', - description: 'Merge a patch into one registered namespace\'s user layer, validate the resolved candidate, persist through the provider, then commit and emit. A validation failure rejects before anything is persisted. Writes to one namespace are serialized: concurrent updates apply in call order, each merging over the previous write\'s committed section.', - parameters: [{ name: 'ns', description: 'the registered namespace to update.' }, { name: 'patch', description: 'plain-object patch over the user section.' }, { name: 'expectedRevision', description: 'the descriptor `revision` the caller read; a namespace that moved past it rejects with {@link SettingsConflictError}.' }], - throws: ['{TypeError} when `ns` is not a lowercase hyphenated identifier.'], + signature: 'async replace(ns: string, section: object, expectedRevision?: number): Promise', + description: 'Reset all live fields, then set the supplied fields; ordinary config is preserved.', + parameters: [{ name: 'ns', description: 'Profile entry id.' }, { name: 'section', description: 'Complete form values.' }, { name: 'expectedRevision', description: 'Revision returned by describe.' }], }, { - signature: 'async replace( ns: Namespace & SettingsNamespaceInput, section: object, expectedRevision?: number, ): Promise', - description: 'Replace one registered namespace\'s user section wholesale, validate, persist, then commit and emit. Keys absent from `section` fall back to the composition `base` and schema defaults — this is the removal/reset path a merge-only patch cannot express (`replace({})` re-inherits everything).', - parameters: [{ name: 'ns', description: 'the registered namespace to replace.' }, { name: 'section', description: 'the complete next user section.' }, { name: 'expectedRevision', description: 'the descriptor `revision` the caller read; a namespace that moved past it rejects with {@link SettingsConflictError}.' }], - throws: ['{TypeError} when `ns` is not a lowercase hyphenated identifier.'], - }, - { - signature: 'async mutate( ns: Namespace & SettingsNamespaceInput, ops: readonly SettingsPathOp[], expectedRevision?: number, ): Promise', - description: 'Apply path-addressed edits to one registered namespace\'s user section, validate, persist, then commit and emit. The ops are applied to the section as it stands when the write reaches the front of the queue, so a caller never has to restate fields it did not touch — and, crucially, cannot delete fields it never saw. This is the write path for any caller holding a redacted view; `replace` remains the wholesale reset.', - parameters: [{ name: 'ns', description: 'the registered namespace to edit.' }, { name: 'ops', description: 'ordered path edits; later ops observe earlier ones.' }, { name: 'expectedRevision', description: 'the descriptor `revision` the caller read; a namespace that moved past it rejects with {@link SettingsConflictError}.' }], - throws: ['{TypeError} when `ns` is not a lowercase hyphenated identifier.'], + signature: 'async mutate(ns: string, ops: readonly SettingsPathOp[], expectedRevision?: number): Promise', + description: 'Apply field edits without restating redacted secrets; unsetting an array index removes its element.', + parameters: [{ name: 'ns', description: 'Profile entry id.' }, { name: 'ops', description: 'Ordered form edits.' }, { name: 'expectedRevision', description: 'Revision returned by describe.' }], }, ], }, @@ -2507,9 +2584,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async configure(patch: SpeechSelectionPatch): Promise', - description: 'Persist changed preference fields; the resulting language must be accepted by the selected provider.', + description: 'Persist changed selection fields into this plugin\'s profile entry; the resulting language must be accepted by the selected provider.', parameters: [{ name: 'patch', description: 'explicit provider or language changes.' }], - returns: 'after persistence and the resolved preference update.', + returns: 'after the profile write and the live update it applies.', }, { signature: 'prepare(id: SpeechProviderId): void', @@ -3475,10 +3552,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the complete committed workspace order.', }, { - signature: 'archiveSession(sessionId: SessionId): Promise', - description: 'Archive one session durably. The session must exist (live or in session persistence); its workspace accounting — or lack of one — is irrelevant. Archiving drops the session\'s pin in the same durable write (pinning and archival are mutually exclusive). An already archived id resolves without writing.', - parameters: [{ name: 'sessionId', description: 'The session to archive.' }], - returns: 'resolution after durability.', + signature: 'archiveSession(sessionId: SessionId, options: ArchiveSessionOptions = {}): Promise', + description: 'Archive one session durably. The session must exist (live or in session persistence); its workspace accounting — or lack of one — is irrelevant. Without `stopActivity` the session must also be inactive: the `workspace/session-activity` waterfall is asked once, and any reported activity rejects with WorkspaceActiveSessionError before anything is written. With `stopActivity` the archive is written without an activity check, and the `workspace/session-stop` providers are then asked to stop the session\'s work: the durable archive set is what a provider\'s `agent/pre-step` gate reads, so every wake the stops induce is already blocked. Archiving drops the session\'s pin in the same durable write (pinning and archival are mutually exclusive). An already archived id resolves without writing, asking, or stopping.', + parameters: [{ name: 'sessionId', description: 'The session to archive.' }, { name: 'options', description: 'Whether running work is stopped instead of refusing.' }], + returns: 'resolution after durability and, with `stopActivity`, after every stop request was issued.', }, { signature: 'unarchiveSession(sessionId: SessionId): Promise', @@ -3662,6 +3739,14 @@ export const EVENT_API: readonly EventApiEntry[] = [ description: 'One Agent changed running state.', parameters: [{ name: 'sessionId', description: 'Agent and Session identity.' }, { name: 'running', description: 'whether the Agent is running.' }], }, + { + name: 'app-boot/config-reload', + mode: 'emit', + signature: '\'app-boot/config-reload\'(): void', + summary: 'Profile patches were reconciled into the running Loader tree: every entry update settled and no new inactive entry was introduced.', + description: 'Profile patches were reconciled into the running Loader tree: every entry update settled and no new inactive entry was introduced. Carries no diff; listeners re-read Loader entries.', + parameters: [], + }, { name: 'approval/request', mode: 'waterfall', @@ -3930,17 +4015,9 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'settings/document-updated', mode: 'emit', signature: '\'settings/document-updated\'(ns: SettingsNamespace, revision: number): void', - summary: 'One registered namespace\'s RAW user section changed, whether or not the resolved value did.', - description: 'One registered namespace\'s RAW user section changed, whether or not the resolved value did. `settings/updated` is the consumer-facing event and stays deep-equal-gated; this one exists for configuration surfaces, which must learn that a field went from inherited to overridden (same resolved value, different meaning) and that their held revision is stale. Listener containment matches `settings/updated`.', - parameters: [{ name: 'ns', description: 'the namespace whose stored section changed.' }, { name: 'revision', description: 'the namespace\'s new revision.' }], - }, - { - name: 'settings/updated', - mode: 'emit', - signature: '\'settings/updated\'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void', - summary: 'Committed change to one registered namespace\'s resolved value.', - description: 'Committed change to one registered namespace\'s resolved value. Emitted after the provider persisted (for `update`) or published (`provider`) the change; never emitted when the resolved value is deep-equal. Listener failures are contained and logged — a sync throw and an async rejection alike — except `INVARIANT`-coded failures, which rethrow after every listener ran; that rethrow reaches the emitter only from synchronous listeners, so invariant checks on this event must not be async functions.', - parameters: [{ name: 'ns', description: 'the namespace whose resolved value changed.' }, { name: 'next', description: 'the new resolved value.' }, { name: 'prev', description: 'the previous resolved value.' }, { name: 'source', description: 'whether the change entered through `update()` or the provider.' }], + summary: 'One profile entry\'s form values, availability, or page policy changed.', + description: 'One profile entry\'s form values, availability, or page policy changed. Form clients re-read its schema, resolved values, and revision.', + parameters: [{ name: 'ns', description: 'Profile entry id.' }, { name: 'revision', description: 'The entry\'s new revision.' }], }, { name: 'skills/change', @@ -4110,10 +4187,50 @@ export const EVENT_API: readonly EventApiEntry[] = [ description: 'A workflow run started — the script\'s meta block validated, the body about to execute. Paired with Events[\'workflow/end\'].', parameters: [{ name: 'info', description: 'the run\'s identity snapshot (id + meta).' }], }, + { + name: 'workspace/session-activity', + mode: 'waterfall', + signature: '\'workspace/session-activity\'( request: SessionActivityRequest, next: () => Promise, ): Promise', + summary: 'Ask the composed providers what still runs for a session before it is archived.', + description: 'Ask the composed providers what still runs for a session before it is archived. A listener prepends its own SessionActivity entries to the result of `next()`; the registry\'s innermost callback returns an empty list, so a composition without providers archives freely. Any non-empty result refuses the archive without a write.', + parameters: [{ name: 'request', description: 'the session about to be archived.' }, { name: 'next', description: 'delegate to the remaining providers.' }], + }, + { + name: 'workspace/session-stop', + mode: 'parallel', + signature: '\'workspace/session-stop\'(request: SessionActivityRequest): Promise | void', + summary: 'Stop a session\'s running work because the caller archived it with `stopActivity`; the archive set is durable when this dispatches.', + description: 'Stop a session\'s running work because the caller archived it with `stopActivity`; the archive set is durable when this dispatches. Each provider stops its own families — cancelling a turn, its subagent descendants, owned jobs, or active schedules — through the same cancel paths the user\'s own stop actions use, so the session log ends every open turn regularly and a later unarchive can continue the conversation. Listeners issue their stop requests without waiting for running work to settle; a listener may await its own durability barrier. A rejection is logged by the registry and does not undo the archive.', + parameters: [{ name: 'request', description: 'the session being archived.' }], + }, ] /** Shapes of every exported type the Service and Event signatures reference (transitively), sorted by name. */ export const TYPE_API: readonly TypeApiEntry[] = [ + { + name: 'AccountDetails', + declaration: 'export interface AccountDetails {\n readonly profile: {\n readonly status: \'ready\';\n readonly value: AccountProfile;\n } | {\n readonly status: \'failed\';\n };\n readonly balance: {\n readonly status: \'ready\';\n readonly value: readonly AccountWallet[];\n readonly bonusWallets: readonly AccountWallet[];\n } | {\n readonly status: \'failed\';\n };\n}', + }, + { + name: 'AccountLinks', + declaration: 'export interface AccountLinks {\n readonly usageUrl: string;\n readonly topUpUrl: string;\n}', + }, + { + name: 'AccountProfile', + declaration: 'export interface AccountProfile {\n readonly id: AccountUserId | null;\n readonly name: string | null;\n readonly contact: string | null;\n readonly avatarUrl?: string | null;\n}', + }, + { + name: 'AccountUserId', + declaration: 'export type AccountUserId = Branded<\'AccountUserId\'>;', + }, + { + name: 'AccountView', + declaration: 'export interface AccountView {\n readonly status: \'signed-out\' | \'credential-stored\';\n readonly links: AccountLinks;\n readonly attempt: SignInAttemptView | null;\n}', + }, + { + name: 'AccountWallet', + declaration: 'export interface AccountWallet {\n readonly currency: \'CNY\' | \'USD\';\n readonly balance: string;\n}', + }, { name: 'AdapterRegistrationHandle', declaration: 'export interface AdapterRegistrationHandle {\n (): void;\n replace(providers: string[]): void;\n}', @@ -4202,6 +4319,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ApprovalRequestEvent', declaration: 'export interface ApprovalRequestEvent {\n readonly agent: Agent;\n readonly toolName: string;\n readonly callId?: ToolCallId;\n readonly reason?: string;\n readonly signal?: AbortSignal;\n}', }, + { + name: 'ArchiveSessionOptions', + declaration: 'export interface ArchiveSessionOptions {\n readonly stopActivity?: boolean;\n}', + }, { name: 'AskUserQuestionAnswer', declaration: 'export interface AskUserQuestionAnswer {\n answers: AskUserQuestionAnswerItem[];\n}', @@ -4312,7 +4433,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AuthorizationSession', - declaration: 'export interface AuthorizationSession {\n readonly method: string;\n readonly signal: AbortSignal;\n notify(notice: AuthorizationNotice): void;\n prompt(prompt: AuthorizationPrompt): Promise;\n}', + declaration: 'export interface AuthorizationSession {\n readonly method: string;\n readonly signal: AbortSignal;\n commit(record: CredentialRecord): Promise;\n notify(notice: AuthorizationNotice): void;\n prompt(prompt: AuthorizationPrompt): Promise;\n}', }, { name: 'AuthorizationSettlement', @@ -4364,7 +4485,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ClientArtifactBaseline', - declaration: 'export interface ClientArtifactBaseline {\n readonly path: string;\n readonly mtimeMs: number;\n readonly size: number;\n}', + declaration: 'export interface ClientArtifactBaseline {\n readonly path: string;\n readonly mtimeMs: number;\n readonly ctimeMs: number;\n readonly size: number;\n}', }, { name: 'CollectedOutput', @@ -5476,7 +5597,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PermissionCatalog', - declaration: 'export interface PermissionCatalog {\n options: PresetOption[];\n}', + declaration: 'export interface PermissionCatalog {\n options: PresetOption[];\n defaultOptions: PresetOption[];\n defaultPreset: string;\n}', + }, + { + name: 'PlatformSession', + declaration: 'export interface PlatformSession {\n readonly origin: string;\n readonly token: string;\n readonly embeddedPageDist?: string;\n readonly requestHeaders?: Readonly>;\n}', }, { name: 'PluginChange', @@ -5582,6 +5707,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PreToolDecision', declaration: 'export type PreToolDecision = {\n kind: \'allow\';\n} | {\n kind: \'deny\';\n reason: string;\n info?: ToolErrorInfo;\n} | {\n kind: \'cancel\';\n} | {\n kind: \'ask\';\n reason?: string;\n};', }, + { + name: 'ProductTelemetryRecord', + declaration: 'export interface ProductTelemetryRecord {\n eventName: string;\n body: string;\n timestamp: number;\n severityNumber?: SeverityNumber;\n attributes?: Record>;\n}', + }, + { + name: 'ProductTelemetryScalar', + declaration: 'export type ProductTelemetryScalar = string | number | boolean;', + }, { name: 'ProfilePnpmInvocation', declaration: 'export interface ProfilePnpmInvocation {\n readonly command: string;\n readonly args: readonly string[];\n readonly env: Readonly>;\n}', @@ -5736,7 +5869,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RenderedDocumentBytes', - declaration: 'export interface RenderedDocumentBytes extends Omit {\n readonly data: string;\n readonly missingFonts: string[];\n readonly generation: OfficeToPdfGeneration;\n}', + declaration: 'export interface RenderedDocumentBytes extends WorkspaceFileBytes {\n readonly missingFonts: string[];\n readonly generation: OfficeToPdfGeneration;\n}', }, { name: 'ReplayEnvelope', @@ -5902,6 +6035,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionAccess', declaration: 'export type SessionAccess = \'read\' | \'write\';', }, + { + name: 'SessionActivity', + declaration: 'export interface SessionActivity {\n readonly kind: SessionActivityKind;\n readonly items?: readonly SessionActivityItem[];\n}', + }, + { + name: 'SessionActivityItem', + declaration: 'export interface SessionActivityItem {\n readonly id: string;\n readonly label?: string;\n}', + }, + { + name: 'SessionActivityKind', + declaration: 'export type SessionActivityKind = keyof SessionActivityKindMap;', + }, + { + name: 'SessionActivityKindMap', + declaration: 'export interface SessionActivityKindMap {\n}', + }, + { + name: 'SessionActivityRequest', + declaration: 'export interface SessionActivityRequest {\n readonly sessionId: SessionId;\n}', + }, { name: 'SessionAddress', declaration: 'export type SessionAddress = {\n readonly kind: \'session\';\n readonly sessionId: SessionId;\n} | {\n readonly kind: \'subagent\';\n readonly parentSessionId: SessionId;\n readonly childSessionId: SessionId;\n readonly mode: \'one-shot\' | \'continuable\' | \'unknown\';\n};', @@ -6394,10 +6547,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionWorkspacePathApplication', declaration: 'export type SessionWorkspacePathApplication = NativeFileApplication;', }, - { - name: 'SettingsApplies', - declaration: 'export type SettingsApplies = \'live\' | \'restart\';', - }, { name: 'SettingsDescribeOptions', declaration: 'export interface SettingsDescribeOptions {\n redactSecrets?: boolean;\n}', @@ -6408,7 +6557,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SettingsDescriptor', - declaration: 'export interface SettingsDescriptor {\n ns: SettingsNamespace;\n schema: unknown;\n value: unknown;\n revision: number;\n base?: unknown;\n user?: unknown;\n applies: SettingsApplies;\n secrets?: RedactedSecret[];\n}', + declaration: 'export interface SettingsDescriptor {\n ns: SettingsNamespace;\n autoGenerate: boolean;\n schema: unknown;\n value: unknown;\n revision: number;\n base?: unknown;\n user?: unknown;\n applies: \'live\';\n secrets?: RedactedSecret[];\n}', }, { name: 'SettingsDocumentOpenValue', @@ -6420,7 +6569,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SettingsNamespaceView', - declaration: 'export interface SettingsNamespaceView {\n ns: string;\n schema: JsonValue;\n value: JsonValue;\n base?: JsonValue;\n user?: JsonValue;\n applies: \'live\' | \'restart\';\n secrets: SettingsSecretView[];\n revision: number;\n}', + declaration: 'export interface SettingsNamespaceView {\n autoGenerate: boolean;\n ns: string;\n schema: JsonValue;\n value: JsonValue;\n base?: JsonValue;\n user?: JsonValue;\n applies: \'live\';\n secrets: SettingsSecretView[];\n revision: number;\n}', }, { name: 'SettingsPathOp', @@ -6430,22 +6579,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SettingsPathOpView', declaration: 'export type SettingsPathOpView = {\n op: \'set\';\n path: string[];\n value: JsonValue;\n} | {\n op: \'unset\';\n path: string[];\n};', }, - { - name: 'SettingsRegisterOptions', - declaration: 'export interface SettingsRegisterOptions {\n base?: Partial;\n applies?: SettingsApplies;\n validate?: (value: T) => void;\n}', - }, { name: 'SettingsSecretView', declaration: 'export interface SettingsSecretView {\n path: string[];\n set: boolean;\n}', }, - { - name: 'SettingsSectionHooks', - declaration: 'export interface SettingsSectionHooks {\n setSource(current: () => T): void;\n onChange(): void;\n validate?: (value: T) => void;\n}', - }, - { - name: 'SettingsUpdateSource', - declaration: 'export type SettingsUpdateSource = \'update\' | \'provider\';', - }, { name: 'ShellExecRequest', declaration: 'export interface ShellExecRequest {\n command: string;\n workdir?: string | undefined;\n timeoutMs?: number | undefined;\n onExpiry?: ShellExpiryPolicy | undefined;\n stdoutMaxBytes?: number | undefined;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxPolicy?: SandboxExecutionPolicy | undefined;\n}', @@ -6486,6 +6623,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ShellSandboxInfo', declaration: 'export interface ShellSandboxInfo {\n mode: SandboxMode;\n denied: boolean;\n enforcement?: SandboxEnforcement;\n runnerFailed?: boolean;\n}', }, + { + name: 'SignInAttemptId', + declaration: 'export type SignInAttemptId = Branded<\'SignInAttemptId\'>;', + }, + { + name: 'SignInAttemptView', + declaration: 'export interface SignInAttemptView {\n readonly id: SignInAttemptId;\n readonly phase: \'initializing\' | \'waiting-browser\' | \'exchanging\' | \'committing\' | \'succeeded\' | \'cancelled\' | \'expired\' | \'failed\';\n readonly authorizeUrl?: string;\n readonly expiresAt?: number;\n readonly errorCode?: SignInErrorCode;\n}', + }, + { + name: 'SignInErrorCode', + declaration: 'export type SignInErrorCode = \'network\' | \'protocol\' | \'expired\' | \'storage\';', + }, { name: 'SkillCandidate', declaration: 'export interface SkillCandidate extends SkillSummary {\n readonly rank: number;\n readonly locator: unknown;\n readonly metadata?: Readonly>;\n}', @@ -6728,7 +6877,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentRuntime', - declaration: 'export class SubagentRuntime extends TypertRemoteService {\n static Config: z;\n constructor(ctx: Context, config: Config);\n resolveMaxDepth(configured?: number | \'provider-managed\'): number | undefined;\n async startContinuable(spec: ContinuableStartSpec): Promise;\n async sendMessage(sender: Agent, targetId: SessionId, content: ContentBlock[], options: SubagentSendMessageOptions): Promise;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async drainContinuableDescendants(parents: readonly Agent[]): Promise;\n async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise;\n listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise;\n listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise;\n @Remote(\'prompt\')\n async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise;\n @Remote(\'interruptByParent\')\n interruptByParent(childSessionId: SessionId, parentSessionId: SessionId, mode: \'continuable\'): SubagentInterruptReceipt;\n registerProvider(provider: SubagentProvider): () => void;\n getProvider(name: string): SubagentProvider | undefined;\n list(): string[];\n async start(name: string, request: SubagentStartRequest): Promise;\n}', + declaration: 'export class SubagentRuntime extends TypertRemoteService {\n static Config;\n constructor(ctx: Context, private config: Config);\n resolveMaxDepth(configured?: number | \'provider-managed\'): number | undefined;\n async startContinuable(spec: ContinuableStartSpec): Promise;\n async sendMessage(sender: Agent, targetId: SessionId, content: ContentBlock[], options: SubagentSendMessageOptions): Promise;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async drainContinuableDescendants(parents: readonly Agent[]): Promise;\n async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise;\n listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise;\n listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise;\n @Remote(\'prompt\')\n async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise;\n @Remote(\'interruptByParent\')\n interruptByParent(childSessionId: SessionId, parentSessionId: SessionId, mode: \'continuable\'): SubagentInterruptReceipt;\n registerProvider(provider: SubagentProvider): () => void;\n getProvider(name: string): SubagentProvider | undefined;\n list(): string[];\n async start(name: string, request: SubagentStartRequest): Promise;\n}', }, { name: 'SubagentSendMessageOptions', @@ -7014,6 +7163,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ToolAdditionBlock', declaration: 'export interface ToolAdditionBlock {\n type: \'tool-addition\';\n toolName: string;\n tool?: never;\n}', }, + { + name: 'ToolCallBlock', + declaration: 'export interface ToolCallBlock {\n type: \'tool-call\';\n id: ToolCallId;\n name: string;\n arguments: string;\n}', + }, + { + name: 'ToolCallId', + declaration: 'export type ToolCallId = Branded<\'ToolCallId\'>;', + }, { name: 'ToolCallKind', declaration: 'export type ToolCallKind = \'read\' | \'edit\' | \'delete\' | \'move\' | \'search\' | \'execute\' | \'fetch\' | \'other\';', @@ -7428,7 +7585,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'WorkspaceArchiveSessionRequest', - declaration: 'export interface WorkspaceArchiveSessionRequest {\n readonly sessionId: SessionId;\n}', + declaration: 'export interface WorkspaceArchiveSessionRequest {\n readonly sessionId: SessionId;\n readonly stopActivity?: boolean;\n}', }, { name: 'WorkspaceArchiveValue', diff --git a/packages/extensions/tool-cordis/src/config.ts b/packages/extensions/tool-cordis/src/config.ts new file mode 100644 index 0000000000..b1067be9ac --- /dev/null +++ b/packages/extensions/tool-cordis/src/config.ts @@ -0,0 +1,120 @@ +/** Live plugin Config discovery projected from the running Loader tree. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Entry } from '@deepseek-ai/cordis-plugin-loader' +// Declares `Context.pluginPackages`, the profile package lookup that resolves an entry's package directory. +import { createConfigProjector, isNativeConfigSchema, LOADER_EXPRESSION_SCHEMA, type NativeConfigSchema } from '@deepseek-ai/dsh-app-boot' +import type { JsonValue } from '@deepseek-ai/dsh-util-values' + +/** + * `schema`: the running plugin declares a native Schemastery Config. `absent`: it runs without one. + * `unsupported`: its Config is not a native Schemastery graph. `tree`: a Loader carrier (`cordis:group`, + * `cordis:include`) whose `config` is a child entry list, not a plugin Config. `inactive`: the entry is + * disabled, was never imported, or its fiber has been disposed, so no Config is read. A plugin whose + * `apply` failed keeps its fiber and still reports its Config. + */ +type ConfigStatus = 'schema' | 'absent' | 'unsupported' | 'tree' | 'inactive' + +interface LiveConfig { + /** Loader entry id, unique across nested include trees (`parent:child`). */ + id: string + /** Tree-local id that profile patches address. */ + patchId: string + name: string + status: ConfigStatus + native?: NativeConfigSchema +} + +function liveConfig(entry: Entry): LiveConfig { + const { id, name } = entry.options + const listed = { id: entry.id, patchId: id, name } + if (entry.subgroup !== undefined || entry.subtree !== undefined) return { ...listed, status: 'tree' } + const fiber = entry.fiber + if (fiber === undefined || fiber.uid === null || entry.disabled) return { ...listed, status: 'inactive' } + const config: unknown = fiber.runtime?.Config + if (config === undefined || config === null) return { ...listed, status: 'absent' } + if (!isNativeConfigSchema(config)) return { ...listed, status: 'unsupported' } + return { ...listed, status: 'schema', native: config } +} + +function listing({ id, patchId, name, status }: LiveConfig): { [key: string]: JsonValue } { + return { id, patchId, name, status } +} + +/** Model-supplied query fields, validated at the tool JSON boundary. */ +interface ConfigQuery { + entry: string | undefined + name: string | undefined + offset: number + limit: number +} + +const DEFAULT_LIMIT = 25 +const MAX_LIMIT = 100 + +function parseQuery(input: JsonValue | undefined): ConfigQuery { + const fields = input !== undefined && input !== null && typeof input === 'object' && !Array.isArray(input) ? input : {} + const text = (key: string): string | undefined => { + const value = fields[key] + if (value === undefined) return undefined + if (typeof value !== 'string' || value === '') throw new Error(`${key} must be a non-empty string`) + return value + } + const offset = fields.offset ?? 0 + const limit = fields.limit ?? DEFAULT_LIMIT + if (!Number.isInteger(offset) || typeof offset !== 'number' || offset < 0) throw new Error('offset must be a non-negative integer') + if (!Number.isInteger(limit) || typeof limit !== 'number' || limit < 1 || limit > MAX_LIMIT) { + throw new Error(`limit must be an integer from 1 to ${MAX_LIMIT}`) + } + return { entry: text('entry'), name: text('name'), offset, limit } +} + +/** Resolve the package directory behind an entry's plugin name through the profile package lookup. */ +function packageDir(ctx: Context, entry: Entry): { packageDir?: string } { + const baseUrl = entry.parent.tree.ctx.baseUrl + const dir = baseUrl === undefined ? undefined : ctx.get('pluginPackages')?.packageOf(entry.options.name, baseUrl)?.dir + return dir === undefined ? {} : { packageDir: dir } +} + +/** Projection output is JSON by construction; the CLI schema dump serializes the same values. */ +function toJson(value: object): JsonValue { + return JSON.parse(JSON.stringify(value)) as JsonValue +} + +/** + * Answer the `Config.listConfigs` inspect query from the live Loader tree. + * Without `entry`, return one page of the directory: each entry's Loader id, patch id, plugin name, and Config + * status, optionally filtered to one exact plugin `name`, with `total` and `nextOffset` (null on the last page). + * With `entry`, return that entry with its resolved `packageDir` (the directory holding the package README and + * built `lib/`, when the profile package lookup resolves it) and project its native Config into one self-contained + * JSON Schema 2020-12 document whose `$defs` carry the shared and `loaderExpression` definitions, plus omission + * acceptance and projection limitations. + * Validators and transform callbacks are not executed; runtime-created Agent preset trees are outside the Loader. + * @param ctx - Host context; its `loader`, when the profile was mounted by one, owns the entry tree. + * @param input - model-supplied query: `entry` (Loader entry id), or `name`, `offset` (default 0), and `limit` (1 to 100, default 25). + * @returns one directory page, or one entry's status and projected schema. + * @throws when no Loader mounted this Host, when a query field is malformed, or when the entry id matches no live entry. + */ +export async function queryLiveConfig(ctx: Context, input: JsonValue | undefined): Promise { + const loader = ctx.get('loader') + if (loader === undefined) throw new Error('Config inspection requires the Loader that mounted this profile; this Host was composed without one') + const query = parseQuery(input) + const entries = [...loader.entries()].map(liveConfig) + if (query.entry === undefined) { + const matching = query.name === undefined ? entries : entries.filter(candidate => candidate.name === query.name) + const page = matching.slice(query.offset, query.offset + query.limit).map(listing) + const end = query.offset + page.length + return { entries: page, total: matching.length, nextOffset: end < matching.length ? end : null } + } + const entry = entries.find(candidate => candidate.id === query.entry) + if (entry === undefined) throw new Error(`unknown entry id ${JSON.stringify(query.entry)}`) + const listed = { ...listing(entry), ...packageDir(ctx, loader.resolve(query.entry)) } + if (entry.native === undefined) return listed + const project = await createConfigProjector() + const { schema, definitions, acceptsMissing, limitations } = project(entry.native, 'config') + return toJson({ ...listed, acceptsMissing, limitations, schema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $defs: { ...definitions, loaderExpression: LOADER_EXPRESSION_SCHEMA }, + ...schema, + } }) +} diff --git a/packages/extensions/tool-cordis/src/index.ts b/packages/extensions/tool-cordis/src/index.ts index d52ed7a076..1686e15cb2 100644 --- a/packages/extensions/tool-cordis/src/index.ts +++ b/packages/extensions/tool-cordis/src/index.ts @@ -43,8 +43,8 @@ export function apply(ctx: Context): void { description: 'Run a read-only query declared by an Inspect Provider. platform, provider, and method must come from ' + 'cordis_inspect_list, and input must satisfy that method\'s schema. Use this Tool before writing plugin code ' - + 'to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot ' - + 'trees and props. Host queries run locally. A Client query waits for the first valid page response and ' + + 'to read exact Service methods, Event modes, plugin Config schemas, Tool schemas, theme tokens, or live ' + + 'Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and ' + 'remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service ' + 'methods or modify the runtime.', parameters: { diff --git a/packages/extensions/tool-cordis/src/providers.ts b/packages/extensions/tool-cordis/src/providers.ts index 1c1998ab18..23b85f606a 100644 --- a/packages/extensions/tool-cordis/src/providers.ts +++ b/packages/extensions/tool-cordis/src/providers.ts @@ -1,10 +1,10 @@ /** First-party Host inspect providers registered by the Cordis tool package. */ import type { Context } from '@deepseek-ai/cordis' -import { HOST_BUILTIN_INSPECTION } from '@deepseek-ai/dsh-cordis-host-runner' import type { HostCordisInspectProviderRegistration } from '@deepseek-ai/dsh-cordis-host-runner' import type { JsonValue } from '@deepseek-ai/dsh-util-values' import { EVENT_API, queryEventApi, queryServiceApi } from './api-catalog.ts' +import { queryLiveConfig } from './config.ts' const EMPTY_INPUT = { type: 'object', properties: {}, additionalProperties: false } as const const ANY_OUTPUT = { description: 'JSON data owned by this inspect provider.' } as const @@ -16,11 +16,24 @@ const SERVICE_OUTPUT = { const EVENT_OUTPUT = { description: 'Compact Event directory, or one exact Event contract with only its referenced type declarations.', } as const +const CONFIG_INPUT: JsonValue = { + type: 'object', + properties: { + entry: { type: 'string', description: 'Exact Loader entry id from the directory; returns that entry\'s projected Config schema.' }, + name: { type: 'string', description: 'Exact plugin package name; limits the directory to its entries.' }, + offset: { type: 'number', description: 'Zero-based directory offset; defaults to 0.' }, + limit: { type: 'number', description: 'Directory page size, from 1 to 100; defaults to 25.' }, + }, + additionalProperties: false, +} +const CONFIG_OUTPUT = { + description: 'One directory page of live entries with patch ids, Config status, total, and nextOffset, or one entry\'s projected JSON Schema with shared definitions, omission acceptance, and projection limitations.', +} as const const HOST_EVENTS = EVENT_API.filter(event => !event.name.startsWith('cordis/')) /** * Construct Host providers over generated Catalogs, evaluator declarations, and live Tool scope. - * @param ctx - Host context used for Agent-scoped live Tool queries. + * @param ctx - Host context used for live Loader Config and Agent-scoped Tool queries. * @returns registrations for static catalogs and live Host capabilities. */ export function hostInspectProviders(ctx: Context): HostCordisInspectProviderRegistration[] { @@ -41,10 +54,14 @@ export function hostInspectProviders(ctx: Context): HostCordisInspectProviderReg EVENT_INPUT, EVENT_OUTPUT, ), - registration('Builtin', 'Plain-JavaScript symbols available to a dynamic Host half.', 'listBuiltins', () => ({ - builtins: HOST_BUILTIN_INSPECTION, - referencedTypes: [], - } as unknown as JsonValue)), + registration( + 'Config', + 'Progressive live plugin Config discovery: paged entry directory with schema status, then one entry\'s exact JSON Schema.', + 'listConfigs', + input => queryLiveConfig(ctx, input), + CONFIG_INPUT, + CONFIG_OUTPUT, + ), { manifest: { id: 'Tool', diff --git a/packages/extensions/tool-cordis/tests/config.spec.ts b/packages/extensions/tool-cordis/tests/config.spec.ts new file mode 100644 index 0000000000..eff3452135 --- /dev/null +++ b/packages/extensions/tool-cordis/tests/config.spec.ts @@ -0,0 +1,128 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Group from '@deepseek-ai/cordis-plugin-group' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import { PluginPackages } from '@deepseek-ai/dsh-app-boot' +import z from '@deepseek-ai/schemastery' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { queryLiveConfig } from '../src/config.ts' + +/** The Config provider reads the running Loader tree: only live fibers expose a Config, carriers never do. */ + +const modules: Record = { + 'cordis:group': Group, + 'with-schema': { name: 'with-schema', apply() {}, Config: z.object({ port: z.number().default(80).description('Listen port.') }) }, + 'no-config': { name: 'no-config', apply() {} }, + 'foreign-schema': { name: 'foreign-schema', apply() {}, Config: { '~standard': { validate: (value: unknown) => ({ value }) } } }, +} + +interface Ids { + withSchema: string + noConfig: string + foreign: string + off: string + group: string +} + +/** A Loader tree whose base directory holds `node_modules/with-schema/package.json`, so the package lookup resolves that plugin. */ +async function loaded(): Promise<{ ctx: Context; ids: Ids; packageDir: string }> { + const dir = mkdtempSync(join(tmpdir(), 'dsh-tool-cordis-config-')) + onTestFinished(() => { rmSync(dir, { recursive: true, force: true }) }) + const packageDir = join(dir, 'node_modules', 'with-schema') + mkdirSync(packageDir, { recursive: true }) + writeFileSync(join(packageDir, 'package.json'), '{"name":"with-schema","version":"1.0.0"}\n') + const ctx = new Context() + ctx.baseUrl = `${pathToFileURL(dir).href}/` + await ctx.plugin(PluginPackages) + await ctx.plugin(Loader) + vi.spyOn(ctx.loader, 'import').mockImplementation((name: string) => Promise.resolve(modules[name])) + const ids: Ids = { + withSchema: await ctx.loader.create({ name: 'with-schema' }), + noConfig: await ctx.loader.create({ name: 'no-config' }), + foreign: await ctx.loader.create({ name: 'foreign-schema' }), + off: await ctx.loader.create({ name: 'with-schema', disabled: true }), + group: await ctx.loader.create({ name: 'cordis:group', group: true, config: [{ id: 'nested', name: 'no-config' }] }), + } + await ctx.loader.await() + return { ctx, ids, packageDir } +} + +interface Page { entries: Array<{ id: string; name: string }>; total: number; nextOffset: number | null } + +/** Directory pages are plain JSON; a round trip gives the test its typed view. */ +function asPage(value: unknown): Page { + return JSON.parse(JSON.stringify(value)) as Page +} + +async function directory(ctx: Context): Promise> { + const { entries } = asPage(await queryLiveConfig(ctx, { limit: 100 })) + return Object.fromEntries(entries.map(entry => [entry.id, entry])) +} + +describe('the Config inspect provider', () => { + it('lists every live entry with its Loader id, patch id, and Config status', async () => { + const { ctx, ids } = await loaded() + const listed = await directory(ctx) + expect(Object.keys(listed).sort()).toEqual([ids.withSchema, ids.noConfig, ids.foreign, ids.off, ids.group, 'nested'].sort()) + expect(listed[ids.withSchema]).toEqual({ id: ids.withSchema, patchId: ids.withSchema, name: 'with-schema', status: 'schema' }) + expect(listed[ids.noConfig]).toMatchObject({ status: 'absent' }) + expect(listed[ids.foreign]).toMatchObject({ status: 'unsupported' }) + expect(listed[ids.off]).toMatchObject({ name: 'with-schema', status: 'inactive' }) + expect(listed[ids.group]).toMatchObject({ name: 'cordis:group', status: 'tree' }) + expect(listed.nested).toMatchObject({ patchId: 'nested', status: 'absent' }) + await ctx.fiber.dispose() + }) + + it('pages the directory, filters it by plugin name, and rejects malformed query fields', async () => { + const { ctx, ids } = await loaded() + const first = asPage(await queryLiveConfig(ctx, { limit: 2 })) + expect(first.entries).toHaveLength(2) + expect(first).toMatchObject({ total: 6, nextOffset: 2 }) + const last = asPage(await queryLiveConfig(ctx, { offset: 4, limit: 2 })) + expect(last).toMatchObject({ total: 6, nextOffset: null }) + const named = asPage(await queryLiveConfig(ctx, { name: 'with-schema' })) + expect(named.entries.map(entry => entry.id).sort()).toEqual([ids.withSchema, ids.off].sort()) + expect(named).toMatchObject({ total: 2, nextOffset: null }) + await expect(queryLiveConfig(ctx, { limit: 0 })).rejects.toThrow('limit must be an integer from 1 to 100') + await expect(queryLiveConfig(ctx, { offset: -1 })).rejects.toThrow('offset must be a non-negative integer') + await expect(queryLiveConfig(ctx, { name: '' })).rejects.toThrow('name must be a non-empty string') + await ctx.fiber.dispose() + }) + + it('reports a plugin disabled after it ran as inactive', async () => { + const { ctx, ids } = await loaded() + await ctx.loader.resolve(ids.withSchema).update({ disabled: true }) + await ctx.loader.await() + expect((await directory(ctx))[ids.withSchema]).toMatchObject({ status: 'inactive' }) + expect(await queryLiveConfig(ctx, { entry: ids.withSchema })).not.toHaveProperty('schema') + await ctx.fiber.dispose() + }) + + it('projects one entry with its package directory and reports status alone without a native Config', async () => { + const { ctx, ids, packageDir } = await loaded() + const projected = await queryLiveConfig(ctx, { entry: ids.withSchema }) + expect(projected).toMatchObject({ + id: ids.withSchema, status: 'schema', packageDir, acceptsMissing: true, limitations: [], + schema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $defs: { loaderExpression: { type: 'object', required: ['__jsExpr'] } }, + anyOf: [ + { type: ['object', 'null'], properties: { port: { default: 80, description: 'Listen port.' } } }, + { $ref: '#/$defs/loaderExpression' }, + ], + }, + }) + expect(await queryLiveConfig(ctx, { entry: ids.off })).toEqual({ id: ids.off, patchId: ids.off, name: 'with-schema', status: 'inactive', packageDir }) + expect(await queryLiveConfig(ctx, { entry: ids.noConfig })).not.toHaveProperty('packageDir') + expect(await queryLiveConfig(ctx, { entry: ids.group })).toEqual({ id: ids.group, patchId: ids.group, name: 'cordis:group', status: 'tree' }) + await expect(queryLiveConfig(ctx, { entry: 'missing' })).rejects.toThrow('unknown entry id "missing"') + await ctx.fiber.dispose() + }) + + it('rejects a Host composed without a Loader', async () => { + await expect(queryLiveConfig(new Context(), undefined)).rejects.toThrow('requires the Loader') + }) +}) diff --git a/packages/extensions/tool-cordis/tests/host.spec.ts b/packages/extensions/tool-cordis/tests/host.spec.ts index 8763e44c44..87bd3b25f8 100644 --- a/packages/extensions/tool-cordis/tests/host.spec.ts +++ b/packages/extensions/tool-cordis/tests/host.spec.ts @@ -27,7 +27,7 @@ describe('the cordis-inspect-providers host entry', () => { const fiber = ctx.plugin(CordisInspectProviders) await fiber - expect(ctx.cordisInspect.list().map(provider => provider.id)).toEqual(['Service', 'Event', 'Builtin', 'Tool']) + expect(ctx.cordisInspect.list().map(provider => provider.id)).toEqual(['Service', 'Event', 'Config', 'Tool']) await fiber.dispose() expect(ctx.cordisInspect.list()).toEqual([]) diff --git a/packages/extensions/tool-cordis/tsconfig.json b/packages/extensions/tool-cordis/tsconfig.json index 61c41a5d9f..f852c14aa2 100644 --- a/packages/extensions/tool-cordis/tsconfig.json +++ b/packages/extensions/tool-cordis/tsconfig.json @@ -28,6 +28,18 @@ }, { "path": "../cordis-host-runner" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../boot/app-boot" + }, + { + "path": "../../util/values" + }, + { + "path": "../../../vendor/group" } ] } diff --git a/packages/extensions/ui-cordis/tsconfig.json b/packages/extensions/ui-cordis/tsconfig.json index 21ac19afdc..1c8567fd9f 100644 --- a/packages/extensions/ui-cordis/tsconfig.json +++ b/packages/extensions/ui-cordis/tsconfig.json @@ -12,7 +12,7 @@ "path": "../../../vendor/cordis" }, { - "path": "../../client/locale" + "path": "../../client/locale/tsconfig.client.json" }, { "path": "../cordis-client-runner" diff --git a/packages/host/README.i18n.yaml b/packages/host/README.i18n.yaml index 0c7ec8a7e2..c45b78cfa1 100644 --- a/packages/host/README.i18n.yaml +++ b/packages/host/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/README.md -README.md: 0b595eb7e63f33ed8e11ec0f39438f5d79b6ab33 -README.zh.md: 6a7507b7d8d2b2d791952783235a80280c8c0408 +README.md: f5e7832539404b8fb1fb856973f08d5dd5c87eb5 +README.zh.md: 402358f4f4097630ba8deda4551d6bc52ff01b11 diff --git a/packages/host/README.md b/packages/host/README.md index 0b595eb7e6..f5e7832539 100644 --- a/packages/host/README.md +++ b/packages/host/README.md @@ -1,5 +1,5 @@ --- -description: "Package map for the web GUI host half: the HTTP and SPA servers, workspace-directory picking implementations, the open-in-app launch routes, and the plugin inventory projection." +description: "Package map for the web GUI host half: the HTTP and SPA servers, workspace-directory picking implementations, the open-in-app launch routes, the plugin inventory projection, and explicit product event reporting." kind: "package-group" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The `host/` group provides the web GUI's plain HTTP server, the SPA dist server that serves the built Web shell, the workspace-directory picking seam with its native, browse, and adaptive composition packages, the open-in-app application probe and launch routes, and the read-only plugin inventory projection. All eight packages are product packages; the browser transport lives in [`client/`](../client/README.md), and the composed application is [`apps/cli`](../../apps/cli/README.md) booting the [`dsh-base` bundle](../bundle/base/cordis.patch.yml) that serves the web app under `apps/web/`. The picker backends replace one another behind the shared seam. +The `host/` group provides the web GUI's plain HTTP server, the SPA dist server that serves the built Web shell, the workspace-directory picking seam with its native, browse, and adaptive composition packages, the open-in-app application probe and launch routes, the read-only plugin inventory projection, and explicit product event reporting. These packages are product packages; the browser transport lives in [`client/`](../client/README.md), and the composed application is [`apps/cli`](../../apps/cli/README.md) booting the [`dsh-base` bundle](../bundle/base/cordis.patch.yml) that serves the web app under `apps/web/`. The picker backends replace one another behind the shared seam. ## Table of Contents @@ -22,7 +22,7 @@ The `host/` group provides the web GUI's plain HTTP server, the SPA dist server ## Packages -Eight packages play the host roles; each package README owns its contract and configuration. +The packages play the host roles; each package README owns its contract and configuration. | Package | Role | ctx key | |---|---|---| @@ -34,6 +34,7 @@ Eight packages play the host roles; each package README owns its contract and co | [`directory-picker-auto/`](directory-picker-auto/README.md) | Host-adaptive chooser that mounts the matching backend at boot | mounts a backend | | [`open-in-app/`](open-in-app/README.md) | Application probe, icon, and launch routes opening the workspace directory in an installed application | consumes `ctx.webServer` | | [`plugin-inventory/`](plugin-inventory/README.md) | Read-only projection of current Loader entries | Remote `pluginInventory/list` | +| [`product-telemetry-otel/`](product-telemetry-otel/README.md) | Explicit product usage events over OTLP/HTTP | `ctx.productTelemetry` | ----- @@ -45,6 +46,7 @@ Start with the subsystem references for the transport and the workspace records, - [HTTP server subsystem](../../docs/subsystems/web-server.md) — the webserver's routes, matching order, and config. - [Workspace subsystem](../../docs/subsystems/workspace.md) — the workspace records the directory picker feeds. - [Web config-tree boot and transport layering](../../.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md) — ownership of the Web transport layers. +- [Product telemetry](../../docs/subsystems/product-telemetry.md) — explicit analytics event submission. ## Dev Note diff --git a/packages/host/README.zh.md b/packages/host/README.zh.md index 6a7507b7d8..402358f4f4 100644 --- a/packages/host/README.zh.md +++ b/packages/host/README.zh.md @@ -1,5 +1,5 @@ --- -description: "Web GUI Host 侧的包映射:HTTP 与 SPA 服务器、工作区目录选择实现、open-in-app 启动路由和插件清单投影。" +description: "Web GUI Host 侧的包映射:HTTP 与 SPA 服务器、工作区目录选择实现、open-in-app 启动路由、插件清单投影和显式产品事件上报。" kind: "package-group" --- @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -`host/` 组提供 Web GUI 的普通 HTTP 服务器、服务已构建 Web 壳的 SPA dist 服务器、带原生/浏览/自适应组合包的工作区目录选择 seam、open-in-app 的应用探测与启动路由,以及只读的插件清单投影。这八个包都是产品包;浏览器传输位于 [`client/`](../client/README.zh.md),组合应用是 [`apps/cli`](../../apps/cli/README.zh.md),它启动 [`dsh-base` 组合包](../bundle/base/cordis.patch.yml) 来提供 `apps/web/` 下的 Web 应用。选择器后端可在共享 seam 后互相替换。 +`host/` 组提供 Web GUI 的普通 HTTP 服务器、服务已构建 Web 壳的 SPA dist 服务器、带原生/浏览/自适应组合包的工作区目录选择 seam、open-in-app 的应用探测与启动路由,只读的插件清单投影以及显式产品事件上报。这些包都是产品包;浏览器传输位于 [`client/`](../client/README.zh.md),组合应用是 [`apps/cli`](../../apps/cli/README.zh.md),它启动 [`dsh-base` 组合包](../bundle/base/cordis.patch.yml) 来提供 `apps/web/` 下的 Web 应用。选择器后端可在共享 seam 后互相替换。 ## 目录 @@ -22,7 +22,7 @@ kind: "package-group" ## 包 -八个包分别承担 Host 角色;各包的 README 拥有自己的约定与配置。 +各包分别承担 Host 角色;各包的 README 拥有自己的约定与配置。 | 包 | 职责 | ctx 键 | |---|---|---| @@ -34,6 +34,7 @@ kind: "package-group" | [`directory-picker-auto/`](directory-picker-auto/README.zh.md) | 在启动时挂载匹配后端的宿主自适应选择器 | 挂载一个后端 | | [`open-in-app/`](open-in-app/README.zh.md) | 在已安装应用中打开 workspace 目录的应用探测、图标与启动路由 | 消费 `ctx.webServer` | | [`plugin-inventory/`](plugin-inventory/README.zh.md) | 当前 Loader 条目的只读投影 | Remote `pluginInventory/list` | +| [`product-telemetry-otel/`](product-telemetry-otel/README.zh.md) | 通过 OTLP/HTTP 显式上报产品使用事件 | `ctx.productTelemetry` | ----- @@ -45,6 +46,7 @@ kind: "package-group" - [HTTP 服务器子系统](../../docs/subsystems/web-server.zh.md)——webserver 的路由、匹配顺序与配置。 - [工作区子系统](../../docs/subsystems/workspace.zh.md)——目录选择器所喂给的工作区记录。 - [Web 配置树启动与传输分层](../../.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)——Web 传输各层的所有权。 +- [产品埋点](../../docs/subsystems/product-telemetry.zh.md)——显式提交分析事件。 ## 开发备注 diff --git a/packages/host/product-telemetry-otel/README.i18n.yaml b/packages/host/product-telemetry-otel/README.i18n.yaml new file mode 100644 index 0000000000..ea4a841892 --- /dev/null +++ b/packages/host/product-telemetry-otel/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/host/product-telemetry-otel/README.md +README.md: af1f2c415fc21db874abc7bb0d9e25c7149fcabf +README.zh.md: ffdb682d9a8a0a4bf406f71322e062f14b666cf2 diff --git a/packages/host/product-telemetry-otel/README.md b/packages/host/product-telemetry-otel/README.md new file mode 100644 index 0000000000..af1f2c415f --- /dev/null +++ b/packages/host/product-telemetry-otel/README.md @@ -0,0 +1,108 @@ +--- +description: "Configure explicit product usage events, OTLP/HTTP routing, batching, and shutdown limits." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-host-product-telemetry-otel + +English | [中文](README.zh.md) + +## Summary + +Send selected product usage events to an OTLP/HTTP collector. Events carry a name, string summary, occurrence time, and scalar or one-level object attributes. Mounting the plugin collects nothing automatically; applications explicitly submit each event. Delivery is best effort and does not confirm warehouse ingestion. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Mount the plugin in a Cordis composition with the application identity; override the collector endpoint when needed. The shipped profiles do not mount it. Set `DSH_APP_VERSION` to the running application release version in the launcher environment; the schema rejects an absent version. + +```yaml +- name: '@deepseek-ai/dsh-host-product-telemetry-otel' + config: + endpoint: https://dsh-otel-collector.deepseeksvc.com/v1/logs + serviceName: deepseek-harness + serviceVersion: !!js process.env.DSH_APP_VERSION + compression: gzip + scheduledDelayMillis: 30000 +``` + +| Field | Default | Meaning | +|---|---|---| +| `endpoint` | `https://dsh-otel-collector.deepseeksvc.com/v1/logs` | Full HTTP(S) logs URL | +| `serviceName`, `serviceVersion` | required | Application identity on the OTel resource | +| `channel` | `dsh_otel_report` | Collector `x-channel` header | +| `compression` | SDK environment | `gzip` or `none`; omission honors OTel compression environment variables | +| `maxExportBatchSize`, `maxQueueSize` | `512`, `2048` | Record-count limits; batch size cannot exceed queue size | +| `scheduledDelayMillis` | `30000` | Partial-batch export interval | +| `timeoutMillis` | `15000` | Exporter HTTP and retry deadline | +| `exportTimeoutMillis` | `20000` | Processor batch export deadline | +| `shutdownTimeoutMillis` | `21000` | Outer wait for shutdown; expiry reports possible loss | + +The default endpoint routes explicitly submitted events to the production product collector. Test and custom deployments must override it. Only `x-channel` and SDK protocol headers reach the collector; ambient OTel headers and client certificates are not inherited. + +The 30-second interval batches product events; the exporter has a 15-second retry window inside the processor’s 20-second batch deadline. The outer 21-second wait bounds plugin disposal, including SDK `forceFlush()` work that the processor deadline does not cover. An unreachable collector can delay disposal for the full 21 seconds. A full 2,048-record queue requires four 512-record batches and may not drain before that deadline. Interactive compositions needing a shorter exit should override these budgets; neither configuration guarantees delivery. + +Consumers inject `productTelemetry` and call `emit()` with explicitly selected analytics fields. Event names and field semantics belong to their product and analytics owners. The plugin reads no Session, account, credential, or device identifier. Callers must exclude prompts, responses, file contents, credentials, and other unapproved values. + +The collector expects a string body and attributes containing strings, numbers, booleans, or objects of those scalars. Callers supply occurrence time in milliseconds; the plugin assigns observation time and defaults severity to INFO. Invalid transport configuration fails at activation. Export failures produce local warnings without making event submission wait for the network. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +A private OTel logger feeds `BatchLogRecordProcessor` and the SDK HTTP delegate with its JSON log serializer. The delegate receives explicit headers and an HTTP agent; only shared timeout and compression settings use SDK environment resolution. The direct `@opentelemetry/core` dependency matches `sdk-logs` at 2.9.0 so exporter result enums share one TypeScript identity. The SDK owns queueing, transient-error retries, and compression; plugin disposal drains pending records with a bounded wait. Export completion is observed separately because SDK shutdown can resolve after a rejected export. No global OTel provider is installed. + +[`src/index.ts`](src/index.ts) owns configuration and submission. No runtime invariant companion is published: delivery has no independent local acknowledgement to compare with the SDK's queue. + +
+ +----- + + +## Further Exploration + +- [Product telemetry](../../../docs/subsystems/product-telemetry.md) — consumer types and service reference. +- [Session telemetry](../../session/session-telemetry-otel/README.md) — separate feedback-authorized Session reporting. +- [Testing policy](../../../docs/testing.md) — Loader composition and network fixtures. + +----- + + +## Model Experience + +None, as the plugin exports explicit analytics records without contributing model context. + +#### KV Cache effect + +None; event submission does not change model requests. + +## Known Limitations and Deferred Work + + + +Delivery and collection remain limited to the following capabilities. + +- Callers own event selection, renderer-to-host transport, and any permitted identity attributes. +- The queue is memory-only; overflow, network failure, and process exit can lose events. There is no durable outbox or warehouse acknowledgement. +- The SDK batches by record count, not encoded bytes. Callers must keep records within the collector's 4 MB limit and choose batch sizes appropriate to the receiver. +- Caller-selected strings are not redacted automatically. This package does not decide product disclosure or consent policy. + + +### Dev Note + +None. diff --git a/packages/host/product-telemetry-otel/README.zh.md b/packages/host/product-telemetry-otel/README.zh.md new file mode 100644 index 0000000000..ffdb682d9a --- /dev/null +++ b/packages/host/product-telemetry-otel/README.zh.md @@ -0,0 +1,108 @@ +--- +description: "配置显式产品使用事件、OTLP/HTTP 路由、批量发送与退出等待上限。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-host-product-telemetry-otel + +[English](README.md) | 中文 + +## 概述 + +将选定的产品使用事件发送到 OTLP/HTTP 接收服务。事件包含名称、字符串摘要、发生时间,以及标量或单层对象属性。挂载插件不会自动采集信息;应用需要显式提交每个事件。发送采用尽力而为方式,不代表数据已入仓。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [延伸阅读](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与待办工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +在 Cordis 组合中挂载插件并提供应用标识;需要时可覆盖接收地址。内置 profile 不挂载本插件。启动器环境须将 `DSH_APP_VERSION` 设为运行中应用的发布版本;缺少版本时 schema 会拒绝配置。 + +```yaml +- name: '@deepseek-ai/dsh-host-product-telemetry-otel' + config: + endpoint: https://dsh-otel-collector.deepseeksvc.com/v1/logs + serviceName: deepseek-harness + serviceVersion: !!js process.env.DSH_APP_VERSION + compression: gzip + scheduledDelayMillis: 30000 +``` + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `endpoint` | `https://dsh-otel-collector.deepseeksvc.com/v1/logs` | 完整 HTTP(S) 日志地址 | +| `serviceName`, `serviceVersion` | 必填 | OTel resource 中的应用标识 | +| `channel` | `dsh_otel_report` | 接收服务的 `x-channel` 请求头 | +| `compression` | SDK 环境变量 | `gzip` 或 `none`;省略时遵循 OTel 压缩环境变量 | +| `maxExportBatchSize`, `maxQueueSize` | `512`, `2048` | 记录条数上限;批次大小不能超过队列大小 | +| `scheduledDelayMillis` | `30000` | 不满批次时的发送间隔 | +| `timeoutMillis` | `15000` | Exporter HTTP 发送与重试的超时时间 | +| `exportTimeoutMillis` | `20000` | Processor 批次导出的超时时间 | +| `shutdownTimeoutMillis` | `21000` | 退出时的等待上限;超时会提示可能丢失数据 | + +默认接收地址将显式提交的事件发送到生产产品 collector,测试和自定义部署必须覆盖该地址。只向 collector 发送 `x-channel` 和 SDK 协议请求头,不继承宿主 OTel 请求头或客户端证书。 + +30 秒间隔用于批量发送产品事件;exporter 的 15 秒重试窗口位于 processor 的 20 秒批次期限内。外层 21 秒等待限制插件卸载时间,包括 processor 期限未覆盖的 SDK `forceFlush()`。collector 不可达时,卸载可能等待完整的 21 秒。2,048 条满队列需要四个 512 条批次,可能无法在期限前排空。要求更快退出的交互式应用组合应覆盖这些时间配置;两种配置都不保证送达。 + +消费方注入 `productTelemetry`,调用 `emit()` 提交明确选定的分析字段。事件名称与字段含义由产品和数据分析负责人定义。插件不读取 Session、账号、凭证或设备标识。调用方必须排除提示词、回答、文件内容、凭证及其他未经批准的数据。 + +接收服务要求 body 为字符串,attributes 的值为字符串、数字、布尔值或由这些标量组成的对象。调用方以毫秒提供发生时间;插件填写观测时间,默认严重程度为 INFO。无效传输配置在激活时报错。发送失败产生本地警告,事件提交不等待网络。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +私有 OTel logger 将记录交给 `BatchLogRecordProcessor`,再由 SDK HTTP delegate 和 JSON 日志序列化器发送。delegate 使用显式请求头和 HTTP agent,只有共享的超时与压缩配置使用 SDK 环境变量解析。直接依赖的 `@opentelemetry/core` 与 `sdk-logs` 对齐为 2.9.0,使导出结果枚举共享同一个 TypeScript 类型身份。SDK 负责队列、临时错误重试与压缩;插件卸载时在限定时间内发送剩余记录。发送完成结果单独观测,因为 SDK 在发送被拒绝后仍可能正常完成退出。不安装全局 OTel provider。 + +[`src/index.ts`](src/index.ts) 负责配置与提交。不发布运行时不变量伴随模块:本地没有独立的送达确认可与 SDK 队列比较。 + +
+ +----- + + +## 延伸阅读 + +- [产品埋点](../../../docs/subsystems/product-telemetry.zh.md)——消费方类型与服务参考。 +- [会话上报](../../session/session-telemetry-otel/README.zh.md)——独立的、由反馈授权的 Session 上报。 +- [测试策略](../../../docs/testing.zh.md)——Loader 组合与网络测试夹具。 + +----- + + +## 模型体验 + +无,因为插件仅发送显式分析记录,不提供模型上下文。 + +#### KV Cache 影响 + +无;提交事件不会改变模型请求。 + +## 已知限制与待办工作 + + + +发送和采集受以下能力限制。 + +- 调用方拥有事件选择、渲染进程到宿主的传输以及获准使用的标识属性。 +- 队列仅存在于内存;队列溢出、网络故障和进程退出可能丢失事件。没有持久发件箱或入仓确认。 +- SDK 按记录条数而非编码字节数分批。调用方必须确保记录不超过接收服务的 4 MB 限制,并选择接收服务适用的批次大小。 +- 不自动脱敏调用方选择的字符串。本包不决定产品告知或同意策略。 + + +### 开发备注 + +无。 diff --git a/packages/host/product-telemetry-otel/package.json b/packages/host/product-telemetry-otel/package.json new file mode 100644 index 0000000000..2bcdeb691b --- /dev/null +++ b/packages/host/product-telemetry-otel/package.json @@ -0,0 +1,48 @@ +{ + "name": "@deepseek-ai/dsh-host-product-telemetry-otel", + "description": "Explicit product usage events exported through OpenTelemetry HTTP logs", + "version": "0.1.6-alpha.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/host/product-telemetry-otel" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/api-logs": "^0.220.0", + "@opentelemetry/core": "2.9.0", + "@opentelemetry/otlp-exporter-base": "^0.220.0", + "@opentelemetry/otlp-transformer": "^0.220.0", + "@opentelemetry/resources": "^2.9.0", + "@opentelemetry/sdk-logs": "^0.220.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^" + } +} diff --git a/packages/host/product-telemetry-otel/src/index.ts b/packages/host/product-telemetry-otel/src/index.ts new file mode 100644 index 0000000000..eef6ec5dce --- /dev/null +++ b/packages/host/product-telemetry-otel/src/index.ts @@ -0,0 +1,171 @@ +/** Explicit product usage events over OTLP/HTTP; no automatic collection or Session access. */ +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { SeverityNumber, type Logger } from '@opentelemetry/api-logs' +import { validateHeaderValue } from 'node:http' +import { JsonLogsSerializer } from '@opentelemetry/otlp-transformer' +import { createOtlpHttpExportDelegate, getSharedConfigurationFromEnvironment, httpAgentFactoryFromOptions } from '@opentelemetry/otlp-exporter-base/node-http' +import { CompressionAlgorithm, getSharedConfigurationDefaults, mergeOtlpSharedConfigurationWithDefaults, OTLPExporterBase } from '@opentelemetry/otlp-exporter-base' +import { ExportResultCode } from '@opentelemetry/core' +import { resourceFromAttributes } from '@opentelemetry/resources' +import { BatchLogRecordProcessor, LoggerProvider } from '@opentelemetry/sdk-logs' + +declare module '@deepseek-ai/cordis' { + interface Context { + productTelemetry: ProductTelemetry + } +} + +/** Scalar values accepted by the collector's Arrow attributes map. */ +export type ProductTelemetryScalar = string | number | boolean + +/** Explicitly selected analytics fields; object values may contain scalars only. */ +export interface ProductTelemetryRecord { + /** Product/DA-owned event name. */ + eventName: string + /** Human-readable summary; never a prompt, response, credential, or file contents. */ + body: string + /** Event occurrence time in Unix milliseconds. Observation time is assigned on enqueue. */ + timestamp: number + /** OTel severity; omitted values use INFO. */ + severityNumber?: SeverityNumber + /** Business fields selected by the caller; no automatic device or account identity. */ + attributes?: Record> +} + +/** Collector routing, application identity, and bounded in-memory batch settings. */ +export interface Config { + /** Full HTTP(S) logs URL. */ + endpoint: string + /** Collector routing header. */ + channel: string + /** Resource service.name supplied by the application composition. */ + serviceName: string + /** Resource service.version supplied by the application composition. */ + serviceVersion: string + /** Omit to honor OTEL_EXPORTER_OTLP_LOGS_COMPRESSION / OTEL_EXPORTER_OTLP_COMPRESSION. */ + compression?: 'none' | 'gzip' + /** Maximum records per export; must not exceed maxQueueSize. */ + maxExportBatchSize: number + /** Maximum queued records; the SDK drops new records when full. */ + maxQueueSize: number + /** Delay before exporting a partial batch. */ + scheduledDelayMillis: number + /** Exporter HTTP deadline, including SDK transient-error retries. */ + timeoutMillis: number + /** Processor deadline for one batch export. */ + exportTimeoutMillis: number + /** Outer shutdown wait; pending exports may be lost after this deadline. */ + shutdownTimeoutMillis: number +} + +const positiveInteger = () => z.number().step(1).min(1).max(2_147_483_647) + +/** Loader validation and defaults for application compositions. */ +export const Config: z, Config> = z.object({ + endpoint: z.string().default('https://dsh-otel-collector.deepseeksvc.com/v1/logs'), + channel: z.string().min(1).default('dsh_otel_report'), + serviceName: z.string().required(), + serviceVersion: z.string().required(), + compression: z.union(['none', 'gzip']), + maxExportBatchSize: positiveInteger().default(512), + maxQueueSize: positiveInteger().default(2048), + scheduledDelayMillis: positiveInteger().default(30000), + timeoutMillis: positiveInteger().default(15000), + exportTimeoutMillis: positiveInteger().default(20000), + shutdownTimeoutMillis: positiveInteger().default(21000), +}) + +/** Host analytics sender. Mounting alone sends nothing; the owning fiber drains it on unload. */ +export default class ProductTelemetry extends Service { + static Config = Config + private readonly logger: Logger + + constructor(ctx: Context, config: Config) { + let endpoint: URL + try { + endpoint = new URL(config.endpoint) + } catch (cause) { + throw new Error('product-telemetry-otel: endpoint must be a valid HTTP(S) URL', { cause }) + } + try { + validateHeaderValue('x-channel', config.channel) + } catch (cause) { + throw new Error('product-telemetry-otel: channel must be a valid HTTP header value', { cause }) + } + if (!['http:', 'https:'].includes(endpoint.protocol)) { + throw new Error('product-telemetry-otel: endpoint must use HTTP or HTTPS') + } + if (config.maxExportBatchSize > config.maxQueueSize) { + throw new Error('product-telemetry-otel: maxExportBatchSize must not exceed maxQueueSize') + } + super(ctx, 'productTelemetry') + const shared = mergeOtlpSharedConfigurationWithDefaults({ + timeoutMillis: config.timeoutMillis, + ...(config.compression === undefined ? {} : { + compression: config.compression === 'gzip' ? CompressionAlgorithm.GZIP : CompressionAlgorithm.NONE, + }), + }, getSharedConfigurationFromEnvironment('LOGS'), getSharedConfigurationDefaults()) + const transport = { + ...shared, + url: config.endpoint, + // This collector must not inherit another endpoint's headers or TLS client identity. + headers: () => Promise.resolve({ 'Content-Type': 'application/json', 'x-channel': config.channel }), + agentFactory: httpAgentFactoryFromOptions({ keepAlive: true }), + } + const exporter = new OTLPExporterBase(createOtlpHttpExportDelegate(transport, JsonLogsSerializer)) + const provider = new LoggerProvider({ + resource: resourceFromAttributes({ + 'service.name': config.serviceName, + 'service.version': config.serviceVersion, + }), + processors: [new BatchLogRecordProcessor({ + maxExportBatchSize: config.maxExportBatchSize, + maxQueueSize: config.maxQueueSize, + scheduledDelayMillis: config.scheduledDelayMillis, + exportTimeoutMillis: config.exportTimeoutMillis, + exporter: { + export: (records, callback) => { + exporter.export(records, (result) => { + // SDK flush/shutdown can resolve after export failure; observe the actual completion. + if (result.code !== ExportResultCode.SUCCESS) ctx.logger.warn('Product telemetry export failed', result.error) + callback(result) + }) + }, + forceFlush: () => exporter.forceFlush(), + shutdown: () => exporter.shutdown(), + }, + })], + }) + this.logger = provider.getLogger('@deepseek-ai/dsh-host-product-telemetry-otel') + ctx.effect(() => async () => { + let timer!: ReturnType + const deadline = new Promise((resolve) => { + timer = setTimeout(() => { + ctx.logger.warn('Product telemetry shutdown deadline exceeded; pending events may be lost') + resolve() + }, config.shutdownTimeoutMillis) + }) + try { + await Promise.race([provider.shutdown(), deadline]) + } finally { + clearTimeout(timer) + } + }) + } + + /** + * Enqueue one selected product event without waiting for network delivery. + * Queue admission and shutdown completion are not collector or warehouse acknowledgements. + * @param record - caller-owned event containing only approved analytics fields. + */ + emit(record: ProductTelemetryRecord): void { + const severityNumber = record.severityNumber ?? SeverityNumber.INFO + this.logger.emit({ + ...record, + observedTimestamp: Date.now(), + severityNumber, + severityText: SeverityNumber[severityNumber], + }) + } +} diff --git a/packages/host/product-telemetry-otel/tests/fixtures/driver.ts b/packages/host/product-telemetry-otel/tests/fixtures/driver.ts new file mode 100644 index 0000000000..a0ad9389d8 --- /dev/null +++ b/packages/host/product-telemetry-otel/tests/fixtures/driver.ts @@ -0,0 +1,43 @@ +/** Loader composition with a loopback collector and one explicit synthetic event. */ +import { createServer } from 'node:http' +import { once } from 'node:events' +import { gunzipSync } from 'node:zlib' +import { writeFile } from 'node:fs/promises' +import type {} from '@deepseek-ai/dsh-host-product-telemetry-otel' +import { bootProductionProfile } from '../../../../test-support/loader-smoke/tests/fixtures/production-profile.ts' + +const configPath = process.argv[2] +if (configPath === undefined) throw new Error('product telemetry fixture requires an overlay') +const captures: unknown[] = [] +const server = createServer((req, res) => { + const chunks: Buffer[] = [] + req.on('data', chunk => chunks.push(chunk as Buffer)) + req.on('end', () => { + const bytes = Buffer.concat(chunks) + const body: unknown = JSON.parse(gunzipSync(bytes).toString()) + captures.push({ channel: req.headers['x-channel'], compression: req.headers['content-encoding'], body }) + res.writeHead(200, { 'content-type': 'application/json' }).end('{}') + }) +}) +try { + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('collector has no port') + process.env.DSH_APP_VERSION = 'synthetic-release' + process.env.DSH_PRODUCT_TELEMETRY_TEST_ENDPOINT = `http://127.0.0.1:${address.port}/v1/logs` + const ctx = await bootProductionProfile({ binName: 'product-telemetry-test', profile: 'headless', overlayPaths: [configPath] }) + try { + const telemetry = ctx.get('productTelemetry') + if (telemetry === undefined) throw new Error('product telemetry did not activate') + telemetry.emit({ eventName: 'telemetry.synthetic', timestamp: 1_800_000_000_000, body: 'Synthetic test', attributes: { synthetic: true } }) + } finally { + await ctx.fiber.dispose() + } + await writeFile('captures.json', JSON.stringify(captures)) +} finally { + const closed = once(server, 'close') + server.close() + server.closeAllConnections() + await closed +} diff --git a/packages/host/product-telemetry-otel/tests/fixtures/telemetry.patch.yml b/packages/host/product-telemetry-otel/tests/fixtures/telemetry.patch.yml new file mode 100644 index 0000000000..d9104a0bfb --- /dev/null +++ b/packages/host/product-telemetry-otel/tests/fixtures/telemetry.patch.yml @@ -0,0 +1,15 @@ +- id: session-telemetry-otel + disabled: true +- id: headless-startup + disabled: true +- id: headless-runner + disabled: true +- insert: + - id: product-telemetry-otel + name: '@deepseek-ai/dsh-host-product-telemetry-otel' + config: + endpoint: !!js process.env.DSH_PRODUCT_TELEMETRY_TEST_ENDPOINT + serviceName: dsh-telemetry-test + serviceVersion: !!js process.env.DSH_APP_VERSION + compression: gzip + scheduledDelayMillis: 60000 diff --git a/packages/host/product-telemetry-otel/tests/loader-composition.e2e.ts b/packages/host/product-telemetry-otel/tests/loader-composition.e2e.ts new file mode 100644 index 0000000000..13acb46e00 --- /dev/null +++ b/packages/host/product-telemetry-otel/tests/loader-composition.e2e.ts @@ -0,0 +1,23 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { expect, it } from 'vitest' +import { runLoaderSmoke, LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke' + +it('exports the explicitly submitted event through the headless Loader composition', async () => { + let captures: unknown + const driver = resolve(import.meta.dirname, 'fixtures/driver.ts') + await runLoaderSmoke({ + label: 'product telemetry composition', + tempDirPrefix: 'product-telemetry-', + binScript: driver, + libBinScript: driver, + configPath: resolve(import.meta.dirname, 'fixtures/telemetry.patch.yml'), + tsconfigPath: resolve(import.meta.dirname, '../../../../tsconfig.base.json'), + inspect: async (cwd) => { captures = JSON.parse(await readFile(resolve(cwd, 'captures.json'), 'utf8')) }, + }) + expect(captures).toMatchObject([{ + channel: 'dsh_otel_report', compression: 'gzip', + body: { resourceLogs: [{ scopeLogs: [{ logRecords: [{ eventName: 'telemetry.synthetic', body: { stringValue: 'Synthetic test' } }] }] }] }, + }]) + expect(JSON.stringify(captures).match(/"eventName"/g)).toHaveLength(1) +}, LOADER_SMOKE_TEST_TIMEOUT_MS) diff --git a/packages/host/product-telemetry-otel/tests/telemetry.spec.ts b/packages/host/product-telemetry-otel/tests/telemetry.spec.ts new file mode 100644 index 0000000000..22f7df5c30 --- /dev/null +++ b/packages/host/product-telemetry-otel/tests/telemetry.spec.ts @@ -0,0 +1,203 @@ +import { createServer, type IncomingHttpHeaders } from 'node:http' +import { once } from 'node:events' +import { gunzipSync } from 'node:zlib' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { LoggerProvider } from '@opentelemetry/sdk-logs' +import { SeverityNumber } from '@opentelemetry/api-logs' +import ProductTelemetry, { Config } from '../src/index.ts' + +interface Capture { + headers: IncomingHttpHeaders + body: { resourceLogs: { resource: unknown; scopeLogs: { logRecords: Record[] }[] }[] } +} +const cleanup: (() => Promise)[] = [] +afterEach(async () => { + try { + for (const dispose of cleanup.splice(0).reverse()) await dispose() + } finally { + vi.unstubAllEnvs() + vi.restoreAllMocks() + vi.useRealTimers() + } +}) + +beforeEach(() => { + vi.stubEnv('OTEL_EXPORTER_OTLP_COMPRESSION', undefined) + vi.stubEnv('OTEL_EXPORTER_OTLP_LOGS_COMPRESSION', undefined) +}) + +async function collector(statuses = [200]) { + const captures: Capture[] = [] + const server = createServer((req, res) => { + const chunks: Buffer[] = [] + req.on('data', chunk => chunks.push(chunk as Buffer)) + req.on('end', () => { + const raw = Buffer.concat(chunks) + const bytes = req.headers['content-encoding'] === 'gzip' ? gunzipSync(raw) : raw + captures.push({ headers: req.headers, body: JSON.parse(bytes.toString()) as Capture['body'] }) + res.writeHead(statuses.shift() ?? 200, { 'content-type': 'application/json' }).end('{}') + }) + }) + cleanup.push(async () => { + const closed = once(server, 'close') + server.close() + server.closeAllConnections() + await closed + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('collector has no port') + return { captures, endpoint: `http://127.0.0.1:${address.port}/v1/logs` } +} + +function config(endpoint: string, overrides: Partial = {}): Config { + return Config({ endpoint, serviceName: 'synthetic-test', serviceVersion: '1', scheduledDelayMillis: 60_000, ...overrides }) +} + +function context() { + const ctx = new Context() + cleanup.push(() => ctx.fiber.dispose()) + return ctx +} + +const event = { eventName: 'telemetry.synthetic', body: 'Synthetic test', timestamp: 1_800_000_000_000 } + +describe('explicit product telemetry', () => { + it.each(['gzip', 'none', undefined] as const)('drains typed events with %s compression and removes the service', async (compression) => { + const target = await collector() + const ctx = context() + const fiber = await ctx.plugin(ProductTelemetry, config(target.endpoint, compression === undefined ? {} : { compression })) + const service = ctx.productTelemetry + expect(target.captures).toEqual([]) + service.emit({ ...event, attributes: { text: 'test', count: 2, duration: 1.5, synthetic: true, model: { provider: 'test', count: 1, enabled: false } } }) + service.emit({ ...event, severityNumber: SeverityNumber.ERROR }) + await fiber.dispose() + expect(ctx.get('productTelemetry')).toBeUndefined() + service.emit(event) + expect(target.captures).toHaveLength(1) + const [capture] = target.captures + expect(capture?.headers['x-channel']).toBe('dsh_otel_report') + expect(capture?.headers['content-encoding']).toBe(compression === 'gzip' ? 'gzip' : undefined) + const logs = capture?.body.resourceLogs.flatMap(r => r.scopeLogs.flatMap(s => s.logRecords)) + expect(logs).toHaveLength(2) + expect(logs?.[0]).toMatchObject({ + eventName: event.eventName, body: { stringValue: event.body }, severityNumber: 9, + timeUnixNano: '1800000000000000000', + }) + expect(logs?.[0]?.['attributes']).toEqual(expect.arrayContaining([ + { key: 'text', value: { stringValue: 'test' } }, + { key: 'count', value: { intValue: 2 } }, + { key: 'duration', value: { doubleValue: 1.5 } }, + { key: 'synthetic', value: { boolValue: true } }, + { key: 'model', value: { kvlistValue: { values: [{ key: 'provider', value: { stringValue: 'test' } }, { key: 'count', value: { intValue: 1 } }, { key: 'enabled', value: { boolValue: false } }] } } }, + ])) + expect(Number(logs?.[0]?.['observedTimeUnixNano'])).toBeGreaterThan(0) + expect(logs?.[1]?.['severityNumber']).toBe(17) + expect(JSON.stringify(capture)).not.toContain('user.id') + }) + + it('isolates collector headers from ambient OpenTelemetry credentials', async () => { + vi.stubEnv('OTEL_EXPORTER_OTLP_HEADERS', 'Authorization=Bearer%20synthetic-secret,x-user-id=synthetic-user') + vi.stubEnv('OTEL_EXPORTER_OTLP_LOGS_HEADERS', 'x-log-token=synthetic-token,x-channel=other-collector') + const target = await collector() + const ctx = context() + const fiber = await ctx.plugin(ProductTelemetry, config(target.endpoint)) + ctx.productTelemetry.emit(event) + await fiber.dispose() + expect(target.captures).toHaveLength(1) + expect(target.captures[0]?.headers).toMatchObject({ 'x-channel': 'dsh_otel_report' }) + expect(target.captures[0]?.headers).not.toHaveProperty('authorization') + expect(target.captures[0]?.headers).not.toHaveProperty('x-user-id') + expect(target.captures[0]?.headers).not.toHaveProperty('x-log-token') + expect(process.env['OTEL_EXPORTER_OTLP_HEADERS']).toContain('synthetic-secret') + }) + + it('does not export on mount or empty shutdown', async () => { + const target = await collector() + const fiber = await context().plugin(ProductTelemetry, config(target.endpoint)) + await fiber.dispose() + expect(target.captures).toEqual([]) + }) + + it('exports at the batch threshold and retries a transient rejection', async () => { + const target = await collector([503, 200]) + const ctx = context() + await ctx.plugin(ProductTelemetry, config(target.endpoint, { maxExportBatchSize: 1 })) + ctx.productTelemetry.emit(event) + await vi.waitFor(() => { expect(target.captures).toHaveLength(2) }, { timeout: 10_000 }) + expect(target.captures[0]?.body).toEqual(target.captures[1]?.body) + }) + + it('exports a partial batch on its configured interval', async () => { + const target = await collector() + const ctx = context() + await ctx.plugin(ProductTelemetry, config(target.endpoint, { scheduledDelayMillis: 10 })) + ctx.productTelemetry.emit(event) + await vi.waitFor(() => { expect(target.captures).toHaveLength(1) }) + }) + + it('reports a permanent export rejection without failing the caller', async () => { + const target = await collector([400]) + const ctx = context() + const warn = vi.spyOn(ctx.logger, 'warn') + const fiber = await ctx.plugin(ProductTelemetry, config(target.endpoint)) + expect(() => { ctx.productTelemetry.emit(event) }).not.toThrow() + await fiber.dispose() + expect(target.captures).toHaveLength(1) + expect(warn).toHaveBeenCalledWith('Product telemetry export failed', expect.any(Error)) + }) + + it.each([ + { endpoint: 'broken' }, { endpoint: 'ftp://collector.test/logs' }, + { channel: '' }, { channel: 'bad\nchannel' }, { channel: '中文' }, { timeoutMillis: 0 }, + { maxExportBatchSize: 0 }, { maxExportBatchSize: 2, maxQueueSize: 1 }, + { maxQueueSize: -1 }, { scheduledDelayMillis: 0 }, { exportTimeoutMillis: Infinity }, + { shutdownTimeoutMillis: 2_147_483_648 }, + ])('rejects invalid configuration %j before exposing the service', (invalid) => { + const ctx = context() + expect(() => new ProductTelemetry(ctx, config('http://collector.test/v1/logs', invalid))).toThrow() + expect(ctx.get('productTelemetry')).toBeUndefined() + }) + + it.each([ + [{ endpoint: 'broken' }, 'endpoint must be a valid HTTP(S) URL'], + [{ channel: 'bad\nchannel' }, 'channel must be a valid HTTP header value'], + ] as const)('names invalid transport fields before registering the service', (invalid, message) => { + const ctx = context() + expect(() => new ProductTelemetry(ctx, config('http://collector.test/v1/logs', invalid))) + .toThrow(`product-telemetry-otel: ${message}`) + expect(ctx.get('productTelemetry')).toBeUndefined() + }) + + it.each([ + ['OTEL_EXPORTER_OTLP_COMPRESSION', 'gzip'], + ['OTEL_EXPORTER_OTLP_LOGS_COMPRESSION', 'gzip'], + ])('honors %s when compression is omitted', async (name, value) => { + vi.stubEnv(name, value) + const target = await collector() + const ctx = context() + const fiber = await ctx.plugin(ProductTelemetry, config(target.endpoint)) + ctx.productTelemetry.emit(event) + await fiber.dispose() + expect(target.captures[0]?.headers['content-encoding']).toBe('gzip') + }) + + it('bounds a stalled shutdown and observes its later settlement', async () => { + const target = await collector() + const ctx = context() + const warn = vi.spyOn(ctx.logger, 'warn') + const fiber = await ctx.plugin(ProductTelemetry, config(target.endpoint, { shutdownTimeoutMillis: 20 })) + const pending = Promise.withResolvers() + vi.spyOn(LoggerProvider.prototype, 'shutdown').mockReturnValue(pending.promise) + vi.useFakeTimers() + const disposal = fiber.dispose() + await vi.advanceTimersByTimeAsync(20) + await disposal + expect(warn).toHaveBeenCalledWith('Product telemetry shutdown deadline exceeded; pending events may be lost') + pending.resolve(undefined) + await pending.promise + await vi.advanceTimersByTimeAsync(0) + }) +}) diff --git a/packages/host/product-telemetry-otel/tsconfig.json b/packages/host/product-telemetry-otel/tsconfig.json new file mode 100644 index 0000000000..f0063ae28d --- /dev/null +++ b/packages/host/product-telemetry-otel/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + } + ] +} diff --git a/packages/interaction/permission-presets/README.i18n.yaml b/packages/interaction/permission-presets/README.i18n.yaml index 0c759a7c68..9736e5ff1b 100644 --- a/packages/interaction/permission-presets/README.i18n.yaml +++ b/packages/interaction/permission-presets/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/interaction/permission-presets/README.md -README.md: 1cee5bd48cf0bee5fb5792e81bc279785a102368 -README.zh.md: 2ce5995d69ced6af4a30205ab86920f52c617988 +README.md: 19ada7153fbc48a733fdcd69faefc6e32b4d7619 +README.zh.md: fab3487690fea0cb25831961ed7bc487f0761532 diff --git a/packages/interaction/permission-presets/README.md b/packages/interaction/permission-presets/README.md index 1cee5bd48c..19ada7153f 100644 --- a/packages/interaction/permission-presets/README.md +++ b/packages/interaction/permission-presets/README.md @@ -135,7 +135,7 @@ These limits define what the preset service does not offer. They are current pac - **`custom` is derived-only** — callers can switch away from an unmatched knob combination but cannot target or persist a named custom preset through this service. - **The configured preset table is fixed for the plugin lifetime** — only the fixed Auto contribution can change the live process catalog without reloading this service. - **Auto cannot become a default** — it exists only while its integration effect is live and is intentionally absent from the `permission` settings schema. -- **Stored defaults must remain in the preset table** — removing the referenced preset makes Permission settings registration fail until the `permission` section in `settings.yaml` is updated or reset. +- **Configured defaults must name a configured preset** — removing a referenced preset requires changing `defaultPreset` in the same Config edit. ### Dev Note diff --git a/packages/interaction/permission-presets/README.zh.md b/packages/interaction/permission-presets/README.zh.md index 2ce5995d69..fab3487690 100644 --- a/packages/interaction/permission-presets/README.zh.md +++ b/packages/interaction/permission-presets/README.zh.md @@ -135,7 +135,7 @@ kind: "package-reference" - **`custom` 只能推导得出**:调用方可以从不匹配的旋钮组合切换出去,但无法通过此服务选中或持久化一个名为 custom 的预设。 - **配置预设表在插件生命周期内固定**:只有固定的 Auto contribution 可以在不重新加载本服务的情况下改变实时进程目录。 - **Auto 不能成为默认值**:它只在 integration effect 存活期间存在,并且有意不进入 `permission` 设置 schema。 -- **已存储的默认值必须保留在 preset 表中**:移除被引用的 preset 会导致权限设置注册失败,直到更新或重置 `settings.yaml` 中的 `permission` 分节。 +- **配置的默认值必须指向已配置预设**——移除被引用预设时,必须在同一次 Config 编辑中修改 `defaultPreset`。 ### 开发备注 diff --git a/packages/interaction/permission-presets/package.json b/packages/interaction/permission-presets/package.json index 2d1f9a9489..6de1320cbc 100644 --- a/packages/interaction/permission-presets/package.json +++ b/packages/interaction/permission-presets/package.json @@ -60,7 +60,6 @@ "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -77,9 +76,9 @@ "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^" } } diff --git a/packages/interaction/permission-presets/src/index.ts b/packages/interaction/permission-presets/src/index.ts index eb42d94598..5c8bd39c0e 100644 --- a/packages/interaction/permission-presets/src/index.ts +++ b/packages/interaction/permission-presets/src/index.ts @@ -11,6 +11,9 @@ * * @module dsh-permission-presets */ +import type {} from '@deepseek-ai/dsh-settings' + +import type { Volatile } from '@deepseek-ai/cordis' import { Context } from '@deepseek-ai/cordis' import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand' @@ -25,7 +28,6 @@ import { SANDBOX_MODES, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' import type {} from '@deepseek-ai/dsh-shell' import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' import { APPROVAL_POLICIES, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' -import type {} from '@deepseek-ai/dsh-settings' // Type-only: resolves the required projection service and optional settings/command children. import type {} from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-commands' @@ -85,9 +87,6 @@ const AUTO_PRESET_SPEC: PresetSpec = { approval: 'never', } -/** Settings namespace carrying the default for future sessions. */ -export const PERMISSION_SETTINGS_NAMESPACE = 'permission' - /** * The projection unit's knob state: the last seen value of each knob event, * null before an override (composition defaults apply at view time). @@ -160,12 +159,12 @@ export interface Config { * never). The names `custom` and `auto` are reserved for derived state and * the Auto review integration respectively. */ - presets?: Record + presets: Record /** * Default for new sessions. When omitted, the preset matching the composed * sandbox and approval defaults is used. */ - defaultPreset?: string + defaultPreset: Volatile } /** @@ -176,7 +175,7 @@ export interface Config { */ export class PermissionPresetService extends TypertRemoteService { // Inline schema call: the config catalog walks `static Config` statically. - static Config: z = z.object({ + static Config = z.object({ presets: z.dict(z.object({ sandbox: z.union(SANDBOX_MODES as SandboxMode[]).required(), approval: z.union(APPROVAL_POLICIES as ApprovalPolicy[]).required(), @@ -192,7 +191,7 @@ export class PermissionPresetService extends TypertRemoteService { name: 'danger-full-access', description: 'Full file access without approval prompts.', }, }), - defaultPreset: z.string(), + defaultPreset: z.string().volatile(), }) static inject = ['shell', 'approval', 'sessions', 'sessionProjections'] @@ -203,8 +202,10 @@ export class PermissionPresetService extends TypertRemoteService { constructor(ctx: Context, config: Config) { super(ctx, 'permissionPresets') + + ctx.inject(['settings'], (child) => { child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)) }) // The schema defaulted the table — the cast records that runtime fact. - this.presets = config.presets as Record + this.presets = config.presets if (CUSTOM_PRESET in this.presets) { throw new Error(`permission: "${CUSTOM_PRESET}" is reserved for the derived not-a-preset state and cannot name a table entry`) } @@ -215,31 +216,16 @@ export class PermissionPresetService extends TypertRemoteService { throw new Error('permission: the mounted bash executor does not confine (no sandboxMode) — presets bundle a sandbox mode, so composing this plugin over an unconfined executor is a misconfiguration') } const inferredDefault = this.derive(EMPTY_KNOBS) - const defaultPreset = config.defaultPreset ?? inferredDefault + const defaultPreset = config.defaultPreset.get() ?? inferredDefault if (defaultPreset === CUSTOM_PRESET) { throw new Error('permission: composed sandbox and approval defaults match no preset; configure defaultPreset explicitly') } this.resolve(defaultPreset) - const baseSettings: PermissionSettings = { defaultPreset } - this.defaultSettings = () => baseSettings - const presetChoices = Object.keys(this.presets).map((name) => { - const choice = z.const(name) - const label = this.presets[name]?.name - return label === undefined ? choice : choice.description(label) - }) - const settingsSchema: z = z.object({ - defaultPreset: z.union(presetChoices).required(), - }) - ctx.inject(['settings'], (settingsCtx) => { - settingsCtx.settings.installSection(ctx, PERMISSION_SETTINGS_NAMESPACE, settingsSchema, baseSettings, { - setSource: (current) => { - this.defaultSettings = current - }, - // The source thunk reads the latest scope snapshot at session creation; - // no process-level registration needs replacement on change. - onChange: () => {}, - }) - }) + this.defaultSettings = () => { + const defaultPreset = config.defaultPreset.get() ?? inferredDefault + if (!Object.hasOwn(this.presets, defaultPreset)) throw new Error(`permission: unknown default preset "${defaultPreset}"`) + return { defaultPreset } + } const selectionSchema = zod.object({ currentValue: zod.string().min(1), @@ -301,7 +287,11 @@ export class PermissionPresetService extends TypertRemoteService { */ @Remote('catalog') catalog(): PermissionCatalog { - return { options: this.names.map(name => this.optionOf(name)) } + return { + options: this.names.map(name => this.optionOf(name)), + defaultOptions: Object.keys(this.presets).map(name => this.optionOf(name)), + defaultPreset: this.defaultSettings().defaultPreset, + } } /** diff --git a/packages/interaction/permission-presets/src/types.ts b/packages/interaction/permission-presets/src/types.ts index dc519cdeff..d1d5fb530e 100644 --- a/packages/interaction/permission-presets/src/types.ts +++ b/packages/interaction/permission-presets/src/types.ts @@ -26,6 +26,10 @@ export interface PresetOption { export interface PermissionCatalog { /** Every currently selectable preset, in contribution order. */ options: PresetOption[] + /** Configured presets eligible as defaults for future sessions. */ + defaultOptions: PresetOption[] + /** Effective default when the Config field is omitted. */ + defaultPreset: string } /** Whole `permissions` Session projection: current durable selection only. */ diff --git a/packages/interaction/permission-presets/tests/permission-presets.spec.ts b/packages/interaction/permission-presets/tests/permission-presets.spec.ts index 157b6a132f..918c1efd0b 100644 --- a/packages/interaction/permission-presets/tests/permission-presets.spec.ts +++ b/packages/interaction/permission-presets/tests/permission-presets.spec.ts @@ -8,29 +8,14 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' import PermissionPresetService, { - AUTO_PRESET, CUSTOM_PRESET, PERMISSION_SETTINGS_NAMESPACE, + AUTO_PRESET, CUSTOM_PRESET, } from '@deepseek-ai/dsh-permission-presets' -import type { Config } from '@deepseek-ai/dsh-permission-presets' -import { SettingsProvider } from '@deepseek-ai/dsh-settings' -import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' +import { liveConfig } from '../../../settings/settings/tests/live-config.ts' -/** Writable memory provider for the permission/settings lifecycle specs. */ -class MemorySettings extends SettingsProvider { - readonly doc: Record = {} - readonly writable = true - - protected load(): Promise> { - return Promise.resolve(structuredClone(this.doc)) - } - - protected persist(ns: SettingsNamespace, section: Record): Promise { - this.doc[ns] = structuredClone(section) - return Promise.resolve() - } -} +const configurations = new WeakMap>>() async function mounted(options: { - config?: Config + config?: NonNullable[0]> bashDefault?: SandboxMode | undefined approvalDefault?: ApprovalPolicy | undefined projection?: boolean @@ -63,7 +48,6 @@ async function mountedStore(options: { approvalDefault?: ApprovalPolicy | undefi const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(MemorySettings) ctx.provide('shell', { sandboxMode: 'workspace-write', resolve() { throw new Error('permission tests do not execute bash') }, @@ -73,7 +57,7 @@ async function mountedStore(options: { approvalDefault?: ApprovalPolicy | undefi ctx.provide('approval', { config: { policy: 'approvalDefault' in options ? options.approvalDefault : 'ask' }, }) - await ctx.plugin(PermissionPresetService, {}) + configurations.set(ctx, await liveConfig(ctx, PermissionPresetService)) return ctx } @@ -176,7 +160,7 @@ describe('PermissionPresetService', () => { 'read-only': { sandbox: 'read-only', approval: 'ask' }, 'workspace-write': { sandbox: 'workspace-write', approval: 'ask' }, 'danger-full-access': { sandbox: 'danger-full-access', approval: 'never' }, - } } satisfies Config + } } satisfies NonNullable[0]> const ctx = await mounted({ config }) await mountAuto(ctx) const session = freshSession('shared-bundle-switch') @@ -346,7 +330,7 @@ describe('new-session default', () => { ['approval/policy', { policy: 'ask' }], ]) - await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, { + await configurations.get(ctx)!.update({ defaultPreset: 'danger-full-access', }) expect(ctx.permissionPresets.defaultPreset).toBe('danger-full-access') @@ -360,7 +344,7 @@ describe('new-session default', () => { it('preserves a seeded legacy session instead of applying the latest user default', async () => { const ctx = await mountedStore() - await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, { + await configurations.get(ctx)!.update({ defaultPreset: 'danger-full-access', }) const legacy = freshSession('legacy-source') @@ -375,7 +359,7 @@ describe('new-session default', () => { it('preserves composition defaults when an empty stored session resumes', async () => { const ctx = await mountedStore() - await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, { + await configurations.get(ctx)!.update({ defaultPreset: 'danger-full-access', }) const resumed = ctx.sessions.create(SessionId('empty-resumed'), { seed: [] }) @@ -399,7 +383,7 @@ describe('new-session default', () => { const existing = ctx.sessions.create(SessionId('existing-before-permission')) expect(existing.snapshotEvents()).toEqual([]) - await ctx.plugin(PermissionPresetService, {}) + configurations.set(ctx, await liveConfig(ctx, PermissionPresetService)) expect(existing.snapshotEvents().map(event => event.type)).toEqual([ 'permission/preset', 'sandbox/mode', 'approval/policy', ]) @@ -421,7 +405,7 @@ describe('new-session default', () => { existing.append('sandbox/mode', { mode: 'read-only' }) existing.append('approval/policy', { policy: 'never' }) - await ctx.plugin(PermissionPresetService, {}) + configurations.set(ctx, await liveConfig(ctx, PermissionPresetService)) // The remount sweep must read the folded knob events instead of treating // the session as fresh; no default preset events may overwrite the // overrides (read-only + never matches no preset table entry). @@ -461,12 +445,12 @@ describe('new-session default', () => { }) }) - it('rejects a stored default outside the configured preset table', async () => { + it('fails to read a stored default outside the configured preset table until it is repaired', async () => { const ctx = await mountedStore() await mountAuto(ctx) - await expect(ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, { - defaultPreset: AUTO_PRESET, - })).rejects.toThrow() + await configurations.get(ctx)!.update({ defaultPreset: AUTO_PRESET }) + expect(() => ctx.permissionPresets.defaultPreset).toThrow(/unknown default preset/) + await configurations.get(ctx)!.update({ defaultPreset: 'workspace-write' }) expect(ctx.permissionPresets.defaultPreset).toBe('workspace-write') }) }) diff --git a/packages/interaction/permission-presets/tests/projection.spec.ts b/packages/interaction/permission-presets/tests/projection.spec.ts index 63cf815a9a..d9d301dd47 100644 --- a/packages/interaction/permission-presets/tests/projection.spec.ts +++ b/packages/interaction/permission-presets/tests/projection.spec.ts @@ -19,11 +19,15 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import CommandRuntime from '@deepseek-ai/dsh-commands' import PermissionPresetService from '@deepseek-ai/dsh-permission-presets' import { AUTO_PRESET } from '@deepseek-ai/dsh-permission-presets' -import type { Config } from '@deepseek-ai/dsh-permission-presets' import ApprovalService from '@deepseek-ai/dsh-user-approval' +import { omitsGeneratedPage } from '../../../settings/settings/tests/live-config.ts' -async function harness(options: { withPermission?: boolean; config?: Config } = {}): Promise<{ ctx: Context; session: Session }> { - const ctx = new Context() +async function harness(options: { + withPermission?: boolean + config?: NonNullable[0]> + ctx?: Context +} = {}): Promise<{ ctx: Context; session: Session }> { + const ctx = options.ctx ?? new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(CommandRuntime) @@ -198,3 +202,8 @@ describe('/permission command', () => { event.type !== 'command/run' && event.type !== 'command/done')).toEqual(before) }) }) + +it('keeps its own instance off the generated Settings pages', () => omitsGeneratedPage(async (ctx) => { + await harness({ ctx, withPermission: false }) + return ctx.plugin(PermissionPresetService, {}) +})) diff --git a/packages/interaction/permission-presets/tsconfig.json b/packages/interaction/permission-presets/tsconfig.json index fd01854e9f..b4dec0574b 100644 --- a/packages/interaction/permission-presets/tsconfig.json +++ b/packages/interaction/permission-presets/tsconfig.json @@ -38,14 +38,14 @@ { "path": "../../session/session-projection" }, - { - "path": "../../settings/settings" - }, { "path": "../commands" }, { "path": "../../typert/protocol" + }, + { + "path": "../../settings/settings" } ] } diff --git a/packages/jobs/jobs/README.i18n.yaml b/packages/jobs/jobs/README.i18n.yaml index c12feed8d4..dfe7842a8b 100644 --- a/packages/jobs/jobs/README.i18n.yaml +++ b/packages/jobs/jobs/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/jobs/jobs/README.md -README.md: 1c7a933d6fa1bda03b37bf3768ac9798cb7d1325 -README.zh.md: a36924fe3ffbfa83e565e0aeecfcb6ffbeb0519d +README.md: 38454678cdaabb650ee02c55f60f71c6a9cd0e37 +README.zh.md: ab130743816a175d06976cad17a022e7e9a06421 diff --git a/packages/jobs/jobs/README.md b/packages/jobs/jobs/README.md index 1c7a933d6f..38454678cd 100644 --- a/packages/jobs/jobs/README.md +++ b/packages/jobs/jobs/README.md @@ -80,12 +80,15 @@ This section explains the design decisions behind the contract and points at the | [`src/types.ts`](src/types.ts) | Shared vocabulary: `JobSpec`, `JobHandle`, `JobHooks`, `JobOutcome`, `JobEvent`, and the read results | | [`src/view.ts`](src/view.ts) | Client-safe leaf: `JobView`, `JobChunk`, `JobStatus`, and the merge-extensible `JobKindMap` | | [`src/brand.ts`](src/brand.ts) | `JobId` branded identifier, importable without the agent dependency | +| [`src/archive-admission.ts`](src/archive-admission.ts) | The `job` family of the Workspace registry's archive admission, installed by the seam's constructor for every implementation | | [`src/invariant.ts`](src/invariant.ts) | Invariant companion: checks the announced event protocol per job (registered first, one settlement, removal last) and each announced projection against the registry's own read | ### Service operations Each read or control operation accepts an optional caller `SessionId`; omitting it permits only unowned jobs: `list` and `get` return fresh projections, `read` advances the model's cursor and hands out the producer's result once after settlement, `readAt` reads retained chunks at an absolute offset without consuming anything, `kill` invokes producer cancellation before changing status and records the reason for the terminal `detail`, `wait` blocks up to a timeout, `remove` drops a settled record a caller collected through its own wait and never handed out, and `start()` preflights access, validation, and admission before invoking the producer's `run()` once while refusing any owner no attached controller serves; `events.subscribe` delivers registration, progress, stopping, settlement, removal, and output commits at owner, scope, or process granularity. +Every implementation also answers the Workspace registry's archive admission ([seam](../../workspace/workspace/README.md)), installed by the seam's constructor through the abstract `list` and `kill` alone: `workspace/session-activity` reports the running or stopping jobs the asked Session owns as the `job` family, one item per job with its label; `workspace/session-stop` kills each of them with the reason `session archived`, one at a time, so a producer that throws on cancel is logged while the Session's other jobs still stop. Unowned jobs belong to nobody and are never reported or killed for a Session. + ----- diff --git a/packages/jobs/jobs/README.zh.md b/packages/jobs/jobs/README.zh.md index a36924fe3f..ab13074381 100644 --- a/packages/jobs/jobs/README.zh.md +++ b/packages/jobs/jobs/README.zh.md @@ -80,12 +80,15 @@ kind: "package-reference" | [`src/types.ts`](src/types.ts) | 共享词汇:`JobSpec`、`JobHandle`、`JobHooks`、`JobOutcome`、`JobEvent` 与读取结果 | | [`src/view.ts`](src/view.ts) | 客户端安全叶子:`JobView`、`JobChunk`、`JobStatus` 与可合并扩展的 `JobKindMap` | | [`src/brand.ts`](src/brand.ts) | `JobId` 带类型标记的标识符,无需 agent 依赖即可导入 | +| [`src/archive-admission.ts`](src/archive-admission.ts) | Workspace 注册表归档准入中的 `job` 族,由接缝构造函数为每个实现安装 | | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:校验每个 job 的事件协议(先 registered、恰一次结算、最后 removed)以及每个通告的投影与注册表自身读取的一致性 | ### 服务操作 每个读取或控制操作接收可选的调用方 `SessionId`,省略时仅允许访问无主 job:`list` 与 `get` 返回全新投影,`read` 推进模型游标并在结算后把生产方的 result 交出一次,`readAt` 按绝对偏移读取保留块且不消耗任何东西,`kill` 在改变状态前调用生产方取消并为终态 `detail` 记录原因,`wait` 阻塞至超时,`remove` 丢弃调用方经自己的等待收走且从未交出的已结算记录,`start()` 在调用生产方 `run()` 一次之前预检访问、校验与准入,同时拒绝任何没有已附加控制器服务的所有者;`events.subscribe` 按所有者、scope 或进程粒度投递注册、进度、停止中、结算、移除与输出提交。 +每个实现还会回答 Workspace 注册表的归档准入([接缝](../../workspace/workspace/README.zh.md)),由接缝的构造函数只通过抽象的 `list` 与 `kill` 安装:`workspace/session-activity` 把被询问会话拥有的运行中或停止中任务作为 `job` 族报告,每个任务一项、附其 label;`workspace/session-stop` 以原因 `session archived` 逐个 kill 它们,因此一个在取消时抛错的生产方只记日志,该会话的其他任务仍会停止。无主任务不属于任何会话,绝不会为某个会话被报告或 kill。 + ----- diff --git a/packages/jobs/jobs/package.json b/packages/jobs/jobs/package.json index ea35be2cea..6a2e3e92a2 100644 --- a/packages/jobs/jobs/package.json +++ b/packages/jobs/jobs/package.json @@ -45,13 +45,20 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-workspace": { + "optional": true + } + }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/jobs/jobs/src/archive-admission.ts b/packages/jobs/jobs/src/archive-admission.ts new file mode 100644 index 0000000000..a7e876b011 --- /dev/null +++ b/packages/jobs/jobs/src/archive-admission.ts @@ -0,0 +1,48 @@ +/** + * The `job` family of the Workspace registry's archive admission: the + * background jobs a Session owns that have not settled, and their kill when + * the Session is archived with its work. Installed by every registry + * implementation through the seam's constructor, so it holds for each of + * them through the abstract `list` and `kill` alone. + * + * @module @deepseek-ai/dsh-jobs + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionActivity } from '@deepseek-ai/dsh-workspace' +import type { JobRegistry } from './index.ts' +import type { JobView } from './view.ts' + +/** + * Answer `workspace/session-activity` with the running or stopping jobs the + * asked Session owns, and `workspace/session-stop` by killing each of them. + * Both listeners live as long as `ctx`'s fiber — the registry's own. + * @param ctx - the registry's registration context. + * @param registry - the registry whose `list` and `kill` answer. + */ +export function installJobArchiveAdmission(ctx: Context, registry: JobRegistry): void { + ctx.on('workspace/session-activity', async ({ sessionId }, next) => { + const jobs = runningJobs(registry, sessionId) + const rest = await next() + if (jobs.length === 0) return rest + const own: SessionActivity = { kind: 'job', items: jobs.map(job => ({ id: job.id, label: job.label })) } + return [own, ...rest] + }) + ctx.on('workspace/session-stop', ({ sessionId }) => { + for (const job of runningJobs(registry, sessionId)) { + try { + registry.kill(job.id, sessionId, 'session archived') + } catch (error: unknown) { + // A producer throwing on cancel must not keep the Session's other jobs running once it is archived. + ctx.logger.warn(`jobs: killing "${job.id}" for an archived Session failed: ${String(error)}`) + } + } + }) +} + +/** The jobs the Session owns that have not settled; unowned jobs in the same listing belong to nobody. */ +function runningJobs(registry: JobRegistry, owner: SessionId): JobView[] { + return registry.list(owner) + .filter(job => job.owner === owner && (job.status === 'running' || job.status === 'stopping')) +} diff --git a/packages/jobs/jobs/src/index.ts b/packages/jobs/jobs/src/index.ts index deb6e2d59f..dcfd95b737 100644 --- a/packages/jobs/jobs/src/index.ts +++ b/packages/jobs/jobs/src/index.ts @@ -11,6 +11,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-session' +import { installJobArchiveAdmission } from './archive-admission.ts' import type { JobEvents, JobId, JobOutputRead, JobRead, JobSpec, JobView } from './types.ts' export { JobId } from './types.ts' @@ -90,6 +91,9 @@ export abstract class JobRegistry extends Service { throw new Error('@deepseek-ai/dsh-jobs is the abstract job registry seam; load an implementation such as @deepseek-ai/dsh-jobs-local instead') } super(ctx, 'jobs') + // Archive admission: the Workspace registry asks what still runs for a + // Session before hiding it; owned jobs answer here for every implementation. + installJobArchiveAdmission(ctx, this) } /** Lifecycle and output events, filtered per subscription. */ diff --git a/packages/jobs/jobs/src/view.ts b/packages/jobs/jobs/src/view.ts index 39a1eb45d1..ed3af9c0d5 100644 --- a/packages/jobs/jobs/src/view.ts +++ b/packages/jobs/jobs/src/view.ts @@ -7,6 +7,8 @@ */ import type { SessionId } from '@deepseek-ai/dsh-session/types' +// Type-only: the Workspace registry's archive-admission family map this seam merges `job` into. +import type {} from '@deepseek-ai/dsh-workspace/types' import type { JobId } from './brand.ts' /** @@ -16,6 +18,13 @@ import type { JobId } from './brand.ts' */ export type JobStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed' +declare module '@deepseek-ai/dsh-workspace/types' { + interface SessionActivityKindMap { + /** A background job owned by this session is running or stopping. */ + job: true + } +} + /** * Producer-defined job kinds. Plugins extend this map by declaration merging; * the registry treats every value as an opaque id namespace. diff --git a/packages/jobs/jobs/tests/archive-admission.spec.ts b/packages/jobs/jobs/tests/archive-admission.spec.ts new file mode 100644 index 0000000000..be323ecdf2 --- /dev/null +++ b/packages/jobs/jobs/tests/archive-admission.spec.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { SessionId } from '@deepseek-ai/dsh-session' +import { JobId, JobRegistry } from '@deepseek-ai/dsh-jobs' +import type { JobEvents, JobOutputRead, JobRead, JobSpec, JobStatus, JobView } from '@deepseek-ai/dsh-jobs' +import type { SessionActivity } from '@deepseek-ai/dsh-workspace' + +interface Row { + readonly id: string + readonly label: string + readonly owner?: SessionId + status: JobStatus +} + +/** + * A registry whose listing the test scripts: the seam's archive-admission + * listeners are driven through the abstract `list` and `kill` alone. + */ +class ScriptedJobRegistry extends JobRegistry { + readonly rows: Row[] = [] + readonly kills: [JobId, SessionId | undefined, string | undefined][] = [] + refusing: string | undefined + + readonly events: JobEvents = { subscribe: () => () => {} } + + start(_spec: JobSpec): JobId { + throw new Error('not scripted') + } + + list(caller?: SessionId): JobView[] { + return this.rows + .filter(row => row.owner === undefined || row.owner === caller) + .map(row => this.view(row)) + } + + get(id: JobId): JobView { + const row = this.rows.find(candidate => candidate.id === id) + if (row === undefined) throw new Error(`unknown job ${id}`) + return this.view(row) + } + + read(id: JobId): JobRead { + return { chunks: [], lossy: false, job: this.get(id) } + } + + readAt(_id: JobId, from: number): JobOutputRead { + return { chunks: [], next: from, lossy: false } + } + + kill(id: JobId, caller?: SessionId, reason?: string): 'requested' | 'already-finished' { + if (id === this.refusing) throw new Error('producer refuses cancel') + this.kills.push([id, caller, reason]) + const row = this.rows.find(candidate => candidate.id === id) + if (row !== undefined) row.status = 'stopping' + return 'requested' + } + + wait(id: JobId): Promise { + return Promise.resolve(this.get(id)) + } + + remove(): void {} + + attachController(): () => void { + return () => {} + } + + private view(row: Row): JobView { + return { + id: JobId(row.id), + kind: 'bash', + label: row.label, + ...row.owner === undefined ? {} : { owner: row.owner }, + status: row.status, + startedAt: 0, + output: { total: 0, earliest: 0 }, + } + } +} + +async function harness(): Promise<{ ctx: Context; jobs: ScriptedJobRegistry }> { + const ctx = new Context() + await ctx.plugin(ScriptedJobRegistry) + return { ctx, jobs: ctx.jobs as ScriptedJobRegistry } +} + +function ask(ctx: Context, sessionId: SessionId): Promise { + return ctx.waterfall('workspace/session-activity', { sessionId }, () => Promise.resolve([])) +} + +function stop(ctx: Context, sessionId: SessionId): Promise { + return ctx.parallel('workspace/session-stop', { sessionId }) +} + +const owner = SessionId('owner') +const other = SessionId('other') + +describe('Job archive admission', () => { + it('reports the running or stopping jobs the session owns, never unowned, foreign, or settled ones', async () => { + const { ctx, jobs } = await harness() + jobs.rows.push( + { id: 'bash-1', label: 'pnpm run build', owner, status: 'running' }, + { id: 'bash-2', label: 'pnpm test', owner, status: 'stopping' }, + { id: 'bash-3', label: 'done', owner, status: 'completed' }, + { id: 'bash-4', label: 'theirs', owner: other, status: 'running' }, + { id: 'bash-5', label: 'unowned', status: 'running' }, + ) + ctx.on('workspace/session-activity', async (_request, next) => [...(await next()), { kind: 'turn' }]) + expect(await ask(ctx, owner)).toEqual([ + { kind: 'job', items: [{ id: 'bash-1', label: 'pnpm run build' }, { id: 'bash-2', label: 'pnpm test' }] }, + { kind: 'turn' }, + ]) + expect(await ask(ctx, SessionId('nobody'))).toEqual([{ kind: 'turn' }]) + }) + + it('kills each owned live job with the archive reason and keeps going when one producer refuses', async () => { + const { ctx, jobs } = await harness() + jobs.rows.push( + { id: 'bash-1', label: 'stubborn', owner, status: 'running' }, + { id: 'bash-2', label: 'quiet', owner, status: 'running' }, + { id: 'bash-3', label: 'theirs', owner: other, status: 'running' }, + ) + jobs.refusing = 'bash-1' + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + await stop(ctx, owner) + expect(jobs.kills).toEqual([[JobId('bash-2'), owner, 'session archived']]) + expect(warn.mock.calls.map(([message]) => String(message))).toEqual([expect.stringContaining('killing "bash-1"')]) + expect(jobs.get(JobId('bash-3')).status).toBe('running') + // A second stop finds the survivor already stopping and asks it again; the refusal is logged again. + await stop(ctx, owner) + expect(jobs.kills).toHaveLength(2) + }) + + it('stops answering once the registry fiber is disposed', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(ScriptedJobRegistry) + const jobs = ctx.jobs as ScriptedJobRegistry + jobs.rows.push({ id: 'bash-1', label: 'pnpm run build', owner, status: 'running' }) + expect(await ask(ctx, owner)).toEqual([{ kind: 'job', items: [{ id: 'bash-1', label: 'pnpm run build' }] }]) + await fiber.dispose() + expect(await ask(ctx, owner)).toEqual([]) + await stop(ctx, owner) + expect(jobs.kills).toEqual([]) + }) +}) diff --git a/packages/jobs/jobs/tsconfig.json b/packages/jobs/jobs/tsconfig.json index 82aa23134b..274d8808e2 100644 --- a/packages/jobs/jobs/tsconfig.json +++ b/packages/jobs/jobs/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../workspace/workspace" } ] } diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index ac07d1a93c..10e933caf1 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: e03b1024bf83fa39603c3c2413c3270df70b7bda -README.zh.md: 9f14e175d95ff0cac3e77c972a9bbc940c9d9e9f +README.md: 8b71e435884b8ea0364e7bea0924f2f351f11ec5 +README.zh.md: 90709e09b73e0448a90a1f2d224203ad3ce7bd36 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index e03b1024bf..8b71e43588 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -25,7 +25,7 @@ Stream DeepSeek models through `deepseek-official` using the Messages API. Confi ## Use this package -Mount this plugin when a composition streams DeepSeek models through the harness LLM service. It registers the single `deepseek-official` route and resolves connection facts per request, so a composition entry plus an optional user settings section drive the whole adapter. +Mount this plugin with the harness LLM service to serve `deepseek-official`. It captures connection options from Config references once per operation. The adapter accepts the LLM service's [request-only user inputs](../llm/README.md#use-this-package) alongside durable history; omitting request-only identity and attribution does not alter provider content. @@ -82,13 +82,19 @@ The official root is `https://api.deepseek.com/anthropic`. An explicit `baseURL` Messages sends text, thinking, tool calls, and tool results as content blocks, reasoning effort as `output_config.effort`, and images as Files references or inline base64. Models declaring `systemPromptUpdate: in-history` retain the initial top-level system and send new system snapshots after their corresponding user/tool-result turn; undeclared models use the latest snapshot as the top-level system. Replay metadata preserves the model and thinking signatures. Invalid replay metadata emits a warning and omits signatures while retaining text and tool history. +### Account credentials + +When the [account provider](../../credentials/deepseek-account-platform/README.md) returns a stored token for the resolved endpoint, that token takes priority over the configured API key. Eligibility follows the provider's `inferenceOrigin`, which defaults to `https://api.deepseek.com`. Other origins and signed-out accounts use the configured API-key reference. Signing out removes the account grant and preserves API keys. + +Messages and Files requests send account tokens as `x-dsh-auth-token` without a Bearer prefix; API keys use `x-api-key`. Neither credential mode follows redirects. + ### Streaming with thinking and images An image-capable route chooses each durable reference's request target and resolves it into a deterministic request version. Omitting `imagePixelBudget` sizes the target on the published vision token grid of 14px patches, 3:1 downsampling, and at most 1024 tokens per image, so a square image keeps up to 1302×1302 pixels and a 16:9 image is sent as 1708×961 for the provider's 1708×966 grid; a positive integer replaces the grid with a total-pixel budget, and `low` uses 512×512 total pixels. Every request image is capped at 4096 pixels per side, the provider limit for requests carrying 15 or more images, and `imageMaxBytes` defaults to 2 MiB. Alpha images use WebP effort 0 and opaque images use JPEG on the 85/75/60 quality ladder, keeping the smallest output when every candidate exceeds the target. Every retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current filesystem maps the attachment provider's host object, that text also carries a read-only execution-world path and the extension for a writable copy. Text-only and unlisted routes receive stable attachment placeholders while durable history keeps the image references. The adapter normally uploads those exact request bytes through `/v1/files` and sends file-id references. Files requests and model requests containing file ids include `anthropic-beta: files-api-2025-04-14`. All requests reject redirects so credentials remain on the configured origin. A failed or timed-out file resolution rebuilds the whole model request with inline base64 under the inline budget; one request never mixes file ids and inline images. Caller cancellation stops the request. -Cached ids are scoped by endpoint and API key, refreshed before expiry, invalidated from provider stale-file errors, and resolved through singleflight with waiter-local cancellation. Uploads request expiry through `expires_after[anchor]=created_at` and `expires_after[seconds]`. Messages file metadata omits remote expiry, so its local reuse deadline uses the original upload time plus `fileExpiresAfterSeconds`; this does not guarantee remote deletion. Quota failure deletes one configured batch of the oldest harness-owned files before one upload retry. +Cached ids are scoped by endpoint and credential, refreshed before expiry, invalidated from provider stale-file errors, and resolved through singleflight with waiter-local cancellation. Uploads request expiry through `expires_after[anchor]=created_at` and `expires_after[seconds]`. Messages file metadata omits remote expiry, so its local reuse deadline uses the original upload time plus `fileExpiresAfterSeconds`; this does not guarantee remote deletion. Quota failure deletes one configured batch of the oldest harness-owned files before one upload retry. Files mode bounds retained request versions by `maxRequestFilesBytes` and `maxImagesPerRequest`; inline fallback has its own base64 budget. Both remove an oldest prefix in configured byte or count quanta. Each omitted image gets its own model-visible placeholder with its display name or attachment id and, when available, normalized dimensions, media type, and current read-only path. The stepped high-watermark policy avoids rewriting an old request prefix after every new image. @@ -96,7 +102,7 @@ Files mode bounds retained request versions by `maxRequestFilesBytes` and `maxIm ### Dynamic configuration -Connection facts are re-read once per operation through the optional settings and credentials seams. A `llm-deepseek:` section in the user settings document overrides any field without a restart; a snapshot that fails a beyond-schema bound keeps the last good facts and logs the failure. The API key resolves per stream call from the same snapshot that supplies the endpoint, image and Files policies, and idle budget, so a rejected settings generation contributes none of them. Image requests resolve the attachment service at request time, so load order does not freeze image availability. +Connection options are captured from volatile Config references once per operation. Config validation rejects invalid candidates before form persistence. Credentials resolve from the same snapshot as the endpoint, image and Files policies, and idle budget. Attachment services resolve at request time. ### Provider-specific request fields @@ -108,7 +114,7 @@ Configuration accepts Messages only and has no `protocol` field. If resolution r Successful Files responses must contain valid JSON. JSON decoding failures from upload, list, retrieve, and delete throw `INVALID_RESPONSE` with the operation and HTTP status in the message, the status in `LlmError.failure`, and the original parser error as `cause`. Body-read transport and cancellation errors retain their identity. -Non-2xx responses fail with stable codes: `AUTH` (401/403), `QUOTA`, `RATE_LIMIT`, `CONTEXT_WINDOW_EXCEEDED`, `INVALID_REQUEST`, `SERVER`, and `HTTP_` otherwise; pre-response transport failures throw `TRANSPORT`, caller aborts throw `ABORTED`, and stream-idle expiry throws `TIMEOUT`. Request-extension preparation, field collision, or post-2xx acceptance fails with `REQUEST_EXTENSION`. A normalized-image rejection names every plausible attachment and its durable position when the provider does not identify a file id. Stale-file rejection invalidates the named mappings (or every mapping used by the attempt) and permits one replacement model request. Protocol violations throw `STREAM_CLOSED` or `MALFORMED_RESPONSE`, and a terminal `stop` with no content blocks becomes `EMPTY_RESPONSE`, which the default retry policy retries. A request with no key anywhere fails with `MISSING_CREDENTIAL`, and a malformed credential fails with `INVALID_CREDENTIAL` naming the reference to fix — never any part of the key. +Non-2xx responses fail with stable codes: `AUTH` (401/403), `QUOTA`, `RATE_LIMIT`, `CONTEXT_WINDOW_EXCEEDED`, `INVALID_REQUEST`, `SERVER`, and `HTTP_` otherwise; pre-response transport failures throw `TRANSPORT`, caller aborts throw `ABORTED`, and stream-idle expiry throws `TIMEOUT`. Request-extension preparation, field collision, or post-2xx acceptance fails with `REQUEST_EXTENSION`. A normalized-image rejection names every plausible attachment and its durable position when the provider does not identify a file id. Stale-file rejection invalidates the named mappings (or every mapping used by the attempt) and permits one replacement model request. Protocol violations throw `STREAM_CLOSED` or `MALFORMED_RESPONSE`, and a terminal `stop` with no content blocks becomes `EMPTY_RESPONSE`, which the default retry policy retries. A request with neither an eligible account token nor an API key fails with `MISSING_CREDENTIAL`, and a malformed credential fails with `INVALID_CREDENTIAL` naming the reference to fix — never any part of the key. ----- @@ -192,7 +198,7 @@ Loop-retained response blocks append to the next request and preserve its earlie These limits define where the adapter stops and future work begins. They are current package constraints, not a general DeepSeek comparison or a task backlog. -- **A settings `models` list replaces the composition list wholesale** — settings-layer merging is per-field, and arrays are one field; per-entry catalog merging would need a keyed shape. +- **Replacing `models` replaces the complete catalog list** — use path edits when changing one model entry. - **`tool_choice` is not mapped** — not part of the core vocabulary (shared with the pi-ai twin). - **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy or interception configuration. - **Messages in-history system updates require a retained user or tool-result turn** — if all user input after an update is omitted and the preceding wire turn is assistant, serialization fails with `UNSUPPORTED_CONTENT` before the next assistant or at the end of the request. Text or an empty tool result can retain that turn. Moving the update to an earlier turn is not supported; the [input-history decision](../../../.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md) records the ordering constraint. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 9f14e175d9..90709e09b7 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -当组合需要通过 harness LLM(大语言模型)服务流式调用 DeepSeek 模型时挂载本插件。它注册唯一的 `deepseek-official` 路由,并按请求解析连接事实,因此组合条目加可选用户设置分节即可驱动整个适配器。 +将此插件与 harness LLM 服务一起挂载以提供 `deepseek-official`。它在每次操作开始时从 Config 引用捕获连接选项。 适配器接受 LLM 服务的[仅供请求使用的 user 输入](../llm/README.zh.md#use-this-package),并可将其与持久历史混用;省略请求输入的身份与来源不会改变提供方内容。 @@ -82,13 +82,19 @@ kind: "package-reference" Messages 以内容块发送文本、思考、工具调用和工具结果,以 `output_config.effort` 发送推理强度,并以 Files 引用或内联 base64 发送图片。声明 `systemPromptUpdate: in-history` 的模型保留初始顶层 system,在对应 user/tool-result 轮次之后发送新的 system 快照;未声明能力时,使用最新快照作为顶层 system。回放元数据保留模型与思考签名。无效的回放元数据产生警告并省略签名,不丢弃文本或工具历史。 +### 账号凭据 + +当[账号提供者](../../credentials/deepseek-account-platform/README.zh.md)为已解析端点返回保存的 token 时,该 token 优先于配置的 API Key。适用范围由提供者的 `inferenceOrigin` 决定,默认为 `https://api.deepseek.com`。其他源以及已退出登录的账号使用配置的 API Key 引用。退出登录会删除账号授权,保留 API Key。 + +Messages 和 Files 请求通过 `x-dsh-auth-token` 发送账号 token,不加 Bearer 前缀;API Key 使用 `x-api-key`。两种凭据模式均拒绝重定向。 + ### 带 thinking 与图片的流式调用 支持图片的路由为每个持久引用选定请求目标,再把它解析为确定性请求版本。省略 `imagePixelBudget` 时按官方公布的视觉 token 网格定目标,即 14 px patch、3:1 降采样、单图最多 1024 token,因此正方形图片最多保留 1302×1302 像素,16:9 图片以 1708×961 发送、对应提供方 1708×966 的网格;正整数会用总像素预算取代网格,`low` 使用总计 512×512 像素。每张请求图片单边最多 4096 像素,这是提供方对包含 15 张及以上图片的请求的限制;`imageMaxBytes` 默认为 2 MiB。带 alpha 的图片使用 effort 0 的 WebP,不透明图片使用 JPEG,并采用 85/75/60 质量阶梯;全部候选都超过目标时保留最小输出。每张保留图片前都有文本,注明完整附件 id 与实际请求尺寸。当前文件系统可以映射附件提供方的宿主对象时,该文本还携带只读执行世界路径与可写副本使用的扩展名。纯文本与未列出路由接收稳定附件占位符,而持久历史继续保留图片引用。 适配器通常通过 `/v1/files` 上传这些确切请求字节,并发送 file-id 引用。Files 请求与包含 file id 的模型请求均携带 `anthropic-beta: files-api-2025-04-14`。全部请求拒绝重定向,确保凭据仅发送到配置的源。文件解析失败或超时会按内联预算,用内联 base64 重建整份模型请求;一次请求绝不混用 file id 与内联图片。调用方取消会停止请求。 -缓存 id 按端点与 API key 限定作用域,在到期前刷新,根据提供方的陈旧文件错误失效,并通过带等待方局部取消的 singleflight 解析。上传通过 `expires_after[anchor]=created_at` 与 `expires_after[seconds]` 请求过期。Messages 文件元数据不含远端过期时间,因此本地复用期限使用原始上传时间加 `fileExpiresAfterSeconds`;这不保证远端文件删除。配额失败会先删除一批配置数量的最旧 harness 文件,再重试一次上传。 +缓存 id 按端点与凭据限定作用域,在到期前刷新,根据提供方的陈旧文件错误失效,并通过带等待方局部取消的 singleflight 解析。上传通过 `expires_after[anchor]=created_at` 与 `expires_after[seconds]` 请求过期。Messages 文件元数据不含远端过期时间,因此本地复用期限使用原始上传时间加 `fileExpiresAfterSeconds`;这不保证远端文件删除。配额失败会先删除一批配置数量的最旧 harness 文件,再重试一次上传。 Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留请求版本;内联回退有独立 base64 预算。两种模式都按配置的字节或数量量子移除最旧前缀。每张省略图片都有自己的模型可见占位符,包含显示名或附件 id,以及可用时的规范化尺寸、媒体类型与当前只读路径。分阶高水位策略避免每新增一张图片都改写旧请求前缀。 @@ -96,7 +102,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 ### 动态配置 -连接事实通过可选 settings 与凭据 seam 每次操作重新读取一次。用户设置文档中的 `llm-deepseek:` 分节无需重启即可覆盖任何字段;违反 schema 之外约束的快照会保留最后有效事实并记录失败。API 密钥从提供端点、图片与 Files 策略及空闲预算的同一快照按流调用解析,因此被拒绝的设置代际不会贡献其中任何事实。图片请求在请求时解析附件服务,因此加载顺序不会冻结图片可用性。 +连接选项在每次操作开始时从 volatile Config 引用捕获。Config 验证在表单持久化前拒绝无效候选值。凭据使用与端点、图像及 Files 策略、空闲预算相同的快照解析。附件服务在请求时解析。 ### 提供方专用请求字段 @@ -108,7 +114,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 成功的 Files 响应必须包含有效 JSON。上传、列举、获取和删除操作的 JSON 解码失败抛出 `INVALID_RESPONSE`,消息包含操作名称与 HTTP 状态,`LlmError.failure` 保留该状态,`cause` 保留原始解析错误。读取响应体时的传输和取消错误保留其原有身份。 -非 2xx 响应以稳定 code 失败:`AUTH`(401/403)、`QUOTA`、`RATE_LIMIT`、`CONTEXT_WINDOW_EXCEEDED`、`INVALID_REQUEST`、`SERVER` 以及其他情况的 `HTTP_`;响应前传输失败抛出 `TRANSPORT`,调用方中止抛出 `ABORTED`,流空闲超时抛出 `TIMEOUT`。请求扩展准备、字段冲突或 2xx 后接受失败使用 `REQUEST_EXTENSION`。当提供方未指出 file id 时,规范化图片拒绝会列出所有可能附件及其持久位置。陈旧文件拒绝会使点名映射(或该次尝试使用的全部映射)失效,并允许一次替换模型请求。协议违规抛出 `STREAM_CLOSED` 或 `MALFORMED_RESPONSE`;不带内容块的终止 `stop` 变成 `EMPTY_RESPONSE`,默认重试策略会重试它。任何位置都没有密钥的请求以 `MISSING_CREDENTIAL` 失败;格式错误的凭据以 `INVALID_CREDENTIAL` 失败,并点名需要修复的引用——绝不包含密钥的任何部分。 +非 2xx 响应以稳定 code 失败:`AUTH`(401/403)、`QUOTA`、`RATE_LIMIT`、`CONTEXT_WINDOW_EXCEEDED`、`INVALID_REQUEST`、`SERVER` 以及其他情况的 `HTTP_`;响应前传输失败抛出 `TRANSPORT`,调用方中止抛出 `ABORTED`,流空闲超时抛出 `TIMEOUT`。请求扩展准备、字段冲突或 2xx 后接受失败使用 `REQUEST_EXTENSION`。当提供方未指出 file id 时,规范化图片拒绝会列出所有可能附件及其持久位置。陈旧文件拒绝会使点名映射(或该次尝试使用的全部映射)失效,并允许一次替换模型请求。协议违规抛出 `STREAM_CLOSED` 或 `MALFORMED_RESPONSE`;不带内容块的终止 `stop` 变成 `EMPTY_RESPONSE`,默认重试策略会重试它。既没有适用账号 token 也没有 API Key 的请求以 `MISSING_CREDENTIAL` 失败;格式错误的凭据以 `INVALID_CREDENTIAL` 失败,并点名需要修复的引用——绝不包含密钥的任何部分。 ----- @@ -192,7 +198,7 @@ loop 保留的响应块会追加到下一个请求,并保留其更早的可复 这些限制说明适配器在哪里停止、由未来工作接续。它们是当前包约束,不是通用 DeepSeek 对比或任务积压。 -- **设置中的 `models` 列表会整体替换组合列表**——设置层按字段合并,数组只算一个字段;按条目合并目录需要带键的形状。 +- **替换 `models` 会替换完整目录列表**——修改单个模型条目时使用路径编辑。 - **不映射 `tool_choice`**——不属于核心词汇(与 pi-ai 孪生共享)。 - **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**——没有共享代理或拦截配置。 - **Messages 历史内 system 更新需要保留用户或工具结果轮次**——若更新后的全部用户输入都被省略,且前一个协议轮次是 assistant,序列化会在下一个 assistant 之前或请求结束处以 `UNSUPPORTED_CONTENT` 失败。文本或空工具结果可以保留该轮次。不支持将更新移到更早的轮次;[输入历史决策](../../../.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md)记录了排序约束。 diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 92a8643857..7d90f57c92 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -37,10 +37,12 @@ "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-timeout": "workspace:^" + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-deepseek-account": "workspace:^" }, "dependencies": { + "@deepseek-ai/cosmokit": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-util-values": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", @@ -62,7 +64,6 @@ "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-log-deepseek": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-http-proxy": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", @@ -72,6 +73,15 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-credentials-local": "workspace:^", - "@deepseek-ai/dsh-settings-file": "workspace:^" + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-deepseek-account": "workspace:^" + }, + "peerDependenciesMeta": { + "@deepseek-ai/cordis-plugin-loader": { + "optional": true + }, + "@deepseek-ai/dsh-deepseek-account": { + "optional": true + } } } diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 4b0f3704b9..4490dd9b6e 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -80,9 +80,12 @@ export class DeepSeekAdapter extends LlmAdapter { const { messages, versions } = await prepareImages( options.messages, connection, options.model, this.dependencies.resolveAttachments?.(), this.imageAccess, signal, ) - const key = await this.dependencies.resolveApiKey(connection) - const files = new RequestFiles(this.files, { baseURL: connection.baseURL, apiKey: key }, - connection.filePolicy, connection.filesApiTimeoutMs, signal, activity) + const accountToken = await this.dependencies.resolveAccountToken?.(connection) + const key = accountToken ?? await this.dependencies.resolveApiKey(connection) + const files = new RequestFiles(this.files, { + baseURL: connection.baseURL, apiKey: key, accountCredential: accountToken !== undefined, + }, + connection.filePolicy, connection.filesApiTimeoutMs, signal, activity) let inline = false while (true) { signal.throwIfAborted() @@ -112,7 +115,8 @@ export class DeepSeekAdapter extends LlmAdapter { headers: { ...attributionHeaders(), 'content-type': 'application/json', 'accept': 'text/event-stream', - 'x-api-key': key, 'anthropic-version': '2023-06-01', + ...accountToken === undefined ? { 'x-api-key': key } : { 'x-dsh-auth-token': accountToken }, + 'anthropic-version': '2023-06-01', ...fileIds === undefined || fileIds.size === 0 ? {} : { 'anthropic-beta': MESSAGES_FILES_BETA }, 'x-deepseek-harness-user-id': this.dependencies.resolveUserId(), ...options.sessionId === undefined ? {} : { 'x-deepseek-harness-session-id': String(options.sessionId) }, diff --git a/packages/llm/llm-deepseek/src/config.ts b/packages/llm/llm-deepseek/src/config.ts index edfbc252b4..f2ba3bd23a 100644 --- a/packages/llm/llm-deepseek/src/config.ts +++ b/packages/llm/llm-deepseek/src/config.ts @@ -1,5 +1,8 @@ /** Plugin configuration and complete request-local resolution for DeepSeek. */ +import type { Volatile } from '@deepseek-ai/cordis' + import z from '@deepseek-ai/schemastery' +import { isVolatile } from '@deepseek-ai/cosmokit' import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { ModelModality, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' @@ -24,43 +27,54 @@ const MODEL_MODALITIES = ['text', 'image'] as const satisfies readonly ModelModa */ export interface Config { /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ - apiKeyEnv?: string + apiKeyEnv: Volatile /** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */ - baseURL?: string + baseURL: Volatile /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */ - thinking?: 'enabled' | 'disabled' + thinking: Volatile<'enabled' | 'disabled' | undefined> /** Default thinking effort (default `high`); `off` disables thinking per request. */ - reasoningEffort?: 'off' | 'low' | 'high' | 'max' + reasoningEffort: Volatile<'off' | 'low' | 'high' | 'max' | undefined> /** Default per-request output cap (default 256,000); a model's own cap and explicit request values win. */ - maxTokens?: number + maxTokens: Volatile /** Positive context capacity used when the selected model has no exact value (default 1,000,000). */ - defaultContextWindow?: number + defaultContextWindow: Volatile /** Advisory models shown by discovery consumers; defaults to V41 Flash and V4 Pro. */ - models?: DeepSeekCatalogModel[] + models: Volatile /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ - streamIdleTimeoutMs?: number + streamIdleTimeoutMs: Volatile /** Maximum accumulated file-referenced image bytes per chat request (default 128 MiB). */ - maxRequestFilesBytes?: number + maxRequestFilesBytes: Volatile /** Maximum accumulated base64 image payload after Files API fallback (default 20 MiB). */ - maxInlineRequestImageBytes?: number + maxInlineRequestImageBytes: Volatile /** Maximum number of represented images per chat request (default 600). */ - maxImagesPerRequest?: number + maxImagesPerRequest: Volatile /** Raw-byte removal step after the request exceeds its file bound (default 64 MiB). */ - imageOffloadByteQuantum?: number + imageOffloadByteQuantum: Volatile /** Base64-byte removal step after inline fallback exceeds its bound (default 10 MiB). */ - inlineImageOffloadByteQuantum?: number + inlineImageOffloadByteQuantum: Volatile /** Image-count removal step after the request exceeds its count bound (default 20). */ - imageOffloadCountQuantum?: number + imageOffloadCountQuantum: Volatile /** Maximum duration of one request-image Files API resolution (default one minute). */ - filesApiTimeoutMs?: number + filesApiTimeoutMs: Volatile /** Explicit lifetime assigned to each uploaded image (default seven days). */ - fileExpiresAfterSeconds?: number + fileExpiresAfterSeconds: Volatile /** Remaining lifetime below which an indexed file is replaced (default one hour). */ - fileRefreshMarginSeconds?: number + fileRefreshMarginSeconds: Volatile /** Oldest harness-owned files deleted before one quota-recovery upload retry (default 100). */ - fileQuotaCleanupBatch?: number + fileQuotaCleanupBatch: Volatile /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ - retryPolicy?: RetryPolicyConfig + retryPolicy: Volatile +} + +/** Plain options accepted by the provider resolver. */ +export type Options = { [K in keyof Config]?: Config[K] extends Volatile ? Exclude : never } + +/** Read the current value behind every reference of a validated Config. + * @param config Parsed plugin Config. + * @returns Plain options for the resolver. + */ +export function plainOptions(config: Config): Options { + return Object.fromEntries(Object.entries(config).map(([key, value]) => [key, isVolatile(value) ? value.get() : value])) } const catalogModel: z = z.object({ @@ -75,26 +89,26 @@ const catalogModel: z = z.object({ systemPromptUpdate: z.const('in-history'), }) -export const Config: z = z.object({ - apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV), - baseURL: z.string(), - thinking: z.union(['enabled', 'disabled']), - reasoningEffort: z.union(['off', 'low', 'high', 'max']), - maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_TOKENS), - defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW), - models: z.array(catalogModel).default(DEFAULT_MODELS), - streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS), - maxRequestFilesBytes: z.number().step(1).min(1).default(DEFAULT_MAX_REQUEST_FILES_BYTES), - maxInlineRequestImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES), - maxImagesPerRequest: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGES_PER_REQUEST), - imageOffloadByteQuantum: z.number().step(1).min(1).default(DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM), - inlineImageOffloadByteQuantum: z.number().step(1).min(1).default(DEFAULT_INLINE_IMAGE_OFFLOAD_BYTE_QUANTUM), - imageOffloadCountQuantum: z.number().step(1).min(1).default(DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM), - filesApiTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_FILES_API_TIMEOUT_MS), - fileExpiresAfterSeconds: z.number().step(1).min(3_600).max(2_592_000).default(DEFAULT_FILE_EXPIRY_SECONDS), - fileRefreshMarginSeconds: z.number().step(1).min(0).default(DEFAULT_FILE_REFRESH_MARGIN_SECONDS), - fileQuotaCleanupBatch: z.number().step(1).min(1).max(1_000).default(DEFAULT_FILE_QUOTA_CLEANUP_BATCH), - retryPolicy: RetryPolicySchema, +export const Config = z.object({ + apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV).volatile(), + baseURL: z.string().volatile(), + thinking: z.union(['enabled', 'disabled']).volatile(), + reasoningEffort: z.union(['off', 'low', 'high', 'max']).volatile(), + maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_TOKENS).volatile(), + defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW).volatile(), + models: z.array(catalogModel).default(DEFAULT_MODELS).volatile(), + streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS).volatile(), + maxRequestFilesBytes: z.number().step(1).min(1).default(DEFAULT_MAX_REQUEST_FILES_BYTES).volatile(), + maxInlineRequestImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES).volatile(), + maxImagesPerRequest: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGES_PER_REQUEST).volatile(), + imageOffloadByteQuantum: z.number().step(1).min(1).default(DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM).volatile(), + inlineImageOffloadByteQuantum: z.number().step(1).min(1).default(DEFAULT_INLINE_IMAGE_OFFLOAD_BYTE_QUANTUM).volatile(), + imageOffloadCountQuantum: z.number().step(1).min(1).default(DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM).volatile(), + filesApiTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_FILES_API_TIMEOUT_MS).volatile(), + fileExpiresAfterSeconds: z.number().step(1).min(3_600).max(2_592_000).default(DEFAULT_FILE_EXPIRY_SECONDS).volatile(), + fileRefreshMarginSeconds: z.number().step(1).min(0).default(DEFAULT_FILE_REFRESH_MARGIN_SECONDS).volatile(), + fileQuotaCleanupBatch: z.number().step(1).min(1).max(1_000).default(DEFAULT_FILE_QUOTA_CLEANUP_BATCH).volatile(), + retryPolicy: RetryPolicySchema.volatile(), }) /** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */ @@ -196,7 +210,7 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee * gateway that checkout is meant to use. * @returns validated connection facts plus the credential reference. */ -export function resolveAdapterOptions(config: Config, environment?: LaunchEnvironmentSnapshot): ResolvedDeepSeekOptions { +export function resolveAdapterOptions(config: Options, environment?: LaunchEnvironmentSnapshot): ResolvedDeepSeekOptions { // Settings updates can reach this resolver without schema validation. if (Object.hasOwn(config, 'protocol')) { throw new Error('llm-deepseek: protocol is not configurable; remove it and use a Messages-compatible baseURL') diff --git a/packages/llm/llm-deepseek/src/file-store.ts b/packages/llm/llm-deepseek/src/file-store.ts index 0963d2eeab..7d5bf4e8d1 100644 --- a/packages/llm/llm-deepseek/src/file-store.ts +++ b/packages/llm/llm-deepseek/src/file-store.ts @@ -23,6 +23,8 @@ export interface DeepSeekFilePolicy { export interface DeepSeekFileConnection { baseURL: string apiKey: string + /** Use the DSH account header; omitted for ordinary API keys. */ + accountCredential?: boolean } /** Result of one file-id resolution. */ @@ -136,6 +138,7 @@ export class DeepSeekFileStore { return new DeepSeekFilesClient({ baseURL: connection.baseURL, apiKey: connection.apiKey, + ...connection.accountCredential === undefined ? {} : { accountCredential: connection.accountCredential }, ...this.fetchImpl === undefined ? {} : { fetch: this.fetchImpl }, }) } diff --git a/packages/llm/llm-deepseek/src/files-api.ts b/packages/llm/llm-deepseek/src/files-api.ts index abf9f12191..181580ce6f 100644 --- a/packages/llm/llm-deepseek/src/files-api.ts +++ b/packages/llm/llm-deepseek/src/files-api.ts @@ -71,6 +71,8 @@ export function isFilesQuotaError(error: unknown): error is DeepSeekFilesError { interface FilesApiOptions { baseURL: string apiKey: string + /** Use the DSH account header; omitted for ordinary API keys. */ + accountCredential?: boolean fetch?: typeof fetch } @@ -129,6 +131,7 @@ function providerErrorDetail(value: unknown): { message?: string; detail: string /** Direct Files client retaining the configured URL root and refusing redirects before credentials can leave its origin. */ export class DeepSeekFilesClient { private readonly baseURL: string + private readonly accountCredential: boolean private readonly apiKey: string private readonly fetchImpl: typeof fetch @@ -137,6 +140,7 @@ export class DeepSeekFilesClient { */ constructor(options: FilesApiOptions) { this.apiKey = options.apiKey + this.accountCredential = options.accountCredential === true this.fetchImpl = options.fetch ?? globalThis.fetch this.baseURL = messagesApiRoot(options.baseURL) } @@ -145,7 +149,7 @@ export class DeepSeekFilesClient { let response: Response try { const headers = new Headers(attributionHeaders()) - headers.set('x-api-key', this.apiKey) + headers.set(this.accountCredential ? 'x-dsh-auth-token' : 'x-api-key', this.apiKey) headers.set('anthropic-version', '2023-06-01') headers.set('anthropic-beta', MESSAGES_FILES_BETA) response = await this.fetchImpl(`${this.baseURL}${path}`, { diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index cc5cfdc52f..473f2dfde5 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -1,17 +1,19 @@ -/** Register DeepSeek Messages with request-local settings and credentials. */ +/** Register DeepSeek Messages with live configuration and request-local credentials. */ +import type {} from '@deepseek-ai/dsh-settings' +import type {} from '@deepseek-ai/dsh-deepseek-account' +import type {} from '@deepseek-ai/cordis-plugin-loader' import type { Context } from '@deepseek-ai/cordis' import { assertUsableApiKey, LlmError, resolveImageAttachmentAccess } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-fs' import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' -import type {} from '@deepseek-ai/dsh-settings' import { deepEqualJson } from '@deepseek-ai/dsh-util-values' import { getOrCreateAnonymousUserId, type AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' import { DeepSeekAdapter } from './adapter.ts' -import { Config, resolveAdapterOptions } from './config.ts' +import { Config, plainOptions, resolveAdapterOptions } from './config.ts' import type { ResolvedDeepSeekOptions } from './config.ts' -export { Config, resolveAdapterOptions, PUBLIC_BASE_URL } from './config.ts' -export type { ResolvedDeepSeekOptions } from './config.ts' +export { Config, plainOptions, resolveAdapterOptions, PUBLIC_BASE_URL } from './config.ts' +export type { Options, ResolvedDeepSeekOptions } from './config.ts' export { DEFAULT_CONTEXT_WINDOW, DEFAULT_FILE_EXPIRY_SECONDS, @@ -55,28 +57,8 @@ const NS = 'llm-deepseek' const PROVIDER = 'deepseek-official' export function apply(ctx: Context, config: Config): void { - let current: () => Config = () => config - let lastRaw: Config | undefined - let lastGood: ResolvedDeepSeekOptions | undefined - const options = (): ResolvedDeepSeekOptions => { - const raw = current() - if (raw === lastRaw && lastGood !== undefined) return lastGood - try { - const next = resolveAdapterOptions(raw, launchEnvironmentOf(ctx)) - lastRaw = raw - lastGood = next - return next - } catch (error) { - // Static composition resolves before anything registers, so this branch - // only sees a live settings snapshot failing a beyond-schema bound: - // keep serving the last good facts and say so once per bad snapshot. - if (lastGood === undefined) throw error - lastRaw = raw - ctx.logger.error('llm-deepseek: keeping the last good configuration after an invalid settings section') - ctx.logger.error(error) - return lastGood - } - } + ctx.inject(['settings'], (child) => { child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)) }) + const options = (): ResolvedDeepSeekOptions => resolveAdapterOptions(plainOptions(config), launchEnvironmentOf(ctx)) options() const resolveApiKey = async (connection: ResolvedDeepSeekOptions): Promise => { @@ -110,6 +92,7 @@ export function apply(ctx: Context, config: Config): void { ctx.logger.warn(`llm-deepseek: unusable Messages replay state on assistant history for route "${provider}/${model}"; sending provider-neutral content (${reason})`) }, resolveApiKey, + resolveAccountToken: connection => ctx.get('deepseekAccount')?.resolveToken(connection.baseURL) ?? Promise.resolve(undefined), resolveUserId, resolveAttachments: () => ctx.get('attachments'), resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess( @@ -124,14 +107,21 @@ export function apply(ctx: Context, config: Config): void { }, }) ctx.llm.registerConfigurableProviders([ - { provider: PROVIDER, displayName: 'DeepSeek', settingsNs: NS, settingsPath: [] }, + { provider: PROVIDER, displayName: 'DeepSeek', settingsNs: ctx.fiber.entry?.options.id ?? NS, settingsPath: [] }, ]) // Route effects bind to this apply fiber via the stable `ctx` reference, // even when a swap runs inside the scoped settings callback below. const registration = ctx.llm.registerAdapter([PROVIDER], adapter) let registeredPolicy = options().retryPolicy const ensureRegistrationFacts = (): void => { - const policy = options().retryPolicy + let policy: ResolvedDeepSeekOptions['retryPolicy'] + try { + policy = options().retryPolicy + } catch (error) { + // A stored config the resolver refuses keeps the current registration; each request fails on its own resolve. + ctx.logger.warn(error) + return + } if (deepEqualJson(policy, registeredPolicy)) return // The registry captures the retry policy at registration, so it is the one // fact per-request resolution cannot refresh. `replace` re-reads it in one @@ -142,12 +132,5 @@ export function apply(ctx: Context, config: Config): void { registeredPolicy = policy } - ctx.inject(['settings'], (settingsCtx) => { - settingsCtx.settings.installSection(ctx, NS, Config, config, { - setSource: (source) => { - current = source - }, - onChange: ensureRegistrationFacts, - }) - }) + ctx.on('loader/volatile-update', ensureRegistrationFacts) } diff --git a/packages/llm/llm-deepseek/src/types.ts b/packages/llm/llm-deepseek/src/types.ts index 180227f484..cc623b0a50 100644 --- a/packages/llm/llm-deepseek/src/types.ts +++ b/packages/llm/llm-deepseek/src/types.ts @@ -95,6 +95,8 @@ export interface DeepSeekAdapterOptions { * `MISSING_CREDENTIAL` when no key is available anywhere. */ resolveApiKey: (connection: DeepSeekConnectionOptions) => Promise + /** Resolve a DSH account token only for an eligible official endpoint. */ + resolveAccountToken?: (connection: DeepSeekConnectionOptions) => Promise /** Resolve the harness-home anonymous id shared with telemetry and feedback. */ resolveUserId: () => AnonymousUserId /** Resolve the current durable attachment service; absence rejects image input. */ diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index bffec7e6ce..205279dc13 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -30,7 +30,7 @@ afterEach(async () => { vi.unstubAllEnvs() vi.unstubAllGlobals() }) -async function boot(models?: Messages.Config['models']) { +async function boot(models?: Messages.Options['models']) { const home = await mkdtemp(join(tmpdir(), 'dsh-messages-e2e-')) cleanups.push(() => rm(home, { recursive: true, force: true })) vi.stubEnv('DSH_HOME', home) diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index f12fe10e87..015c05c332 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -1,4 +1,5 @@ /** HTTP lifecycle, routing and optional Cordis services under real composition. */ +import type { DeepSeekAccount } from '@deepseek-ai/dsh-deepseek-account' import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -20,7 +21,7 @@ import LlmRuntime, { createAssistantMessage, createSystemMessage, createToolResu import type { Message } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' import LocalCredentials from '@deepseek-ai/dsh-credentials-local' -import FileSettings from '@deepseek-ai/dsh-settings-file' +import { profileComposition } from '../../../settings/settings/tests/profile-composition.ts' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { DeepSeekAdapter } from '../src/adapter.ts' import { object } from '../src/replay.ts' @@ -250,15 +251,14 @@ describe('Cordis provider composition', () => { const { ctx, home } = await context() vi.stubEnv('DEEPSEEK_API_KEY', '') await writeFile(join(home, '.credentials.yaml'), 'version: 1\nrefs:\n DEEPSEEK_API_KEY: stored-key\n', { mode: 0o600 }) - await writeFile(join(home, 'settings.yaml'), '{}\n') const template = await readFile(new URL('fixtures/cordis.yml', import.meta.url), 'utf8') - await writeFile(join(home, 'cordis.yml'), template.replaceAll('{{endpoint}}', JSON.stringify(http.url)).replaceAll('{{settings}}', JSON.stringify(join(home, 'settings.yaml'))).replaceAll('{{credentials}}', JSON.stringify(join(home, '.credentials.yaml')))) + await writeFile(join(home, 'cordis.yml'), template.replaceAll('{{endpoint}}', JSON.stringify(http.url)).replaceAll('{{credentials}}', JSON.stringify(join(home, '.credentials.yaml')))) ctx.baseUrl = pathToFileURL(home).href + '/' await ctx.plugin(Loader) ctx.loader.builtins.include = Include const modules = new Map([ ['@deepseek-ai/dsh-llm', LlmRuntime], ['@deepseek-ai/dsh-llm-deepseek', Messages], - ['@deepseek-ai/dsh-credentials-local', LocalCredentials], ['@deepseek-ai/dsh-settings-file', FileSettings], + ['@deepseek-ai/dsh-credentials-local', LocalCredentials], ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-agent-loop', AgentLoop], ['@deepseek-ai/dsh-session', SessionStore], ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRuntime], @@ -273,8 +273,7 @@ describe('Cordis provider composition', () => { if (!modules.has(name)) throw new Error(`unexpected module ${name}`) return modules.get(name) }) - await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(home, 'cordis.yml')).href } }) - await ctx.loader.await() + await profileComposition(ctx, home, join(home, 'cordis.yml')) return { ctx, http } } @@ -416,8 +415,9 @@ describe('Cordis provider composition', () => { await chunks(ctx.llm.stream(options())) expect(second.requests[0]).toMatchObject({ headers: { 'x-api-key': 'rotated' }, body: { max_tokens: 51 } }) await ctx.settings.update(Messages.name, { models: [{ id: 'duplicate' }, { id: 'duplicate' }], baseURL: http.url }) - await chunks(ctx.llm.stream(options())) - expect(second.requests).toHaveLength(2) + const refused: unknown = (await chunks(ctx.llm.stream(options()))).find(chunk => chunk.type === 'finish') + expect(JSON.stringify(refused)).toContain('duplicate catalog model') + expect(second.requests).toHaveLength(1) expect(http.requests).toHaveLength(1) await ctx.settings.update(Messages.name, { models: [{ id: MODEL }], baseURL: http.url }) await chunks(ctx.llm.stream(options())) @@ -446,3 +446,33 @@ describe('Cordis provider composition', () => { expect(ctx.llm.listProviders()).toEqual([]) }) }) + +it('rejects invalid catalog context windows at the options resolver', () => { + expect(() => Messages.resolveAdapterOptions({ models: [{ id: 'invalid-window', contextWindow: 0 }] })).toThrow('contextWindow must be a positive integer') +}) + + +it.each([ + ['https://api.deepseek.com', 'account-token'], + ['https://custom.example.test', 'ambient-key'], +] as const)('selects account or API-key credentials from the actual endpoint %s', async (baseURL, expected) => { + vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key') + const { ctx } = await context() + // This consumer uses only resolveToken; the real provider owns origin validation in its own suite. + ctx.provide('deepseekAccount', { + resolveToken: (url: string) => Promise.resolve(url === 'https://api.deepseek.com' ? 'account-token' : undefined), + } as DeepSeekAccount) + await ctx.plugin(LlmRuntime) + await ctx.plugin(Messages, { baseURL }) + const request = vi.fn((_input, init) => { + const headers = new Headers(init?.headers) + expect(headers.has('authorization')).toBe(false) + expect(headers.get('x-api-key')).toBe(expected === 'account-token' ? null : expected) + expect(headers.get('x-dsh-auth-token')).toBe(expected === 'account-token' ? expected : null) + expect(init?.redirect).toBe('error') + return Promise.resolve(new Response(sse(textEvents), { status: 200 })) + }) + vi.stubGlobal('fetch', request) + await assemble(ctx.llm.stream(options())) + expect(request).toHaveBeenCalledOnce() +}) diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 9872b90f89..1a2cb322d6 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -15,7 +15,7 @@ import type { } from '@deepseek-ai/dsh-attachment' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local' -import { FileSettingsProvider } from '@deepseek-ai/dsh-settings-file' +import { liveConfig } from '../../../settings/settings/tests/live-config.ts' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { ContextFormed } from '@deepseek-ai/dsh-llm' import { assemble } from './assemble.ts' @@ -27,7 +27,7 @@ declare module '@deepseek-ai/dsh-llm' { } } -const NS = 'llm-deepseek' +const configurations = new WeakMap>>() const KEY_REF = credentialRef('DEEPSEEK_API_KEY') const IMAGE_REF: ImageAttachmentRef = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), @@ -111,11 +111,10 @@ async function home(): Promise { interface Harness { ctx: Context - settingsFiber: { dispose(): Promise } } /** - * Real dynamic composition: llm + settings-file + credentials-local + + * Real dynamic composition: llm + profile Config edits + credentials-local + * llm-deepseek over one temp harness home. `watch: false` keeps every change * flowing through the in-process write path, which is deterministic; external * file watching is the providers' own covered concern. @@ -128,11 +127,9 @@ async function boot(dir: string, config: object): Promise { }) await ctx.plugin(LlmRuntime) await ctx.plugin(StaticAttachmentStore) - const settingsFiber = ctx.plugin(FileSettingsProvider, { path: join(dir, 'settings.yaml'), watch: false }) - await settingsFiber await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false }) - await ctx.plugin(LlmDeepSeek, { ...config }) - return { ctx, settingsFiber } + configurations.set(ctx, await liveConfig(ctx, LlmDeepSeek, config)) + return { ctx } } function prompt(ctx: Context) { @@ -151,7 +148,7 @@ describe('request-level dynamic configuration', () => { await prompt(ctx) expect(serverA.headers[0]?.['x-api-key']).toBe('first-key') - await ctx.settings.update(NS, { baseURL: serverB.url }) + await configurations.get(ctx)!.update({ baseURL: serverB.url }) await ctx.credentials.set(KEY_REF, 'second-key') await prompt(ctx) @@ -199,7 +196,7 @@ describe('request-level dynamic configuration', () => { const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' }) await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2) - await ctx.settings.update(NS, { + await configurations.get(ctx)!.update({ models: [{ id: 'settings-model', name: 'From Settings', inputModalities: ['text', 'image'] }], }) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ @@ -225,7 +222,7 @@ describe('request-level dynamic configuration', () => { })] await assemble(ctx, { model: 'deepseek-flash', messages }) - await ctx.settings.update(NS, { maxRequestFilesBytes: 4, imageOffloadByteQuantum: 2 }) + await configurations.get(ctx)!.update({ maxRequestFilesBytes: 4, imageOffloadByteQuantum: 2 }) // A request whose retained exact bytes exceed the tightened budget names the occurrences to offload. const rejected = await assemble(ctx, { model: 'deepseek-flash', messages }) expect(rejected.finish).toMatchObject({ @@ -263,7 +260,7 @@ describe('request-level dynamic configuration', () => { observed.push(ctx.llm.listProviders().map(provider => provider.id)) }) - await ctx.settings.update(NS, { + await configurations.get(ctx)!.update({ retryPolicy: { mode: 'always', backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 } }, }) expect(ctx.llm.providerRetryPolicy('deepseek-official')).toEqual({ @@ -276,21 +273,21 @@ describe('request-level dynamic configuration', () => { expect(observed).toEqual([['deepseek-official']]) }) - it('keeps the last good options when a settings snapshot fails beyond-schema validation', async () => { + it('fails catalog reads while a stored snapshot fails beyond-schema validation, and recovers on repair', async () => { const dir = await home() const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' }) // Schema-valid but resolver-invalid: duplicate catalog ids pass the array - // schema and fail the explicit resolve step. - await ctx.settings.update(NS, { models: [{ id: 'dup' }, { id: 'dup' }] }) - await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2) - await ctx.settings.update(NS, { models: [{ id: 'recovered' }] }) + // schema; the profile stores them and every resolve step fails until the row is repaired. + await configurations.get(ctx)!.update({ models: [{ id: 'dup' }, { id: 'dup' }] }) + await expect(ctx.llm.listModels('deepseek-official')).rejects.toThrow('duplicate') + await configurations.get(ctx)!.update({ models: [{ id: 'recovered' }] }) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ { provider: 'deepseek-official', id: 'recovered', name: 'recovered', inputModalities: ['text'] }, ]) }) - it('keeps the whole last-good snapshot when a rejected one changed the URL', async () => { + it('sends nothing while a stored snapshot fails beyond-schema validation', async () => { const dir = await home() const good = await mockServer([{ kind: 'sse', events: textEvents }]) const rejected = await mockServer([{ kind: 'sse', events: textEvents }]) @@ -298,57 +295,21 @@ describe('request-level dynamic configuration', () => { const { ctx } = await boot(dir, { baseURL: good.url }) // One snapshot moves the endpoint and fails the resolve step beyond the - // schema (duplicate catalog ids). - await ctx.settings.update(NS, { - baseURL: rejected.url, - models: [{ id: 'dup' }, { id: 'dup' }], - }) - - await prompt(ctx) - // The rejected generation contributes nothing: not its endpoint, and — the - // regression this pins — not its key either. + // schema (duplicate catalog ids): no request reaches either endpoint. + await configurations.get(ctx)!.update({ baseURL: rejected.url, models: [{ id: 'dup' }, { id: 'dup' }] }) + expect(JSON.stringify((await prompt(ctx)).finish)).toContain('duplicate catalog model') expect(rejected.requests).toHaveLength(0) - expect(good.requests).toHaveLength(1) - expect(good.headers[0]?.['x-api-key']).toBe('good-key') + expect(good.requests).toHaveLength(0) + + await configurations.get(ctx)!.update({ models: [{ id: 'deepseek-v4-flash' }] }) + await prompt(ctx) + expect(rejected.requests).toHaveLength(1) + expect(rejected.headers[0]?.['x-api-key']).toBe('good-key') }) - it.each(['messages', 'chat-completions'])('rejects a persisted protocol=%s section and recovers after removing the field', async (protocol) => { + it.each(['messages', 'chat-completions'])('refuses a stored protocol=%s when the adapter mounts', async (protocol) => { const dir = await home() - const composition = await mockServer([{ kind: 'sse', events: textEvents }]) - const repaired = await mockServer([{ kind: 'sse', events: textEvents }]) - vi.stubEnv('DEEPSEEK_API_KEY', 'composition-key') - vi.stubEnv('REPAIRED_DEEPSEEK_KEY', 'repaired-key') - const section = { baseURL: repaired.url, apiKeyEnv: 'REPAIRED_DEEPSEEK_KEY' } - await writeFile(join(dir, 'settings.yaml'), JSON.stringify({ [NS]: { ...section, protocol } })) - const { ctx } = await boot(dir, { baseURL: composition.url }) - - await prompt(ctx) - expect(composition.requests).toHaveLength(1) - expect(composition.headers[0]?.['x-api-key']).toBe('composition-key') - expect(repaired.requests).toHaveLength(0) - - await ctx.settings.replace(NS, section) - await prompt(ctx) - expect(repaired.requests).toHaveLength(1) - expect(repaired.headers[0]?.['x-api-key']).toBe('repaired-key') - expect(ctx.settings.get(NS)).not.toHaveProperty('protocol') + await expect(boot(dir, { baseURL: 'http://127.0.0.1:1', protocol })).rejects.toThrow(/protocol/) }) - it('falls back to the composition entry when settings detach', async () => { - vi.stubEnv('DEEPSEEK_API_KEY', '') - const dir = await home() - await writeFile(join(dir, '.credentials.yaml'), 'version: 1\nrefs:\n DEEPSEEK_API_KEY: steady-key\n', { mode: 0o600 }) - const serverA = await mockServer([{ kind: 'sse', events: textEvents }]) - const serverB = await mockServer([{ kind: 'sse', events: textEvents }]) - const { ctx, settingsFiber } = await boot(dir, { baseURL: serverA.url }) - - await ctx.settings.update(NS, { baseURL: serverB.url }) - await prompt(ctx) - expect(serverB.requests).toHaveLength(1) - - await settingsFiber.dispose() - await prompt(ctx) - expect(serverA.requests).toHaveLength(1) - expect(serverA.headers[0]?.['x-api-key']).toBe('steady-key') - }) }) diff --git a/packages/llm/llm-deepseek/tests/files-api.spec.ts b/packages/llm/llm-deepseek/tests/files-api.spec.ts index daed0ebedf..e0d44d2625 100644 --- a/packages/llm/llm-deepseek/tests/files-api.spec.ts +++ b/packages/llm/llm-deepseek/tests/files-api.spec.ts @@ -174,6 +174,7 @@ describe('DeepSeekFilesClient', () => { const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { expect(requestUrl(url)).toBe('https://api.deepseek.com/v1/files') expect(init?.method).toBe('POST') + expect(init?.redirect).toBe('error') const headers = new Headers(init?.headers) expect(headers.get('x-api-key')).toBe('key') expect(headers.get('user-agent')).toBe(userAgent()) @@ -383,3 +384,17 @@ describe('DeepSeekFilesClient', () => { await expect(client.delete(DeepSeekFileId('file-api-one'))).rejects.toMatchObject({ code: 'INVALID_RESPONSE' }) }) }) + +it('authenticates Files with the raw DSH token', async () => { + const client = new DeepSeekFilesClient({ + baseURL: 'https://api.deepseek.com', apiKey: 'account-token', accountCredential: true, + fetch: (_url, init) => { + const headers = new Headers(init?.headers) + expect(headers.get('x-dsh-auth-token')).toBe('account-token') + expect(headers.has('authorization')).toBe(false) + expect(headers.has('x-api-key')).toBe(false) + return Promise.resolve(new Response(JSON.stringify({ object: 'list', data: [], has_more: false }), { status: 200 })) + }, + }) + await client.list() +}) diff --git a/packages/llm/llm-deepseek/tests/files.spec.ts b/packages/llm/llm-deepseek/tests/files.spec.ts index aee62c111b..4e28f1a1c8 100644 --- a/packages/llm/llm-deepseek/tests/files.spec.ts +++ b/packages/llm/llm-deepseek/tests/files.spec.ts @@ -7,7 +7,7 @@ import { createAssistantMessage, createToolResultMessage, LlmError, ToolCallId } import { DeepSeekFileId } from '../src/file-id.ts' import { DeepSeekFileStore } from '../src/file-store.ts' import { resolveAdapterOptions } from '../src/config.ts' -import type { Config } from '../src/config.ts' +import type { Options as Config } from '../src/config.ts' import { DeepSeekAdapter } from '../src/adapter.ts' import { prepareImages } from '../src/images.ts' import { providerErrorDetail } from '../src/transport.ts' @@ -56,7 +56,7 @@ describe('Messages Files requests', () => { createToolResultMessage({ callId, isError: false, content: [{ type: 'image', attachment: ref }, { type: 'image', attachment: ref }] })] await chunks(h.adapter.stream(options({ model, messages }))) expect(h.readImageRequest).toHaveBeenCalledTimes(1) - expect(h.ensureUploaded).toHaveBeenCalledWith(expect.anything(), { baseURL: 'https://gateway.example/custom', apiKey: 'test-key' }, expect.anything(), expect.any(AbortSignal)) + expect(h.ensureUploaded).toHaveBeenCalledWith(expect.anything(), { baseURL: 'https://gateway.example/custom', apiKey: 'test-key', accountCredential: false }, expect.anything(), expect.any(AbortSignal)) const [url, init] = fetchImpl.mock.calls[0]! expect(url).toBe('https://gateway.example/custom/v1/messages') expect(new Headers(init?.headers).get('anthropic-beta')).toBe('files-api-2025-04-14') diff --git a/packages/llm/llm-deepseek/tests/fixtures/cordis.yml b/packages/llm/llm-deepseek/tests/fixtures/cordis.yml index 7658d5f74b..a64d7c046c 100644 --- a/packages/llm/llm-deepseek/tests/fixtures/cordis.yml +++ b/packages/llm/llm-deepseek/tests/fixtures/cordis.yml @@ -7,10 +7,6 @@ - name: '@deepseek-ai/dsh-agent-loop' config: agents: [] -- name: '@deepseek-ai/dsh-settings-file' - config: - path: {{settings}} - watch: false - name: '@deepseek-ai/dsh-credentials-local' config: path: {{credentials}} diff --git a/packages/llm/llm-deepseek/tests/helpers.ts b/packages/llm/llm-deepseek/tests/helpers.ts index d5f8e780d7..3ddfcac7a0 100644 --- a/packages/llm/llm-deepseek/tests/helpers.ts +++ b/packages/llm/llm-deepseek/tests/helpers.ts @@ -11,7 +11,7 @@ import { BlockAssembler, createAssistantMessage, createUserMessage } from '@deep import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { resolveAdapterOptions } from '../src/index.ts' import { DeepSeekAdapter } from '../src/adapter.ts' -import type { Config } from '../src/config.ts' +import type { Options as Config } from '../src/config.ts' import { DeepSeekFileStore } from '../src/file-store.ts' export const prepareExtensions = async () => ({ fields: {}, accept: async () => {} }) @@ -92,7 +92,7 @@ export function sourceModuleLoader(importModule: (specifier: string) => Promise< return { version: 'v2', import: importModule, - get loadCache(): never { throw new Error('unexpected module cache access') }, + loadCache: new Map(), register(): never { throw new Error('unexpected module hook registration') }, getOrCreateModuleJob(): never { throw new Error('unexpected module job creation') }, resolveSync(): never { throw new Error('unexpected synchronous module resolution') }, diff --git a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts index 0626186629..141e53a406 100644 --- a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts +++ b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts @@ -1,12 +1,4 @@ -/** - * Real-composition guard for the dynamic-configuration chain: LlmRuntime, - * settings-file, credentials-local, and llm-deepseek boot from a test-only - * cordis.yml through the actual Loader + Include path, external edits of - * settings.yaml and the credentials document hot-publish through their providers, and the very - * next request carries the fresh base URL and credential. The same adapter - * composition without settings or credentials entries keeps entry-config - * behavior — the documented optional-inject fallback. - */ +/** Profile patch edits and credential updates reach the next real adapter request. */ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -21,7 +13,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { credentialRef } from '@deepseek-ai/dsh-credentials' import LocalCredentialProvider from '@deepseek-ai/dsh-credentials-local' -import FileSettingsProvider from '@deepseek-ai/dsh-settings-file' +import { profileComposition } from '../../../settings/settings/tests/profile-composition.ts' import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import * as SessionLogDeepSeek from '@deepseek-ai/dsh-session-log-deepseek' @@ -54,10 +46,9 @@ async function loadComposition( const fresh = options.reuseRoot === undefined root = options.reuseRoot ?? await mkdtemp(join(tmpdir(), 'dsh-llm-composition-')) vi.stubEnv('DSH_HOME', root) - const settingsPath = join(root, 'settings.yaml') + const settingsPath = join(root, 'profile', 'cordis.patch.yml') const credentialsPath = join(root, '.credentials.yaml') if (options.withDynamic && fresh) { - await writeFile(settingsPath, '# personal settings\n') await writeFile(credentialsPath, 'version: 1\nrefs:\n DEEPSEEK_API_KEY: boot-key\n', { mode: 0o600 }) } @@ -80,11 +71,6 @@ async function loadComposition( " name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek'", ...options.withDynamic ? [ - '- id: settings', - " name: '@deepseek-ai/dsh-settings-file'", - ' config:', - ` path: ${JSON.stringify(settingsPath)}`, - ' debounceMs: 10', '- id: credentials', " name: '@deepseek-ai/dsh-credentials-local'", ' config:', @@ -111,7 +97,6 @@ async function loadComposition( ['@deepseek-ai/dsh-deepseek-llm-api-extensions', DeepSeekLlmApiExtensionRegistry], ['@deepseek-ai/dsh-session-log-deepseek', SessionLogDeepSeek], ['@deepseek-ai/dsh-plugin-package-inventory-deepseek', DeepSeekPluginPackageInventory], - ['@deepseek-ai/dsh-settings-file', FileSettingsProvider], ['@deepseek-ai/dsh-credentials-local', LocalCredentialProvider], ['@deepseek-ai/dsh-llm-deepseek', LlmDeepSeek], ]) @@ -130,10 +115,10 @@ async function loadComposition( if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) return modules.get(specifier) }) - await ctx.loader.create({ - name: 'cordis:include', - config: { path: pathToFileURL(configPath).href }, - }) + if (options.withDynamic) { + return { ctx, settingsPath: await profileComposition(ctx, root, configPath), credentialsPath } + } + await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) await ctx.loader.await() return { ctx, settingsPath, credentialsPath } } @@ -195,15 +180,15 @@ describe('llm-deepseek real dynamic composition', () => { const serverB = await mockServer([{ kind: 'sse', events: textEvents }]) const { ctx, settingsPath, credentialsPath } = await loadComposition({ withDynamic: true, baseURL: serverA.url }) - expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual([NS]) + expect(ctx.settings.describe().map(entry => entry.ns)).toContain(NS) await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) expect(serverA.headers[0]?.['x-api-key']).toBe('boot-key') expect(serverA.headers[0]?.['x-deepseek-harness-user-id']).toBe(getOrCreateAnonymousUserId()) // External edits, exactly as a user or the web UI would leave them on disk. - await writeFile(settingsPath, `llm-deepseek:\n baseURL: ${serverB.url}\n`) + await writeFile(settingsPath, JSON.stringify([{ id: NS, config: { baseURL: serverB.url } }])) await vi.waitFor(() => { - expect((ctx.get('settings')!.get(NS) as { baseURL?: string }).baseURL).toBe(serverB.url) + expect((ctx.settings.describe().find(row => row.ns === NS)!.value as { baseURL?: string }).baseURL).toBe(serverB.url) }, { timeout: 5000 }) await writeFile(credentialsPath, 'version: 1\nrefs:\n DEEPSEEK_API_KEY: rotated-key\n', { mode: 0o600 }) await vi.waitFor(async () => { diff --git a/packages/llm/llm-deepseek/tests/prepared-call.spec.ts b/packages/llm/llm-deepseek/tests/prepared-call.spec.ts index 68e38db8ee..e35f4a7a7f 100644 --- a/packages/llm/llm-deepseek/tests/prepared-call.spec.ts +++ b/packages/llm/llm-deepseek/tests/prepared-call.spec.ts @@ -1,7 +1,7 @@ /** Prepared calls retain their endpoint and credential generation. */ import { afterEach, expect, it } from 'vitest' import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' -import { Config, DeepSeekAdapter, resolveAdapterOptions } from '../src/index.ts' +import { Config, DeepSeekAdapter, plainOptions, resolveAdapterOptions } from '../src/index.ts' import type { DeepSeekConnectionOptions } from '../src/index.ts' import { assemble, chunks, MODEL, options, server } from './helpers.ts' @@ -26,7 +26,7 @@ function adapter(connection: () => DeepSeekConnectionOptions) { it.each([false, true])('uses Messages, schema=%s', async (schema) => { const http = await endpoint() const raw = { baseURL: http.url } - const connection = resolveAdapterOptions(schema ? Config(raw) : raw) + const connection = resolveAdapterOptions(schema ? plainOptions(Config(raw)) : raw) const response = await assemble(adapter(() => connection).stream(options())) expect(response.message.content).toEqual([{ type: 'text', text: 'Hello 世界' }]) diff --git a/packages/llm/llm-deepseek/tests/request-pricing.spec.ts b/packages/llm/llm-deepseek/tests/request-pricing.spec.ts index cb4b31d3c6..32a91d800d 100644 --- a/packages/llm/llm-deepseek/tests/request-pricing.spec.ts +++ b/packages/llm/llm-deepseek/tests/request-pricing.spec.ts @@ -5,7 +5,7 @@ import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { deepSeekImageRequestPricing } from '../src/request-pricing.ts' import { resolveAdapterOptions } from '../src/index.ts' -import type { Config } from '../src/index.ts' +import type { Options as Config } from '../src/index.ts' const VISION_MODEL = { id: 'vision', diff --git a/packages/llm/llm-deepseek/tests/runtime.e2e.ts b/packages/llm/llm-deepseek/tests/runtime.e2e.ts index e2634f8d64..7e9c0e2832 100644 --- a/packages/llm/llm-deepseek/tests/runtime.e2e.ts +++ b/packages/llm/llm-deepseek/tests/runtime.e2e.ts @@ -25,7 +25,7 @@ import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-e import * as PluginPackageInventoryDeepSeek from '@deepseek-ai/dsh-plugin-package-inventory-deepseek' import * as SessionLogDeepSeek from '@deepseek-ai/dsh-session-log-deepseek' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' -import type { Config } from '@deepseek-ai/dsh-llm-deepseek' +import type { Options as Config } from '@deepseek-ai/dsh-llm-deepseek' import type { WireRequest } from '../src/wire-types.ts' import { assemble, type AssembledResult } from './assemble.ts' diff --git a/packages/llm/llm-deepseek/tests/runtime.spec.ts b/packages/llm/llm-deepseek/tests/runtime.spec.ts index 55ab3acb4f..4d657ec263 100644 --- a/packages/llm/llm-deepseek/tests/runtime.spec.ts +++ b/packages/llm/llm-deepseek/tests/runtime.spec.ts @@ -70,7 +70,7 @@ function noExtensions(): Promise { /** Direct adapter over the plugin's real resolve step, with a static key. */ function adapterOf( - config: Partial & { apiKey?: string } = {}, + config: Partial & { apiKey?: string } = {}, attachments?: AttachmentStore, files?: LlmDeepSeek.DeepSeekFileStore, ): DeepSeekAdapter { @@ -1860,7 +1860,7 @@ describe('plugin registration and config', () => { it('uses the default model catalog when apply is called directly', async () => { const ctx = new Context() await ctx.plugin(LlmRuntime) - LlmDeepSeek.apply(ctx, { baseURL: 'http://127.0.0.1:1' }) + LlmDeepSeek.apply(ctx, LlmDeepSeek.Config({ baseURL: 'http://127.0.0.1:1' })) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'] }, { @@ -2060,11 +2060,11 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) expect(() => { - LlmDeepSeek.apply(ctx, { + LlmDeepSeek.apply(ctx, LlmDeepSeek.Config({ baseURL: 'http://127.0.0.1:1', models: [{ id: 'invalid-context', contextWindow: 0 }], - }) - }).toThrow(/contextWindow must be a positive integer/) + })) + }).toThrow(/contextWindow/) expect(ctx.llm.listProviders()).toEqual([]) }) diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index de32f4680f..f9f96861c9 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -6,7 +6,7 @@ import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import { resolveAdapterOptions } from '../src/config.ts' import { modelInfo } from '../src/model-info.ts' -import type { Config } from '../src/config.ts' +import type { Options as Config } from '../src/config.ts' import { imagePricing, inlineImages, prepareImages } from '../src/images.ts' import { readReplay, replayState } from '../src/replay.ts' import { serialize } from '../src/serialize.ts' diff --git a/packages/llm/llm-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json index 876332451e..4d5b712122 100644 --- a/packages/llm/llm-deepseek/tsconfig.json +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -44,9 +44,6 @@ { "path": "../../util/launch-environment" }, - { - "path": "../../settings/settings" - }, { "path": "../../util/timeout" }, @@ -55,6 +52,15 @@ }, { "path": "../../util/http-proxy" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../settings/settings" + }, + { + "path": "../../credentials/deepseek-account" } ] } diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 06072e5b87..c5fbc218d7 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md -README.md: 35d4ce1ad9318c4c27a71af362014d53bf3e3798 -README.zh.md: bd12102d0b35963495907ee53a69d2dd339f5ffc +README.md: 7b1708b957202369382d8a93b2bbcefd31565f67 +README.zh.md: ed780ad235b1d8df2b821be6a0595ecd8b8e951b diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 35d4ce1ad9..7b1708b957 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -106,17 +106,17 @@ For self-hosted Chat Completions endpoints, `thinkingTokenBudgetField` selects t ### Change configuration at runtime -Profiles are re-read once per operation through the optional settings seam: the base and the user's `llm-pi-ai:` settings section merge per provider, so a user can add a route, override one field of a composition route, or point a route at another proxy, all effective on the next request with no restart. A section the adapter could not serve is refused where it is written — `settings.mutate` answers `settings-rejected` — and a stored section that later fails keeps the namespace's last good value. When the route set or a route's retry policy changes, the plugin re-registers atomically: a conflicting route leaves the previous routes serving. +Each operation captures the current `providers` Config reference. New or changed provider profiles are validated before form persistence; unchanged catalog failures remain editable. Route-set or retry-policy changes update registration atomically, preserving previous routes if another adapter owns a requested route. ### Discover models from endpoints -The plugin answers "which models can this provider serve?" for a route a configuration surface is editing or drafting. A route the installed catalog ships is answered from that catalog with no network call, preserving its `input` array as discovery `inputModalities`; only a route the catalog does not describe is interrogated over the wire. `openai-completions` and `openai-responses` use `GET {baseURL}/models` with bearer auth, while `anthropic-messages` uses native `GET /v1/models?limit=1000` semantics with `x-api-key` and `anthropic-version`; its listing URL accepts the API root with or without a trailing `/v1` because gateway documentation publishes both spellings, and only that listing URL normalizes the segment, so model requests receive the configured `baseURL` unchanged. A named configured route supplies its stored credential and profile `headers` inside the Host, so deployment headers configured through `settings.yaml` or Cordis config reach model discovery without becoming discovery-request or Models-page fields; a key typed into the form still wins over the stored credential. The parser accepts either the standard `data` array or an enriched `models` map, normalizing each candidate's id, display name, context window, and output-token cap; Anthropic's `max_input_tokens` and `max_tokens` feed the same capacity fields, a map key remains the request id even when its entry names a different canonical id, primitive-valued map properties are ignored, and a missing display name falls back to that request id. The reply is candidate metadata a surface may offer for adoption — nothing is stored, and `settings.yaml` remains the only thing that decides what a route serves. +The plugin answers "which models can this provider serve?" for a route a configuration surface is editing or drafting. A route the installed catalog ships is answered from that catalog with no network call, preserving its `input` array as discovery `inputModalities`; only a route the catalog does not describe is interrogated over the wire. `openai-completions` and `openai-responses` use `GET {baseURL}/models` with bearer auth, while `anthropic-messages` uses native `GET /v1/models?limit=1000` semantics with `x-api-key` and `anthropic-version`; its listing URL accepts the API root with or without a trailing `/v1` because gateway documentation publishes both spellings, and only that listing URL normalizes the segment, so model requests receive the configured `baseURL` unchanged. A named configured route supplies its stored credential and profile `headers` inside the Host, so deployment headers configured through `cordis.patch.yml` or Cordis config reach model discovery without becoming discovery-request or Models-page fields; a key typed into the form still wins over the stored credential. The parser accepts either the standard `data` array or an enriched `models` map, normalizing each candidate's id, display name, context window, and output-token cap; Anthropic's `max_input_tokens` and `max_tokens` feed the same capacity fields, a map key remains the request id even when its entry names a different canonical id, primitive-valued map properties are ignored, and a missing display name falls back to that request id. The reply is candidate metadata a surface may offer for adoption — nothing is stored, and `cordis.patch.yml` remains the only thing that decides what a route serves. ### Failures and recovery A route pi-ai does not ship needs `api`, `baseURL`, and a non-empty `models` list; an unserviceable profile is refused where it is written, naming the route and model. Failures carry stable codes: a credential that cannot be used fails with `INVALID_CREDENTIAL` naming the route and reference, a route whose `apiKeyEnv` reference resolves to nothing fails with `MISSING_CREDENTIAL`, an unconfigured model fails with `UNKNOWN_MODEL`, and terminal provider failures distinguish `QUOTA` from transient `RATE_LIMIT`. `GenerateOptions.stop` is rejected with `UNSUPPORTED_OPTION` because pi-ai's common streaming UI cannot guarantee it across providers. -Settings writes strictly validate each new or changed provider after merging its composition and user layers. During namespace registration, stored catalog failures retain the namespace and provider rows, with the first available model diagnostic or route failure in `LlmConfigurableProvider.error`; unchanged failed providers do not block edits elsewhere. Serviceable models remain selectable, while unresolved models remain in the editable configuration and fail with `INVALID_CONFIG` before network I/O if requested directly. Repairing or deleting the offending configuration clears its diagnostic. Schema and self-contained profile errors still reject loading. Later external edits validate changed providers and retain the last accepted section on failure. +Config updates strictly validate changed providers. Initial loading retains stored catalog failures as editable provider diagnostics; unchanged failed providers do not block edits elsewhere. Serviceable models remain selectable, and unresolved models fail before network I/O. Repairing or deleting the offending configuration clears its diagnostic. Changing `displayName`, `apiKeyEnv`, or `baseURL` without resolving the provider's model errors still rejects the save. For example, renaming an OpenRouter route whose model `111` needs an `api` cannot be saved on its own: repair or remove that model in the same editor draft, then save the complete provider configuration. Intermediate repairs remain in the draft until the whole provider validates; other providers can be saved independently. @@ -138,7 +138,7 @@ The adapter is built on immutable snapshots and per-operation resolution. Each o | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | Plugin entry: profile resolution, settings wiring, directory and route registration | +| [`src/index.ts`](src/index.ts) | Config snapshots, directory and route registration | | [`src/auth.ts`](src/auth.ts) | The credential store and ambient auth context over the harness credential plane | | [`src/login.ts`](src/login.ts) | Authorization flows for the installed providers that ship a login | | [`src/config.ts`](src/config.ts) | Profile schema, resolution, and serviceability checks | @@ -152,7 +152,7 @@ The adapter is built on immutable snapshots and per-operation resolution. Each o ### Registration and directory -The plugin declares every installed catalog provider it can authenticate in the configurable-provider directory, joined with every route the current profiles declare, so configuration surfaces can offer the full catalog before any route exists. Each entry carries `declared` — whether pi-ai ships nothing under that key — because only the adapter can distinguish a hand-declared route from a narrowed catalog route. Route registration is atomic: a candidate set that collides with another adapter leaves the previous routes serving. A bare mount with zero routes is the dormant posture: nothing registers until a settings section supplies profiles, and routes drop when it empties. +The plugin declares every installed catalog provider it can authenticate in the configurable-provider directory, joined with every route the current profiles declare, so configuration surfaces can offer the full catalog before any route exists. Each entry carries `declared` — whether pi-ai ships nothing under that key — because only the adapter can distinguish a hand-declared route from a narrowed catalog route. Route registration is atomic: a candidate set that collides with another adapter leaves the previous routes serving. A bare mount with zero routes is the dormant posture: nothing registers until the Config supplies profiles, and routes drop when it empties. ### Replay and vocabulary @@ -217,10 +217,10 @@ These limits define where the adapter stops and future work begins. They are cur - **`maxRequestImageBytes` counts base64 image payload only** — text, tools, descriptors, and JSON structure ride outside the bound, so it must sit below the gateway's request-body cap with headroom. - **A sign-in lives only in the process that started it** — an authorization attempt is not durable, so reloading the page mid-login abandons it and the human starts over. Signing out is `deleteRecord` on the stored record, which forgets it locally without telling the issuer. - **Provider-native discovery answers through this plugin's ambient context** — a route naming no credential defers to the catalog provider's own resolution, which asks for environment values (`AZURE_OPENAI_API_KEY`, `AWS_PROFILE`, and each provider's own set) and for local credential files. Both questions are answered here: the credential seam is consulted before the process environment, and file existence is checked against the host process's filesystem with `~` expanded. What it cannot do is *read* a credential file's contents — a provider that parses `~/.aws/credentials` itself does so directly, outside the seam. -- **Settings can add or override routes, not remove composition routes** — the user layer merges over the composition base, so deleting a `cordis.yml`-provided provider is a composition change. -- **The layered merge has no delete for dict keys** — a `reasoningEfforts` level, `modelOverrides` entry, or `compat` field the base declares can be overridden but not removed by the user layer. +- **Reset restores inherited configuration** — resetting a route supplied by a lower profile layer restores that route. +- **Complete Config replacement can remove inherited dictionary entries** — a field reset instead restores its inherited value. - **`headers` can carry a credential the redactor never sees** — profile resolution rejects names and values Fetch cannot represent, but the dict remains plain strings; store credentials as `apiKeyEnv` references. -- **A route's catalog never refreshes itself** — the catalog is whatever `settings.yaml` says; nothing here queries a provider for the models it serves. +- **Discovery does not change configured models** — adopt discovery results explicitly into the route configuration. - **Anthropic discovery reads at most 1,000 models** — the request uses the API's maximum page size but does not traverse `has_more`; entries beyond the first page must be added by hand. - **One wire protocol per route** — a mixed-protocol catalog route cannot host a model of the other protocol; splitting the provider across two route keys is the workaround. - **A modality declaration is not verified** — a model declaring `image` its gateway does not serve is refused by the provider after prompt admission. The durable image remains in history and the same misdeclared model can fail again; switching to a text-only model remains possible because the shared LLM runtime projects image references into stable text for that request. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index bd12102d0b..ed780ad235 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -106,17 +106,17 @@ profile 的 `models` 列表会替换而非扩展路由的已安装目录;每 ### 运行时更改配置 -profile 通过可选 settings seam 每次操作重新读取:base 与用户的 `llm-pi-ai:` 设置分节按提供方合并,因此用户可以新增路由、覆盖组合路由的一个字段或把路由指向另一个代理,全部在下一个请求生效、无需重启。适配器无法服务的分节会在写入处被拒绝——`settings.mutate` 回答 `settings-rejected`——之后失效的已存储分节会保留 namespace 最后有效值。当路由集合或某路由的重试策略变化时,插件会原子地重新注册:冲突路由会让此前路由继续服务。 +每次操作捕获当前 `providers` Config 引用。新增或修改的 provider 配置在表单持久化前验证;未更改的目录故障仍可编辑。路由集合或重试策略变化时原子更新注册;如果其他适配器已拥有所请求的路由,则保留先前路由。 ### 从端点发现模型 -插件会回答「该提供方可以提供哪些模型?」,供配置界面正在编辑或起草的路由使用。已安装目录提供的路由直接由目录回答,不发网络请求,并将其 `input` 数组保留为发现结果的 `inputModalities`;只有目录未描述的路由才会经网络询问。`openai-completions` 与 `openai-responses` 使用带 bearer 鉴权的 `GET {baseURL}/models`,`anthropic-messages` 则以 `x-api-key` 和 `anthropic-version` 使用原生 `GET /v1/models?limit=1000` 语义;其列表 URL 接受带或不带末尾 `/v1` 的 API 根地址,因为网关文档两种写法都会发布,且只有该列表 URL 会归一化这一段,模型请求收到的仍是配置原样的 `baseURL`。已配置且具名的路由会在 Host 内部提供已存凭据与 profile `headers`,因此通过 `settings.yaml` 或 Cordis 配置设置的部署标头可以到达模型发现请求,但不会成为发现请求或 Models 页面的字段;表单中新键入的密钥仍优先于已存凭据。解析器接受标准 `data` 数组或富信息 `models` 对象,并归一化每个候选的 id、显示名、上下文窗口与最大输出 token 数;Anthropic 的 `max_input_tokens` 与 `max_tokens` 会进入相同容量字段,即使对象条目点名了另一个规范 id,对象键仍是请求 id,原始类型的对象属性会被忽略,缺失的显示名则回退到该请求 id。回答是界面可以提供给用户采纳的候选元数据——不存储任何内容,`settings.yaml` 仍然是决定路由服务内容的唯一事实。 +插件会回答「该提供方可以提供哪些模型?」,供配置界面正在编辑或起草的路由使用。已安装目录提供的路由直接由目录回答,不发网络请求,并将其 `input` 数组保留为发现结果的 `inputModalities`;只有目录未描述的路由才会经网络询问。`openai-completions` 与 `openai-responses` 使用带 bearer 鉴权的 `GET {baseURL}/models`,`anthropic-messages` 则以 `x-api-key` 和 `anthropic-version` 使用原生 `GET /v1/models?limit=1000` 语义;其列表 URL 接受带或不带末尾 `/v1` 的 API 根地址,因为网关文档两种写法都会发布,且只有该列表 URL 会归一化这一段,模型请求收到的仍是配置原样的 `baseURL`。已配置且具名的路由会在 Host 内部提供已存凭据与 profile `headers`,因此通过 `cordis.patch.yml` 或 Cordis 配置设置的部署标头可以到达模型发现请求,但不会成为发现请求或 Models 页面的字段;表单中新键入的密钥仍优先于已存凭据。解析器接受标准 `data` 数组或富信息 `models` 对象,并归一化每个候选的 id、显示名、上下文窗口与最大输出 token 数;Anthropic 的 `max_input_tokens` 与 `max_tokens` 会进入相同容量字段,即使对象条目点名了另一个规范 id,对象键仍是请求 id,原始类型的对象属性会被忽略,缺失的显示名则回退到该请求 id。回答是界面可以提供给用户采纳的候选元数据——不存储任何内容,`cordis.patch.yml` 仍然是决定路由服务内容的唯一事实。 ### 失败与恢复 pi-ai 不提供的路由需要 `api`、`baseURL` 与非空 `models` 列表;无法服务的 profile 会在写入处被拒绝,并点名路由与模型。失败携带稳定 code:无法使用的凭据以 `INVALID_CREDENTIAL` 失败并点名路由与引用,`apiKeyEnv` 引用解析为空的路由以 `MISSING_CREDENTIAL` 失败,未配置模型以 `UNKNOWN_MODEL` 失败,终止性提供方失败则区分 `QUOTA` 与暂时性 `RATE_LIMIT`。`GenerateOptions.stop` 以 `UNSUPPORTED_OPTION` 被拒绝,因为 pi-ai 的通用流式 UI 无法跨提供方保证它。 -Settings 写入会在合并组合层与用户层后严格校验每个新增或修改的提供方。命名空间注册时,已存储配置的目录解析错误会保留命名空间与提供方行,并通过 `LlmConfigurableProvider.error` 优先返回首个模型诊断,无模型诊断时返回路由错误;未修改的错误提供方不会阻止其他编辑。可解析的模型仍可选择,无法解析的模型保留在可编辑配置中,直接请求时会在网络 I/O 前以 `INVALID_CONFIG` 失败。修复或删除错误配置会清除诊断。Schema 与 profile 自身的约束错误仍会拒绝加载。后续外部文件编辑会校验变化的提供方,失败时保留最后一次接受的分节。 +Config 更新严格验证发生变化的 provider。初始加载将已存储的目录故障保留为可编辑的 provider 诊断;未更改的故障 provider 不阻止其他编辑。可用模型仍可选择,无法解析的模型在网络 I/O 前失败。修复或删除问题配置会清除其诊断。 只修改 `displayName`、`apiKeyEnv` 或 `baseURL` 而未解决提供方的模型配置错误时,保存仍会被拒绝。例如,OpenRouter 路由的模型 `111` 缺少 `api` 时,不能单独保存路由名称的修改:需要在同一份编辑草稿中修复或删除该模型,再保存完整的提供方配置。中间修复状态保留在草稿中,直到整条提供方配置通过校验;其他提供方可以独立保存。 @@ -138,7 +138,7 @@ Settings 写入会在合并组合层与用户层后严格校验每个新增或 | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | 插件入口:profile 解析、settings 接线、目录与路由注册 | +| [`src/index.ts`](src/index.ts) | Config 快照、目录及路由注册 | | [`src/auth.ts`](src/auth.ts) | 覆盖 harness 凭据平面的凭据存储与 ambient auth context | | [`src/login.ts`](src/login.ts) | 面向提供登录的已安装提供方的授权流程 | | [`src/config.ts`](src/config.ts) | Profile schema、解析与可服务性校验 | @@ -217,10 +217,10 @@ pi-ai 事件变成 harness 的推理、文本、工具调用、用量与 finish - **`maxRequestImageBytes` 只计算 base64 图片载荷**,文本、工具、描述符与 JSON 结构在该上限之外,因此它必须留有余量地低于网关请求体上限。 - **登录只存在于发起它的进程中**——授权尝试不持久,因此登录中途刷新页面会放弃它,用户需要重新开始。退出登录是对已存储记录执行 `deleteRecord`,只在本地忘记它,不会告知签发方。 - **提供方原生发现经本插件的 ambient context 回答**——不点名凭据的路由交由目录提供方自身解析,它会询问环境值(`AZURE_OPENAI_API_KEY`、`AWS_PROFILE` 及各提供方自有集合)与本地凭据文件。两个问题都在这里得到回答:凭据 seam 先于进程环境被查询,文件存在性则针对宿主进程的文件系统以 `~` 展开后检查。它做不到的是*读取*凭据文件内容——自行解析 `~/.aws/credentials` 的提供方会直接读取,不经该 seam。 -- **设置可以新增或覆盖路由,不能移除组合路由**——用户层覆盖组合 base,因此删除 `cordis.yml` 提供的提供方属于组合变更。 -- **分层合并对字典键没有删除**——base 声明的 `reasoningEfforts` 等级、`modelOverrides` 条目或 `compat` 字段可以被用户层覆盖,但不能被移除。 +- **重置恢复继承配置**——重置下层 profile 提供的路由会恢复该路由。 +- **完整替换 Config 可以移除继承的字典条目**——字段重置则恢复其继承值。 - **`headers` 可以携带 redactor 永远看不到的凭据**——profile 解析会拒绝 Fetch 无法表示的名称与值,但该字典仍是纯字符串;以 `apiKeyEnv` 引用存储凭据。 -- **路由目录不会自行刷新**——目录就是 `settings.yaml` 的内容;这里没有任何机制向提供方查询它提供的模型。 +- **发现操作不更改已配置模型**——需显式将发现结果采纳到路由配置中。 - **Anthropic 模型发现最多读取 1,000 个模型**——请求使用 API 的最大页大小,但不会遍历 `has_more`;第一页之外的条目需要手工添加。 - **每条路由一种协议格式**——混合协议目录路由无法承载另一协议格式的模型;把提供方拆到两个路由键是变通办法。 - **模态声明不受校验**——声明 `image` 而其网关不支持的模型会在提示词准入后被提供方拒绝。持久图片仍留在历史中,同一误声明模型可能再次失败;切换到纯文本模型仍然可行,因为共享 LLM 运行时会针对该请求把图片引用投影为稳定文本。 diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index aded0159a4..937a44838e 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -34,8 +34,8 @@ "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-timeout": "workspace:^" + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^" }, "dependencies": { "@deepseek-ai/dsh-brand": "workspace:^", @@ -52,8 +52,14 @@ "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/dsh-http-proxy": "workspace:^" + "@deepseek-ai/dsh-http-proxy": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^" + }, + "peerDependenciesMeta": { + "@deepseek-ai/cordis-plugin-loader": { + "optional": true + } } } diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index d626c1ac71..191c77c0d2 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -3,7 +3,7 @@ * catalog supplies defaults keyed by model id, and a profile's own model * entries override them field by field, so a route naming a catalog provider * stays configuration-free while a route pi-ai has never heard of is fully - * describable from `settings.yaml`. + * describable from `cordis.patch.yml`. * * Strict resolution rejects unserviceable models before settings writes. * Deferred resolution retains their diagnostics so stored catalog drift does diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 2f9e563008..b07cd57bfb 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -12,6 +12,7 @@ * * @module dsh-llm-pi-ai/config */ +import type { Volatile } from '@deepseek-ai/cordis' import type { CacheRetention, ChatTemplateKwargValue, ModelThinkingLevel, Provider, ThinkingBudgets, Transport } from '@earendil-works/pi-ai' import z from '@deepseek-ai/schemastery' @@ -224,9 +225,12 @@ export interface Config { * the dormant settings-driven posture: the adapter mounts with no routes * and registers them the moment a settings section supplies profiles. */ - providers?: Record + providers: Volatile> } +/** Plain options accepted by the provider resolver. */ +export type Options = { [K in keyof Config]?: Config[K] extends Volatile ? T : never } + const thinkingBudgets = z.object({ minimal: z.number(), low: z.number(), @@ -345,8 +349,8 @@ const profile = z.object({ }) /** Runtime schema for {@link Config}. */ -export const Config: z = z.object({ - providers: z.dict(profile).default({}), +export const Config = z.object({ + providers: z.dict(profile).default({}).volatile(), }) /** @@ -357,7 +361,7 @@ export const Config: z = z.object({ * @param previous - current resolved section; omission checks every provider. * @throws Error naming the route and configuration entry that cannot be served. */ -export function assertServiceable(config: Config, previous?: Config): void { +export function assertServiceable(config: Options, previous?: Options): void { const changed = Object.fromEntries(Object.entries(config.providers ?? {}).filter(([provider, profile]) => !deepEqualJson(profile, previous?.providers?.[provider]))) resolveProfiles(changed) diff --git a/packages/llm/llm-pi-ai/src/discovery.ts b/packages/llm/llm-pi-ai/src/discovery.ts index 77845db69e..d171b30235 100644 --- a/packages/llm/llm-pi-ai/src/discovery.ts +++ b/packages/llm/llm-pi-ai/src/discovery.ts @@ -10,7 +10,7 @@ * * Neither path is a catalog refresh. Nothing here is stored: the request * carries a draft the user is still editing, and the reply is candidate - * metadata the surface offers for adoption. `settings.yaml` remains the only + * metadata the surface offers for adoption. `cordis.patch.yml` remains the only * thing that decides what a route serves. * * OpenAI-compatible and Anthropic Messages protocols are interrogated through diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index dfa2a8159e..389acba7cc 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -54,13 +54,15 @@ * * @module @deepseek-ai/dsh-llm-pi-ai */ +import type {} from '@deepseek-ai/dsh-settings' + +import type {} from '@deepseek-ai/cordis-plugin-loader' import type { Context } from '@deepseek-ai/cordis' import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' import { assertUsableApiKey, LlmError, resolveImageAttachmentAccess } from '@deepseek-ai/dsh-llm' import type { AdapterRegistrationHandle, DirectoryRegistrationHandle, LlmConfigurableProvider } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-fs' -import type {} from '@deepseek-ai/dsh-settings' import { deepEqualJson } from '@deepseek-ai/dsh-util-values' import { PiAiAdapter } from './adapter.ts' import { authContextFrom, credentialStoreFrom } from './auth.ts' @@ -75,6 +77,7 @@ export { PiAiAdapter } from './adapter.ts' export type { PiAiAdapterOptions } from './adapter.ts' export { Config } from './config.ts' export type { + Options, PiAiCompatProfile, PiAiModality, PiAiModelOverride, @@ -120,6 +123,7 @@ function registrationFacts(profiles: ReadonlyMap, + settingsNs: string, ): LlmConfigurableProvider[] { const catalog = new Set(catalogProviderIds()) const entries = new Map() @@ -127,7 +131,7 @@ function directoryEntries( entries.set(provider, { provider, displayName, - settingsNs: NS, + settingsNs, settingsPath: ['providers', provider], // Membership of the installed catalog, not of the settings document: // narrowing a shipped provider's models stores a profile too, and that @@ -143,8 +147,9 @@ function directoryEntries( /** Register one generic pi-ai adapter for all configured provider routes. */ export function apply(ctx: Context, config: Config): void { - let current: () => Config = () => config - let lastRaw: Config | undefined + ctx.inject(['settings'], (child) => { child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)) }) + const settingsNs = ctx.fiber.entry?.options.id ?? NS + let lastRaw: ReturnType | undefined let memoized: ReadonlyMap | undefined /** * The resolved profiles for the current configuration, memoized by the raw @@ -156,14 +161,24 @@ export function apply(ctx: Context, config: Config): void { * Scalar configuration errors still reject resolution. */ const profiles = (): ReadonlyMap => { - const raw = current() + const raw = config.providers.get() if (raw === lastRaw && memoized !== undefined) return memoized - const next = resolveProfiles(raw.providers, 'deferred') + const next = resolveProfiles(structuredClone(raw) as import('./config.ts').Options['providers'], 'deferred') lastRaw = raw memoized = next return next } profiles() + ctx.on('internal/config', function (this: import('@deepseek-ai/cordis').Fiber, _raw, next) { + const raw: unknown = next() + if (this !== ctx.fiber) return raw + const candidate = Config(raw as import('./config.ts').Options) + assertServiceable( + { providers: structuredClone(candidate.providers.get()) } as import('./config.ts').Options, + { providers: structuredClone(config.providers.get()) } as import('./config.ts').Options, + ) + return raw + }) const resolveApiKey = async ( provider: string, @@ -224,7 +239,7 @@ export function apply(ctx: Context, config: Config): void { let directory: DirectoryRegistrationHandle | undefined let directoryFacts: unknown const ensureDirectory = (): void => { - const entries = directoryEntries(profiles()) + const entries = directoryEntries(profiles(), settingsNs) if (deepEqualJson(entries, directoryFacts)) return // Atomic replace, never dispose-then-register: a route another adapter // family already declares (a profile keyed `deepseek-official`) would @@ -257,7 +272,7 @@ export function apply(ctx: Context, config: Config): void { // except the stored credential and deployment-owned headers: the curated UI // accepts neither, so an already-configured route supplies both inside the // Host rather than widening the discovery request. - ctx.llm.registerModelDiscovery(NS, (request, signal) => discoverModels( + ctx.llm.registerModelDiscovery(settingsNs, (request, signal) => discoverModels( { ...request, ...signal === undefined ? {} : { signal } }, () => storedDiscoveryProfile(request.provider), )) @@ -292,46 +307,11 @@ export function apply(ctx: Context, config: Config): void { } ensureRegistrationFacts() - ctx.inject(['settings'], (settingsCtx) => { - let registering = true - settingsCtx.settings.installSection(ctx, NS, Config, config, { - validate: (value) => { - // Stored catalog drift must not prevent registration of the repair UI. - if (registering) { - resolveProfiles(value.providers, 'deferred') - } else { - assertServiceable(value, current()) - } - }, - setSource: (source) => { - current = source - }, - onChange: () => { - // Named here rather than left to the settings watcher: `assertServiceable` - // cannot see the llm registry, so a profile claiming a route another - // adapter family owns is stored successfully and only fails at this swap. - // Without its own diagnostic that refusal reaches the operator as a - // generic "settings: watcher failed", naming neither the route nor why it - // is not serving. The previous routes keep serving either way. - try { - ensureRegistrationFacts() - } catch (error) { - ctx.logger.error('llm-pi-ai: keeping the previously registered routes after a refused update') - ctx.logger.error(error) - } - // The directory follows the profiles the registry accepted, so a route - // that failed to register is not advertised as configurable. A refused - // directory swap is contained here for the same reason the registry's - // is: the previous entries keep serving, and `directoryFacts` stays put - // so returning to a working configuration re-applies. - try { - ensureDirectory() - } catch (error) { - ctx.logger.error('llm-pi-ai: keeping the previous configurable-provider directory after a refused update') - ctx.logger.error(error) - } - }, - }) - registering = false + ctx.on('loader/volatile-update', () => { + try { ensureRegistrationFacts(); ensureDirectory() } + catch (error) { + ctx.logger.error('llm-pi-ai: configuration conflicts with an existing provider route') + ctx.logger.error(error) + } }) } diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index 85534d6773..e574461018 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -1,11 +1,9 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { StreamChunk } from '@deepseek-ai/dsh-llm' -import FileSettingsProvider from '@deepseek-ai/dsh-settings-file' +import { liveConfig } from '../../../settings/settings/tests/live-config.ts' +const configurations = new WeakMap>>() import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' import type { ContextFormed } from '@deepseek-ai/dsh-llm' @@ -25,8 +23,6 @@ declare module '@deepseek-ai/dsh-llm' { } } -const homes: string[] = [] - // Routes name their credential by reference; the value lives in the // environment, which is the layer the adapter falls back to without a // mounted credentials seam. @@ -39,28 +35,18 @@ beforeEach(() => { afterEach(async () => { vi.unstubAllEnvs() await closeMockServers() - await Promise.all(homes.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) }) -/** A throwaway $DSH_HOME with an empty settings document. */ -async function home(): Promise { - const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-catalog-')) - homes.push(dir) - await writeFile(join(dir, 'settings.yaml'), '') - return dir -} - -/** The dormant composition plus a real settings service, as the product mounts it. */ -async function bootWithSettings(dir: string, config: LlmPiAi.Config): Promise { +/** The dormant composition with Loader-managed live Config. */ +async function bootWithSettings(config: LlmPiAi.Options): Promise { const ctx = new Context() await ctx.plugin(LlmRuntime) - await ctx.plugin(FileSettingsProvider, { path: join(dir, 'settings.yaml'), watch: false }) - await ctx.plugin(LlmPiAi, config) + configurations.set(ctx, await liveConfig(ctx, LlmPiAi, config)) return ctx } /** A complete hand-declared route: nothing about it exists in pi-ai's catalog. */ -function gateway(baseURL: string, overrides: Record = {}): LlmPiAi.Config { +function gateway(baseURL: string, overrides: Record = {}): LlmPiAi.Options { return { providers: { 'acme-gateway': { @@ -75,7 +61,7 @@ function gateway(baseURL: string, overrides: Record = {}): LlmP } } -async function harness(config: LlmPiAi.Config): Promise { +async function harness(config: LlmPiAi.Options): Promise { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmPiAi, config) @@ -232,9 +218,8 @@ describe('hand-declared providers', () => { // The resolver-level cases above cannot see a break between the settings // document and `LlmModelInfo`, so each rung is asserted once more through // a written section, the plugin's own registration, and `ctx.llm`. - const dir = await home() - const ctx = await bootWithSettings(dir, {}) - await ctx.settings.update('llm-pi-ai', { + const ctx = await bootWithSettings({}) + await configurations.get(ctx)!.update({ providers: { 'acme-gateway': { api: 'openai-completions', @@ -1011,9 +996,8 @@ describe('compat switches', () => { // judged by this adapter's section validator before it is stored. // schemastery keeps the null, so nothing but that check stands between it // and `Model.compat`. - const dir = await home() - const ctx = await bootWithSettings(dir, {}) - await expect(ctx.settings.update('llm-pi-ai', { + const ctx = await bootWithSettings({}) + await expect(configurations.get(ctx)!.update({ providers: { 'acme-gateway': { api: 'openai-completions', @@ -1030,9 +1014,8 @@ describe('compat switches', () => { // changes the request the provider receives, not merely the resolved model. vi.stubEnv(KEY_ENV, 'test-key') const server = await mockServer([{ events: textEvents }]) - const dir = await home() - const ctx = await bootWithSettings(dir, {}) - await ctx.settings.update('llm-pi-ai', { + const ctx = await bootWithSettings({}) + await configurations.get(ctx)!.update({ providers: { 'acme-gateway': { apiKeyEnv: KEY_ENV, @@ -1195,15 +1178,14 @@ describe('resolution snapshots', () => { describe('configurable-provider directory', () => { it('keeps the previous directory when a route collides with another adapter family', async () => { - const dir = await home() - const ctx = await bootWithSettings(dir, {}) + const ctx = await bootWithSettings({}) ctx.llm.registerConfigurableProviders([ { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] }, ]) const before = ctx.llm.listConfigurableProviders().length expect(before).toBeGreaterThan(30) - await ctx.settings.update('llm-pi-ai', { + await configurations.get(ctx)!.update({ providers: { 'deepseek-official': { api: 'openai-completions', @@ -1221,11 +1203,10 @@ describe('configurable-provider directory', () => { }) it('replaces its entries atomically as declared routes come and go', async () => { - const dir = await home() - const ctx = await bootWithSettings(dir, {}) + const ctx = await bootWithSettings({}) const catalogOnly = ctx.llm.listConfigurableProviders().length - await ctx.settings.update('llm-pi-ai', { + await configurations.get(ctx)!.update({ providers: { 'acme-gateway': { displayName: 'Acme Gateway', @@ -1239,7 +1220,7 @@ describe('configurable-provider directory', () => { expect(ctx.llm.listConfigurableProviders().find(entry => entry.provider === 'acme-gateway')?.displayName) .toBe('Acme Gateway') - await ctx.settings.replace('llm-pi-ai', {}) + await configurations.get(ctx)!.replace({}) expect(ctx.llm.listConfigurableProviders()).toHaveLength(catalogOnly) }) diff --git a/packages/llm/llm-pi-ai/tests/compat-upgrade.spec.ts b/packages/llm/llm-pi-ai/tests/compat-upgrade.spec.ts index 7fe2b925db..1dd72645cf 100644 --- a/packages/llm/llm-pi-ai/tests/compat-upgrade.spec.ts +++ b/packages/llm/llm-pi-ai/tests/compat-upgrade.spec.ts @@ -9,7 +9,7 @@ function configured(compat: Record, api = 'openai-completions') } function resolved(compat: Record, api = 'openai-completions') { - return resolveProfiles(configured(compat, api).providers).get('gateway')?.piProvider?.getModels()[0]?.compat + return resolveProfiles(structuredClone(configured(compat, api).providers.get()) as import('../src/config.ts').Options['providers']).get('gateway')?.piProvider?.getModels()[0]?.compat } describe('pi-ai gateway compatibility declarations', () => { diff --git a/packages/llm/llm-pi-ai/tests/config.spec.ts b/packages/llm/llm-pi-ai/tests/config.spec.ts index ed63dd5748..fe667fe58d 100644 --- a/packages/llm/llm-pi-ai/tests/config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/config.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' -import { assertServiceable, Config, resolveProfiles } from '../src/config.ts' +import { assertServiceable, Config, resolveProfiles, type Options } from '../src/config.ts' /** Validate one hand-declared route, with the caller's fields layered onto it. */ const routeWith = (profile: Record): (() => unknown) => - () => Config({ + () => ({ providers: Config({ providers: { 'acme-gateway': { api: 'openai-completions', @@ -12,7 +12,7 @@ const routeWith = (profile: Record): (() => unknown) => ...profile, }, }, - }) + }).providers.get() }) /** Validate that route with the caller's fields on its single model entry. */ const configWith = (model: Record): (() => unknown) => @@ -68,7 +68,7 @@ describe('modality schema boundary', () => { // well-typed, and the namespace validator is what refuses it. Asserting // only the schema would report this route as writable. expect(routeWith({ defaultInput: [] })).not.toThrow() - expect(() => { assertServiceable(routeWith({ defaultInput: [] })() as Config) }) + expect(() => { assertServiceable(routeWith({ defaultInput: [] })() as Options) }) .toThrow(/defaultInput must name at least one modality/) }) @@ -101,7 +101,7 @@ describe('request image policy bounds', () => { [field]: value, }, }, - } as Config + } as Options expect(() => { assertServiceable(programmatic) }).toThrow(message) diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index ad6eddf547..ee0faca3ca 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -1,19 +1,20 @@ +import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import LlmRuntime, { LlmAdapter } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local' -import { FileSettingsProvider } from '@deepseek-ai/dsh-settings-file' +import { liveConfig } from '../../../settings/settings/tests/live-config.ts' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import AuthorizationService from '@deepseek-ai/dsh-authorization' -import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' import { assemble } from './assemble.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' -const NS = 'llm-pi-ai' +const configurations = new WeakMap>>() +const initialConfigs = new WeakMap() /** Minimal foreign adapter: only needs to own a route the pi-ai plugin then wants. */ class StubAdapter extends LlmAdapter { @@ -39,18 +40,18 @@ async function home(): Promise { async function boot( dir: string, - config: LlmPiAi.Config, - options: { authorization?: boolean; watchSettings?: boolean } = {}, + config: LlmPiAi.Options, + options: { authorization?: boolean } = {}, ): Promise { const ctx = new Context() cleanups.push(async () => { await ctx.fiber.dispose() }) await ctx.plugin(LlmRuntime) - await ctx.plugin(FileSettingsProvider, { path: join(dir, 'settings.yaml'), watch: options.watchSettings ?? false }) await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false }) if (options.authorization === true) await ctx.plugin(AuthorizationService) - await ctx.plugin(LlmPiAi, config) + initialConfigs.set(ctx, config) + configurations.set(ctx, await liveConfig(ctx, LlmPiAi, config)) return ctx } @@ -75,52 +76,25 @@ describe('login flows in a real composition', () => { }) describe('request-level dynamic profiles', () => { - // Real filesystem notifications can lag behind chokidar's stability window on busy hosts. - it('retains the last accepted profiles after an invalid external edit and accepts a repaired file', { timeout: 30_000 }, async () => { - const dir = await home() - const path = join(dir, 'settings.yaml') - await writeFile(path, JSON.stringify({ [NS]: { providers: { deepseek: {} } } })) - const ctx = await boot(dir, {}, { watchSettings: true }) - - await writeFile(path, JSON.stringify({ [NS]: { providers: { openrouter: { models: [{ id: '111' }] } } } })) - // The raw section proves the watcher processed the edit even though validation kept the old resolved value. - await expect.poll(() => ctx.settings.describe().find(section => section.ns === NS)?.user, { timeout: 10_000 }) - .toEqual({ providers: { openrouter: { models: [{ id: '111' }] } } }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'deepseek' }]) - - await writeFile(path, JSON.stringify({ [NS]: { providers: { - openrouter: { api: 'openai-completions', models: [{ id: '111' }] }, - } } })) - await expect.poll(() => ctx.llm.listProviders(), { timeout: 10_000 }) - .toEqual([{ id: 'openrouter', name: 'openrouter' }]) - expect((await ctx.llm.listModels('openrouter')).map(model => model.id)).toEqual(['111']) - }) - it('keeps stored catalog failures editable while isolating requests and validating changed providers', async () => { vi.stubEnv('PI_DYNAMIC_KEY', '') const dir = await home() const server = await mockServer([{ events: textEvents }, { events: textEvents }]) const known = getBuiltinModels('openrouter').find(model => model.api === 'openai-completions')! - const path = join(dir, 'settings.yaml') - const stored = JSON.stringify({ - [NS]: { providers: { openrouter: { - apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url, - models: [{ id: known.id }, { id: '111' }], - } } }, - }) - await writeFile(path, stored) + const stored = { providers: { openrouter: { + apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url, + models: [{ id: known.id }, { id: '111' }], + } } } await writeFile(join(dir, '.credentials.yaml'), 'version: 1\nrefs:\n PI_DYNAMIC_KEY: fake-key\n', { mode: 0o600 }) - const ctx = await boot(dir, {}) + const ctx = await boot(dir, stored) const failure = 'llm-pi-ai: provider "openrouter" model "111" needs an api; ' + 'the installed catalog does not describe it, so set the route\'s api to the wire protocol its endpoint speaks' - expect(ctx.settings.describe().map(section => section.ns)).toContain(NS) expect(ctx.llm.listProviders()).toEqual([{ id: 'openrouter', name: 'openrouter' }]) expect(ctx.llm.listConfigurableProviders()).toContainEqual({ - provider: 'openrouter', displayName: 'openrouter', settingsNs: NS, + provider: 'openrouter', displayName: 'openrouter', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openrouter'], declared: false, error: failure, }) - expect(await readFile(path, 'utf8')).toBe(stored) expect((await ctx.llm.listModels('openrouter')).map(model => model.id)).toEqual([known.id]) const bad = await assemble(ctx, { provider: 'openrouter', model: '111', messages: [] }) expect(bad.finish).toMatchObject({ kind: 'error', failure: { code: 'INVALID_CONFIG', message: failure } }) @@ -128,17 +102,17 @@ describe('request-level dynamic profiles', () => { const good = await assemble(ctx, { provider: 'openrouter', model: known.id, messages: [] }) expect(good.message.content).toEqual([{ type: 'text', text: 'hello' }]) - await ctx.settings.update(NS, { providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } } }) + await configurations.get(ctx)!.update({ providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } } }) expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openrouter', 'deepseek']) - const beforeRejected = await readFile(path, 'utf8') - await expect(ctx.settings.update(NS, { providers: { openrouter: { displayName: 'Edited' } } })).rejects.toThrow(failure) - expect(await readFile(path, 'utf8')).toBe(beforeRejected) + const beforeRejected: unknown = configurations.get(ctx)!.entry.options.config + await expect(configurations.get(ctx)!.update({ providers: { openrouter: { displayName: 'Edited' } } })).rejects.toThrow(failure) + expect(configurations.get(ctx)!.entry.options.config).toEqual(beforeRejected) const diagnostics: Array = [] ctx.on('llm/adapters-updated', () => { diagnostics.push(ctx.llm.listConfigurableProviders().find(entry => entry.provider === 'openrouter')?.error) }) - await ctx.settings.mutate(NS, [{ op: 'set', path: ['providers', 'openrouter', 'api'], value: 'openai-completions' }]) + await configurations.get(ctx)!.update({ providers: { openrouter: { api: 'openai-completions' } } }) expect(diagnostics).toEqual([undefined]) expect(ctx.llm.listProviders()[0]).toEqual({ id: 'openrouter', name: 'openrouter' }) const repaired = await assemble(ctx, { provider: 'openrouter', model: '111', messages: [] }) @@ -148,12 +122,11 @@ describe('request-level dynamic profiles', () => { it('allows removing an obsolete override and deleting a route whose catalog cannot be built', async () => { const dir = await home() - await writeFile(join(dir, 'settings.yaml'), JSON.stringify({ [NS]: { providers: { + const stored = { providers: { anthropic: { modelOverrides: { 'removed-model': { maxTokens: 4096 } } }, 'retired-route': {}, - } } })) - const ctx = await boot(dir, {}) - expect(ctx.settings.describe().map(section => section.ns)).toContain(NS) + } } + const ctx = await boot(dir, stored) expect(ctx.llm.listConfigurableProviders().find(entry => entry.provider === 'anthropic')?.error) .toContain('modelOverrides names "removed-model"') expect(ctx.llm.listConfigurableProviders().find(entry => entry.provider === 'retired-route')?.error) @@ -161,8 +134,7 @@ describe('request-level dynamic profiles', () => { expect((await ctx.llm.listModels('anthropic')).length).toBeGreaterThan(0) await expect(ctx.llm.resolveModelInfo('anthropic', 'removed-model')).rejects.toThrow('modelOverrides names "removed-model"') await expect(ctx.llm.resolveModelInfo('retired-route', 'anything')).rejects.toThrow('resolves no models') - await ctx.settings.mutate(NS, [{ op: 'unset', path: ['providers', 'retired-route'] }]) - await ctx.settings.mutate(NS, [{ op: 'unset', path: ['providers', 'anthropic', 'modelOverrides', 'removed-model'] }]) + await configurations.get(ctx)!.replace({ providers: { anthropic: {} } }) expect(ctx.llm.listProviders()).toEqual([{ id: 'anthropic', name: 'anthropic' }]) expect(ctx.llm.listConfigurableProviders().find(entry => entry.provider === 'retired-route')).toBeUndefined() expect(ctx.llm.listConfigurableProviders().find(entry => entry.provider === 'anthropic')?.error).toBeUndefined() @@ -192,7 +164,7 @@ describe('request-level dynamic profiles', () => { settingsPath: ['providers', 'openai'], declared: false, }) - await ctx.settings.update(NS, { + await configurations.get(ctx)!.update({ providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } }, }) expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek']) @@ -203,7 +175,7 @@ describe('request-level dynamic profiles', () => { expect(server.headers[0]?.authorization).toBe('Bearer pk-from-settings') // Emptying the user layer returns the adapter to its dormant state. - await ctx.settings.replace(NS, {}) + await configurations.get(ctx)!.replace(initialConfigs.get(ctx)!) expect(ctx.llm.listProviders()).toEqual([]) }) @@ -220,7 +192,7 @@ describe('request-level dynamic profiles', () => { }) expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) - await ctx.settings.update(NS, { + await configurations.get(ctx)!.update({ providers: { deepseek: { apiKeyEnv: 'PI_LIVE_KEY', baseURL: server.url } }, }) expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai', 'deepseek']) @@ -231,7 +203,7 @@ describe('request-level dynamic profiles', () => { // Reset the user layer: the settings-born route unregisters, the // composition route stays. - await ctx.settings.replace(NS, {}) + await configurations.get(ctx)!.replace(initialConfigs.get(ctx)!) expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) const removed = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) expect(removed.finish).toMatchObject({ kind: 'error', failure: { code: 'NO_ADAPTER' } }) @@ -258,7 +230,7 @@ describe('request-level dynamic profiles', () => { const dir = await home() const ctx = await boot(dir, { providers: { openai: {} } }) - await ctx.settings.update(NS, { + await configurations.get(ctx)!.update({ providers: { openai: { retryPolicy: { mode: 'always', backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 } }, @@ -282,11 +254,11 @@ describe('request-level dynamic profiles', () => { // that lists no models of its own. The section schema resolves the whole // profile set, so this is refused where it is written rather than stored // and then quietly disabling every route in the namespace. - await expect(ctx.settings.update(NS, { providers: { 'not-a-real-provider': {} } })) + await expect(configurations.get(ctx)!.update({ providers: { 'not-a-real-provider': {} } })) .rejects.toThrow(/resolves no models/) expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) - await expect(ctx.settings.update(NS, { + await expect(configurations.get(ctx)!.update({ providers: { openai: { headers: { 'bad header name': 'value' } } }, })).rejects.toThrow(/provider "openai" header "bad header name" is not valid for Fetch/) expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) @@ -304,7 +276,7 @@ describe('request-level dynamic profiles', () => { // Another adapter owns `anthropic`; the registry must refuse to hand it over. ctx.llm.registerAdapter(['anthropic'], new StubAdapter()) - await ctx.settings.update(NS, { + await configurations.get(ctx)!.update({ providers: { openai: { apiKeyEnv: 'PI_LIVE_KEY', baseURL: `${server.url}/v1` }, anthropic: { apiKeyEnv: 'PI_OTHER_KEY' }, @@ -321,7 +293,7 @@ describe('request-level dynamic profiles', () => { // Reverting to the working configuration re-applies, even though its // facts equal the ones the registry already holds. - await ctx.settings.replace(NS, {}) + await configurations.get(ctx)!.replace(initialConfigs.get(ctx)!) expect(ctx.llm.listProviders().map(provider => provider.id).sort()).toEqual(['anthropic', 'openai']) await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) expect(server.paths).toEqual(['/v1/responses', '/v1/responses']) @@ -334,7 +306,7 @@ describe('request-level dynamic profiles', () => { // Same routes, different YAML key order: nothing about the registration // changed, so no swap should happen at all. - await ctx.settings.update(NS, { providers: { anthropic: {}, openai: {} } }) + await configurations.get(ctx)!.update({ providers: { anthropic: {}, openai: {} } }) expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(before) }) }) diff --git a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts index ecbeb254b4..e9d1699c34 100644 --- a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts +++ b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts @@ -1,12 +1,4 @@ -/** - * Real-composition guard for the dormant pi-ai posture: LlmRuntime, - * settings-file, credentials-local, and a bare `llm-pi-ai` row boot from a - * test-only cordis.yml through the actual Loader + Include path, an external - * edit of settings.yaml registers the route live, and the next request - * carries the credential the credentials document supplies. A hand-mounted `ctx.plugin` cannot - * catch Loader export-shape failures, which is why the twin adapter has the - * same guard. - */ +/** Profile patch edits and credential updates reach the next real adapter request. */ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -14,11 +6,11 @@ import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import Loader from '@deepseek-ai/cordis-plugin-loader' +import Loader, { type ModuleLoaderV2 } from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import LlmRuntime, { createMessage, createUserMessage, userAgent } from '@deepseek-ai/dsh-llm' import LocalCredentialProvider from '@deepseek-ai/dsh-credentials-local' -import FileSettingsProvider from '@deepseek-ai/dsh-settings-file' +import { profileComposition } from '../../../settings/settings/tests/profile-composition.ts' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import { assemble } from './assemble.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' @@ -47,19 +39,12 @@ afterEach(async () => { /** Boot the dormant composition: a bare `llm-pi-ai` row with no config at all. */ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string }> { root = await mkdtemp(join(tmpdir(), 'dsh-pi-composition-')) - const settingsPath = join(root, 'settings.yaml') - await writeFile(settingsPath, '# personal settings\n') await writeFile(join(root, '.credentials.yaml'), 'version: 1\nrefs:\n PI_COMPOSITION_KEY: key-from-store\n', { mode: 0o600 }) const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ '- id: llm', " name: 'test-llm-service'", - '- id: settings', - " name: '@deepseek-ai/dsh-settings-file'", - ' config:', - ` path: ${JSON.stringify(settingsPath)}`, - ' debounceMs: 10', '- id: credentials', " name: '@deepseek-ai/dsh-credentials-local'", ' config:', @@ -77,23 +62,24 @@ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string } ctx.loader.builtins.include = Include const modules = new Map([ ['test-llm-service', LlmRuntime], - ['@deepseek-ai/dsh-settings-file', FileSettingsProvider], ['@deepseek-ai/dsh-credentials-local', LocalCredentialProvider], ['@deepseek-ai/dsh-llm-pi-ai', LlmPiAi], ]) - ctx.loader.internal = { + const internal: ModuleLoaderV2 = { version: 'v2', - async import(specifier: string) { + loadCache: new Map(), + import: (specifier: string) => { if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) - return modules.get(specifier) + return Promise.resolve(modules.get(specifier)) }, - } as unknown as NonNullable - await ctx.loader.create({ - name: 'cordis:include', - config: { path: pathToFileURL(configPath).href }, - }) - await ctx.loader.await() - return { ctx, settingsPath } + register(): never { throw new Error('unexpected module hook registration') }, + getOrCreateModuleJob(): never { throw new Error('unexpected module job creation') }, + resolveSync(): never { throw new Error('unexpected synchronous module resolution') }, + load(): never { throw new Error('unexpected module load') }, + } + ctx.loader.internal = internal + const patchPath = await profileComposition(ctx, root, configPath) + return { ctx, settingsPath: patchPath } } describe('llm-pi-ai real dormant composition', () => { @@ -107,11 +93,12 @@ describe('llm-pi-ai real dormant composition', () => { // Exactly what the web Models page leaves on disk. await writeFile(settingsPath, [ - 'llm-pi-ai:', - ' providers:', - ' deepseek:', - ' apiKeyEnv: PI_COMPOSITION_KEY', - ` baseURL: ${server.url}`, + '- id: llm-pi-ai', + ' config:', + ' providers:', + ' deepseek:', + ' apiKeyEnv: PI_COMPOSITION_KEY', + ` baseURL: ${server.url}`, '', ].join('\n')) await vi.waitFor(() => { @@ -129,18 +116,19 @@ describe('llm-pi-ai real dormant composition', () => { const { ctx, settingsPath } = await loadComposition() await writeFile(settingsPath, [ - 'llm-pi-ai:', - ' providers:', - ' acme-gateway:', - ' apiKeyEnv: PI_COMPOSITION_KEY', - ' api: openai-completions', - ` baseURL: ${server.url}`, - ' headers:', - ' X-Company-Code: private-tenant', - ' Accept: text/plain', - ' User-Agent: deployment-owned', - ' models:', - ' - id: acme-bootstrap', + '- id: llm-pi-ai', + ' config:', + ' providers:', + ' acme-gateway:', + ' apiKeyEnv: PI_COMPOSITION_KEY', + ' api: openai-completions', + ` baseURL: ${server.url}`, + ' headers:', + ' X-Company-Code: private-tenant', + ' Accept: text/plain', + ' User-Agent: deployment-owned', + ' models:', + ' - id: acme-bootstrap', '', ].join('\n')) await vi.waitFor(() => { @@ -167,11 +155,12 @@ describe('llm-pi-ai real dormant composition', () => { ]) const { ctx, settingsPath } = await loadComposition() await writeFile(settingsPath, [ - 'llm-pi-ai:', - ' providers:', - ' deepseek:', - ' apiKeyEnv: PI_COMPOSITION_KEY', - ` baseURL: ${server.url}`, + '- id: llm-pi-ai', + ' config:', + ' providers:', + ' deepseek:', + ' apiKeyEnv: PI_COMPOSITION_KEY', + ` baseURL: ${server.url}`, '', ].join('\n')) await vi.waitFor(() => { @@ -227,11 +216,12 @@ describe('llm-pi-ai real dormant composition', () => { const server = await mockServer([{ events: textEvents }]) const { ctx, settingsPath } = await loadComposition() await writeFile(settingsPath, [ - 'llm-pi-ai:', - ' providers:', - ' deepseek:', - ' apiKeyEnv: PI_COMPOSITION_KEY', - ` baseURL: ${server.url}`, + '- id: llm-pi-ai', + ' config:', + ' providers:', + ' deepseek:', + ' apiKeyEnv: PI_COMPOSITION_KEY', + ` baseURL: ${server.url}`, '', ].join('\n')) await vi.waitFor(() => { diff --git a/packages/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json index c7d43104c0..36c2181a39 100644 --- a/packages/llm/llm-pi-ai/tsconfig.json +++ b/packages/llm/llm-pi-ai/tsconfig.json @@ -38,14 +38,17 @@ { "path": "../../credentials/authorization" }, - { - "path": "../../settings/settings" - }, { "path": "../../util/timeout" }, { "path": "../../util/http-proxy" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../settings/settings" } ] } diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts index 8fd91ece9e..1f2a3262e4 100644 --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts @@ -15,8 +15,15 @@ import * as Retry from '../src/index.ts' let context: Context | undefined const servers: Promise[] = [] +let failureDiagnostics: (() => object) | undefined -afterEach(async () => { +afterEach(async (test) => { + const diagnose = failureDiagnostics + failureDiagnostics = undefined + // Capture the pending request before disposal aborts it and closes its socket. + if (test.task.result?.state === 'fail' && diagnose !== undefined) { + console.error('transport recovery failure:', JSON.stringify(diagnose())) + } const ownedContext = context context = undefined // A failed transport can still await a stalled socket while its context disposes. @@ -207,19 +214,44 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { }) it('turns a stalled body into TIMEOUT and succeeds on the next request', async () => { + const started = performance.now() + let phase = 'starting server' + const observed: { server?: MockLlmServer; agent?: Agent } = {} + let idleSettled = false + failureDiagnostics = () => { + const events = observed.agent?.session.snapshotEvents() ?? [] + return { + node: process.version, + phase, + elapsedMs: Math.round(performance.now() - started), + idleSettled, + agentStatus: observed.agent?.status, + requestCount: observed.server?.requests.length ?? 0, + requests: observed.server?.requests.map(({ attempt, behavior, outcome, chunksSent }) => + ({ attempt, behavior, outcome, chunksSent })), + retries: events.filter(event => event.type === 'llm/retry').map(event => event.data), + lastEvents: events.slice(-10).map(event => ({ seq: event.seq, type: event.type })), + } + } const server = await start(['stall', 'success'], { apiKey: 'mock-key', successText: 'recovered after timeout', }) + observed.server = server + phase = 'mounting context' // This crosses the real HTTP idle timer, so leave scheduler slack between // the stalled attempt and the mock server's immediate successful response. context = await harness(server.baseURL, { streamIdleTimeoutMs: 1_000 }) + phase = 'creating agent' const agent = await context.agentLoop.create(SessionId('wire-stall'), { provider: 'deepseek-official', model: 'mock-model', }) - await sendAndWait(context, agent) + observed.agent = agent + phase = 'awaiting agent idle' + await sendAndWait(context, agent).finally(() => { idleSettled = true }) + phase = 'asserting recovery' expect(server.requests.map(record => record.behavior)).toEqual(['stall', 'success']) expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) diff --git a/packages/preset/agent-preset-registry/README.i18n.yaml b/packages/preset/agent-preset-registry/README.i18n.yaml index 7033a03980..93f84a50f9 100644 --- a/packages/preset/agent-preset-registry/README.i18n.yaml +++ b/packages/preset/agent-preset-registry/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/agent-preset-registry/README.md -README.md: 9a4c02abb87f4529292133caad3a7d4381715820 -README.zh.md: 7419f79f31375b9742f9db972322d3906ccbbe3d +README.md: 7a1ee3e733fc54194515dbf093e53af0c9698e40 +README.zh.md: 11206a3f9237cbca237220a81fd98ec5eea1083e diff --git a/packages/preset/agent-preset-registry/README.md b/packages/preset/agent-preset-registry/README.md index 9a4c02abb8..7a1ee3e733 100644 --- a/packages/preset/agent-preset-registry/README.md +++ b/packages/preset/agent-preset-registry/README.md @@ -43,7 +43,7 @@ Choose an Agent’s tools, prompt sections and skills through declarative preset |---|---|---| | `default` | required | Preset ID used when none is requested | -The Web definitions come from the `dsh-web-app` bundle. Definitions are ordinary plugin rows; the registry neither scans directories nor accepts preset paths. The `agent-presets` settings namespace retains the user default and `modeSelectionEnabled`; hiding the chooser uses the deployment default. +The Web definitions come from the `dsh-web-app` bundle. Definitions are ordinary plugin rows; the registry neither scans directories nor accepts preset paths. The `selectedDefault` and `modeSelectionEnabled` volatile fields of the `agent-preset-registry` entry retain the user default and the chooser visibility; hiding the chooser uses the deployment `default`. The registry writes no declarations. A new preset or an override of a shipped one is a bundle patch: an `insert` of a `@deepseek-ai/dsh-agent-preset` row, or a patch keyed by that row’s id, installed into the profile with `plugin_manager`; Creator mode authors such bundles in conversation. diff --git a/packages/preset/agent-preset-registry/README.zh.md b/packages/preset/agent-preset-registry/README.zh.md index 7419f79f31..11206a3f92 100644 --- a/packages/preset/agent-preset-registry/README.zh.md +++ b/packages/preset/agent-preset-registry/README.zh.md @@ -43,7 +43,7 @@ kind: "package-reference" |---|---|---| | `default` | 必填 | 未显式指定时使用的 preset ID | -Web 内置定义来自 `dsh-web-app` bundle。定义使用普通插件行;注册表不扫描目录,也不接受 preset 路径。`agent-presets` settings 命名空间保留用户默认值和 `modeSelectionEnabled`;隐藏选择器时使用部署默认值。 +Web 内置定义来自 `dsh-web-app` bundle。定义使用普通插件行;注册表不扫描目录,也不接受 preset 路径。`agent-preset-registry` 条目的 volatile 字段 `selectedDefault` 与 `modeSelectionEnabled` 保留用户默认值和选择器可见性;隐藏选择器时使用部署 `default`。 注册表不写入任何声明。新建 preset 或覆盖内置 preset 都是 bundle 补丁:插入一行 `@deepseek-ai/dsh-agent-preset`,或按该行 id 写覆盖补丁,再用 `plugin_manager` 安装到 profile;创造模式在对话中编写这类 bundle。 diff --git a/packages/preset/agent-preset-registry/package.json b/packages/preset/agent-preset-registry/package.json index b3250887ad..71b0d8865a 100644 --- a/packages/preset/agent-preset-registry/package.json +++ b/packages/preset/agent-preset-registry/package.json @@ -83,7 +83,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-settings-file": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", diff --git a/packages/preset/agent-preset-registry/src/index.ts b/packages/preset/agent-preset-registry/src/index.ts index 04303eff56..d8f54dddc3 100644 --- a/packages/preset/agent-preset-registry/src/index.ts +++ b/packages/preset/agent-preset-registry/src/index.ts @@ -4,7 +4,8 @@ import z from '@deepseek-ai/schemastery' import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { SettingsScope } from '@deepseek-ai/dsh-settings' +// Type-only: the optional `settings` service this registry keeps off the generated pages. +import type {} from '@deepseek-ai/dsh-settings' import type {} from '@deepseek-ai/dsh-tools' import type { AgentPresetRoster } from './types.ts' import { entryListProblem, type PresetDefinition } from './definition.ts' @@ -18,16 +19,6 @@ export { entryListProblem, type PresetDefinition } from './definition.ts' export { auditRows, livePresetMounts, leakedServices, serviceForAgent, standingMountFor, type PresetMount, type RowAudit } from './mount.ts' export type { AgentPreset, Config } from './preset.ts' -/** User-selected default and visibility of the new-session chooser. */ -export interface AgentPresetSettings { - default: string - modeSelectionEnabled: boolean -} -/** Accepted settings fields. */ -export const AgentPresetSettingsSchema: z = z.object({ - default: z.string(), modeSelectionEnabled: z.boolean(), -}) - declare module '@deepseek-ai/cordis' { interface Context { agentPresets: AgentPresetRegistry @@ -57,24 +48,22 @@ interface Binding { /** Registry of YAML-declared presets and the revisions live Agents retain. */ export class AgentPresetRegistry extends TypertRemoteService { static inject = ['loader', 'sessionProjections'] - static Config: z = z.object({ default: z.string().required() }) + static Config = z.object({ + default: z.string().required(), + selectedDefault: z.string().volatile(), + modeSelectionEnabled: z.boolean().default(true).volatile(), + }) private readonly owner: Context private readonly definitions = new Map() private readonly generations = new Map() private readonly bindings = new WeakMap() private readonly switches = new Map>() - private settings: SettingsScope | undefined constructor(ctx: Context, public config: Config) { super(ctx, 'agentPresets') this.owner = ctx ctx.sessionProjections.register(agentPresetProjectionDefinition) - ctx.inject(['settings'], (settingsCtx) => { - this.settings = settingsCtx.settings.register('agent-presets', AgentPresetSettingsSchema, { - base: { default: config.default, modeSelectionEnabled: true }, - }) - settingsCtx.effect(() => () => { this.settings = undefined }, 'agent-presets.settings') - }) + ctx.inject(['settings'], (child) => { child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)) }) ctx.on('session/event', (session, event) => { if (event.type === 'agent-preset/selected') ctx.emit('agent-preset/selected', session.id, event.data.agentPreset) }) @@ -84,9 +73,8 @@ export class AgentPresetRegistry extends TypertRemoteService { get defaultId(): string { return this.policy().defaultId } private policy(): { enabled: boolean; defaultId: string } { - const settings = this.settings?.get() - const enabled = settings?.modeSelectionEnabled ?? true - return { enabled, defaultId: enabled ? settings?.default ?? this.config.default : this.config.default } + const enabled = this.config.modeSelectionEnabled.get() + return { enabled, defaultId: enabled ? this.config.selectedDefault.get() ?? this.config.default : this.config.default } } /** Register and eagerly load a definition; activation failure remains visible in the roster. diff --git a/packages/preset/agent-preset-registry/src/preset.ts b/packages/preset/agent-preset-registry/src/preset.ts index b2bdab1198..27474f8051 100644 --- a/packages/preset/agent-preset-registry/src/preset.ts +++ b/packages/preset/agent-preset-registry/src/preset.ts @@ -1,3 +1,4 @@ +import type { Volatile } from '@deepseek-ai/cordis' /** Public preset roster and selection configuration. */ /** One declared preset and its current activation failure, if any. */ export interface AgentPreset { @@ -12,4 +13,8 @@ export interface AgentPreset { export interface Config { /** Deployment default when the caller omits a preset. */ default: string + /** User-selected default while the chooser is shown; edited through Settings. */ + selectedDefault: Volatile + /** Whether new-session surfaces expose preset selection and the saved default applies. */ + modeSelectionEnabled: Volatile } diff --git a/packages/preset/agent-preset-registry/tests/harness.ts b/packages/preset/agent-preset-registry/tests/harness.ts index 6be58b887a..c420624fe3 100644 --- a/packages/preset/agent-preset-registry/tests/harness.ts +++ b/packages/preset/agent-preset-registry/tests/harness.ts @@ -9,10 +9,14 @@ import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import AgentPresets, { type PresetDefinition } from '../src/index.ts' +import { liveConfig } from '../../../settings/settings/tests/live-config.ts' + +/** Loader-backed registry entries by harness context, for tests that edit live fields. */ +export const liveRegistries = new WeakMap>>() export const plugin = (name: string): string => new URL(`./fixtures/plugins/${name}.js`, import.meta.url).href export const contribution = (tool: string): PresetDefinition => ({ id: tool, plugins: [{ name: plugin('contribute'), config: { tool } }] }) -export async function harness(): Promise { +export async function harness(options: { live?: boolean } = {}): Promise { const ctx = new Context() ctx.baseUrl = new URL('./fixtures/', import.meta.url).href await ctx.plugin(Loader) @@ -24,7 +28,8 @@ export async function harness(): Promise { await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(AgentPresets, { default: 'standard' }) + if (options.live) liveRegistries.set(ctx, await liveConfig(ctx, AgentPresets, { default: 'standard' })) + else await ctx.plugin(AgentPresets, { default: 'standard' }) return ctx } export async function declare(ctx: Context, config: PresetDefinition) { diff --git a/packages/preset/agent-preset-registry/tests/registry.spec.ts b/packages/preset/agent-preset-registry/tests/registry.spec.ts index 76190cf66a..778fa2f834 100644 --- a/packages/preset/agent-preset-registry/tests/registry.spec.ts +++ b/packages/preset/agent-preset-registry/tests/registry.spec.ts @@ -2,7 +2,11 @@ import { afterEach, describe, expect, it } from 'vitest' import { createScope } from '@deepseek-ai/dsh-scope' import { assembleContextFor } from '@deepseek-ai/dsh-agent' import { entryListProblem, livePresetMounts } from '../src/index.ts' -import { currentKey, harness, declare, contribution, agentOn } from './harness.ts' +import { currentKey, harness, declare, contribution, agentOn, liveRegistries } from './harness.ts' +import { omitsGeneratedPage } from '../../../settings/settings/tests/live-config.ts' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import AgentPresets from '../src/index.ts' import type { Context } from '@deepseek-ai/cordis' const contexts: Context[] = [] @@ -193,26 +197,25 @@ it('allows an isolated service and resolves it through the Agent composition', a }) it('keeps policy preferences while hiding and restoring the chooser', async () => { - const ctx = await setup() - const { default: Settings } = await import('@deepseek-ai/dsh-settings-file') - const { mkdtemp, rm } = await import('node:fs/promises') - const { tmpdir } = await import('node:os') - const pathModule = await import('node:path') - const join = (...parts: string[]) => pathModule.join(...parts) - const path = await mkdtemp(join(tmpdir(), 'preset-policy-')) - const settings = await ctx.plugin(Settings, { path: join(path, 'settings.yml'), watch: false }) - try { - await ctx.settings.update('agent-presets', { default: 'minimal', modeSelectionEnabled: true }) - expect(ctx.agentPresets.defaultId).toBe('minimal') - await ctx.settings.update('agent-presets', { default: 'minimal', modeSelectionEnabled: false }) - expect(ctx.agentPresets.defaultId).toBe('standard') - await ctx.settings.update('agent-presets', { default: 'minimal', modeSelectionEnabled: true }) - expect(ctx.agentPresets.defaultId).toBe('minimal') - await settings.dispose() - expect(ctx.agentPresets.defaultId).toBe('standard') - } finally { await settings.dispose(); await rm(path, { recursive: true, force: true }) } + const ctx = await harness({ live: true }) + contexts.push(ctx) + const live = liveRegistries.get(ctx)! + await live.update({ selectedDefault: 'minimal', modeSelectionEnabled: true }) + expect(ctx.agentPresets.defaultId).toBe('minimal') + await live.update({ modeSelectionEnabled: false }) + expect(ctx.agentPresets.defaultId).toBe('standard') + await live.update({ modeSelectionEnabled: true }) + expect(ctx.agentPresets.defaultId).toBe('minimal') + await live.replace({ default: 'standard' }) + expect(ctx.agentPresets.defaultId).toBe('standard') }) +it('keeps its own instance off the generated Settings pages', () => omitsGeneratedPage(async (ctx) => { + await ctx.plugin(Loader) + await ctx.plugin(SessionProjectionRegistry) + return ctx.plugin(AgentPresets, { default: 'standard' }) +})) + it('refuses an unscoped binding and a second child join without leaking references', async () => { const ctx = await setup() const definition = await declare(ctx, contribution('standard')) diff --git a/packages/preset/agent-preset/README.i18n.yaml b/packages/preset/agent-preset/README.i18n.yaml index 7ef142d9cc..edcf09e391 100644 --- a/packages/preset/agent-preset/README.i18n.yaml +++ b/packages/preset/agent-preset/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/agent-preset/README.md -README.md: b9274969b5c219e9120ba754864e87c03c409bcb -README.zh.md: aba74e630713291d1f17a4fe5668aed8985521ea +README.md: 99802e2592e046c0580a77b5891a5b79c1e3f1f5 +README.zh.md: 1130b015a8f7bafcf9b675abd3ca6d654d8e2d7f diff --git a/packages/preset/agent-preset/README.md b/packages/preset/agent-preset/README.md index b9274969b5..99802e2592 100644 --- a/packages/preset/agent-preset/README.md +++ b/packages/preset/agent-preset/README.md @@ -47,6 +47,8 @@ Define an Agent’s child plugins in ordinary Cordis YAML. Declare several prese The declaration row’s `id` addresses Loader edits; `config.id` is the preset identity saved by sessions. Child entry IDs may be omitted and assigned by Loader. +The package also ships the `skills/` directory that creator mode mounts through `skill-filesystem`: `cordis-plugin-development` (a short procedure with `references/` read on demand and copyable `templates/` bundles), `editing-cordis-compositions`, and `cordis-composition-reference`, whose `references/packages.md` is generated by `scripts/gen-plugin-packages.ts` and freshness-gated in `doc-sync`. The two procedure skills tell the agent to read inspection results first, then the package README under the `packageDir` that `Config.listConfigs` reports, then built `lib/` or checkout sources. Every shipped `SKILL.md`, rendered as a `skill` tool result, stays under the 8192-character threshold above which the standard preset's tool-result pruner trims results. + ## Understand the implementation diff --git a/packages/preset/agent-preset/README.zh.md b/packages/preset/agent-preset/README.zh.md index aba74e6307..1130b015a8 100644 --- a/packages/preset/agent-preset/README.zh.md +++ b/packages/preset/agent-preset/README.zh.md @@ -47,6 +47,8 @@ kind: "package-reference" 声明行的 `id` 是 Loader 编辑地址;`config.id` 是会话保存的 preset 标识符。子插件可省略行 ID,由 Loader 分配。 +本包还随包出货创造模式通过 `skill-filesystem` 挂载的 `skills/` 目录:`cordis-plugin-development`(简短流程,配合按需读取的 `references/` 与可复制的 `templates/` 组合包)、`editing-cordis-compositions`,以及 `cordis-composition-reference`,后者的 `references/packages.md` 由 `scripts/gen-plugin-packages.ts` 生成并在 `doc-sync` 中做新鲜度门禁。两个流程 skill 让 agent 先读检查结果,再读 `Config.listConfigs` 报告的 `packageDir` 下的包 README,最后读构建后的 `lib/` 或 checkout 源码。每个出货的 `SKILL.md` 作为 `skill` 工具结果渲染后都保持在 8192 字符阈值之下,标准 preset 的工具结果修剪器超过该阈值才会裁剪。 + ## 理解实现 diff --git a/packages/preset/agent-preset/package.json b/packages/preset/agent-preset/package.json index 05c622fbf4..1009a1001b 100644 --- a/packages/preset/agent-preset/package.json +++ b/packages/preset/agent-preset/package.json @@ -37,8 +37,13 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent-preset-registry": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^", + "@types/js-yaml": "^4.0.9", + "js-yaml": "^4.2.0" } } diff --git a/packages/preset/agent-preset/skills/cordis-composition-reference/SKILL.md b/packages/preset/agent-preset/skills/cordis-composition-reference/SKILL.md new file mode 100644 index 0000000000..8fcb9d59b6 --- /dev/null +++ b/packages/preset/agent-preset/skills/cordis-composition-reference/SKILL.md @@ -0,0 +1,29 @@ +--- +name: cordis-composition-reference +description: Use when writing or reading a Cordis bundle patch or agent preset for this harness and you need the Loader YAML dialect (insert, override by id, group, disabled, isolate, !!js) or the list of installable Harness plugin packages and what each provides. +--- + +# Cordis composition reference + +Reference material for bundle patches and preset plugin lists. Procedures live in `cordis-plugin-development` and `editing-cordis-compositions`. + +## Loader patch dialect + +A profile composes an ordered list of patch layers over the bundle entry lists. Each patch is a mapping: + +- `insert: [rows]` appends rows; with an `id` naming an existing `group: true` row, the rows are appended inside that group's `config` list. +- A patch with an `id` and no `insert` targets the existing row with that id. Supplied fields replace the row's fields; `config` is replaced wholesale, never deep-merged, so restate every field the row needs. A truthy `name` asserts the existing plugin name rather than renaming it. +- Non-insert patches without a nonempty `id`, and targets that match no row, are warned about and skipped. + +A row has `id`, `name` (the plugin package specifier; inserted relative paths are anchored beside their patch file), optional `config`, and optional `disabled`, `inject`, `intercept`, and `isolate`. + +- `group: true` with `name: cordis:group` makes `config` a nested entry list and allows patches to insert into it by id. `cordis:include` loads a literal YAML or JSON entry list from `config.path`. +- `disabled` accepts a boolean, null, or a `!!js` expression evaluated against the Loader context at every mount decision. A disabled row omits required `config` unless `group: true` forces activation. +- `!!js` scalars are Loader expressions, never `!js`. Inside `config` they are evaluated after the row's declared injections activate, against that plugin's context (`ctx.`), so `!!js dshHomePath('sessions')` and `!!js "!ctx.get('profileContext')"` are valid. Other row metadata stays literal. +- `isolate` maps service names to `true` or a realm label; a preset plugin that provides a service isolates the provider and all consumers together. Scope controls contributions and event visibility; `isolate` controls service instances. + +`plugin_manager` `list_plugins` reports each row's `enabled` flag and `fiberPhase`; `set_plugin` and `set_bundle` answer `overridden` when a higher-priority layer wins. + +## Installable plugin packages + +`references/packages.md` lists every workspace package that exports a Cordis plugin, grouped by package group, with whether its row takes `config`. Search it with `grep -n ` rather than reading it whole. Plugins outside this list come from installed bundles; `plugin_manager` `list_bundles` names them. diff --git a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md new file mode 100644 index 0000000000..1ce0249ee8 --- /dev/null +++ b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md @@ -0,0 +1,503 @@ +# Loadable Harness plugin packages + +This file is GENERATED from workspace manifests (`scripts/gen-plugin-packages.ts`) and verified fresh by `pnpm run verify-plugin-packages` (part of `doc-sync`); do not edit it by hand. + +Every package below exports a Cordis plugin that a bundle patch can name in a Loader row. `Config` marks packages whose row accepts a `config` mapping; query `Config.listConfigs` through `cordis_inspect_query` (filter by `name`, then query the `entry` id) for the mounted schema. Packages under `experimental` are pre-stable. + +## acp + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-acp` | yes | Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio | + +## api + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-api-account-controller` | no | Expose safe account operations over authenticated Remote | +| `@deepseek-ai/dsh-api-gateway` | yes | Typert Remote Host dispatcher and Client API endpoint | +| `@deepseek-ai/dsh-api-job-controller` | yes | Job Remote observation stream and the reference-counted client job-output service | +| `@deepseek-ai/dsh-api-remotes` | no | Remote BFF assembly for application-selected Host capabilities | +| `@deepseek-ai/dsh-api-session-controller` | yes | Session Remote commands, cold reads, and live control transport | +| `@deepseek-ai/dsh-api-settings-controller` | yes | Remote owner for the configuration surfaces over the settings-domain seams | +| `@deepseek-ai/dsh-api-terminal-controller` | yes | Session-owned interactive terminals with shell discovery, screen recovery and typed Remote control | +| `@deepseek-ai/dsh-api-workspace-controller` | yes | Workspace Remote commands and reconnect-safe state transport | +| `@deepseek-ai/dsh-api-workspace-files` | yes | Workspace file service and Client resource provider: bounded reads, directory listing, and live metadata over the workspaceFiles Remote namespace | + +## attachment + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-attachment-local` | yes | Private content-addressed DSH_HOME attachment storage | + +## boot + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-config-editor` | no | Persist plugin configuration through profile patches and Loader reconciliation | +| `@deepseek-ai/dsh-hmr` | yes | Coordinated module and profile configuration hot reload | +| `@deepseek-ai/dsh-plugin-manager` | yes | Current-profile plugin and bundle management shared by dsh CLI, Web and agent tools | + +## browser-use + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-browser-use` | no | Exclusive named browser-use provider registration | + +## bundle + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-acp-app` | no | The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base | +| `@deepseek-ai/dsh-headless` | yes | The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer | +| `@deepseek-ai/dsh-sdk-app` | yes | The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base | +| `@deepseek-ai/dsh-web-app` | yes | The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line) | + +## client + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-client-connection` | yes | Authenticated RPC transport and generation lifecycle | +| `@deepseek-ai/dsh-client-file-upload` | no | Agent-scoped browser file upload, streaming intake, and staged receipt service | +| `@deepseek-ai/dsh-client-hmr` | yes | Web client graph synchronization and rebuilt-bundle reload transport | +| `@deepseek-ai/dsh-client-locale` | no | Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries | +| `@deepseek-ai/dsh-client-modules` | no | 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 | +| `@deepseek-ai/dsh-client-resources` | no | Unified client resource model: protocol-registered providers turn URL addresses into live values, consumed through the useResource global standard hook | +| `@deepseek-ai/dsh-client-shortcuts` | yes | Application keyboard command registry and physical-key routing | +| `@deepseek-ai/dsh-client-ui-agent-preset` | no | Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor | +| `@deepseek-ai/dsh-client-ui-approval` | no | Approval composer takeover over the scoped Remote Event waterfall | +| `@deepseek-ai/dsh-client-ui-attachment` | no | Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots | +| `@deepseek-ai/dsh-client-ui-brand-official` | no | Official DeepSeek Harness brand occupants for the Web client's sidebar slots | +| `@deepseek-ai/dsh-client-ui-chat` | no | Chat Conversation target, node definitions, renderers, and details surface | +| `@deepseek-ai/dsh-client-ui-commands` | no | Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry | +| `@deepseek-ai/dsh-client-ui-conversation` | no | Target-neutral Conversation assembly, shell, composer, queue, and view navigation | +| `@deepseek-ai/dsh-client-ui-deliverables` | no | Changed-files card with per-file comparison tabs, delivery cards, and clickable final-response file references for Web | +| `@deepseek-ai/dsh-client-ui-directory-picker-browse` | no | In-app directory browsing surface: the workspace directory-flow owner rendering the host's listing and creation primitives | +| `@deepseek-ai/dsh-client-ui-directory-picker-native` | no | Native directory-picker surface: the renderless workspace directory-flow occupant driving the local Desktop or Host OS chooser | +| `@deepseek-ai/dsh-client-ui-goal` | no | Session goal surface: GoalBar docked above the composer, read from the goal session projection | +| `@deepseek-ai/dsh-client-ui-input-trigger` | no | Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources | +| `@deepseek-ai/dsh-client-ui-jobs` | no | Session-header background-job list with on-demand streaming record panels | +| `@deepseek-ai/dsh-client-ui-layout` | no | Shell plugin: three-column AppFrame with drag handles, ctx.layout viewing-state service (navigation + panels) | +| `@deepseek-ai/dsh-client-ui-message-feedback` | no | The Web feedback surface: per-message Like/Dislike in the assistant-message action strip and the feedback dialog behind both ratings and /feedback, backed by the messageFeedback and sessionFeedback Host Remotes | +| `@deepseek-ai/dsh-client-ui-model-selection` | no | Model selection over the shared model catalog, Session projection, and session.selectModel | +| `@deepseek-ai/dsh-client-ui-open-in-app` | no | Web "Open In..." controls: the Session-header split button opening the workspace directory in an installed application, and the document preview's default-application controls for one file | +| `@deepseek-ai/dsh-client-ui-permission-presets` | no | Permission surfaces: a new-session default in General settings and a current-session /permission popup over the permissions projection | +| `@deepseek-ai/dsh-client-ui-plan` | no | Plan mode controls, persistent transcript plan cards, and sidebar Markdown previews | +| `@deepseek-ai/dsh-client-ui-plugin-manager` | no | Plugin management for the dsh web client: the sidebar Plugins panel installs, enables, disables, retries, and composes installed plugin packages | +| `@deepseek-ai/dsh-client-ui-reference` | no | Unified Web @file and @session reference source | +| `@deepseek-ai/dsh-client-ui-renderer` | no | Browser UI renderer: React slot bindings, ctx.uiRenderer, and the assembled application root | +| `@deepseek-ai/dsh-client-ui-schedule` | no | Read-only active Schedule catalog in the Web Session header | +| `@deepseek-ai/dsh-client-ui-session` | no | Session Controller adapter for React and session-scoped slots | +| `@deepseek-ai/dsh-client-ui-settings` | no | Settings domain base plugin: shared configuration forms and the canonical settings slot-type contract | +| `@deepseek-ai/dsh-client-ui-settings-account` | yes | Manage DeepSeek login and open Platform billing pages | +| `@deepseek-ai/dsh-client-ui-settings-agent-loop` | no | Settings page of the agent loop on the dsh web client's Plugins page: the parallel tool-call cap of the agent-loop namespace | +| `@deepseek-ai/dsh-client-ui-settings-general` | no | Settings ownerless-copy and product onboarding plugin: the General section, shell trigger/header chrome content, settings dictionaries, and the versioned welcome notice | +| `@deepseek-ai/dsh-client-ui-settings-models` | yes | Models settings and shared product-onboarding dialogs over existing settings and credential joins | +| `@deepseek-ai/dsh-client-ui-settings-plugin-inventory` | no | Read-only Cordis Loader inventory tab in Web Plugins settings | +| `@deepseek-ai/dsh-client-ui-settings-plugins` | no | Built-in plugins settings section for the dsh web client: the Settings navigation entry and the tab chrome feature-owned tabs register into | +| `@deepseek-ai/dsh-client-ui-settings-shell` | no | Settings page of the shell executor on the dsh web client's Plugins page: the command timeout and the per-stream output cap of the shell namespace | +| `@deepseek-ai/dsh-client-ui-settings-subagent` | no | Settings page of Subagent delegation on the dsh web client's Plugins page: recursion depth, parallel capacity, and the models agents may choose for subagents | +| `@deepseek-ai/dsh-client-ui-settings-web-search` | no | Settings page of the DeepSeek web-search provider on the dsh web client's Plugins page: its API key, endpoint, and per-request search budget | +| `@deepseek-ai/dsh-client-ui-shortcuts` | no | Keyboard shortcut reference, recording, and local preference editing | +| `@deepseek-ai/dsh-client-ui-sidebar` | no | Sidebar plugin: session multi-level tree, search, grouping, state dots | +| `@deepseek-ai/dsh-client-ui-sidebar-browser` | no | Sandboxed Web browser tabs for the right Sidebar | +| `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` | yes | Extensible Sidebar previews for Office documents, spreadsheets, Markdown, code, images, PDF, HTML, and plain text | +| `@deepseek-ai/dsh-client-ui-sidebar-files` | no | Workspace file tree tab type for the right Sidebar: lazy directory listing over the workspaceFiles Remote namespace, opening files into the Sidebar | +| `@deepseek-ai/dsh-client-ui-sidebar-right` | no | Right Sidebar: the docking surface's session-bound state, its panel and header expand control, and the navigation service over it | +| `@deepseek-ai/dsh-client-ui-sidebar-terminal` | no | Interactive shell tabs for the right Sidebar | +| `@deepseek-ai/dsh-client-ui-skill` | no | Web skill references and the dedicated skill tool row | +| `@deepseek-ai/dsh-client-ui-subagent` | no | Subagent conversation catalog, continuation routing UI, and '@' reference source | +| `@deepseek-ai/dsh-client-ui-theme` | yes | Theme plugin: Host bootstrap for the pre-plugin palette; DOM-free ThemeRuntime for light/dark/system state; --dsw-* token styles and Appearance settings row | +| `@deepseek-ai/dsh-client-ui-tool` | no | Client Tool call-tree renderer and keyed per-tool presentation slot | +| `@deepseek-ai/dsh-client-ui-trajectory` | no | Trajectory event ledger with an interactive timing overview: pure-consumer plugin registering into the conversation ViewMap (no service) | +| `@deepseek-ai/dsh-client-ui-user-questions` | no | Web ask_user_question composer takeover and plan-review presentation UI | +| `@deepseek-ai/dsh-client-ui-workflow-run` | no | Durable workflow-run Conversation Node and nested member disclosure for dsh web | +| `@deepseek-ai/dsh-client-ui-workspace` | no | Workspace picker plugin: one WorkspacePicker registered into the sidebar and empty-state workspace slots | + +## compaction + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-command-compact` | no | Human-facing slash command for explicit session compaction | +| `@deepseek-ai/dsh-compaction-basic` | yes | Token-meter-driven compaction policy and LLM summarization backend for the DeepSeek Harness | +| `@deepseek-ai/dsh-compaction-image-offload` | no | Durable image offload for image-capable routes: replace over-budget request images with placeholders and retry | +| `@deepseek-ai/dsh-compaction-tool-result-pruner` | yes | Replay-safe model-free head/middle/tail pruning for tool-result surface nodes | + +## computer-use + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-computer-use` | no | Exclusive named computer-use provider registration | + +## context + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-agent-instructions` | yes | Workspace context loader for AGENTS.md/CLAUDE.md instruction files | +| `@deepseek-ai/dsh-file-reference-local` | yes | Local-filesystem ctx.fileReferences provider with bounded fuzzy indexes | +| `@deepseek-ai/dsh-session-reference` | yes | Cross-session snapshot references and durable untrusted model context (ctx.sessionReferenceResolver) | +| `@deepseek-ai/dsh-time-context` | yes | Opt-in durable per-step context with the current time and elapsed time | +| `@deepseek-ai/dsh-tmux-context` | yes | Opt-in durable per-step context with this agent's tmux pane and window location | + +## core + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-agent` | no | Agent interface, registry, initiator scope, and event vocabulary for the DeepSeek Harness | +| `@deepseek-ai/dsh-agent-default-model` | yes | Default model selection shared by Agent entry points | +| `@deepseek-ai/dsh-agent-loop` | yes | The concrete agent loop plugin for the DeepSeek Harness | +| `@deepseek-ai/dsh-agent-tool-presentation` | yes | Agent-plane presentation selector: composes one agent's tools as PTC mode, native, or both | +| `@deepseek-ai/dsh-session` | no | Event-sourced session store for the DeepSeek Harness | +| `@deepseek-ai/dsh-system-prompt` | yes | System prompt assembly registry for the DeepSeek Harness | +| `@deepseek-ai/dsh-tools` | yes | Tool registry and execution pipeline for the DeepSeek Harness | + +## credentials + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-authorization` | no | Authorization seam (ctx.authorization): plugin-owned flows that obtain a credential through a conversation with the human | +| `@deepseek-ai/dsh-credentials-local` | yes | File-backed credentials provider ($DSH_HOME/.env under the live process environment) for the DeepSeek Harness | +| `@deepseek-ai/dsh-deepseek-account-platform` | yes | Authorize DeepSeek accounts through browser PKCE | + +## deliverables + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-tool-present` | yes | Explicit workspace file delivery declarations for the DeepSeek Harness | +| `@deepseek-ai/dsh-workspace-changes` | yes | Per-turn workspace file changes recorded from git working-tree snapshots and whole-file captures, with per-file comparisons, for the DeepSeek Harness | + +## document + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-office-to-pdf` | yes | Shared Office-to-PDF conversion with bounded queues and caching | + +## experimental + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-experimental-agent-team` | yes | Implicit-root Agent Teams roster, durable peer mailbox, and shared task DAG | +| `@deepseek-ai/dsh-experimental-api-speech-to-text` | yes | Authenticated experimental speech transcription for browser clients | +| `@deepseek-ai/dsh-experimental-auto-review` | no | Per-tool LLM authorization review for the DeepSeek Harness Auto permission preset | +| `@deepseek-ai/dsh-experimental-browser-use-chrome-devtools-mcp` | yes | Experimental per-Session Chromium browser tools through chrome-devtools-mcp | +| `@deepseek-ai/dsh-experimental-browser-use-playwright-mcp` | yes | Experimental per-Session Chromium browser tools through @playwright/mcp | +| `@deepseek-ai/dsh-experimental-browser-use-stagehand-native` | yes | Experimental Stagehand browser tools with separately configured native models | +| `@deepseek-ai/dsh-experimental-client-ui-agent-team` | no | Web Agent Teams roster, task board, and teammate navigation | +| `@deepseek-ai/dsh-experimental-client-ui-voice-input` | no | Record speech and insert editable text into the conversation draft | +| `@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp` | yes | Experimental computer use through an installed Cua Driver MCP executable | +| `@deepseek-ai/dsh-experimental-computer-use-cua-driver-native` | no | Experimental computer-use provider embedding the Cua Driver native npm SDK | +| `@deepseek-ai/dsh-experimental-inspector` | yes | Experimental cross-realm CDP hub for Host debugging and Client Runtime inspection | +| `@deepseek-ai/dsh-experimental-ptc-runtime-python` | yes | CPython subprocess implementation of the DeepSeek Harness PTC execution seam | +| `@deepseek-ai/dsh-experimental-speech-to-text` | yes | Experimental speech recognition with independently selectable providers | +| `@deepseek-ai/dsh-experimental-speech-to-text-sensevoice` | yes | Local SenseVoice ONNX transcription with a managed sherpa-onnx process | +| `@deepseek-ai/dsh-experimental-tool-agent-team` | yes | Scoped model-facing Agent Teams tools over ctx.agentTeams | + +## extensions + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-client-ui-cordis` | no | Cordis dynamic-plugin definition card: the keyed cordis_define tool row with its run/stop switch | +| `@deepseek-ai/dsh-cordis-client-runner` | no | Browser half of dynamic dual-half plugin packages: event subscription, closure evaluation, guard facade, and loader entries | +| `@deepseek-ai/dsh-cordis-host-runner` | yes | Dynamic package definition registry, host-half sandbox lifecycle, and invoke handler table for model-mounted dual-half packages | +| `@deepseek-ai/dsh-tool-cordis` | no | Read-only runtime API inspection for Harness plugin development | + +## feedback + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-command-feedback` | no | Log-only session feedback: the record event, the sessionFeedback Host Remote, and the human-facing slash command | +| `@deepseek-ai/dsh-message-feedback` | yes | Canonical Session-log ratings and notes for finalized assistant messages | + +## fs + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-fs-local` | yes | Local-filesystem implementation of the DeepSeek Harness filesystem seam (ctx.fs) | +| `@deepseek-ai/dsh-fs-observation-policy` | no | File-context policy plugin for the DeepSeek Harness — observed-state, read-before-edit, and version-guarded write/edit added over the ctx.fs provider seam through the fs/* event gate (no service API) | +| `@deepseek-ai/dsh-fs-sandbox` | yes | Sandbox-enforcing implementation of the DeepSeek Harness filesystem seam: fences write/edit by the per-call sandbox mode (read-only denies mutation, workspace-write contains it to the workspace + temp roots) while reads pass through | +| `@deepseek-ai/dsh-tool-fs` | yes | Model-facing filesystem tools (read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs) | +| `@deepseek-ai/dsh-tool-fs-search` | yes | Model-facing filesystem discovery tools (glob, grep) backed by the packaged ripgrep binary (@vscode/ripgrep) | +| `@deepseek-ai/dsh-tool-str-replace-editor` | yes | Model-facing view, create, literal replace, and line insert tool over the Harness filesystem service | + +## goal + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-command-goal` | no | Human-facing slash command for persisted same-session goals | +| `@deepseek-ai/dsh-goal` | yes | Event-sourced same-session goal state and lifecycle service for the DeepSeek Harness | +| `@deepseek-ai/dsh-goal-round-driver` | no | Race-fenced same-session goal-round driver | +| `@deepseek-ai/dsh-tool-goal` | yes | Model-facing same-session goal tools with execution-time authority checks | + +## guard + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-repeat-tool-reminder` | yes | Repeat-tool-call guard plugin: advisory reminders when an agent loops on identical tool calls | +| `@deepseek-ai/dsh-tool-call-timeout-policy` | no | Tool-call timeout policy: a tools/execute wrapper that arms a per-tool deadline on exec.signal and returns TOOL_TIMEOUT when it wins | + +## hooks + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-hooks-claude-code` | yes | Bridge plugin: run a Claude Code hooks.json / settings hook config on the DeepSeek Harness interception seams | +| `@deepseek-ai/dsh-hooks-codex` | yes | Bridge plugin: run a Codex hooks.json hook config on the DeepSeek Harness interception seams | + +## host + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-host-directory-picker-auto` | no | Adaptive chooser of the directory-picker seam: resolves the host situation at boot and mounts the native or browse backend for the DeepSeek Harness web GUI host | +| `@deepseek-ai/dsh-host-directory-picker-browse` | yes | In-app browsing backend of the directory-picker seam (listing/creation primitives over the host filesystem) | +| `@deepseek-ai/dsh-host-directory-picker-native` | no | Native-OS-chooser backend of the directory-picker seam for the DeepSeek Harness web GUI host | +| `@deepseek-ai/dsh-host-frontend-static` | yes | SPA dist server for the Web shell: owns the webserver fallback seat, serving explicit index entries and static assets with traversal rejection and 404 misses | +| `@deepseek-ai/dsh-host-open-in-app` | yes | Host half of open-in-app: resolved application catalog, icons, and the launch endpoint as three webServer routes | +| `@deepseek-ai/dsh-host-plugin-inventory` | no | Read-only Remote projection of current Cordis Loader plugin state | +| `@deepseek-ai/dsh-host-product-telemetry-otel` | yes | Explicit product usage events exported through OpenTelemetry HTTP logs | +| `@deepseek-ai/dsh-host-webserver` | yes | Web route-registration plugin: HTTP and upgrade routes, index transform taps, and static dist fallback; knows no harness concepts | + +## interaction + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-commands` | no | Plugin-owned human command registry for DeepSeek Harness UIs | +| `@deepseek-ai/dsh-permission-presets` | yes | User-facing permission presets (ctx.permissionPresets) for the DeepSeek Harness: one product-level Permissions select bundling the sandbox-mode and approval-policy knobs, written through to their own session events | +| `@deepseek-ai/dsh-tool-ask-user` | no | Model-facing ask_user_question tool over the ctx.userQuestions seam | +| `@deepseek-ai/dsh-user-approval` | yes | User-approval seam (ctx.approval) for the DeepSeek Harness: one-shot permission decisions dispatched to composed answerers over the approval/request waterfall, fail-closed by default | +| `@deepseek-ai/dsh-user-questions` | no | Abstract user-questions seam (ctx.userQuestions) for asking the human during agent runs | + +## jobs + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-jobs-local` | yes | Process-local implementation of the DeepSeek Harness background job registry seam | +| `@deepseek-ai/dsh-tool-jobs` | yes | Model-facing background job control tools (job_output, job_list, job_kill) over the ctx.jobs registry | + +## llm + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-deepseek-llm-api-extensions` | no | Additive request-field registry for the official DeepSeek LLM API adapter | +| `@deepseek-ai/dsh-llm` | no | Provider-neutral LLM service interface for the DeepSeek Harness | +| `@deepseek-ai/dsh-llm-deepseek` | yes | DeepSeek Messages adapter | +| `@deepseek-ai/dsh-llm-pi-ai` | yes | pi-ai-backed DeepSeek adapter for the DeepSeek Harness LLM seam (design-verification twin of dsh-llm-deepseek) | +| `@deepseek-ai/dsh-llm-retry` | yes | Provider-routed LLM request retry policy for the DeepSeek Harness | +| `@deepseek-ai/dsh-plugin-package-inventory-deepseek` | yes | Active Loader-backed plugin package inventory for official DeepSeek LLM API requests | +| `@deepseek-ai/dsh-token-meter` | yes | Replay-aware token measurement service (ctx.tokenMeter) for the DeepSeek Harness | + +## lsp + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-lsp` | no | Abstract LSP capability seam (ctx.lsp) for the DeepSeek Harness — language-server provider registry keyed by branded id and extension mapping, order-independent per-query selection, normalized definition/references/implementation/hover requests and results, and the LspError taxonomy | +| `@deepseek-ai/dsh-lsp-stdio` | yes | Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open goToDefinition/findReferences/goToImplementation/hover queries in the host filesystem namespace | +| `@deepseek-ai/dsh-tool-lsp` | yes | Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with goToDefinition/findReferences/goToImplementation/hover operations, one-based UTF-16 cursor coordinates, bounded location rendering, and hover normalization | + +## mcp + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-mcp-client` | yes | MCP client bridge: connects to MCP servers and registers their tools on ctx.tools | +| `@deepseek-ai/dsh-mcp-resources` | no | Scoped MCP resource discovery and reading through shared model tools | + +## plan + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-plan-mode` | yes | Logged per-agent plan mode with deployment guidance, a direct slash command, and a user-reviewed exit | + +## preset + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-agent-preset` | yes | Declare an Agent capability composition in Cordis YAML | +| `@deepseek-ai/dsh-agent-preset-registry` | yes | Declarative Agent preset registry and profile-backed editing | +| `@deepseek-ai/dsh-persona` | yes | Composition-authored deployment persona section for the DeepSeek Harness | + +## ptc-runtime + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-ptc-runtime-node` | yes | Sandboxed Node process implementation of the DeepSeek Harness PTC execution capability | + +## runtime-diagnostics + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-invariants` | yes | Registry service for package-owned DeepSeek Harness runtime invariants | + +## sandbox + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-sandbox-local` | yes | Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, macOS Seatbelt, or the Windows ACL restricted-token runner — functionally probed, fail-closed | +| `@deepseek-ai/dsh-sandbox-policy` | yes | Per-call sandbox policy resolver and current model context: deployment fallbacks plus each session's mode and workspace root, shared by every enforcing capability family | + +## schedule + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-schedule` | no | Agent-scoped durable after, at, and fixed-rate reminders over the session event log | + +## sdk + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-sdk-jsonrpc-server` | yes | Stdio JSON-RPC server plugin for out-of-process DeepSeek Harness SDK clients | + +## session + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-session-checkpoint-policy` | no | Semantic session durability checkpoints before model requests and tool side effects | +| `@deepseek-ai/dsh-session-log-deepseek` | yes | Incremental lossless session-log request extension for the official DeepSeek LLM API | +| `@deepseek-ai/dsh-session-persistence-jsonl` | yes | JSONL durable session persistence backend for the DeepSeek Harness | +| `@deepseek-ai/dsh-session-projection` | no | Session-projection seam: the merge-extensible projection type table, the provider contract, and the ctx.sessionProjections registry serving whole current values of log-derived per-session state | +| `@deepseek-ai/dsh-session-projection-cache` | yes | Persisted projection cache (ctx.sessionProjectionCache): durable per-session checkpoint records on the session_projcache storage domain (per-record layout), throttled write-behind, and the cached listing read | +| `@deepseek-ai/dsh-session-stats` | no | Whole-log conversation counts and wall times projection (sessionStats) for the DeepSeek Harness | +| `@deepseek-ai/dsh-session-telemetry-otel` | yes | OpenTelemetry backend for the DeepSeek Harness telemetry seam: hands captured session records to the OTel JS SDK's log pipeline | +| `@deepseek-ai/dsh-session-title` | yes | Log-backed session title service and provider registry for the DeepSeek Harness | +| `@deepseek-ai/dsh-session-title-all-prompts-llm` | yes | All-user-messages LLM provider plugin for DeepSeek Harness session titles | +| `@deepseek-ai/dsh-session-title-first-prompt-llm` | yes | First-message LLM provider plugin for DeepSeek Harness session titles | +| `@deepseek-ai/dsh-session-turn-outline` | no | Whole-log turn outline projection (turnOutline) for the DeepSeek Harness | + +## session-query + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-session-log-export` | yes | Web Session-log export command and shared download dialog | +| `@deepseek-ai/dsh-session-query-sqlite` | yes | Concrete ctx.sessionQuery backend with SQLite FTS5 search | +| `@deepseek-ai/dsh-tool-session-query` | yes | Workspace-authorized model-facing session history search, trace, and event read tools | + +## settings + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-settings` | no | Abstract user-settings seam (ctx.settings) for the DeepSeek Harness | + +## shell + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-bash-local` | yes | Local-subprocess implementation of the DeepSeek Harness bash executor seam | +| `@deepseek-ai/dsh-bash-sandbox` | yes | Sandbox-consuming implementation of the DeepSeek Harness bash executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts) | +| `@deepseek-ai/dsh-pwsh-local` | yes | Local PowerShell implementation of the DeepSeek Harness bash executor seam | +| `@deepseek-ai/dsh-pwsh-sandbox` | yes | Sandbox-consuming implementation of the DeepSeek Harness PowerShell executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts) | +| `@deepseek-ai/dsh-shell-env` | yes | Tool-independent managed DSH_* shell environment registry | +| `@deepseek-ai/dsh-tool-bash` | yes | Model-facing bash tool with optional generic background-job and sandbox-escalation support | +| `@deepseek-ai/dsh-tool-bash-persistent` | yes | Model-facing owner-scoped persistent Bash tool backed by the Harness PTY service | +| `@deepseek-ai/dsh-tool-pwsh` | yes | Model-facing pwsh tool over the bash executor seam | +| `@deepseek-ai/dsh-tool-pwsh-persistent` | yes | Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service | + +## skill + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-skill` | yes | Agent skill provider registry for the DeepSeek Harness | +| `@deepseek-ai/dsh-skill-badge` | no | Bundled dsh badge skill provider for DeepSeek Harness | +| `@deepseek-ai/dsh-skill-filesystem` | yes | Local filesystem skill provider for the DeepSeek Harness | +| `@deepseek-ai/dsh-skill-office` | yes | Bundled Word, PowerPoint, and Excel workflows and structural checks | +| `@deepseek-ai/dsh-tool-skill` | yes | Model-facing skill loading tool for the DeepSeek Harness | +| `@deepseek-ai/dsh-tool-workspace-dependencies` | yes | The load_workspace_dependencies tool: absolute paths into a bundled Python, Node.js, and pnpm payload | + +## spill + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-spill-local` | yes | Local-filesystem implementation of the DeepSeek Harness spill storage seam (private session-scoped files) | +| `@deepseek-ai/dsh-spill-policy` | yes | Tool-result spill policy for the DeepSeek Harness — replaces oversized plain-text tool results with a retained preview plus a spill-file path (no service API) | + +## ssh + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-fs-ssh` | no | Filesystem provider over the shared POSIX SSH helper | +| `@deepseek-ai/dsh-sandbox-ssh` | no | Remote POSIX sandbox argv provider over the shared SSH helper | +| `@deepseek-ai/dsh-ssh` | yes | Shared OpenSSH connection and versioned POSIX remote helper | +| `@deepseek-ai/dsh-subprocess-ssh` | no | Subprocess and terminal provider over the shared POSIX SSH helper | + +## storage + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-storage` | no | Storage hub (ctx.storage): named backend registry plus mounted data-form facilities for the DeepSeek Harness | +| `@deepseek-ai/dsh-storage-domain` | yes | Domain data form (ctx.storage.domain): schema-validated, event-emitting KV domains over storage backends for the DeepSeek Harness | +| `@deepseek-ai/dsh-storage-json` | yes | JSON file KV storage backend for the DeepSeek Harness storage hub | +| `@deepseek-ai/dsh-storage-sqlite` | yes | SQLite storage backend (kv facet) for the DeepSeek Harness storage hub | + +## subagent + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-subagent` | yes | Abstract subagent seam (ctx.subagents): named-provider registry for delegating to child agents | +| `@deepseek-ai/dsh-subagent-acp` | yes | Out-of-process ACP subagent backend: drives a child agent in a spawned subprocess over the Agent Client Protocol | +| `@deepseek-ai/dsh-subagent-claude-code` | yes | One-shot Claude Code subagent provider over the official Agent SDK | +| `@deepseek-ai/dsh-subagent-codex` | yes | One-shot Codex subagent provider over the official app-server protocol | +| `@deepseek-ai/dsh-subagent-dsh-sdk` | yes | Out-of-process SDK subagent backend: drives a child DeepSeek Harness runtime subprocess over stdio JSON-RPC through the TypeScript SDK client | +| `@deepseek-ai/dsh-subagent-fork-in-process` | yes | In-process fork subagent backend: runs a child agent seeded with a prefix of the parent's log | +| `@deepseek-ai/dsh-subagent-spawn-in-process` | yes | In-process spawn subagent backend: runs a fresh child agent on ctx.agents | +| `@deepseek-ai/dsh-tool-subagent` | yes | Model-facing subagent delegation tool over the ctx.subagents seam | +| `@deepseek-ai/dsh-tool-subagent-control` | no | Globally named send_message, interrupt_agent, and list_agents tools over ctx.subagents continuations | + +## subprocess + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-subprocess-local` | no | Local-subprocess implementation of the DeepSeek Harness subprocess seam | + +## terminal + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-terminal` | no | Persistent PTY session seam for the DeepSeek Harness — owner-scoped ids, backend registry, interactive sends, reads, signals, and awaited cleanup | +| `@deepseek-ai/dsh-terminal-bash` | yes | Persistent shell PTY backend over the DeepSeek Harness subprocess terminal primitive | +| `@deepseek-ai/dsh-tool-terminal` | yes | Six model-facing persistent PTY tools with owner isolation and generic background-job integration | + +## test-support + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-llm-replay` | yes | Replay LLM plugin: short-circuits llm/stream with model chunks reconstructed from a recorded session JSONL (keyless snapshot tests) | + +## todo + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-tool-todo` | yes | Model-facing todo_write tool over the DeepSeek Harness event-sourced session log | + +## typert + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-typert-loader` | yes | Loader integration for generated Typert package contributions | + +## web + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-tool-web` | yes | Model-facing web tools (web_search, web_fetch) over the DeepSeek Harness web capability seam (ctx.web) | +| `@deepseek-ai/dsh-web` | yes | Abstract web access capability seam (ctx.web) for the DeepSeek Harness — search/fetch provider registry, registration-order-independent selection, request/result vocabulary, and the WebError taxonomy | +| `@deepseek-ai/dsh-web-fetch-http` | yes | Anonymous public HTTP(S) fetch provider for the DeepSeek Harness web capability seam (ctx.web) | +| `@deepseek-ai/dsh-web-search-deepseek` | yes | DeepSeek-backed search provider (native web_search via the Anthropic-compatible API) for the DeepSeek Harness web capability seam (ctx.web) | +| `@deepseek-ai/dsh-web-search-exa` | yes | Exa-backed search provider for the DeepSeek Harness web capability seam (ctx.web) | +| `@deepseek-ai/dsh-web-search-perplexity` | yes | Perplexity-backed search provider for the DeepSeek Harness web capability seam (ctx.web) | + +## webhook + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-webhook` | no | Fire-and-forget webhook rule runtime that creates Workspace-backed DeepSeek Harness Sessions | +| `@deepseek-ai/dsh-webhook-github` | yes | Signed GitHub HTTP webhook adapter for the DeepSeek Harness webhook runtime | + +## workflow + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-tool-ralph` | yes | Model-facing fresh-agent Ralph loop over the workflow and subagent seams | +| `@deepseek-ai/dsh-tool-workflow` | yes | Model-facing workflow tool: run a JavaScript orchestration script over ctx.workflowEngine | +| `@deepseek-ai/dsh-workflow-ptc` | yes | Workflow orchestration in the shared sandboxed Node PTC runtime | + +## workspace + +| Package | Config | Description | +|---|---|---| +| `@deepseek-ai/dsh-workspace` | no | Workspace entity registry (ctx.workspaceRegistry): durable workspace records with validated session attachment over the domain data form for the DeepSeek Harness | diff --git a/packages/preset/agent-preset/skills/cordis-plugin-development/SKILL.md b/packages/preset/agent-preset/skills/cordis-plugin-development/SKILL.md index 67aadc1cd7..ffb57b0f7b 100644 --- a/packages/preset/agent-preset/skills/cordis-plugin-development/SKILL.md +++ b/packages/preset/agent-preset/skills/cordis-plugin-development/SKILL.md @@ -5,104 +5,34 @@ description: Use when authoring, installing, configuring, or debugging persisten # Persistent Harness plugins -Use ordinary workspace files to author a bundle, then `plugin_manager install_bundle` to install it in the current profile. Changes affect every session in that profile and survive restart. Load `editing-cordis-compositions` for agent preset changes. +Use ordinary workspace files to author a bundle, then `plugin_manager` with `action: install_bundle` and the absolute package directory as `target` to install it in the current profile. Changes affect every session in that profile and survive restart. Load `editing-cordis-compositions` for agent preset changes. ## Deliver a working plugin first 1. Resolve the requested result and destination. An unspecified visual destination is the current Harness Web UI; a standalone image or HTML file does not complete such a request. Choose reasonable visual details and implement a small first version; install it before visual refinement. -2. Discover only the APIs needed for that version: `cordis_inspect_list`, then targeted `cordis_inspect_query` calls. For UI, query Client `Slots.listSubTree` and the selected slot's registration options and props. Treat the recipes below and returned API declarations as the supported implementation path. Once the chosen slot and registration API are known, write the plugin. Before the first installation, resolve missing declarations through inspection; do not re-check these recipes by reading Loader, manifest-parser, package-manager, React, or slot implementation source. Source-level diagnosis starts from a concrete installation or runtime failure. -3. The first files you write are the installable package, patch, and required Host/Client files in one workspace directory. Check JavaScript syntax and the manifest, then install it. Before that first installation, do not create preview HTML, mock shells, design variants, screenshot scripts, or rasterizer tooling. Use the installed plugin itself as the first preview. +2. Discover only the APIs needed for that version: `cordis_inspect_list`, then targeted `cordis_inspect_query` calls. For UI, query Client `Slots.listSubTree` and the selected slot's registration options and props. Once the chosen slot and registration API are known, write the plugin. +3. Read the matching template under `templates/` with the file-read tool and write its copies into one workspace directory, or write the installable package, patch, and required Host/Client files there yourself. Check JavaScript syntax and the manifest, then install it. Before that first installation, do not create preview HTML, mock shells, design variants, screenshot scripts, or rasterizer tooling. Use the installed plugin itself as the first preview. 4. Read the installation result. After `application: applied`, exercise the capability or inspect the live Client registration. Use the connected page for visual verification when browser control is available. State any verification limitation explicitly; installation and slot registration alone do not establish what the user can see. 5. Fix observed defects in the same plugin. When the requested result works, finish with its location and verification status. Do not continue speculative visual variants, optional features, or a new mock preview. Close any task list you created. -## Package and install +## Knowledge sources, in order -A bundle declares `dsh.bundle.patch` in `package.json`. Its YAML patch inserts plugin entries. Give the package and rows unique names; use the Loader's existing YAML syntax, including `!!js` where expressions are needed. Read an existing patch before editing it: a matching override replaces the complete config. +1. Inspection: `cordis_inspect_query` answers exact Service methods and Event modes (`Service`, `Event`), a mounted plugin's Config JSON Schema (`Config.listConfigs`: filter the paged directory by `name`, then query the `entry` id), the Tools this Agent can call (`Tool`), and live Client Slots and theme tokens (`Slots`, `Theme`). +2. Package documentation: `Config.listConfigs` with `name` set to the package finds its entries; querying one `entry` returns its `packageDir`, the resolved package directory. Read `/README.md`. Bundled packages resolve from the dsh installation and profile-installed bundles from the profile, so never guess the path from `$DSH_PROFILE_DIR`. +3. Source: installed packages ship built `lib/index.js` and `lib/types/**/*.d.ts` with JSDoc under that same `packageDir`, not `src/`; a source checkout of DSH has `packages///src`. Read them when inspection and the README leave a question open, and start source-level diagnosis from a concrete installation or runtime failure. -For a simple drawing, prefer a slot with allocated space, such as `conversation.composer.dock` when available. Keep the first version within that slot’s flow; do not plan a motion path around host controls. This minimal package needs no dependencies, install scripts, or build tool: +`DSH_PROFILE` (profile name) and `DSH_PROFILE_DIR` (its directory, whose `node_modules` holds only profile-installed bundles) are set in every shell call of a profile-launched Harness and absent when the Harness was booted without a profile. Bash reads them as `$DSH_PROFILE`; PowerShell, which the Windows preset uses, reads them as `$env:DSH_PROFILE`. With `dsh` on the PATH, `dsh --profile "$DSH_PROFILE" --dump-config` prints the composed profile. -```json -{ - "name": "@local/my-decoration", - "version": "1.0.0", - "private": true, - "type": "module", - "exports": { ".": "./index.js", "./client": "./client.js" }, - "dsh": { - "bundle": { "patch": "./cordis.patch.yml" }, - "client": { - "platform": "web", - "immediately": true, - "inject": ["@deepseek-ai/dsh-client-ui-conversation"] - } - } -} -``` +## Read next -`index.js` exports `export function apply() {}`. A Host plugin with behavior exports either a service class as default or named `apply`, `inject`, and optional `Config`; do not mix these forms. The bundle's `cordis.patch.yml`: +The files below live in this skill's base directory, which the `skill` tool reported, and the table is the complete list: do not enumerate that directory. In every deployment, including a source checkout, read these files with the file-read tool, write copies into the workspace with the file-write tool, and verify a copy by reading it back. In Desktop the directory sits inside `app.asar`, which only the Host process's own file reads can open; shell commands (`ls`, `cat`, `cp`, `cmp`), the glob and search tools (they run a native ripgrep process), `node`, and pnpm all fail on it. Never install or syntax-check a template in place; copy its contents into the workspace first. -```yaml -- insert: - - id: my-decoration - name: '@local/my-decoration' -``` - -Call `plugin_manager` with `action: install_bundle` and the absolute package directory as `target`. It performs package installation and bundle selection; do not reproduce those steps with shell commands. Only pass `approvedBuilds` after the user explicitly approves the reported pending build scripts. Preserve returned failures and pending states; report success only after observing the requested capability. - -Use `list_plugins` or `list_bundles` to obtain exact identifiers for existing installations. `set_plugin` and `set_bundle` toggle them; `remove_bundle` removes a bundle. Inspect saved-state and activation outcomes separately: `failed` requires diagnosis, `overridden` means a higher-priority layer wins, and `restart-required` means the change is not live. Installing a new bundle can activate through HMR; replacing an installed package requires restart to load a fresh JavaScript module generation. Do not infer updated browser code from an unchanged slot id. - -## Client implementation - -The browser artifact registers a lazy factory whose id equals the package name. React comes from the browser module table; no duplicate React installation, CDN script, or UMD search is needed. For compiled sources, use the deployment's Client build tooling to emit this format; declare non-baseline runtime imports in `dsh.client.external`. - -This `client.js` example uses `conversation.composer.dock` for a small drawing below the composer. Replace the artwork with the requested drawing. Follow the selected slot's props and options. Do not read another plugin's DOM, stylesheet, or component source to estimate placement; choose a slot that already allocates space. Use `shell.overlay` only when the request needs an overlay and its placement is known. - -```js -window.__ModuleLoader__.load({ - id: '@local/my-decoration', - factory(require) { - const React = require('react'); - const h = React.createElement; - function Decoration() { - return h('svg', { - viewBox: '0 0 64 64', width: 48, height: 48, - 'aria-hidden': true, - style: { display: 'block', pointerEvents: 'none' }, - }, h('circle', { cx: 32, cy: 32, r: 24, fill: '#247bbf' })); - } - return { - inject: ['slots'], - apply(ctx) { - ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({ - name: 'conversation.composer.dock', id: 'my-decoration', order: 5, - }, Decoration)); - }, - }; - }, -}); -``` - -Keep factories free of side effects. Register styles, timers, listeners and other resources inside `apply` with `ctx.effect`/`ctx.on` and return their cleanup functions. Component-local styles can render as React elements so unmounting removes them. Verify disposal for resources you add. Inherit the host theme for containers and controls; artwork may use its own colors. Route visible UI text through the Client locale service. Do not replace the app root or append a second application to `document.body`. - -## Verify in the available environment - -Prefer the authenticated page already connected to Harness. Do not launch a separate browser from shell commands, change `HOME`, inspect personal browser profiles, search for authentication tokens, or alter keychains to obtain a screenshot. For a visual-only request without browser control, limit verification to JavaScript syntax, manifest validation, and the live Client slot, then report that visual verification remains unavailable. Before or after installation, do not search for rasterizers, invoke Quick Look, extract SVG into preview files, emulate React/DOM, or implement a custom renderer to compensate for missing browser control. A screenshot of a mock page is not verification of the running plugin. - -For any test subprocess or temporary resource, use a unique owned directory, bound execution, and await cleanup. A failed optional preview must not turn into environment repair or block installation. - -## Connect an MCP server - -Create a configuration-only bundle: its manifest needs a unique name, version, and `dsh.bundle.patch`, but no Host/Client entry files. Insert the already installed MCP client in its patch: - -```yaml -- insert: - - id: demo-mcp - name: '@deepseek-ai/dsh-mcp-client' - config: - serverName: demo - transport: streamable-http - url: http://127.0.0.1:3000/mcp - failOnStartupError: true -``` - -Replace the endpoint, install the bundle through `plugin_manager`, then call `mcp__demo__ping` or another newly available `mcp____` tool to verify the connection. For stdio, use `transport: stdio`, `command`, and optional `args`, `env`, and `cwd`. Ambient credentials are scrubbed; reference existing credentials with Loader `!!js` rather than copying secrets into conversation text. Repair the same bundle on failure instead of creating duplicates. +| Task | File | +|---|---| +| Bundle manifest, display metadata and icon, install and enable semantics, Host plugin export forms, Config | `references/host-plugin.md` | +| A UI plugin rendered in the Web page: Client manifest, module loader, slot registration | `references/ui-plugin.md` | +| Connecting an MCP server through a configuration-only bundle | `references/mcp-bundle.md` | +| Verification limits when no browser control is available | `references/verification.md` | +| UI plugin starting point, four files | `templates/decoration/package.json`, `templates/decoration/cordis.patch.yml`, `templates/decoration/index.js`, `templates/decoration/client.js` | +| MCP bundle starting point, two files | `templates/mcp/package.json`, `templates/mcp/cordis.patch.yml` | +| Loader patch dialect and the list of installable plugin packages | the `cordis-composition-reference` skill | diff --git a/packages/preset/agent-preset/skills/cordis-plugin-development/references/host-plugin.md b/packages/preset/agent-preset/skills/cordis-plugin-development/references/host-plugin.md new file mode 100644 index 0000000000..36d803f112 --- /dev/null +++ b/packages/preset/agent-preset/skills/cordis-plugin-development/references/host-plugin.md @@ -0,0 +1,60 @@ +# Bundles and Host plugins + +A bundle is a package whose `package.json` declares `dsh.bundle.patch`; the YAML patch inserts plugin entries. Give the package and rows unique names; use the Loader's existing YAML syntax, including `!!js` where expressions are needed. Read an existing patch before editing it: a matching override replaces the complete `config`. + +## Manifest + +A Host-only bundle needs no dependencies, install scripts, or build tool: + +```json +{ + "name": "@local/my-plugin", + "version": "1.0.0", + "private": true, + "type": "module", + "exports": { ".": "./index.js" }, + "dsh": { "bundle": { "patch": "./cordis.patch.yml" } } +} +``` + +`cordis.patch.yml`: + +```yaml +- insert: + - id: my-plugin + name: '@local/my-plugin' + config: {} +``` + +## Display metadata and icon + +Plugin Manager cards, bundle details, component rows, and the Settings plugin inventory read display text and an icon from the manifest without activating the plugin. Put the title and description in `locale/en.json` (other languages such as `locale/zh.json` use the same fields), and declare the icon as a top-level `icon` in `package.json`: + +```json +{ "meta": { "title": "My Decoration", "description": "Draws a badge under the composer." } } +``` + +```json +{ + "icon": "./icon.svg", + "exports": { "./package.json": "./package.json", "./locale/*.json": "./locale/*.json" }, + "files": ["locale/*.json", "icon.svg"] +} +``` + +`icon` is a path relative to the manifest directory; SVG, PNG, JPEG, and WebP up to 256 KiB are accepted, while absolute paths, URLs, paths outside the directory, and symlinks leaving it are rejected. Missing fields fall back to `package.json` `name` and `description` and to the panel's default artwork; malformed metadata produces a diagnostic and keeps the valid text. + +## Host plugin export forms + +`index.js` exports one of these forms; do not mix them: + +- `export function apply(ctx, config) {}` with optional `export const inject = ['tools']` and `export const Config`. +- A service class as the default export. + +Register every resource inside `apply` with `ctx.effect` or `ctx.on` and return its cleanup. A plugin that declares `Config` validates the row's `config` at activation; query `Config.listConfigs` for an installed plugin's schema before writing its `config`, and follow `$defs` references in the returned document. + +## Install, enable, and observe + +`plugin_manager` `install_bundle` performs package installation and bundle selection; do not reproduce those steps with shell commands. Only pass `approvedBuilds` after the user explicitly approves the reported pending build scripts. Preserve returned failures and pending states; report success only after observing the requested capability. + +`list_plugins` and `list_bundles` return exact identifiers for existing installations. `set_plugin` and `set_bundle` toggle them; `remove_bundle` removes a bundle. Inspect saved-state and activation outcomes separately: `failed` requires diagnosis, `overridden` means a higher-priority layer wins, and `restart-required` means the change is not live. Installing a new bundle can activate through HMR; replacing an installed package requires restart to load a fresh JavaScript module generation. Do not infer updated browser code from an unchanged slot id. diff --git a/packages/preset/agent-preset/skills/cordis-plugin-development/references/mcp-bundle.md b/packages/preset/agent-preset/skills/cordis-plugin-development/references/mcp-bundle.md new file mode 100644 index 0000000000..f1159538ec --- /dev/null +++ b/packages/preset/agent-preset/skills/cordis-plugin-development/references/mcp-bundle.md @@ -0,0 +1,5 @@ +# Connect an MCP server + +Read `templates/mcp/` with the file-read tool and write its files into your bundle directory. A configuration-only bundle's manifest needs a unique name, version, and `dsh.bundle.patch`, but no Host/Client entry files. Its patch, `templates/mcp/cordis.patch.yml`, inserts the already installed `@deepseek-ai/dsh-mcp-client` with `serverName`, `transport: streamable-http`, `url`, and `failOnStartupError: true`. + +Replace the endpoint, install the bundle through `plugin_manager`, then call `mcp__demo__ping` or another newly available `mcp____` tool to verify the connection. For stdio, use `transport: stdio`, `command`, and optional `args`, `env`, and `cwd`; `Config.listConfigs` on the installed row returns the complete client schema. Ambient credentials are scrubbed; reference existing credentials with Loader `!!js` rather than copying secrets into conversation text. Repair the same bundle on failure instead of creating duplicates. diff --git a/packages/preset/agent-preset/skills/cordis-plugin-development/references/ui-plugin.md b/packages/preset/agent-preset/skills/cordis-plugin-development/references/ui-plugin.md new file mode 100644 index 0000000000..b30350a4b2 --- /dev/null +++ b/packages/preset/agent-preset/skills/cordis-plugin-development/references/ui-plugin.md @@ -0,0 +1,13 @@ +# UI plugins in the Web page + +Read `templates/decoration/` with the file-read tool and write its files into your bundle directory: its `package.json` adds a `dsh.client` section (`platform`, `immediately`, `inject`) and a `./client` export beside the bundle patch. + +`index.js` exports `export function apply() {}`; the patch inserts one row named after the package. For a simple drawing, prefer a slot with allocated space, such as `conversation.composer.dock` when available. Keep the first version within that slot's flow; do not plan a motion path around host controls. Use `shell.overlay` only when the request needs an overlay and its placement is known. + +## Client module + +The browser artifact registers a lazy factory whose id equals the package name. React comes from the browser module table; no duplicate React installation, CDN script, or UMD search is needed. For compiled sources, use the deployment's Client build tooling to emit this format; declare non-baseline runtime imports in `dsh.client.external`. + +`templates/decoration/client.js` registers a lazy factory into `conversation.composer.dock` through `ctx.slots.inject` and `ctx.slots.register`; follow the selected slot's props and options from `Slots.listSubTree` when you change the slot. + +Keep factories free of side effects. Register styles, timers, listeners and other resources inside `apply` with `ctx.effect`/`ctx.on` and return their cleanup functions. Component-local styles can render as React elements so unmounting removes them. Verify disposal for resources you add. Inherit the host theme for containers and controls; artwork may use its own colors. Route visible UI text through the Client locale service. Do not replace the app root or append a second application to `document.body`. Do not read another plugin's DOM, stylesheet, or component source to estimate placement; choose a slot that already allocates space. diff --git a/packages/preset/agent-preset/skills/cordis-plugin-development/references/verification.md b/packages/preset/agent-preset/skills/cordis-plugin-development/references/verification.md new file mode 100644 index 0000000000..1e756432f3 --- /dev/null +++ b/packages/preset/agent-preset/skills/cordis-plugin-development/references/verification.md @@ -0,0 +1,5 @@ +# Verify in the available environment + +Prefer the authenticated page already connected to Harness. Do not launch a separate browser from shell commands, change `HOME`, inspect personal browser profiles, search for authentication tokens, or alter keychains to obtain a screenshot. For a visual-only request without browser control, limit verification to JavaScript syntax, manifest validation, and the live Client slot, then report that visual verification remains unavailable. Before or after installation, do not search for rasterizers, invoke Quick Look, extract SVG into preview files, emulate React/DOM, or implement a custom renderer to compensate for missing browser control. A screenshot of a mock page is not verification of the running plugin. + +For any test subprocess or temporary resource, use a unique owned directory, bound execution, and await cleanup. A failed optional preview must not turn into environment repair or block installation. diff --git a/packages/preset/agent-preset/skills/cordis-plugin-development/templates/decoration/client.js b/packages/preset/agent-preset/skills/cordis-plugin-development/templates/decoration/client.js new file mode 100644 index 0000000000..b35a29dedd --- /dev/null +++ b/packages/preset/agent-preset/skills/cordis-plugin-development/templates/decoration/client.js @@ -0,0 +1,22 @@ +window.__ModuleLoader__.load({ + id: '@local/my-decoration', + factory(require) { + const React = require('react'); + const h = React.createElement; + function Decoration() { + return h('svg', { + viewBox: '0 0 64 64', width: 48, height: 48, + 'aria-hidden': true, + style: { display: 'block', pointerEvents: 'none' }, + }, h('circle', { cx: 32, cy: 32, r: 24, fill: '#247bbf' })); + } + return { + inject: ['slots'], + apply(ctx) { + ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({ + name: 'conversation.composer.dock', id: 'my-decoration', order: 5, + }, Decoration)); + }, + }; + }, +}); diff --git a/packages/preset/agent-preset/skills/cordis-plugin-development/templates/decoration/cordis.patch.yml b/packages/preset/agent-preset/skills/cordis-plugin-development/templates/decoration/cordis.patch.yml new file mode 100644 index 0000000000..8affe81b9a --- /dev/null +++ b/packages/preset/agent-preset/skills/cordis-plugin-development/templates/decoration/cordis.patch.yml @@ -0,0 +1,3 @@ +- insert: + - id: my-decoration + name: '@local/my-decoration' diff --git a/packages/preset/agent-preset/skills/cordis-plugin-development/templates/decoration/index.js b/packages/preset/agent-preset/skills/cordis-plugin-development/templates/decoration/index.js new file mode 100644 index 0000000000..7ef024475d --- /dev/null +++ b/packages/preset/agent-preset/skills/cordis-plugin-development/templates/decoration/index.js @@ -0,0 +1,2 @@ +/** Host half of the decoration bundle; the Client module owns the rendering. */ +export function apply() {} diff --git a/packages/preset/agent-preset/skills/cordis-plugin-development/templates/decoration/package.json b/packages/preset/agent-preset/skills/cordis-plugin-development/templates/decoration/package.json new file mode 100644 index 0000000000..528eca416b --- /dev/null +++ b/packages/preset/agent-preset/skills/cordis-plugin-development/templates/decoration/package.json @@ -0,0 +1,15 @@ +{ + "name": "@local/my-decoration", + "version": "1.0.0", + "private": true, + "type": "module", + "exports": { ".": "./index.js", "./client": "./client.js" }, + "dsh": { + "bundle": { "patch": "./cordis.patch.yml" }, + "client": { + "platform": "web", + "immediately": true, + "inject": ["@deepseek-ai/dsh-client-ui-conversation"] + } + } +} diff --git a/packages/preset/agent-preset/skills/cordis-plugin-development/templates/mcp/cordis.patch.yml b/packages/preset/agent-preset/skills/cordis-plugin-development/templates/mcp/cordis.patch.yml new file mode 100644 index 0000000000..d507d4e9db --- /dev/null +++ b/packages/preset/agent-preset/skills/cordis-plugin-development/templates/mcp/cordis.patch.yml @@ -0,0 +1,8 @@ +- insert: + - id: demo-mcp + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: demo + transport: streamable-http + url: http://127.0.0.1:3000/mcp + failOnStartupError: true diff --git a/packages/preset/agent-preset/skills/cordis-plugin-development/templates/mcp/package.json b/packages/preset/agent-preset/skills/cordis-plugin-development/templates/mcp/package.json new file mode 100644 index 0000000000..825e899888 --- /dev/null +++ b/packages/preset/agent-preset/skills/cordis-plugin-development/templates/mcp/package.json @@ -0,0 +1,7 @@ +{ + "name": "@local/demo-mcp", + "version": "1.0.0", + "private": true, + "type": "module", + "dsh": { "bundle": { "patch": "./cordis.patch.yml" } } +} diff --git a/packages/preset/agent-preset/skills/editing-cordis-compositions/SKILL.md b/packages/preset/agent-preset/skills/editing-cordis-compositions/SKILL.md index 8110b3f80a..86daf27621 100644 --- a/packages/preset/agent-preset/skills/editing-cordis-compositions/SKILL.md +++ b/packages/preset/agent-preset/skills/editing-cordis-compositions/SKILL.md @@ -5,11 +5,11 @@ description: Use when creating, changing, or validating an agent preset or other # Editing Cordis compositions -Agent presets are ordinary `@deepseek-ai/dsh-agent-preset` declarations carried by bundle patches. Nothing edits a declaration in place: a preset is created or changed by installing a bundle whose patch declares or overrides it. This file is the complete contract; do not read DSH package sources to infer formats. +Agent presets are ordinary `@deepseek-ai/dsh-agent-preset` declarations carried by bundle patches. Nothing edits a declaration in place: a preset is created or changed by installing a bundle whose patch declares or overrides it. This file states the declaration format; when it leaves a question open, read the `@deepseek-ai/dsh-agent-preset` README and `lib/types` declarations under the `packageDir` that `cordis_inspect_query` `Config.listConfigs` returns when queried with a `preset-` row's `entry` id, or `packages/preset/agent-preset/src` in a source checkout. ## Where declarations live -The shipped Web presets are `presets/.patch.yml` files of the `@deepseek-ai/dsh-web-app` bundle, ids `standard`, `ptc`, `minimal` and `cordis`. Installed, that bundle sits at `$DSH_HOME/profiles//node_modules/@deepseek-ai/dsh-web-app/`; in a source checkout of DSH it is `packages/bundle/web-app/`. Read one file with `cat` when you need a template; `minimal.patch.yml` is the shortest. +The shipped Web presets are `presets/.patch.yml` files of the `@deepseek-ai/dsh-web-app` bundle, ids `standard`, `ptc`, `minimal` and `cordis`. Installed, the bundle resolves from the dsh installation, not the profile; querying `Config.listConfigs` with the `entry` id of any `preset-` row it declares returns that `packageDir`. In a source checkout of DSH it is `packages/bundle/web-app/`. Read one file with the file-read tool when you need a template; `minimal.patch.yml` is the shortest. In Desktop the bundle sits inside `app.asar`, which shell commands cannot open. Load `cordis-composition-reference` for the patch dialect and the list of plugin packages a preset can mount. A declaration row has these `config` fields: `id` (required, lowercase letters, digits and hyphens), `plugins` (required Cordis entry list), and optional `name`, `description` and `order` (roster position). The Loader row `id` is `preset-` by convention. @@ -65,6 +65,10 @@ Override the declaration by its Loader row id instead of inserting. The override # the shipped list with your changes ``` +## Migrate a legacy preset + +Before declaration rows, a user preset was a directory `$DSH_HOME/.agent-presets//` holding `preset.yml` (display `name`, `description`, `order`) and `agent.cordis.yml` (the plugin entry list). Nothing reads that directory any more. To migrate one, create a bundle as above whose declaration takes `id` from the directory name, `name`, `description`, and `order` from `preset.yml`, and `plugins` from `agent.cordis.yml` verbatim; check each plugin name against `cordis-composition-reference` because packages renamed since the preset was written fail at activation. Install it, verify the row, then delete the legacy directory. + ## Verify `plugin_manager` `list_bundles` lists the installed bundle; `list_plugins` shows the `preset-` row with its activation state. A declaration whose activation fails stays on the roster with its diagnostic and cannot compose a session until the bundle is fixed and reinstalled. Existing sessions and their children keep the plugin revision they started with; validate changed behavior in a new session. Installing a bundle executes plugin code in the Host process, so it requires Full access or approval. @@ -77,4 +81,4 @@ A preset plugin that supplies a service must isolate the provider and all its co ## Extend the Host -For new plugin code or profile-wide capabilities, load `cordis-plugin-development`, author a workspace bundle, and install it with `plugin_manager`. Inspect available APIs through `cordis_inspect_list` and `cordis_inspect_query`; those tools do not invoke Remote methods. Verify the installed capability before reporting completion. +For new plugin code or profile-wide capabilities, load `cordis-plugin-development`, author a workspace bundle, and install it with `plugin_manager`. Inspect available APIs through `cordis_inspect_list` and `cordis_inspect_query`, including a mounted plugin's Config schema through the Host `Config` provider; those tools do not invoke Remote methods. Verify the installed capability before reporting completion. diff --git a/packages/preset/agent-preset/tests/skills.spec.ts b/packages/preset/agent-preset/tests/skills.spec.ts new file mode 100644 index 0000000000..d55e6f2e70 --- /dev/null +++ b/packages/preset/agent-preset/tests/skills.spec.ts @@ -0,0 +1,72 @@ +/** + * Shipped creator skills: every rendered skill stays below the pruner threshold, referenced + * files exist, templates parse, and no skill bans reading DSH sources. + */ +import { execFileSync } from 'node:child_process' +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import * as yaml from 'js-yaml' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' +import { codePointLength } from '@deepseek-ai/dsh-compaction-tool-result-pruner' +import { renderSkillContent } from '@deepseek-ai/dsh-skill' +import { describe, expect, it } from 'vitest' + +const skills = fileURLToPath(new URL('../skills/', import.meta.url)) +const names = readdirSync(skills) + +/** + * `packages/bundle/web-app/presets/standard.patch.yml` prunes a tool result only above `thresholdChars: 8192` + * code points; a skill result below it reaches the model intact. + */ +const PRUNER_THRESHOLD_CHARS = 8192 + +/** The body the `skill` tool returns: the file without its YAML frontmatter. */ +function body(name: string): string { + const text = readFileSync(join(skills, name, 'SKILL.md'), 'utf8') + const end = text.indexOf('\n---\n', 4) + return text.slice(end + '\n---\n'.length) +} + +function markdownFiles(dir: string): string[] { + return readdirSync(dir, { recursive: true, encoding: 'utf8' }) + .filter(file => file.endsWith('.md')) + .map(file => join(dir, file)) +} + +describe('the shipped creator skills', () => { + it('render below the pruner threshold and name only files that exist', () => { + for (const name of names) { + const rendered = renderSkillContent({ + name, provider: 'filesystem', resourceBase: { kind: 'directory', path: join(skills, name) }, content: body(name), + }) + expect(codePointLength(rendered), name).toBeLessThan(PRUNER_THRESHOLD_CHARS) + const referenced = [...body(name).matchAll(/`((?:references|templates)\/[^`]+)`/g)].map(match => match[1] ?? match[0]) + for (const path of referenced) expect(() => statSync(join(skills, name, path)), `${name}: ${path}`).not.toThrow() + } + }) + + it('ship templates whose manifest, patch, and JavaScript parse', () => { + const templates = join(skills, 'cordis-plugin-development', 'templates') + for (const name of readdirSync(templates)) { + const dir = join(templates, name) + const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { dsh: { bundle: { patch: string } } } + const patch = yaml.load(readFileSync(join(dir, manifest.dsh.bundle.patch), 'utf8'), { schema: entryListSchema }) + expect(Array.isArray(patch)).toBe(true) + for (const file of readdirSync(dir).filter(entry => entry.endsWith('.js'))) { + execFileSync(process.execPath, ['--check', join(dir, file)]) + } + } + }) + + it('never forbid reading DSH package sources and never route skill files through the shell', () => { + for (const name of names) { + for (const file of markdownFiles(join(skills, name))) { + const text = readFileSync(file, 'utf8') + expect(text, file).not.toMatch(/do not read (DSH |package |DSH package )?sources/i) + // Desktop ships the skill directory inside app.asar, which only the Host process can open. + expect(text, file).not.toMatch(/`(cat|cp|ls) /) + } + } + }) +}) diff --git a/packages/schedule/schedule/README.i18n.yaml b/packages/schedule/schedule/README.i18n.yaml index 307d1fc97d..8cc722ee59 100644 --- a/packages/schedule/schedule/README.i18n.yaml +++ b/packages/schedule/schedule/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/schedule/schedule/README.md -README.md: 962c343111849ef20fd085dbe8ba454e57a78f8b -README.zh.md: 8913bc53d68ad2eb7406f1ce6780d69ec522b956 +README.md: 55a4c86b88ae5a28219fd927930ec68cc780185e +README.zh.md: 4670803e88b61501a03e9635f6b260948c98610f diff --git a/packages/schedule/schedule/README.md b/packages/schedule/schedule/README.md index 962c343111..55a4c86b88 100644 --- a/packages/schedule/schedule/README.md +++ b/packages/schedule/schedule/README.md @@ -53,7 +53,7 @@ Input that cannot become a reminder — an empty prompt, more than one selector, ### When reminders fire -Due reminders appear as ordinary follow-up messages after the conversation becomes idle; the agent never interrupts a running turn. An already-live idle agent can claim maintenance and deliver immediately without another resume. One-time reminders fire before any repeating batch, and several repeating reminders due at once arrive together in one message ordered by time. If the session is closed or cold when a reminder comes due, it stays overdue until a future live root agent resumes the session — nothing is sent outside the session. A repeating reminder that missed intervals while the session was away presents only its latest due occurrence, not a backlog. The optional Web catalog shows only active records and is not a delivery receipt; dispatch means the follow-up was queued and recorded, not that the model succeeded or the user read the answer. +Due reminders appear as ordinary follow-up messages after the conversation becomes idle; the agent never interrupts a running turn. An already-live idle agent can claim maintenance and deliver immediately without another resume. One-time reminders fire before any repeating batch, and several repeating reminders due at once arrive together in one message ordered by time. If the session is closed or cold when a reminder comes due, it stays overdue until a future live root agent resumes the session — nothing is sent outside the session. A repeating reminder that missed intervals while the session was away presents only its latest due occurrence, not a backlog. The optional Web catalog shows only active records and is not a delivery receipt; dispatch means the follow-up was queued and recorded, not that the model succeeded or the user read the answer. Archiving a live session with active reminders is refused until they stop, and choosing to stop them deletes every active reminder; unarchiving does not bring them back. ----- @@ -122,6 +122,8 @@ The owner splits long waits into bounded timer segments and rereads the wall clo An overdue reminder first checkpoints persistence, then claims the Agent's idle maintenance phase through `runMaintenance()`; if a turn or another maintenance task owns the Agent, the claim is rejected, the record stays active, and the owner retries after `whenIdle()`. A successful maintenance task refolds, samples one decision time, builds the fixed framing, synchronously queues `followup()`, and appends the dispatch before releasing the phase. Dispatch means the follow-up was queued and recorded, not that the model succeeded or the user read the answer. Framing or synchronous follow-up failure writes no dispatch; an append failure faults the owner because the message may already be queued; a barrier rejection leaves dispatch pending for a later ordinary preflight. Agent or plugin disposal cancels timers and stops new work without deleting durable records. +The plugin answers the Workspace registry's archive admission ([seam](../../workspace/workspace/README.md)) for every session it owns a runtime for, from that owner's own fold of the live log — the projection registry is a Client view and is not read here: `workspace/session-activity` reports the active records of the session's own suffix as the `schedule` family, one item per reminder with its prompt as label, and `workspace/session-stop` is a management delete in the [same serialized transaction and barriers](#management-pipeline) as the tool: it awaits `ctx.sessions.flush(session)` before reading the fold, appends the same `delete` change the `schedule_delete` tool records for each active reminder, asks the owner to redrive so its timers clear, then awaits a second barrier after the appends. A failed barrier rejects the stop; the registry logs it and keeps the archive, and the reminders stay for the archived-session `agent/pre-step` gate to block when they fire. The registry writes the archive before it dispatches the stop, so a crash between that write and the delete barrier leaves an archived session whose reminders are still recorded; the next unarchive shows them again, exactly as if the stop had never been requested. A session without a live agent, or a live agent without an owned runtime (published before this plugin loaded, or a runtime that stopped or faulted), reports nothing and has nothing to stop, because nothing of it is armed to fire. + ----- diff --git a/packages/schedule/schedule/README.zh.md b/packages/schedule/schedule/README.zh.md index 8913bc53d6..4670803e88 100644 --- a/packages/schedule/schedule/README.zh.md +++ b/packages/schedule/schedule/README.zh.md @@ -53,7 +53,7 @@ dsh web --patch apps/cli/config/examples/schedule/cordis.yml ### 提醒何时触发 -到期提醒会在会话空闲后作为普通 follow-up 消息出现;agent 绝不会中断正在运行的轮次。已经 live 且空闲的 agent 可以认领 maintenance 并立即交付,无需再次恢复。一次性提醒先于任何重复批次触发;同时到期的多条重复提醒会按时间顺序合并为一条消息。如果会话在提醒到期时已关闭或 cold,提醒会保持逾期,直到未来的 live 根 agent 恢复会话——会话之外不会发送任何内容。错过若干间隔的重复提醒只展示最新一个到期发生时点,不展示积压。可选 Web 目录只显示活动记录,并不充当交付回执;dispatch 表示 follow-up 已入队并被记录,不表示模型成功或用户已读取回答。 +到期提醒会在会话空闲后作为普通 follow-up 消息出现;agent 绝不会中断正在运行的轮次。已经 live 且空闲的 agent 可以认领 maintenance 并立即交付,无需再次恢复。一次性提醒先于任何重复批次触发;同时到期的多条重复提醒会按时间顺序合并为一条消息。如果会话在提醒到期时已关闭或 cold,提醒会保持逾期,直到未来的 live 根 agent 恢复会话——会话之外不会发送任何内容。错过若干间隔的重复提醒只展示最新一个到期发生时点,不展示积压。可选 Web 目录只显示活动记录,并不充当交付回执;dispatch 表示 follow-up 已入队并被记录,不表示模型成功或用户已读取回答。归档仍有活动提醒的活会话会被拒绝,直到这些提醒停止;选择停止它们会删除全部活动提醒,取消归档不会把它们带回来。 ----- @@ -110,6 +110,7 @@ projection 只携带持久记录。它不持久化或传输 scheduled/overdue 日历规范化是确定性的。夏令时缺口内的本地时间会被拒绝;重叠时选择第一次出现的较早时刻。Schedule 的时间校验不会读取浏览器、Session header 中的时区字段、模型 time-context、连接或进程时区,因此回放永不依赖环境时区状态。 + ### 管理流水线 一条 agent 范围的队列把每项已接纳的管理事务与 live owner 的到期事务从 preflight 到任何 post-append barrier 全程串行化。`schedule_create` 建立检查点、分配永不复用的 id、追加 create 事件,再次建立检查点;被取消的调用方在追加前停止。每次成功的管理 preflight 还会要求 live owner 重新计算,这会在先前的 post-append barrier 返回 `persistence_uncertain` 后恢复所保留的 create 或 delete 批次。 @@ -122,6 +123,8 @@ owner 把长等待拆分为有界的 timer 段,并在每次唤醒后重新读 逾期提醒首先为持久化建立检查点,然后通过 `runMaintenance()` 认领 agent 的 idle maintenance phase;如果某个轮次或另一项 maintenance task 已占用 agent,认领会失败,记录保持活动,owner 在 `whenIdle()` 后重试。获准的 maintenance task 会重新折叠、采样一个决策时点、构造固定 framing、同步将 `followup()` 入队,并在释放 phase 前追加 dispatch。dispatch 表示 follow-up 已入队并被记录,不表示模型成功或用户已读取回答。framing 构造或同步 follow-up 失败不会写入 dispatch;追加失败会使 owner 进入故障状态,因为消息可能已经入队;barrier 拒绝则把 dispatch 留给后续普通 preflight。agent 或插件执行资源释放时取消 timer 并停止新工作,但不删除持久记录。 +本插件为每个它拥有 runtime 的会话回答 Workspace 注册表的归档准入([接缝](../../workspace/workspace/README.zh.md)),答案来自该 owner 对活日志的自有 fold——投影注册表是 Client 视图,这里不读它:`workspace/session-activity` 把会话自身后缀中的活动记录作为 `schedule` 族报告,每条提醒一项、以其 prompt 作名称;`workspace/session-stop` 是与工具处于[同一串行事务与屏障](#management-pipeline)之下的管理删除:先 await `ctx.sessions.flush(session)` 再读取 fold,为每条活动提醒追加与 `schedule_delete` 工具所记录的相同的 `delete` 变更,请求 owner 重新驱动以清除其 timer,然后在追加之后 await 第二道屏障。屏障失败会让该 stop 拒绝;注册表记录日志并保留归档,这些提醒留给已归档会话的 `agent/pre-step` 门禁在触发时拦下。注册表先写入归档再派发 stop,因此在该写入与删除屏障之间崩溃会留下一个提醒仍被记录的已归档会话;下次取消归档时它们会再次出现,与从未请求过 stop 时完全一样。没有活 agent 的会话,或没有归属 runtime 的活 agent(在本插件加载前发布,或其 runtime 已停止、已故障),不报告任何内容也没有可停的东西,因为它没有任何已武装、可触发的提醒。 + ----- diff --git a/packages/schedule/schedule/package.json b/packages/schedule/schedule/package.json index a9691390d8..65f4956e15 100644 --- a/packages/schedule/schedule/package.json +++ b/packages/schedule/schedule/package.json @@ -45,6 +45,7 @@ "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { @@ -64,6 +65,7 @@ "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/schedule/schedule/src/index.ts b/packages/schedule/schedule/src/index.ts index 7049246bdf..a59cc4a4e4 100644 --- a/packages/schedule/schedule/src/index.ts +++ b/packages/schedule/schedule/src/index.ts @@ -5,12 +5,18 @@ import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' +import type { SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-persistence' // Type-only: resolves ctx.sessionProjections for the optional projection child. import type {} from '@deepseek-ai/dsh-session-projection' +// Type-only: the Workspace registry's archive-admission events this plugin answers. +import type { SessionActivity } from '@deepseek-ai/dsh-workspace' +import { flushSchedulePersistence } from './persistence.ts' import { scheduleProjectionDefinition } from './projection.ts' import { ScheduleRuntime } from './runtime.ts' import { registerScheduleTools } from './tools.ts' +import { runScheduleTransaction } from './transaction.ts' +import type { ScheduleRecord } from './types.ts' export type * from './types.ts' export { @@ -39,14 +45,71 @@ export const inject = ['agents', 'sessions', 'tools', 'sessionPersistence'] type OwnerCleanup = () => void | Promise -/** Install Schedule only for root agents published after this plugin loads. */ +interface OwnedRuntime { + readonly runtime: ScheduleRuntime + readonly cleanup: OwnerCleanup +} + +/** + * Install Schedule only for root agents published after this plugin loads, + * and answer the Workspace registry's archive admission for every session + * this plugin owns a runtime for. The owner answers from its own fold of the + * live log, so a session without a live agent or without an owned runtime + * — nothing armed that could fire — reports nothing and has nothing to stop. + * Active reminders count as activity. A stop request is a management delete + * in the agent's transaction queue, serialized with the tools and the + * owner's due transaction: it awaits the shared persistence barrier before + * reading the fold, appends the same durable `schedule/change` delete the + * `schedule_delete` tool records for every active reminder, and awaits a + * second barrier after the appends. A failed barrier rejects the stop, which + * the registry logs, leaving the reminders for the archived-session gate to + * block when they fire. + */ export function apply(ctx: Context): void { ctx.inject(['sessionProjections'], (projectionCtx) => { projectionCtx.sessionProjections.register(scheduleProjectionDefinition) }) - const runtimes = new Map() + const runtimes = new Map() let stopping = false + /** The owned runtime's active records for a session id; none without a live agent or an owned runtime. */ + const activeReminders = (sessionId: SessionId): { agent: Agent; active: readonly ScheduleRecord[] } | undefined => { + const agent = ctx.agents.get(sessionId) + const active = agent === undefined ? undefined : runtimes.get(agent)?.runtime.activeRecords() + return agent === undefined || active === undefined ? undefined : { agent, active } + } + + ctx.effect(() => { + const stopActivity = ctx.on('workspace/session-activity', async ({ sessionId }, next) => { + const reminders = activeReminders(sessionId) + const rest = await next() + if (reminders === undefined || reminders.active.length === 0) return rest + const own: SessionActivity = { + kind: 'schedule', + items: reminders.active.map(record => ({ id: record.id, label: record.prompt })), + } + return [own, ...rest] + }) + const stopStop = ctx.on('workspace/session-stop', async ({ sessionId }) => { + const owned = activeReminders(sessionId) + if (owned === undefined) return + const { agent } = owned + await runScheduleTransaction(agent, async () => { + await flushSchedulePersistence(ctx, agent.session) + const active = activeReminders(sessionId)?.active ?? [] + if (active.length === 0) return + for (const record of active) { + agent.session.append('schedule/change', { version: 1, operation: 'delete', id: record.id }) + } + runtimes.get(agent)?.runtime.requestDrive() + await flushSchedulePersistence(ctx, agent.session) + }) + }) + return () => { + stopStop() + stopActivity() + } + }, 'schedule.archiveAdmission()') ctx.effect(() => { const stopCreated = ctx.on('agent/created', ({ agent }) => { @@ -67,19 +130,19 @@ export function apply(ctx: Context): void { try { await runtime.dispose() } finally { - if (runtimes.get(agent) === cleanup) runtimes.delete(agent) + if (runtimes.get(agent)?.cleanup === cleanup) runtimes.delete(agent) } } }, 'schedule.runtime()') - runtimes.set(agent, cleanup) + runtimes.set(agent, { runtime, cleanup }) }) return async () => { stopping = true stopCreated() - const cleanups = [...runtimes.values()] + const owned = [...runtimes.values()] runtimes.clear() - await Promise.allSettled(cleanups.map(cleanup => Promise.resolve(cleanup()))) + await Promise.allSettled(owned.map(({ cleanup }) => Promise.resolve(cleanup()))) } }, 'schedule.lifecycle()') } diff --git a/packages/schedule/schedule/src/runtime.ts b/packages/schedule/schedule/src/runtime.ts index 2da746ce77..72b9286b21 100644 --- a/packages/schedule/schedule/src/runtime.ts +++ b/packages/schedule/schedule/src/runtime.ts @@ -13,7 +13,7 @@ declare module '@deepseek-ai/dsh-llm' { } } -import type { EveryScheduleRecord, OneShotScheduleRecord } from './types.ts' +import type { EveryScheduleRecord, OneShotScheduleRecord, ScheduleRecord } from './types.ts' import { foldScheduleEvents, renderEveryReminderBatchFraming, @@ -106,6 +106,16 @@ export class ScheduleRuntime { this.requestDrive() } + /** + * Active records of the exact runtime suffix, folded from the live log the + * way every drive folds it: the owner answers for what its timers can fire. + * @returns the active records in create order, or nothing while this runtime is stopping, faulted, or reading a corrupt stream. + */ + activeRecords(): readonly ScheduleRecord[] | undefined { + if (this.stopping || this.faulted) return undefined + return this.readFolded()?.active + } + /** Recompute the live projection after a committed mutation or idle transition. */ requestDrive(): void { if (this.stopping || this.faulted) return diff --git a/packages/schedule/schedule/src/types.ts b/packages/schedule/schedule/src/types.ts index cbe3972d32..21a7fa4fdc 100644 --- a/packages/schedule/schedule/src/types.ts +++ b/packages/schedule/schedule/src/types.ts @@ -5,6 +5,8 @@ import type { Branded } from '@deepseek-ai/dsh-brand' import type {} from '@deepseek-ai/dsh-session/types' +// Type-only: the Workspace registry's archive-admission family map this plugin merges `schedule` into. +import type {} from '@deepseek-ai/dsh-workspace/types' /** Stable reminder identity that is unique and never reused within one session. */ export type ScheduleId = Branded<'ScheduleId'> @@ -210,6 +212,13 @@ export type ScheduleDeleteResult = /** Canonical `schedule_delete` value. */ export type ScheduleDeleteValue = ScheduleDeleteResult | ScheduleToolError +declare module '@deepseek-ai/dsh-workspace/types' { + interface SessionActivityKindMap { + /** A scheduled follow-up for this session is still active. */ + schedule: true + } +} + declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** diff --git a/packages/schedule/schedule/tests/plugin.spec.ts b/packages/schedule/schedule/tests/plugin.spec.ts index 35dfd614b2..d6ef1e5d9b 100644 --- a/packages/schedule/schedule/tests/plugin.spec.ts +++ b/packages/schedule/schedule/tests/plugin.spec.ts @@ -143,6 +143,114 @@ describe('Schedule plugin composition', () => { await ctx.fiber.dispose() }) + it('reports active reminders as archive activity and deletes them on a stop request', async () => { + const ctx = await harness() + // Published before the plugin: no owned runtime, so nothing of its log is armed. + const existing = await ctx.agents.create({ sessionId: SessionId('schedule-archive-existing') }) + const plugin = await ctx.plugin(toolSchedule) + existing.agent.session.append('schedule/change', { + version: 1, + operation: 'create', + schedule: toolSchedule.createAfterScheduleRecord(toolSchedule.ScheduleId('schedule-1'), 'orphaned', 3_600, Date.now()), + }) + const askExisting = () => ctx.waterfall( + 'workspace/session-activity', { sessionId: existing.agent.id }, () => Promise.resolve([]), + ) + expect(await askExisting()).toEqual([]) + await ctx.parallel('workspace/session-stop', { sessionId: existing.agent.id }) + expect(existing.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toHaveLength(1) + + const root = await ctx.agents.create({ sessionId: SessionId('schedule-archive') }) + const ask = () => ctx.waterfall( + 'workspace/session-activity', { sessionId: root.agent.id }, () => Promise.resolve([]), + ) + const listeners = (event: 'workspace/session-activity' | 'workspace/session-stop') => + ctx.events._hooks[event]?.length ?? 0 + const changes = () => root.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change') + // How many schedule/change events the log held at each persistence barrier of the root session. + const barriers: number[] = [] + const stopFlush = ctx.on('session/flush', (session) => { + if (session === root.agent.session) barriers.push(changes().length) + }) + expect(await ask()).toEqual([]) + expect(barriers).toEqual([]) + + const created = await ctx.agents.withInitiator(root.agent, () => ctx.tools.execute({ + signal: new AbortController().signal, + callId: ToolCallId('schedule-archive-create'), + name: 'schedule_create', + arguments: { prompt: 'check the build', after_seconds: 3_600 }, + agent: root.agent, + })) + expect(created.isError).toBe(false) + expect(await ask()).toEqual([ + { kind: 'schedule', items: [{ id: 'schedule-1', label: 'check the build' }] }, + ]) + // A session without a live agent has nothing that could fire. + expect(await ctx.waterfall( + 'workspace/session-activity', { sessionId: SessionId('schedule-cold') }, () => Promise.resolve([]), + )).toEqual([]) + + barriers.length = 0 + await ctx.parallel('workspace/session-stop', { sessionId: root.agent.id }) + // The delete is the same durable change the schedule_delete tool records, + // between the same two barriers: one before the fold is read, one after the + // append. The runtime re-drive the delete triggers adds its own preflight + // barrier, so two barriers see the appended delete. + expect(changes().map(event => event.data)).toEqual([ + expect.objectContaining({ operation: 'create' }), + { version: 1, operation: 'delete', id: 'schedule-1' }, + ]) + expect(barriers).toEqual([1, 2, 2]) + expect(await ask()).toEqual([]) + // A second stop still checkpoints before reading, then has nothing left to delete and records nothing. + barriers.length = 0 + await ctx.parallel('workspace/session-stop', { sessionId: root.agent.id }) + expect(changes()).toHaveLength(2) + expect(barriers).toEqual([2]) + // A stop for a session without a live agent neither checkpoints nor appends. + barriers.length = 0 + await ctx.parallel('workspace/session-stop', { sessionId: SessionId('schedule-cold') }) + expect(barriers).toEqual([]) + // A fold that turns unreadable at the barrier faults the owner, which then answers nothing to delete. + const again = await ctx.agents.withInitiator(root.agent, () => ctx.tools.execute({ + signal: new AbortController().signal, + callId: ToolCallId('schedule-archive-create-2'), + name: 'schedule_create', + arguments: { prompt: 'check the deploy', after_seconds: 3_600 }, + agent: root.agent, + })) + expect(again.isError).toBe(false) + const corruptAtBarrier = ctx.on('session/flush', (session) => { + if (session !== root.agent.session) return + Object.defineProperty(root.agent.session, 'snapshotEvents', { + configurable: true, + value: () => { throw new Error('unreadable at the barrier') }, + }) + }) + barriers.length = 0 + await ctx.parallel('workspace/session-stop', { sessionId: root.agent.id }) + corruptAtBarrier() + delete (root.agent.session as { snapshotEvents?: unknown }).snapshotEvents + expect(barriers).toEqual([3]) + expect(changes()).toHaveLength(3) + + // One listener per event is the composed Agent registry's own `turn` family. + expect(listeners('workspace/session-activity')).toBe(2) + expect(listeners('workspace/session-stop')).toBe(2) + await plugin.dispose() + // Disposal withdraws both listeners with the plugin: a stop no longer checkpoints the session. + expect(listeners('workspace/session-activity')).toBe(1) + expect(listeners('workspace/session-stop')).toBe(1) + barriers.length = 0 + await ctx.parallel('workspace/session-stop', { sessionId: root.agent.id }) + expect(barriers).toEqual([]) + stopFlush() + await root.dispose() + await existing.dispose() + await ctx.fiber.dispose() + }) + it('does not checkpoint unrelated idle sessions', async () => { const ctx = await harness() const plugin = await ctx.plugin(toolSchedule) diff --git a/packages/schedule/schedule/tests/runtime.spec.ts b/packages/schedule/schedule/tests/runtime.spec.ts index d86859ac50..7909fd93e7 100644 --- a/packages/schedule/schedule/tests/runtime.spec.ts +++ b/packages/schedule/schedule/tests/runtime.spec.ts @@ -614,6 +614,32 @@ describe('Schedule runtime failure and teardown boundaries', () => { event.type === 'schedule/change' && event.data.operation === 'dispatch')).toEqual([]) }) + it('answers activeRecords from its own fold and withholds it once stopping, faulted, or unreadable', async () => { + const test = await harness() + appendAfter(test, 'schedule-1', 3_600) + const runtime = runtimeFor(test) + runtime.start() + await settle() + expect(runtime.activeRecords()?.map(record => record.id)).toEqual(['schedule-1']) + // A fold that turns unreadable faults the runtime, as a drive would, and answers nothing afterwards. + Object.defineProperty(test.agent.session, 'snapshotEvents', { + configurable: true, + value: () => { throw new Error('unreadable log') }, + }) + expect(runtime.activeRecords()).toBeUndefined() + delete (test.agent.session as { snapshotEvents?: unknown }).snapshotEvents + expect(runtime.activeRecords()).toBeUndefined() + await runtime.dispose() + + const stopped = await harness() + appendAfter(stopped, 'schedule-1', 3_600) + const stoppedRuntime = runtimeFor(stopped) + stoppedRuntime.start() + await settle() + await stoppedRuntime.dispose() + expect(stoppedRuntime.activeRecords()).toBeUndefined() + }) + it('faults on corrupt or unreadable durable state after preflight', async () => { const corrupt = await harness() Object.defineProperty(corrupt.agent.session, 'snapshotEvents', { diff --git a/packages/schedule/schedule/tsconfig.json b/packages/schedule/schedule/tsconfig.json index 8e32dec9db..212a2cb307 100644 --- a/packages/schedule/schedule/tsconfig.json +++ b/packages/schedule/schedule/tsconfig.json @@ -38,6 +38,9 @@ { "path": "../../session/session-projection" }, + { + "path": "../../workspace/workspace" + }, { "path": "../../runtime-diagnostics/invariants" } diff --git a/packages/session-query/session-log-export/tsconfig.client.json b/packages/session-query/session-log-export/tsconfig.client.json index c8a2c65d03..1735561ff3 100644 --- a/packages/session-query/session-log-export/tsconfig.client.json +++ b/packages/session-query/session-log-export/tsconfig.client.json @@ -16,10 +16,10 @@ ], "references": [ { "path": "../../../vendor/cordis" }, - { "path": "../../client/locale" }, + { "path": "../../client/locale/tsconfig.client.json" }, { "path": "../../client/store" }, { "path": "../../client/ui-commands" }, - { "path": "../../client/ui-conversation" }, + { "path": "../../client/ui-conversation/tsconfig.client.json" }, { "path": "../../client/ui-message-feedback" }, { "path": "../../client/ui-primitives" }, { "path": "../../client/ui-renderer" }, diff --git a/packages/settings/README.i18n.yaml b/packages/settings/README.i18n.yaml index 2b0ce5a66a..3b17098c8f 100644 --- a/packages/settings/README.i18n.yaml +++ b/packages/settings/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/settings/README.md -README.md: 47eba359aa9dc1b964892096dc6bd23a5f2d044b -README.zh.md: 3537d20287a6f63dbef6ff66f1d4b897612fc462 +README.md: 63d9ed9e2cd533df7ee65b2df30565efbfd666ad +README.zh.md: cd4b9ed902c114113ad47bb0eca351b2400acaec diff --git a/packages/settings/README.md b/packages/settings/README.md index 47eba359aa..63d9ed9e2c 100644 --- a/packages/settings/README.md +++ b/packages/settings/README.md @@ -1,15 +1,15 @@ --- -description: "Package map for the user-settings capability family: the ctx.settings service that resolves per-namespace configuration, and the YAML/JSON file provider that stores it." +description: "Schema-derived forms for live plugin configuration." kind: "package-group" --- -# settings/ — user-editable configuration +# settings/ — plugin configuration forms English | [中文](README.zh.md) ## Summary -The `settings/` group makes plugin configuration user-editable: a plugin registers a named namespace with a schema, and users override values in one document without touching `cordis.yml`. User overrides win over the deployment's own configuration and schema defaults, and changes apply live. Two packages cover the capability: `settings/` provides the settings service, and `settings-file/` stores every namespace in one YAML or JSON document users can edit. Settings are optional: without a provider mounted, configuration stays exactly as composed. +Edit live plugin configuration through forms derived from each plugin’s Config schema. The active profile patch stores edits, and Loader applies them. Plugins read their own volatile references. ## Table of Contents @@ -17,27 +17,21 @@ The `settings/` group makes plugin configuration user-editable: a plugin registe - [Related documentation](#related-documentation) - [Dev Note](#dev-note) ------ - ## Packages -Two packages cover the capability; each child README owns the full contract, and the subsystem reference owns the exhaustive service surface. +The package README owns the form behavior; the settings reference documents its types. -| Package | Role | ctx key | +| Package | Purpose | ctx key | |---|---|---| -| [`settings/`](settings/README.md) | Settings service: register namespaces and read or change their values | `ctx.settings` | -| [`settings-file/`](settings-file/README.md) | Stores settings in one local YAML/JSON file and publishes external edits live | registers `ctx.settings` | - ------ +| [`settings`](settings/README.md) | Config projection and revisioned form edits | `settings` | ## Related documentation -Start with the subsystem reference for the shared vocabulary, then the capability-seam split this family follows. - -- [Settings subsystem reference](../../docs/subsystems/settings.md) — namespaces, layered resolution, descriptors, change commits, and the generated Cordis surface. -- [Capability seams](../../docs/capability-seams.md) — the Service Definition / Service Provider / Consumer split this family follows. +- [Settings reference](../../docs/subsystems/settings.md) — descriptors and edits. +- [Configuration editor](../boot/config-editor/README.md) — profile persistence. +- [Volatile configuration](../../vendor/loader/README.md) — updates without remounting. ## Dev Note diff --git a/packages/settings/README.zh.md b/packages/settings/README.zh.md index 3537d20287..cd4b9ed902 100644 --- a/packages/settings/README.zh.md +++ b/packages/settings/README.zh.md @@ -1,15 +1,15 @@ --- -description: "用户设置能力族的包映射:解析各 namespace 配置的 ctx.settings 服务,以及存储它的 YAML/JSON 文件提供方。" +description: "用于插件即时配置的 schema 派生表单。" kind: "package-group" --- -# settings/:用户可编辑配置 +# settings/——插件配置表单 [English](README.md) | 中文 ## 概述 -`settings/` 组让插件配置变为用户可编辑:插件用一个 schema 注册具名 namespace,用户在一份文档里覆盖值,无需改动 `cordis.yml`。用户覆盖优先于部署自身的配置与 schema 默认值,变更实时生效。两个包覆盖该能力:`settings/` 提供设置服务,`settings-file/` 把所有 namespace 存进一个用户可编辑的 YAML 或 JSON 文档。设置是可选的:没有挂载提供方时,配置保持组合原样。 +通过各插件 Config schema 派生的表单编辑即时配置。当前 profile patch 保存编辑,Loader 应用更改。插件读取自己的 volatile 引用。 ## 目录 @@ -17,33 +17,27 @@ kind: "package-group" - [相关文档](#related-documentation) - [开发备注](#dev-note) ------ - ## 包 -两个包覆盖该能力;完整约定由各子级 README 负责,穷尽式服务接口面由子系统参考负责。 +包 README 负责表单行为;设置参考负责其类型。 -| 包 | 角色 | ctx 键 | +| 包 | 用途 | ctx 键 | |---|---|---| -| [`settings/`](settings/README.zh.md) | 设置服务:注册 namespace 并读取或修改其值 | `ctx.settings` | -| [`settings-file/`](settings-file/README.zh.md) | 把设置存进一个本地 YAML/JSON 文件并实时发布外部修改 | 注册 `ctx.settings` | - ------ +| [`settings`](settings/README.zh.md) | Config 投影及带修订号的表单编辑 | `settings` | ## 相关文档 -先从子系统参考了解共享词汇,再看本家族遵循的能力 seam 拆分。 - -- [设置子系统参考](../../docs/subsystems/settings.zh.md)——namespace、分层解析、descriptor、变更提交与生成的 Cordis 接口。 -- [能力 seam](../../docs/capability-seams.zh.md)——本家族遵循的 Service Definition / Service Provider / Consumer 拆分。 +- [设置参考](../../docs/subsystems/settings.zh.md)——描述符与编辑。 +- [配置编辑器](../boot/config-editor/README.zh.md)——profile 持久化。 +- [Volatile 配置](../../vendor/loader/README.md)——无需重新挂载的更新。 ## 开发备注
-维护者的工作上下文——点击展开 +维护者工作上下文——点击展开 无。 diff --git a/packages/settings/settings-file/README.md b/packages/settings/settings-file/README.md deleted file mode 100644 index ea578a1739..0000000000 --- a/packages/settings/settings-file/README.md +++ /dev/null @@ -1,150 +0,0 @@ ---- -description: "The file-backed settings provider for users and maintainers choosing, configuring, or debugging the YAML/JSON settings document and its hot reload." -kind: "package-reference" ---- - -# @deepseek-ai/dsh-settings-file - -English | [中文](README.zh.md) - -## Summary - -`dsh-settings-file` keeps every namespace's user settings in one YAML or JSON document, by default `settings.yaml` under the harness home: users can edit the document directly — changes take effect live — or write through the service, which merges concurrent edits safely. YAML writes preserve comments, anchors, and formatting on every untouched node, and a section owned by a plugin that is not loaded is never dropped. Boot fails loud on an invalid document; a live reload that fails keeps the last good sections and warns rather than taking the process down. - -## Table of Contents - -- [Use this package](#use-this-package) -- [Understand the implementation](#understand-the-implementation) -- [Further Exploration](#further-exploration) -- [Model Experience](#model-experience) -- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) -- [Dev Note](#dev-note) - ------ - - -## Use this package - -Mount this provider when a composition wants one user-editable settings document. The common path is explicit: mount the provider, register namespaces through `ctx.settings`, and let users edit the document or a configuration UI write through the service. - -### When to choose it - -Choose it as the default user-settings store: one human-readable document that users can open in any editor, with changes taking effect without a restart. Choose it when comments and formatting in that document matter, because writes preserve them. A non-file store, such as a remote settings backend, is not shipped here; that would need another provider. - -### Minimal configuration - -```yaml -- name: '@deepseek-ai/dsh-settings-file' - config: - path: /absolute/path/to/settings.yaml -``` - -| Field | Default | Meaning | -|---|---|---| -| `path` | `/settings.yaml` | Settings document path; the extension picks the format (`.yaml`, `.yml`, or `.json`) | -| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home used when `path` is omitted | -| `watch` | `true` | Watch the document and hot-publish external edits | -| `debounceMs` | `100` | Watcher write-settle window, in milliseconds | - -The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-settings-file) is the exhaustive source for every accepted field and its JSDoc. - -### Editing the document - -The document is a YAML or JSON mapping of namespace to user section. Users can edit it directly: any change takes effect automatically, and deleting the file resets every namespace to defaults and `base`. A document that exists but is invalid fails plugin load at boot — the provider never silently ignores or overwrites it. Once live, an unreadable or unparsable edit warns and keeps the last good sections, so a hand-edit mistake cannot take the process down. - -### Writing through the service - -Writes through `ctx.settings` never lose concurrent changes: an external edit still in flight, a change the watcher missed, or another process's write is merged into the document before the write lands. YAML edits are leaf-level diffs: only changed values are set and only removed keys deleted, so comments, anchors, and formatting survive on every untouched node and on the key of every changed pair; a changed array or other non-map value replaces wholesale. JSON documents re-serialize without comments. If the on-disk document turned invalid, the write fails loud instead of overwriting the user's manual edit. - -The lock has a 2-second acquisition deadline with exponential backoff; a contender that times out leaves the existing lock in place, because lock age cannot distinguish a crashed owner from a paused live writer — orphan lock recovery is an operator action. The document is created `0600` under an owner-only `0700` directory and replaced atomically through a random-suffix temp sibling that never follows a planted symlink. - -### Failures and recovery - -- An unsupported extension fails at load — the format comes from the extension (`.yaml`, `.yml`, `.json`). -- A missing document is an empty store; deleting the file returns to that state. -- An invalid on-disk document at runtime blocks nothing but keeps the last good sections; a write refuses to overwrite it. -- `prepareDocument()` materializes an absent document as an empty owner-only file before a native editor opens it. - ------ - - -## Understand the implementation - -
-Implementation internals — click to expand - -This section explains the design decisions behind the provider and points at the code that realizes them; the observable behavior is fully covered in [Use this package](#use-this-package). - -### Design philosophy - -- **One explicit defaulting step.** `resolveSpec(config)` resolves the filename, format, watch flag, and debounce window in one step, so programmatic construction that bypasses Schemastery normalization gets the same defaults. -- **Boot fails loud, reload keeps last good.** An existing-but-invalid document fails plugin load; once live, an unreadable or unparsable edit warns and keeps the last good sections. -- **Every write is a read-modify-write.** A persist first reconciles from disk and publishes any difference into the seam, then renders against that fresh text, so a write can never resurrect a stale document or drop an unobserved sibling section. -- **Writes hold a cross-process writer lock.** The read-render-rename cycle runs under a `wx`-created `.lock` sibling with exponential backoff and a 2-second acquisition deadline; readers never take the lock because the rename commit is atomic. -- **YAML edits are leaf-level diffs.** Only changed values are set and only removed keys deleted, preserving comments, anchors, and formatting on untouched nodes. -- **Reloads and writes share one operation chain.** Watcher refreshes and persists from every namespace queue run one at a time in queue order; each render sees the text the previous operation committed. -- **Self-write suppression by content.** The provider caches the last good text; a watcher event whose content equals the cache — its own write included — is a no-op. - -### Source map - -| File | Role | -|---|---| -| [`src/index.ts`](src/index.ts) | Provider: spec resolution, load/parse, read-modify-write under the writer lock, watcher lifecycle, YAML/JSON rendering | -| — | No runtime invariant companion is published; this provider's contracts are file round-trip, watcher timing, and atomic-write behavior — IO effects proven by package tests; the in-process commit relation is owned by `@deepseek-ai/dsh-settings`. | - -### Document lifecycle - -The base service init loads and publishes the document before the service becomes injectable; the provider then starts the watcher and reconciles once at ready to close the startup gap in which a change written between the initial read and the watcher becoming active never fires an event. Every watcher event and every persist queues onto one exclusive operation chain. `reconcileFromDisk` compares on-disk text against the cache, publishes any difference (including absence as the empty document), and throws only on a parse failure so each caller picks its policy — a reload warns and keeps the last good document, a write fails loud. Disposal marks the provider closed, closes the watcher, and waits out every queued or in-flight operation so nothing publishes after teardown. - -### Render paths - -YAML renders by parsing the cached text into a mutable comment-preserving tree and patching one namespace with leaf-level edits; JSON renders by replacing one namespace key and re-serializing with two-space indentation. Before Chokidar opens the target, the provider realpaths its deepest existing ancestor and restores any missing suffix, so Windows cannot mix an 8.3 alias with long-form event paths inside libuv. - -
- ------ - - -## Further Exploration - -Read these pages when the provider-level contract is not enough. They move from the seam contract to the atomic-write primitive and the exhaustive configuration surface. - -- [User-settings service](../settings/README.md) — namespace registration, layered resolution, writes, and the events this provider feeds. -- [Settings subsystem reference](../../../docs/subsystems/settings.md) — namespaces, resolution order, descriptors, and change commits. -- [Settings package map](../README.md) — the two packages of the user-settings capability. -- [Atomic write](../../util/atomic-write/README.md) — the writer lock and atomic replacement every write uses. -- [Home paths](../../util/home-paths/README.md) — `$DSH_HOME` resolution and canonical watch paths. -- [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-settings-file) — every accepted config field and its source declaration. - ------ - - -## Model Experience - -Indirectly, through the consumers of `ctx.settings`, which own any model-facing behavior fed by a stored value; the file provider only stores and publishes namespace sections and registers nothing model-facing itself. - -#### KV Cache effect - -No direct invalidation; the consuming plugin owns any request-prefix changes. - -## Known Limitations and Deferred Work - - - - -These limits define when the provider is a poor fit or needs special operational care. They are current package constraints, not a task backlog. - -- **Same-namespace conflicts stay last-write-wins** — the writer lock and read-modify-write keep concurrent writers from dropping each other's namespaces, but two writers editing one namespace still resolve to the later write; there is no per-value merge or revision check. -- **A missed watcher event stays unseen until the next signal** — reads never re-stat the file, so a change the watcher fails to report is only folded in by the next event, the next write, or a restart. -- **Comment preservation is YAML-only and map-shaped** — JSON documents re-serialize without comments, and comments inside a changed array, or attached inline to a changed scalar value, go with the value they described. -- **No value indirection** — sections hold literal values; `${env:VAR}`-style references for secrets are a deferred seam-level feature. - - -### Dev Note - -
-Working context for maintainers — click to expand - -This Dev Note is working context for maintainers: deferred directions that are not decided. It is explicitly non-authoritative — shipped behavior, limits, and accepted rationale live in the sections above and the package code. Deferred directions: `${env:VAR}`-style value indirection is a seam-level feature — it belongs with the settings service contract when it lands, not with this provider. Orphan lock recovery remains an operator action by design, because lock age cannot distinguish a crashed owner from a paused live writer. - -
diff --git a/packages/settings/settings-file/README.zh.md b/packages/settings/settings-file/README.zh.md deleted file mode 100644 index 668d771bd6..0000000000 --- a/packages/settings/settings-file/README.zh.md +++ /dev/null @@ -1,150 +0,0 @@ ---- -description: "面向用户与维护者的文件型设置提供方:选择、配置或排查 YAML/JSON 设置文档及其热重载。" -kind: "package-reference" ---- - -# @deepseek-ai/dsh-settings-file - -[English](README.md) | 中文 - -## 概述 - -`dsh-settings-file` 把所有 namespace 的用户设置保存在一个 YAML 或 JSON 文档中,默认是 harness home 下的 `settings.yaml`:用户可以直接编辑文档——变更实时生效——也可以经服务写入,后者会安全合并并发编辑。YAML 写入保留每个未触碰节点上的注释、锚点与排版,未加载插件所拥有的分节也绝不会被丢弃。启动时非法文档直接报错;运行中失败的热重载保留最后可用分节并告警,而不是拖垮进程。 - -## 目录 - -- [使用本包](#use-this-package) -- [理解实现](#understand-the-implementation) -- [进一步探索](#further-exploration) -- [模型体验](#model-experience) -- [已知限制与延期工作](#known-limitations-and-deferred-work) -- [开发备注](#dev-note) - ------ - - -## 使用本包 - -当组合需要一份用户可编辑的设置文档时,挂载此提供方。常用路径是显式的:挂载提供方、经 `ctx.settings` 注册 namespace,然后让用户编辑文档或让配置界面经服务写入。 - -### 何时选择 - -把它当作默认的用户设置存储:一份用户可以在任意编辑器中打开的人类可读文档,变更无需重启即可生效。当文档中的注释与排版很重要时也选它,因为写入会保留它们。非文件存储(例如远程设置后端)不随本包提供;那需要另一个提供方。 - -### 最小配置 - -```yaml -- name: '@deepseek-ai/dsh-settings-file' - config: - path: /absolute/path/to/settings.yaml -``` - -| 字段 | 默认值 | 含义 | -|---|---|---| -| `path` | `/settings.yaml` | 设置文档路径;扩展名决定格式(`.yaml`、`.yml` 或 `.json`) | -| `dshHome` | `$DSH_HOME` 或 `~/.dsh` | `path` 省略时使用的 harness home | -| `watch` | `true` | 监听文档并热发布外部编辑 | -| `debounceMs` | `100` | watcher 写入稳定窗口(毫秒) | - -生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-settings-file)是每个受支持字段及其 JSDoc 的穷尽式真源。 - -### 编辑文档 - -文档是 namespace 到用户分节的 YAML 或 JSON 映射。用户可以直接编辑:任何变更都会自动生效,删除文件则让所有 namespace 回到默认值与 `base`。存在但非法的文档在启动时使插件加载失败——提供方绝不会静默忽略或覆盖它。运行中不可读或不可解析的编辑只告警并保留最后可用分节,因此手改出错不会拖垮进程。 - -### 经服务写入 - -经 `ctx.settings` 的写入绝不会丢失并发变更:仍在途中的外部编辑、watcher 漏掉的变更或另一个进程的写入,都会在写入落地前并入文档。YAML 编辑是叶子级 diff:只设置变化的值、只删除被移除的键,因此每个未触碰节点以及每个被改键值对的键上的注释、锚点与排版都得以保留;被改的数组或其他非 map 值整体替换。JSON 文档重新序列化,无注释。若磁盘上的文档已变为非法,写入会明确报错,而不是覆盖用户的手工编辑。 - -锁有 2 秒的获取期限,带指数退避;超时的竞争者不会移除现有锁,因为锁龄无法区分崩溃的所有者与被暂停但仍存活的写入方——遗留锁恢复须由操作者执行。文档以 `0600` 权限创建在仅属主可访问的 `0700` 目录下,并通过一个绝不跟随预埋符号链接的随机后缀临时文件原子替换。 - -### 失败与恢复 - -- 不支持的扩展名在加载时报错——格式由扩展名决定(`.yaml`、`.yml`、`.json`)。 -- 文档缺失即空存储;删除文件即回到该状态。 -- 运行中磁盘文档非法不会阻塞任何操作,但保留最后可用分节;写入拒绝覆盖它。 -- `prepareDocument()` 在原生编辑器打开前,把缺失的文档物化为空的仅属主可访问文件。 - ------ - - -## 理解实现 - -
-实现细节——点击展开 - -本节解释提供方背后的设计决策并指出实现它们的代码位置;可观察行为已在[使用本包](#use-this-package)中完整说明。 - -### 设计理念 - -- **一步显式默认化。** `resolveSpec(config)` 在一步内解析文件名、格式、watch 标志与防抖窗口,因此绕过 Schemastery 规范化的程序化构造也得到同样的默认值。 -- **启动明确报错,重载保留最后可用值。** 存在但非法的文档使插件加载失败;运行中不可读或不可解析的编辑只告警并保留最后可用分节。 -- **每次写入都是读-改-写。** persist 先从磁盘对账并把任何差异发布进 seam,再基于这份新鲜文本渲染,因此写入绝不会复活陈旧文档或丢掉未观察到的同级分节。 -- **写入持有跨进程写锁。** 读-渲染-rename 流程在以 `wx` 创建的同级 `.lock` 保护下运行,带指数退避与 2 秒的获取期限;读取方从不取锁,因为 rename 提交是原子的。 -- **YAML 编辑是叶子级 diff。** 只设置变化的值、只删除被移除的键,保留未触碰节点上的注释、锚点与排版。 -- **重载与写入共享一条操作链。** watcher 刷新与来自各 namespace 队列的 persist 按队列顺序逐个执行;每次渲染都基于上一次操作提交后的文本。 -- **按内容抑制自写。** 提供方缓存最后可用文本;watcher 事件内容与缓存相同(含自己的写入)即为 no-op。 - -### 源码地图 - -| 文件 | 职责 | -|---|---| -| [`src/index.ts`](src/index.ts) | 提供方:spec 解析、加载/解析、写锁下的读-改-写、watcher 生命周期、YAML/JSON 渲染 | -| — | 不发布运行时不变式伴生入口;文件往返、watcher 时序与原子写入行为由包测试证明,进程内提交关系归 `@deepseek-ai/dsh-settings` 所有。 | - -### 文档生命周期 - -基类服务 init 在服务可注入前加载并发布文档;随后提供方启动 watcher,并在 ready 时对账一次,补上「初始读取与 watcher 生效之间写入的变更永不触发事件」的启动缺口。每个 watcher 事件与每次 persist 都排上同一条独占操作链。`reconcileFromDisk` 把磁盘文本与缓存比较,发布任何差异(缺失即空文档),只在解析失败时抛出,让每个调用方自行选择策略——重载告警并保留最后可用文档,写入明确报错。卸载先把提供方标记为已关闭,关闭 watcher,再等待所有已排队或进行中的操作完成,之后不再有任何发布。 - -### 渲染路径 - -YAML 渲染把缓存文本解析成可变的保留注释树,再对一个 namespace 施加叶子级编辑;JSON 渲染替换一个 namespace 键后以两个空格缩进重新序列化。在 Chokidar 打开目标之前,提供方对层级最深的现有祖先路径执行 realpath 解析,再拼回缺失的后缀,从而避免 Windows 在 libuv 内部混用 8.3 别名与长格式事件路径。 - -
- ------ - - -## 进一步探索 - -当提供方级约定不够用时阅读以下页面。它们从 seam 约定逐步进入原子写入原语与穷尽式配置面。 - -- [用户设置服务](../settings/README.zh.md)——namespace 注册、分层解析、写入与本提供方所供的事件。 -- [设置子系统参考](../../../docs/subsystems/settings.zh.md)——namespace、解析顺序、descriptor 与变更提交。 -- [设置包映射](../README.zh.md)——用户设置能力的两个包。 -- [原子写入](../../util/atomic-write/README.zh.md)——每次写入都使用的写锁与原子替换。 -- [主目录路径](../../util/home-paths/README.zh.md)——`$DSH_HOME` 解析与规范化监听路径。 -- [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-settings-file)——每个受支持配置字段及其源声明。 - ------ - - -## 模型体验 - -间接生效:存储值会影响的任何面向模型的行为均由 `ctx.settings` 的消费方负责;文件提供方只存储并发布 namespace 分节,自身不注册任何面向模型的内容。 - -#### KV Cache 影响 - -无直接失效;请求前缀的任何变更均由消费方插件负责。 - -## 已知限制与延期工作 - - - - -这些限制说明本提供方何时不合适或需要特别的运维注意。它们是当前包约束,不是任务积压。 - -- **同 namespace 冲突仍是后写胜出**——写锁加读-改-写让并发写入者不会丢掉彼此的 namespace,但两个写入者编辑同一个 namespace 时仍以较后的写入为准;没有按值合并,也没有修订检查。 -- **漏掉的 watcher 事件在下一个信号前保持不可见**——读取从不重新 stat 文件,因此 watcher 漏报的变更只会在下一个事件、下一次写入或重启时被并入。 -- **注释保留仅限 YAML 且仅限 map 形状**——JSON 文档重新序列化,无注释,且被改数组内部的注释(或行内附着在被改标量值上的注释)随其所描述的值一同被换掉。 -- **无值间接引用**——分节存字面值;面向密钥的 `${env:VAR}` 式引用是暂缓实现的 seam 层功能。 - - -### 开发备注 - -
-维护者的工作上下文——点击展开 - -本开发备注是维护者的工作上下文:尚未决定的暂缓方向。它明确非权威——已发布的行为、限制与已接受的理由见上文各节与包代码。暂缓方向:`${env:VAR}` 式值间接引用是 seam 层功能——落地时应归属设置服务约定,而非本提供方。遗留锁恢复按设计仍是操作者动作,因为锁龄无法区分崩溃的所有者与被暂停但仍存活的写入方。 - -
diff --git a/packages/settings/settings-file/src/index.ts b/packages/settings/settings-file/src/index.ts deleted file mode 100644 index 6a281479a1..0000000000 --- a/packages/settings/settings-file/src/index.ts +++ /dev/null @@ -1,371 +0,0 @@ -/** - * File-backed settings provider. One YAML or JSON document under the user's - * harness home carries every namespace section; external edits hot-publish - * through the seam, and every write re-reads the document under a - * cross-process writer lock before patching it as a comment-preserving - * leaf-level diff. - * @module @deepseek-ai/dsh-settings-file - */ - -import { Context, Service } from '@deepseek-ai/cordis' -import z from '@deepseek-ai/schemastery' -import { watch as chokidarWatch } from 'chokidar' -import { mkdir, readFile, writeFile } from 'node:fs/promises' -import { dirname, extname, join, resolve } from 'node:path' -import { Document, parseDocument } from 'yaml' -import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' -import { canonicalizeWatchPath, resolveDshHome } from '@deepseek-ai/dsh-home-paths' -import { SettingsProvider, type SettingsNamespace } from '@deepseek-ai/dsh-settings' -import { deepEqualJson } from '@deepseek-ai/dsh-util-values' - -/** Plugin config: file location and hot-reload behavior. */ -export interface Config { - /** Settings document path; defaults to `settings.yaml` under the harness home. */ - path?: string - /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */ - dshHome?: string - /** Watch the document and hot-publish external edits; defaults to true. */ - watch?: boolean - /** Watcher write-settle window in milliseconds; defaults to 100. */ - debounceMs?: number -} - -/** Document format derived from the configured file extension. */ -type SettingsFormat = 'yaml' | 'json' - -const FORMATS: Record = { - '.yaml': 'yaml', - '.yml': 'yaml', - '.json': 'json', -} - -/** Fully resolved provider parameters; defaulting happens here, never inline. */ -interface ResolvedSpec { - filename: string - format: SettingsFormat - watch: boolean - debounceMs: number -} - -/** - * Resolve the runtime spec from plugin config: an explicit `path` wins, - * otherwise the document lives at `/settings.yaml`. - * @param config - raw plugin config. - * @returns the resolved file location, format, and watch behavior. - */ -export function resolveSpec(config: Config): ResolvedSpec { - const filename = resolve(config.path ?? join(resolveDshHome(config.dshHome), 'settings.yaml')) - const format = FORMATS[extname(filename)] - if (format === undefined) { - throw new Error(`settings-file: extension "${extname(filename)}" is not supported (use .yaml, .yml, or .json)`) - } - return { - filename, - format, - watch: config.watch ?? true, - debounceMs: config.debounceMs ?? 100, - } -} - -/** Whether a parsed YAML value is a map for diffing purposes. */ -function isMapLike(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -/** - * Apply the difference between one node's stored and next value as minimal - * `setIn`/`deleteIn` edits, recursing through maps, so every untouched node — - * and the key node of every changed pair — keeps its comments, anchors, and - * formatting. Non-map values (arrays and scalars) replace wholesale when - * unequal, taking any comments inside them along. - */ -function patchNode(document: Document, path: readonly string[], current: unknown, next: unknown): void { - if (isMapLike(current) && isMapLike(next)) { - for (const key of Object.keys(current)) { - if (!(key in next)) document.deleteIn([...path, key]) - } - for (const [key, value] of Object.entries(next)) { - patchNode(document, [...path, key], current[key], value) - } - return - } - if (!deepEqualJson(current, next)) document.setIn([...path], next) -} - -/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */ -function isENOENT(error: unknown): boolean { - return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' -} - -/** Whether an exclusive file create found an existing document. */ -function isEEXIST(error: unknown): boolean { - return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' -} - -/** File-backed settings provider (`settings.yaml`/`.json`). */ -export class FileSettingsProvider extends SettingsProvider { - static Config: z = z.object({ - path: z.string(), - dshHome: z.string(), - watch: z.boolean().default(true), - debounceMs: z.number().min(0).default(100), - }) - - private readonly spec: ResolvedSpec - /** - * Raw text of the last successfully parsed or persisted document; - * `undefined` while the file is absent. Watcher events whose content equals - * this cache are no-ops, which is also the self-write suppression. - */ - private text: string | undefined - /** - * Single exclusive operation chain: watcher reloads and document writes run - * one at a time in queue order (settled tail), so a write can never render - * from text a concurrent reload is busy replacing, and a reload can never - * read a half-committed write. - */ - private operations: Promise = Promise.resolve() - /** Set at dispose: refuse new watcher events and let in-flight work no-op. */ - private closed = false - - /** Opaque read of {@link closed}: control flow cannot narrow it across awaits. */ - private isClosed(): boolean { - return this.closed - } - - constructor(ctx: Context, public config: Config) { - super(ctx) - // Programmatic construction may bypass Schemastery normalization; resolve - // the same defaults in one explicit step either way. - this.spec = resolveSpec(config) - } - - /** The local document is always writable through {@link SettingsProvider.update}. */ - get writable(): boolean { - return true - } - - /** The resolved YAML/JSON document path exposed to local configuration surfaces. */ - override get documentPath(): string { - return this.spec.filename - } - - /** Materialize an absent owner-only document, then return its resolved path. */ - override prepareDocument(): Promise { - return this.enqueue(async () => { - await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 }) - await withFileLock(this.spec.filename, async () => { - try { - await writeFile(this.spec.filename, '', { flag: 'wx', mode: 0o600 }) - } catch (error) { - if (isEEXIST(error)) return - throw error - } - this.text = '' - if (!this.isClosed()) this.publish({}) - }) - return this.spec.filename - }) - } - - protected async load(): Promise> { - let text: string - try { - text = await readFile(this.spec.filename, 'utf8') - } catch (error) { - if (!isENOENT(error)) throw error - this.text = undefined - return {} - } - const doc = this.parse(text) - this.text = text - return doc - } - - protected persist(ns: SettingsNamespace, section: Record): Promise { - // One document backs every namespace, so writes from different namespace - // queues serialize with each other and with watcher reloads on the one - // operation chain: each render must see the text the previous operation - // committed, or a sibling section silently vanishes from disk. - return this.enqueue(() => this.persistSection(ns, section)) - } - - /** Queue one exclusive document operation behind every earlier one. */ - private enqueue(operation: () => Promise): Promise { - const task = this.operations.then(operation) - this.operations = task.then(() => undefined, () => undefined) - return task - } - - /** Queue a reload; only an invariant violation escaping a commit can reject it. */ - private queueRefresh(): void { - void this.enqueue(() => this.refresh()).catch((error: unknown) => { - // Only an invariant violation escaping the commit path can reject a - // refresh; keep the operation queue alive and surface it as an error so - // one poisoned commit cannot silently end hot reloading forever. - this.ctx.logger.error('settings-file: reload commit failed at %s', this.spec.filename) - this.ctx.logger.error(error) - }) - } - - private async persistSection(ns: SettingsNamespace, section: Record): Promise { - // The writer lock's exclusive create needs the parent to exist before - // writeFileAtomic gets its own chance to create it. - // 0700: the harness home holds user-private documents. - await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 }) - await withFileLock(this.spec.filename, async () => { - // Read-modify-write: fold in any on-disk state this process has not - // observed yet — an external edit still inside the watcher debounce - // window, a change the watcher missed, or another process's write — so - // the render below can never resurrect a stale document. An unparsable - // on-disk document fails the write loud instead of silently overwriting - // a user's manual edit. - await this.reconcileFromDisk() - const output = this.spec.format === 'yaml' - ? this.renderYaml(ns, section) - : this.renderJson(ns, section) - // 0600: a document that may hold personal values is never world-readable. - await writeFileAtomic(this.spec.filename, output, { mode: 0o600, dirMode: 0o700 }) - this.text = output - }) - } - - override async* [Service.init](): AsyncGenerator<() => Promise | void, void, void> { - // The base init loads and publishes; a parse failure there is a boot - // failure: an existing-but-invalid document must fail loud, never be - // silently ignored or overwritten. - yield* super[Service.init]() - const watcher = this.spec.watch - ? chokidarWatch(await canonicalizeWatchPath(this.spec.filename), { - ignoreInitial: true, - awaitWriteFinish: { - stabilityThreshold: this.spec.debounceMs, - pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)), - }, - }) - : undefined - if (watcher !== undefined) { - watcher.on('all', () => { - if (this.closed) return - this.queueRefresh() - }) - watcher.on('ready', () => { - // The base init's load raced the watcher's own setup: a change written - // between that read and the watcher becoming active never fires an - // event. One reconcile at ready closes the gap. - if (this.closed) return - this.queueRefresh() - }) - watcher.on('error', (error) => { - this.ctx.logger.warn('settings-file: watcher error on %s', this.spec.filename) - this.ctx.logger.warn(error) - }) - } - yield async () => { - // Quiesce every operation chain, even when no watcher is configured. - this.closed = true - await watcher?.close() - await this.operations - } - } - - /** Parse one document text into raw sections, failing on a non-map root. */ - private parse(text: string): Record { - let root: unknown - if (this.spec.format === 'yaml') { - // `prettyErrors` is on only for `linePos`; `error.message` is never - // used, because the parser quotes the offending source line and a - // settings document can hold a `role('secret')` value. - const document = parseDocument(text, { prettyErrors: true }) - if (document.errors.length > 0) { - throw new Error(`settings-file: invalid document at ${this.spec.filename}: ${ - document.errors.map((error) => { - const at = error.linePos?.[0] - /* v8 ignore next -- `prettyErrors` populates linePos on every error; the guard answers its optional type */ - return `${error.code}${at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}`}` - }).join('; ')}`) - } - root = document.toJS() ?? {} - } else { - root = text.trim().length === 0 ? {} : JSON.parse(text) - } - if (typeof root !== 'object' || root === null || Array.isArray(root)) { - throw new TypeError(`settings-file: ${this.spec.filename} must be a map of namespace sections`) - } - return root as Record - } - - /** - * Re-read the document after a watcher event. Unchanged content (including - * this provider's own writes) is a no-op; an unreadable or unparsable - * document keeps the last good sections and warns — a live hot-reload must - * never take the process down. An invariant violation escaping a commit is - * not a reload failure and propagates to the queue's error surface. - */ - private async refresh(): Promise { - if (this.closed) return - try { - await this.reconcileFromDisk() - } catch (error) { - if ((error as { code?: unknown } | null)?.code === 'INVARIANT') throw error - this.ctx.logger.warn('settings-file: reload failed at %s; keeping the last good document', this.spec.filename) - this.ctx.logger.warn(error) - } - } - - /** - * Compare the on-disk text against the cache and publish any difference - * into the seam. Absence publishes the empty document; an unreadable or - * unparsable file throws, so each caller picks its policy — a reload warns - * and keeps the last good document, a write fails loud. - */ - private async reconcileFromDisk(): Promise { - let text: string | undefined - try { - text = await readFile(this.spec.filename, 'utf8') - } catch (error) { - if (!isENOENT(error)) throw error - text = undefined - } - if (text === this.text || this.isClosed()) return - if (text === undefined) { - this.text = undefined - this.publish({}) - return - } - const doc = this.parse(text) - this.text = text - this.publish(doc) - } - - /** - * Render the next YAML text by patching one namespace in the - * comment-preserving document. The next section lands as a leaf-level diff - * against the stored one — only changed values set, only removed keys - * delete — so comments inside the section survive edits to their siblings, - * not just comments outside it. - */ - private renderYaml(ns: SettingsNamespace, section: Record): string { - if (this.text === undefined) { - return new Document({ [ns]: section }).toString() - } - // this.text only ever caches content that parsed successfully, so this - // re-parse (for the mutable comment-preserving tree) cannot fail, and - // parse() already rejected any non-map root. - const document = parseDocument(this.text) - const root: unknown = document.toJS() - patchNode(document, [ns], isMapLike(root) ? root[ns] : undefined, section) - return document.toString() - } - - /** Render the next JSON text by replacing one namespace key. */ - private renderJson(ns: SettingsNamespace, section: Record): string { - const root = this.text === undefined - ? {} - : this.parse(this.text) - root[ns] = section - return `${JSON.stringify(root, null, 2)}\n` - } -} - -export default FileSettingsProvider diff --git a/packages/settings/settings-file/tests/concurrency.spec.ts b/packages/settings/settings-file/tests/concurrency.spec.ts deleted file mode 100644 index 26c98b99e2..0000000000 --- a/packages/settings/settings-file/tests/concurrency.spec.ts +++ /dev/null @@ -1,97 +0,0 @@ -// Cross-instance and writer-lock behavior: two providers on one document are -// the in-process equivalent of two dsh processes sharing a harness home — -// neither knows the other's cache, so only the read-modify-write cycle under -// the `.lock` sibling keeps both namespaces alive on disk. -import { afterEach, describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import z from '@deepseek-ai/schemastery' -import { chmod, mkdtemp, readFile, rm, utimes, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { FileSettingsProvider } from '../src/index.ts' - -const AlphaSchema: z<{ value: number }> = z.object({ value: z.number().default(0) }) -const BetaSchema: z<{ value: number }> = z.object({ value: z.number().default(0) }) - -const cleanups: Array<() => Promise> = [] - -afterEach(async () => { - while (cleanups.length > 0) await cleanups.pop()!() -}) - -async function tempDir(): Promise { - const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-lock-')) - cleanups.push(() => rm(dir, { recursive: true, force: true })) - return dir -} - -async function boot(config: ConstructorParameters[1]): Promise { - const ctx = new Context() - const fiber = ctx.plugin(FileSettingsProvider, config) - cleanups.push(async () => { await fiber.dispose() }) - await fiber - return ctx -} - -describe('cross-instance writes', () => { - it('keeps both namespaces when two providers write the same document concurrently', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - const first = await boot({ path, watch: false }) - const second = await boot({ path, watch: false }) - const alpha = first.settings.register('alpha', AlphaSchema) - const beta = second.settings.register('beta', BetaSchema) - const rounds = [1, 2, 3, 4, 5] - await Promise.all([ - (async () => { for (const value of rounds) await alpha.update({ value }) })(), - (async () => { for (const value of rounds) await beta.update({ value }) })(), - ]) - const text = await readFile(path, 'utf8') - expect(text).toContain('alpha:') - expect(text).toContain('beta:') - // A third instance resolves both final values from the shared document. - const third = await boot({ path, watch: false }) - expect(third.settings.register('alpha', AlphaSchema).get()).toEqual({ value: 5 }) - expect(third.settings.register('beta', BetaSchema).get()).toEqual({ value: 5 }) - }) -}) - -describe('writer lock', () => { - it('waits for a busy writer lock instead of failing', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - const ctx = await boot({ path, watch: false }) - const scope = ctx.settings.register('alpha', AlphaSchema) - await writeFile(`${path}.lock`, 'holder\n') - const release = setTimeout(() => { void rm(`${path}.lock`, { force: true }) }, 120) - cleanups.push(async () => { clearTimeout(release) }) - await scope.update({ value: 7 }) - expect(await readFile(path, 'utf8')).toContain('value: 7') - }) - - it('does not steal an old writer lock', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - await writeFile(path, 'alpha:\n value: 4\n') - const ctx = await boot({ path, watch: false }) - const scope = ctx.settings.register('alpha', AlphaSchema) - const lockPath = `${path}.lock` - await writeFile(lockPath, 'slow-holder\n') - const past = (Date.now() - 60_000) / 1000 - await utimes(lockPath, past, past) - - await expect(scope.update({ value: 9 })).rejects.toThrow(/timed out waiting for the writer lock/) - expect(await readFile(path, 'utf8')).toContain('value: 4') - expect(await readFile(lockPath, 'utf8')).toBe('slow-holder\n') - }, 10_000) - - it.skipIf(process.platform === 'win32')('surfaces a non-contention lock failure as the write error', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - const ctx = await boot({ path, watch: false }) - const scope = ctx.settings.register('alpha', AlphaSchema) - await chmod(dir, 0o500) - cleanups.push(() => chmod(dir, 0o700)) - await expect(scope.update({ value: 1 })).rejects.toThrow(/EACCES|permission/) - }) -}) diff --git a/packages/settings/settings-file/tests/loader-composition.spec.ts b/packages/settings/settings-file/tests/loader-composition.spec.ts deleted file mode 100644 index 015da372a7..0000000000 --- a/packages/settings/settings-file/tests/loader-composition.spec.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Real-composition guard: the provider and a consumer plugin boot from a - * test-only cordis.yml through the actual Loader + Include path, an external - * edit of settings.yaml hot-publishes into the consumer's scope, and the same - * consumer booted WITHOUT a settings entry keeps its entry-config resolution — - * the documented optional-inject fallback. - */ - -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { pathToFileURL } from 'node:url' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import Loader from '@deepseek-ai/cordis-plugin-loader' -import Include from '@deepseek-ai/cordis-plugin-include' -import z from '@deepseek-ai/schemastery' -import { type SettingsScope } from '@deepseek-ai/dsh-settings' -import FileSettingsProvider from '../src/index.ts' - -interface ThemeConfig { - theme: 'dark' | 'light' - fontSize: number -} - -const ThemeSchema: z = z.object({ - theme: z.union(['dark', 'light']).default('dark'), - fontSize: z.number().default(14), -}) - -let root: string | undefined -let context: Context | undefined - -afterEach(async () => { - await context?.fiber.dispose() - context = undefined - if (root !== undefined) await rm(root, { recursive: true, force: true }) - root = undefined -}) - -interface ConsumerState { - scope: SettingsScope | undefined - seen: ThemeConfig[] - /** What the consumer is actually running with, settings or not. */ - applied: ThemeConfig | undefined -} - -async function loadComposition( - options?: { withSettings?: boolean }, -): Promise<{ ctx: Context; state: ConsumerState; settingsPath: string }> { - const withSettings = options?.withSettings ?? true - root = await mkdtemp(join(tmpdir(), 'dsh-settings-composition-')) - const settingsPath = join(root, 'settings.yaml') - await writeFile(settingsPath, 'ui-theme:\n theme: light\n') - - const state: ConsumerState = { scope: undefined, seen: [], applied: undefined } - const consumer = { - name: 'settings-consumer', - apply: (ctx: Context) => { - // The documented consumer shape: no hard dependency — entry config alone - // is the running state, and the scoped inject overlays the user layer - // only while a settings service exists. - const base: Partial = { fontSize: 16 } - state.applied = ThemeSchema(base as ThemeConfig) - ctx.inject(['settings'], (child: Context) => { - const scope = child.settings.register('ui-theme', ThemeSchema, { base }) - state.scope = scope - state.applied = scope.get() - scope.watch((next) => { - state.seen.push(next) - state.applied = next - }) - }) - }, - } - - const configPath = join(root, 'cordis.yml') - await writeFile(configPath, [ - ...withSettings - ? [ - '- id: settings', - " name: '@deepseek-ai/dsh-settings-file'", - ' config:', - ` path: ${JSON.stringify(settingsPath)}`, - ' debounceMs: 10', - ] - : [], - '- id: consumer', - ' name: test-settings-consumer', - '', - ].join('\n')) - - const ctx = new Context() - context = ctx - ctx.baseUrl = pathToFileURL(root).href + '/' - await ctx.plugin(Loader) - ctx.loader.builtins.include = Include - const modules = new Map([ - ['@deepseek-ai/dsh-settings-file', FileSettingsProvider], - ['test-settings-consumer', consumer], - ]) - ctx.loader.internal = { - version: 'v2', - async import(specifier: string) { - if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) - return modules.get(specifier) - }, - } as unknown as NonNullable - await ctx.loader.create({ - name: 'cordis:include', - config: { path: pathToFileURL(configPath).href }, - }) - await ctx.loader.await() - return { ctx, state, settingsPath } -} - -describe('settings-file real composition', () => { - it('boots from cordis.yml and hot-publishes an external settings edit', async () => { - const { ctx, state, settingsPath } = await loadComposition() - - // Composition resolution: user layer over the consumer's composition base. - await vi.waitFor(() => { - expect(state.scope!.get()).toEqual({ theme: 'light', fontSize: 16 }) - }) - expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual(['ui-theme']) - - await writeFile(settingsPath, 'ui-theme:\n theme: dark\n fontSize: 20\n') - await vi.waitFor(() => { - expect(state.scope!.get()).toEqual({ theme: 'dark', fontSize: 20 }) - }, { timeout: 5000 }) - expect(state.seen.at(-1)).toEqual({ theme: 'dark', fontSize: 20 }) - }) - - it('boots the same consumer without a settings entry and keeps entry-config resolution', async () => { - const { ctx, state } = await loadComposition({ withSettings: false }) - - // No settings service anywhere in the composition… - expect(ctx.get('settings')).toBeUndefined() - // …so the consumer runs on schema defaults plus its composition base, and - // never receives a scope. - expect(state.applied).toEqual({ theme: 'dark', fontSize: 16 }) - expect(state.scope).toBeUndefined() - expect(state.seen).toEqual([]) - }) -}) diff --git a/packages/settings/settings-file/tests/local.spec.ts b/packages/settings/settings-file/tests/local.spec.ts deleted file mode 100644 index d4d644c2e6..0000000000 --- a/packages/settings/settings-file/tests/local.spec.ts +++ /dev/null @@ -1,438 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import z from '@deepseek-ai/schemastery' -import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, symlink, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' -import { FileSettingsProvider, resolveSpec } from '../src/index.ts' - -interface ThemeConfig { - theme: 'dark' | 'light' - fontSize: number -} - -const ThemeSchema: z = z.object({ - theme: z.union(['dark', 'light']).default('dark'), - fontSize: z.number().default(14), -}) - -const cleanups: Array<() => Promise> = [] - -afterEach(async () => { - while (cleanups.length > 0) await cleanups.pop()!() -}) - -async function tempDir(): Promise { - const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-local-')) - cleanups.push(() => rm(dir, { recursive: true, force: true })) - return dir -} - -async function boot(config: ConstructorParameters[1]): Promise { - const ctx = new Context() - const fiber = ctx.plugin(FileSettingsProvider, config) - cleanups.push(async () => { await fiber.dispose() }) - await fiber - return ctx -} - -describe('resolveSpec', () => { - it('defaults watch and debounce when construction bypasses schema normalization', () => { - const spec = resolveSpec({ path: '/tmp/anywhere/settings.yaml' }) - expect(spec.watch).toBe(true) - expect(spec.debounceMs).toBe(100) - }) -}) - -describe('boot and reads', () => { - it('resolves defaults over an absent file and reports writable', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - const ctx = await boot({ path, watch: false }) - const scope = ctx.settings.register('ui-theme', ThemeSchema, { - base: { fontSize: 16 }, - }) - expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 }) - expect(ctx.settings.writable).toBe(true) - expect(ctx.settings.documentPath).toBe(path) - }) - - it('prepares an absent owner-only document without changing resolved settings', async () => { - const dir = await tempDir() - const path = join(dir, 'nested', 'settings.yaml') - const ctx = await boot({ path, watch: false }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - - await expect(ctx.settings.prepareDocument()).resolves.toBe(path) - expect(await readFile(path, 'utf8')).toBe('') - if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600) - expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 }) - }) - - it('preparing an existing document preserves its contents', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - const contents = 'ui-theme:\n theme: light\n' - await writeFile(path, contents) - const ctx = await boot({ path, watch: false }) - - await expect(ctx.settings.prepareDocument()).resolves.toBe(path) - expect(await readFile(path, 'utf8')).toBe(contents) - }) - - it('reads sections from an existing yaml document', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - await writeFile(path, 'ui-theme:\n theme: light\n') - const ctx = await boot({ path, watch: false }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 }) - }) - - it('reads sections from a json document', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.json') - await writeFile(path, JSON.stringify({ 'ui-theme': { fontSize: 18 } })) - const ctx = await boot({ path, watch: false }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 }) - }) - - it('defaults the file location under the configured harness home', async () => { - const dir = await tempDir() - const ctx = await boot({ dshHome: dir, watch: false }) - expect(ctx.settings.documentPath).toBe(join(dir, 'settings.yaml')) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - await scope.update({ theme: 'light' }) - const written = await readFile(join(dir, 'settings.yaml'), 'utf8') - expect(written).toContain('theme: light') - }) - - it('reads an empty yaml document as no sections', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - await writeFile(path, '') - const ctx = await boot({ path, watch: false }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 }) - }) - - it('reads an empty json document as no sections', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.json') - await writeFile(path, '') - const ctx = await boot({ path, watch: false }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 }) - }) - - it.skipIf(process.platform === 'win32')('fails loud at boot when the document exists but is unreadable', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - await writeFile(path, 'ui-theme:\n theme: light\n') - await chmod(path, 0o000) - cleanups.push(() => chmod(path, 0o600)) - await expect(boot({ path, watch: false })).rejects.toThrow(/EACCES|permission/i) - }) - - it('fails loud when the document path names a directory', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - await mkdir(path) - await expect(boot({ path, watch: false })).rejects.toThrow(/EISDIR|directory/i) - }) - - it('fails loud on an unsupported extension', async () => { - const dir = await tempDir() - await expect(boot({ path: join(dir, 'settings.toml'), watch: false })) - .rejects.toThrow(/not supported/) - }) - - it('fails loud at boot on unparsable yaml', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - await writeFileAtomic(path, 'ui-theme: [unclosed\n', { mode: 0o600 }) - await expect(boot({ path, watch: false })).rejects.toThrow() - }) - - it('fails loud at boot when the root is not a map of sections', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - await writeFile(path, '- just\n- a list\n') - await expect(boot({ path, watch: false })).rejects.toThrow(/map of namespace sections/) - }) -}) - -describe('persist', () => { - it('writes the merged section, creating the file with owner-only permissions', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - const ctx = await boot({ path, watch: false }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - await scope.update({ theme: 'light' }) - - const written = await readFile(path, 'utf8') - expect(written).toContain('theme: light') - if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600) - // Atomic replace leaves no temp artifact behind. - expect((await readdir(dir)).sort()).toEqual(['settings.yaml']) - }) - - it('serializes cross-namespace writes into one on-disk document', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - const ctx = await boot({ path, watch: false }) - const alpha = ctx.settings.register('alpha', ThemeSchema) - const beta = ctx.settings.register('beta', ThemeSchema) - await Promise.all([ - alpha.update({ theme: 'light' }), - beta.update({ fontSize: 20 }), - ]) - const text = await readFile(path, 'utf8') - expect(text).toContain('alpha:') - expect(text).toContain('beta:') - expect(alpha.get().theme).toBe('light') - expect(beta.get().fontSize).toBe(20) - }) - - it('never follows a planted symlink at a temp path and never leaves the document a symlink', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - const victim = join(dir, 'victim.txt') - await writeFile(victim, 'precious') - // A hostile sibling plants the historic fixed temp name as a symlink. - await symlink(victim, `${path}.tmp`) - const ctx = await boot({ path, watch: false }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - await scope.update({ theme: 'light' }) - - expect(await readFile(victim, 'utf8')).toBe('precious') - expect((await lstat(path)).isSymbolicLink()).toBe(false) - if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600) - expect(await readFile(path, 'utf8')).toContain('theme: light') - }) - - it('preserves comments and unregistered sections across updates', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - await writeFile(path, [ - '# personal settings', - 'ui-theme:', - ' theme: light', - '# owned by a plugin that is not loaded right now', - 'future-plugin:', - ' keep: me', - '', - ].join('\n')) - const ctx = await boot({ path, watch: false }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - await scope.update({ fontSize: 18 }) - - const written = await readFile(path, 'utf8') - expect(written).toContain('# personal settings') - expect(written).toContain('# owned by a plugin that is not loaded right now') - expect(written).toContain('keep: me') - expect(written).toContain('fontSize: 18') - expect(written).toContain('theme: light') - }) - - it('keeps comments inside the section when a sibling key changes', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - await writeFile(path, [ - 'ui-theme:', - ' # chosen during onboarding', - ' theme: light', - ' fontSize: 12', - '', - ].join('\n')) - const ctx = await boot({ path, watch: false }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - await scope.update({ fontSize: 18 }) - const written = await readFile(path, 'utf8') - expect(written).toContain('# chosen during onboarding') - expect(written).toContain('theme: light') - expect(written).toContain('fontSize: 18') - }) - - it('keeps a changed key\'s own-line comment while replacing its value', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - await writeFile(path, [ - 'ui-theme:', - ' # chosen during onboarding', - ' theme: light', - '', - ].join('\n')) - const ctx = await boot({ path, watch: false }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - await scope.update({ theme: 'dark' }) - const written = await readFile(path, 'utf8') - expect(written).toContain('# chosen during onboarding') - expect(written).toContain('theme: dark') - }) - - it('deletes only the removed key on replace, keeping sibling comments', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - await writeFile(path, [ - 'ui-theme:', - ' # chosen during onboarding', - ' theme: light', - ' fontSize: 12', - '', - ].join('\n')) - const ctx = await boot({ path, watch: false }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - await scope.replace({ theme: 'light' }) - const written = await readFile(path, 'utf8') - expect(written).toContain('# chosen during onboarding') - expect(written).toContain('theme: light') - expect(written).not.toContain('fontSize') - }) - - it('keeps an unchanged array\'s comments and replaces a changed array wholesale', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - const TagsSchema: z<{ tags: string[]; label: string }> = z.object({ - tags: z.array(z.string()).default([]), - label: z.string().default(''), - }) - await writeFile(path, [ - 'workspace:', - ' tags:', - ' # pinned by hand', - ' - alpha', - ' label: draft', - '', - ].join('\n')) - const ctx = await boot({ path, watch: false }) - const scope = ctx.settings.register('workspace', TagsSchema) - await scope.update({ label: 'final' }) - const untouched = await readFile(path, 'utf8') - expect(untouched).toContain('# pinned by hand') - expect(untouched).toContain('label: final') - // A changed array replaces wholesale; comments inside it go with it. - await scope.update({ tags: ['beta'] }) - const replaced = await readFile(path, 'utf8') - expect(replaced).not.toContain('# pinned by hand') - expect(replaced).toContain('- beta') - }) - - it('keeps a comment-only document\'s comment when the first section lands', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - // Parses to a null root: the document exists but holds no sections yet. - await writeFile(path, '# reserved for future settings\n') - const ctx = await boot({ path, watch: false }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - await scope.update({ theme: 'light' }) - const written = await readFile(path, 'utf8') - expect(written).toContain('# reserved for future settings') - expect(written).toContain('theme: light') - }) - - it('creates a json document from scratch', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.json') - const ctx = await boot({ path, watch: false }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - await scope.update({ theme: 'light' }) - const written = JSON.parse(await readFile(path, 'utf8')) as Record - expect(written).toEqual({ 'ui-theme': { theme: 'light' } }) - }) - - it('rejects and recovers when the document path becomes a directory', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - const backup = join(dir, 'settings.committed.yaml') - await writeFile(path, 'ui-theme:\n theme: light\n') - const ctx = await boot({ path, watch: false }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - await rename(path, backup) - await mkdir(path) - await expect(scope.update({ theme: 'dark' })).rejects.toThrow() - await rm(path, { recursive: true }) - await rename(backup, path) - expect((await readdir(dir)).sort()).toEqual(['settings.yaml']) - expect(scope.get().theme).toBe('light') - // The failed persist must not poison the document write chain. - await scope.update({ theme: 'dark' }) - expect(scope.get().theme).toBe('dark') - }) - - it('round-trips a json document', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.json') - await writeFile(path, JSON.stringify({ other: { keep: true } }, null, 2)) - const ctx = await boot({ path, watch: false }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - await scope.update({ theme: 'light' }) - const written = JSON.parse(await readFile(path, 'utf8')) as Record - expect(written).toEqual({ other: { keep: true }, 'ui-theme': { theme: 'light' } }) - }) -}) - -describe('watch', () => { - it('publishes an external edit to registered scopes', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - await writeFile(path, 'ui-theme:\n theme: light\n') - const ctx = await boot({ path, debounceMs: 10 }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - expect(scope.get().theme).toBe('light') - - await writeFile(path, 'ui-theme:\n theme: dark\n fontSize: 20\n') - await vi.waitFor(() => { - expect(scope.get()).toEqual({ theme: 'dark', fontSize: 20 }) - }, { timeout: 5000 }) - }) - - it('keeps the last good document over an invalid edit, then recovers', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - await writeFile(path, 'ui-theme:\n theme: light\n') - const ctx = await boot({ path, debounceMs: 10 }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - - // Replace the external edit atomically so this case observes one complete - // invalid document instead of a transient empty file during truncation. - await writeFileAtomic(path, 'ui-theme: [unclosed\n', { mode: 0o600 }) - // The bad edit must never take the live tree down or reset the value. - await new Promise(resolve => setTimeout(resolve, 300)) - expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 }) - - await writeFileAtomic(path, 'ui-theme:\n theme: dark\n', { mode: 0o600 }) - await vi.waitFor(() => { - expect(scope.get().theme).toBe('dark') - }, { timeout: 5000 }) - }) - - it('treats file removal as an empty document', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - await writeFile(path, 'ui-theme:\n theme: light\n') - const ctx = await boot({ path, debounceMs: 10 }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - - await rm(path) - await vi.waitFor(() => { - expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 }) - }, { timeout: 5000 }) - }) - - it('does not republish its own persisted write', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - const ctx = await boot({ path, debounceMs: 10 }) - const events: unknown[] = [] - ctx.on('settings/updated', (ns, _next, _prev, source) => { - events.push({ ns, source }) - }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - await scope.update({ theme: 'light' }) - await new Promise(resolve => setTimeout(resolve, 300)) - expect(events).toEqual([{ ns: 'ui-theme', source: 'update' }]) - }) -}) diff --git a/packages/settings/settings-file/tests/lock-race.spec.ts b/packages/settings/settings-file/tests/lock-race.spec.ts deleted file mode 100644 index 0969ae7a5a..0000000000 --- a/packages/settings/settings-file/tests/lock-race.spec.ts +++ /dev/null @@ -1,125 +0,0 @@ -// A temp-file write failure cannot be timed from outside. The `fs/promises` API -// injects it once so the test can prove that the writer lock still releases. -import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import z from '@deepseek-ai/schemastery' -import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { FileSettingsProvider } from '../src/index.ts' - -const state = vi.hoisted(() => ({ - failTempWrite: false, - failDocumentCreate: false, - holdDocumentCreate: false, - documentCreateStarted: undefined as (() => void) | undefined, - continueDocumentCreate: undefined as Promise | undefined, -})) - -vi.mock('node:fs/promises', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - writeFile: (async (path: unknown, ...rest: never[]) => { - if (state.holdDocumentCreate && String(path).endsWith('settings.yaml')) { - state.holdDocumentCreate = false - state.documentCreateStarted!() - await state.continueDocumentCreate! - } - if (state.failDocumentCreate && String(path).endsWith('settings.yaml')) { - state.failDocumentCreate = false - throw Object.assign(new Error('ENOSPC: injected document create failure'), { code: 'ENOSPC' }) - } - if (state.failTempWrite && String(path).endsWith('.tmp')) { - state.failTempWrite = false - throw Object.assign(new Error('ENOSPC: injected writeFile failure'), { code: 'ENOSPC' }) - } - return (actual.writeFile as (path: unknown, ...args: never[]) => Promise)(path, ...rest) - }) as typeof actual.writeFile, - } -}) - -const AlphaSchema: z<{ value: number }> = z.object({ value: z.number().default(0) }) - -const cleanups: Array<() => Promise> = [] - -afterEach(async () => { - state.failTempWrite = false - state.failDocumentCreate = false - state.holdDocumentCreate = false - state.documentCreateStarted = undefined - state.continueDocumentCreate = undefined - while (cleanups.length > 0) await cleanups.pop()!() -}) - -async function tempDir(): Promise { - const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-lockrace-')) - cleanups.push(() => rm(dir, { recursive: true, force: true })) - return dir -} - -async function boot(config: ConstructorParameters[1]): Promise { - const ctx = new Context() - const fiber = ctx.plugin(FileSettingsProvider, config) - cleanups.push(async () => { await fiber.dispose() }) - await fiber - return ctx -} - -describe('writer-lock failure cleanup', () => { - it('skips publication when an in-flight document create completes during teardown', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - const ctx = new Context() - const fiber = ctx.plugin(FileSettingsProvider, { path, watch: false }) - cleanups.push(async () => { await fiber.dispose() }) - await fiber - const settings = ctx.settings - settings.register('alpha', AlphaSchema) - const published: number[] = [] - ctx.on('settings/document-updated', (_ns, revision) => { published.push(revision) }) - let markStarted!: () => void - const started = new Promise((resolve) => { markStarted = resolve }) - let releaseCreate!: () => void - state.continueDocumentCreate = new Promise((resolve) => { releaseCreate = resolve }) - state.documentCreateStarted = markStarted - state.holdDocumentCreate = true - - const preparing = settings.prepareDocument() - await started - let disposed = false - const disposing = fiber.dispose() - void disposing.then(() => { disposed = true }) - await vi.waitFor(() => { - expect((settings as unknown as { closed: boolean }).closed).toBe(true) - }) - expect(disposed).toBe(false) - releaseCreate() - await expect(preparing).resolves.toBe(path) - await disposing - expect(await readFile(path, 'utf8')).toBe('') - expect(published).toEqual([]) - }) - - it('surfaces an exclusive document-create failure and releases the lock', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - const ctx = await boot({ path, watch: false }) - state.failDocumentCreate = true - await expect(ctx.settings.prepareDocument()).rejects.toThrow(/ENOSPC/) - await expect(access(`${path}.lock`)).rejects.toThrow() - }) - - it('cleans up the temp file and releases the lock when the write fails mid-cycle', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - await writeFile(path, 'alpha:\n value: 1\n') - const ctx = await boot({ path, watch: false }) - const scope = ctx.settings.register('alpha', AlphaSchema) - state.failTempWrite = true - await expect(scope.update({ value: 9 })).rejects.toThrow(/ENOSPC/) - // The document is untouched and the writer lock was released on the way out. - expect(await readFile(path, 'utf8')).toContain('value: 1') - await expect(access(`${path}.lock`)).rejects.toThrow() - }) -}) diff --git a/packages/settings/settings-file/tests/watcher.spec.ts b/packages/settings/settings-file/tests/watcher.spec.ts deleted file mode 100644 index a77e0b7052..0000000000 --- a/packages/settings/settings-file/tests/watcher.spec.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import z from '@deepseek-ai/schemastery' -import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { FileSettingsProvider } from '../src/index.ts' - -// chokidar is the nondeterministic OS boundary: faking it lets these tests -// drive the event pipeline (error events, races with unreadable files) -// deterministically. Real end-to-end watching stays covered by local.spec.ts. -vi.mock('chokidar', async () => { - const { EventEmitter } = await import('node:events') - class FakeWatcher extends EventEmitter { - close = vi.fn(() => Promise.resolve()) - } - const instances: Array<{ path: string; options: unknown; watcher: InstanceType }> = [] - return { - watch: vi.fn((path: string, options: unknown) => { - const watcher = new FakeWatcher() - instances.push({ path, options, watcher }) - return watcher - }), - __instances: instances, - } -}) - -interface FakeChokidar { - __instances: Array<{ - path: string - options: { awaitWriteFinish: { stabilityThreshold: number; pollInterval: number } } - watcher: import('node:events').EventEmitter - }> -} - -async function fakeInstances(): Promise { - const chokidar = await import('chokidar') as unknown as FakeChokidar - return chokidar.__instances -} - -const ThemeSchema: z<{ theme: string }> = z.object({ - theme: z.string().default('dark'), -}) - -const cleanups: Array<() => Promise> = [] - -afterEach(async () => { - while (cleanups.length > 0) await cleanups.pop()!() - ;(await fakeInstances()).length = 0 -}) - -async function tempDir(): Promise { - const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-watch-')) - cleanups.push(() => rm(dir, { recursive: true, force: true })) - return dir -} - -async function boot(config: ConstructorParameters[1]): Promise { - const ctx = new Context() - const fiber = ctx.plugin(FileSettingsProvider, config) - cleanups.push(async () => { await fiber.dispose() }) - await fiber - return ctx -} - -describe('watcher pipeline', () => { - it('clamps the write-settle poll interval for a zero debounce', async () => { - const dir = await tempDir() - await boot({ path: join(dir, 'settings.yaml'), debounceMs: 0 }) - const [instance] = await fakeInstances() - expect(instance!.options.awaitWriteFinish).toEqual({ stabilityThreshold: 0, pollInterval: 1 }) - }) - - it('survives a watcher error and keeps publishing later edits', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - const ctx = await boot({ path, debounceMs: 5 }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - const [instance] = await fakeInstances() - - instance!.watcher.emit('error', new Error('watch backend failure')) - expect(scope.get()).toEqual({ theme: 'dark' }) - - await writeFile(path, 'ui-theme:\n theme: light\n') - instance!.watcher.emit('all', 'change', path) - await vi.waitFor(() => { - expect(scope.get()).toEqual({ theme: 'light' }) - }) - }) - - it('keeps the last good document when the file turns unreadable at runtime', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - await writeFile(path, 'ui-theme:\n theme: light\n') - const ctx = await boot({ path, debounceMs: 5 }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - - await chmod(path, 0o000) - cleanups.push(() => chmod(path, 0o600)) - const [instance] = await fakeInstances() - instance!.watcher.emit('all', 'change', path) - // The warn-and-keep path is asynchronous; give the serialized refresh a turn. - await new Promise(resolve => setTimeout(resolve, 50)) - expect(scope.get()).toEqual({ theme: 'light' }) - }) - - it('keeps the reload queue alive after an invariant violation escapes a commit', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - await writeFile(path, 'ui-theme:\n theme: light\n') - const ctx = await boot({ path, debounceMs: 5 }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - let arm = true - ctx.on('settings/updated', () => { - if (!arm) return - throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' }) - }) - const [instance] = await fakeInstances() - - await writeFile(path, 'ui-theme:\n theme: broken-commit\n') - instance!.watcher.emit('all', 'change', path) - await vi.waitFor(() => { - expect(scope.get().theme).toBe('broken-commit') - }) - - arm = false - await writeFile(path, 'ui-theme:\n theme: recovered\n') - instance!.watcher.emit('all', 'change', path) - await vi.waitFor(() => { - expect(scope.get().theme).toBe('recovered') - }) - }) - - it('quiesces the refresh pipeline before dispose completes', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - await writeFile(path, 'ui-theme:\n theme: light\n') - const ctx = new Context() - const fiber = ctx.plugin(FileSettingsProvider, { path, debounceMs: 5 }) - await fiber - ctx.settings.register('ui-theme', ThemeSchema) - let disposed = false - let postDisposeCommits = 0 - ctx.on('settings/updated', () => { - if (disposed) postDisposeCommits += 1 - }) - - await writeFile(path, 'ui-theme:\n theme: darker\n') - const [instance] = await fakeInstances() - // Two queued refreshes: dispose interrupts one mid-flight and the other - // before it starts, so both closed guards must hold. - instance!.watcher.emit('all', 'change', path) - instance!.watcher.emit('all', 'change', path) - await fiber.dispose() - disposed = true - instance!.watcher.emit('all', 'change', path) - instance!.watcher.emit('ready') - await new Promise(resolve => setTimeout(resolve, 100)) - expect(postDisposeCommits).toBe(0) - }) - - it('treats an event for a still-absent file as a no-op', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - const ctx = await boot({ path, debounceMs: 5 }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - const [instance] = await fakeInstances() - instance!.watcher.emit('all', 'add', path) - await new Promise(resolve => setTimeout(resolve, 50)) - expect(scope.get()).toEqual({ theme: 'dark' }) - }) - - it('folds an unobserved external edit into a write instead of overwriting it', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - await writeFile(path, 'ui-theme:\n theme: light\n') - const ctx = await boot({ path, debounceMs: 5 }) - const theme = ctx.settings.register('ui-theme', ThemeSchema) - const editor = ctx.settings.register('editor', z.object({ - tabWidth: z.number().default(2), - })) - // The external edit has landed on disk but its watcher event has not - // fired yet (a debounce window, or a missed event): the write must fold - // it in, not resurrect the stale document. - await writeFile(path, 'ui-theme:\n theme: light\neditor:\n tabWidth: 8\n') - await theme.update({ theme: 'darker' }) - const text = await readFile(path, 'utf8') - expect(text).toContain('tabWidth: 8') - expect(text).toContain('theme: darker') - // The fold published the unobserved section before the write committed. - expect(editor.get()).toEqual({ tabWidth: 8 }) - }) - - it('reconciles at watcher ready so a change during setup is not missed', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - await writeFile(path, 'ui-theme:\n theme: light\n') - const ctx = await boot({ path, debounceMs: 5 }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - // Written after the initial load but before the watcher became active: - // no 'all' event will ever fire for it. - await writeFile(path, 'ui-theme:\n theme: written-before-ready\n') - const [instance] = await fakeInstances() - instance!.watcher.emit('ready') - await vi.waitFor(() => { - expect(scope.get().theme).toBe('written-before-ready') - }) - }) - - it('fails a write loud when the on-disk document turned invalid unobserved', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - await writeFile(path, 'ui-theme:\n theme: light\n') - const ctx = await boot({ path, debounceMs: 5 }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - const broken = 'ui-theme: [unclosed\n flow: {\n' - await writeFile(path, broken) - await expect(scope.update({ theme: 'darker' })).rejects.toThrow(/invalid document/) - // The user's manual edit stays on disk untouched and the cache keeps the - // last good value. - expect(await readFile(path, 'utf8')).toBe(broken) - expect(scope.get()).toEqual({ theme: 'light' }) - }) -}) diff --git a/packages/settings/settings/README.i18n.yaml b/packages/settings/settings/README.i18n.yaml index ca772a4ea5..cf7563a580 100644 --- a/packages/settings/settings/README.i18n.yaml +++ b/packages/settings/settings/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/settings/settings/README.md -README.md: eabc7bf6f18b7d062462e02b63aa6c0d994755fc -README.zh.md: afa6d621a3a51b855937b7705a55017bd21f04b6 +README.md: a44dd50a4614401afa6e05bb5d55313a8b833732 +README.zh.md: 0f7df0dec04159eaca4e143e10b4a8e6f81452a0 diff --git a/packages/settings/settings/README.md b/packages/settings/settings/README.md index eabc7bf6f1..a44dd50a46 100644 --- a/packages/settings/settings/README.md +++ b/packages/settings/settings/README.md @@ -1,5 +1,5 @@ --- -description: "The user-settings service for plugin authors and maintainers registering configurable namespaces, reading resolved values, or wiring configuration surfaces." +description: "Inspect and edit live plugin configuration through Config-derived forms." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use this package when users must change a plugin's configuration at runtime without restarting or rereading `cordis.yml`. Each namespace combines schema defaults, deployment configuration, and user overrides; readers receive a deep-frozen resolved snapshot and can observe committed changes. Writes affect only user overrides, are serialized per namespace, and may reject stale revisions instead of overwriting newer changes. Durable runtime edits require configured settings storage; without it, the plugin continues with its composed configuration. +Edit fields that plugins declare with `.volatile()` and inspect their effective values. Forms identify each plugin by its profile entry id, preserve secret values, and refuse stale writes. Changes persist through the active profile’s Cordis patch. ## Table of Contents @@ -20,62 +20,23 @@ Use this package when users must change a plugin's configuration at runtime with - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) - [Dev Note](#dev-note) ------ - ## Use this package -Plugins and configuration surfaces use `ctx.settings` to read and change configuration at runtime. The common path: mount a provider, register a namespace with a schema, read and watch the resolved value, and write through the owner scope. - -### When to choose it - -Choose settings when a plugin's configuration should be changeable at runtime — by the user editing a document or by a configuration UI — without restarting or re-reading `cordis.yml`. It fits when several plugins each own one configuration namespace, and when a configuration surface must render schemas, mark user-overridden fields, and persist edits. It is unnecessary when configuration is fixed at load time: without a provider mounted, nothing changes and configuration stays exactly as composed. - -### Mounting a provider - -The service stores nothing by itself; mount a provider such as the shipped file-backed one: +Mount this plugin with Loader and [config-editor](../../boot/config-editor/README.md). The base bundle provides this composition. ```yaml -- name: '@deepseek-ai/dsh-settings-file' - config: - path: /absolute/path/to/settings.yaml +- id: settings + name: '@deepseek-ai/dsh-settings' ``` -`ctx.settings` appears once the provider is live. The provider README owns the full configuration surface; the generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-settings-file) lists every accepted field. +This plugin has no configuration fields. Forms expose only volatile fields from active, uniquely addressed profile entries. Ordinary configuration remains editable through Cordis configuration files. -### Registering a namespace +Once the Loader has settled every entry after Settings starts, a `settings.yaml` left in the harness home by earlier releases is imported once: each section is written into the entry of the same id (`ui-developer-tools` → `ui-settings`, `ui-onboarding` → `ui-settings-general`, `shell` → the platform's shell executor entry), the file is renamed to `settings.yaml.imported` before the first write, and a section the running composition rejects is logged and stays only in the renamed file. -A plugin registers its own namespace with a schemastery schema, optionally supplying the composition entry as the `base` layer so the resolved value starts from what the deployment already configured: +Reset restores the value beneath the profile override, including schema defaults. Home patches and command-line overlays take precedence; a form write that they would override is refused. -```text -const scope = ctx.settings.register('ui-theme', ThemeSchema, { - base: config, // composition entry config; the user layer resolves above it -}) -const theme = scope.get() // deep-frozen resolved snapshot -scope.update({ density: 'compact' }) // merges into the user section and persists -``` - -Literal namespace arguments are checked by TypeScript against the lowercase letter, digit, and hyphen grammar; dynamically supplied strings receive the same validation at runtime. `ctx.settings.installSection(owner, ns, schema, entry, hooks)` packages the optional-service wiring for a consumer plugin: while a settings service exists it registers the namespace with the plugin's composition entry as `base`; when the service goes away the plugin falls back to its entry config and keeps working exactly as composed. - -### Reading and observing values - -`get(ns)` returns the resolved value as a deep-frozen snapshot, `undefined` while the namespace is unregistered. `watch(callback)` invokes the callback after each committed change with `(next, prev)`: invocations of one callback run one at a time in commit order, and failures are contained and logged, so a slow or throwing observer never blocks or breaks other observers. - -### Writing values - -`update(ns, patch)` deep-merges a plain-object patch into the user section only — never into `base` — validates the resolved candidate, persists through the provider, then commits. `replace(ns, section)` sets the user section wholesale, which is the removal/reset path: `replace({})` re-inherits `base` and schema defaults. `mutate(ns, ops)` applies ordered `{ op: 'set' | 'unset', path }` edits to the section as it stands when the write reaches the front of the queue — the removal path for a caller holding an incomplete (for example redacted) view, because rebuilding a section from what a wire surface returned and replacing it wholesale would delete every field the wire never sent back. - -Every write rejects non-JSON-compatible data (a `Date`, `Map`, `BigInt`, non-finite number, or circular reference fails with its `$`-rooted path before anything persists), rejects on a read-only provider, and accepts an optional `expectedRevision`: pass back the `revision` from a descriptor, and a namespace that moved past it refuses the write with `SettingsConflictError` instead of overwriting the writer that landed first. - -### Configuration surfaces - -`describe()` returns one descriptor per registered namespace: the serialized schema, the resolved value, the detached `base` and `user` layers (a field's presence in `user` marks it user-overridden), the effect timing, and the namespace's revision. Pass `redactSecrets: true` on every wire surface: it strips `role('secret')` fields from every layer and enumerates them as `{ path, set }` slots so a page can render write-only inputs without ever receiving a secret. `documentPath` and `prepareDocument()` expose the provider's user-editable file to a native editor when one exists. - -### Events and failures - -`settings/updated (ns, next, prev, source)` fires after each committed change — an in-process write (`source: 'update'`) or an externally observed edit (`source: 'provider'`) — and never when the resolved value is deep-equal. `settings/document-updated (ns, revision)` fires whenever the raw user section changed, even when the resolved value did not, which is what an open editor needs to learn that a field went from inherited to overridden. A stored section the schema rejects keeps the namespace's last good value and warns on reload; at registration the same failure rejects the registration itself. - ------ +Each form reports `autoGenerate`, enabled by default, for clients that build pages from the schema; no shipped client does so yet. A plugin that ships its own page registers `configure({ auto: false }, ctx.fiber)` as an effect inside an optional `ctx.inject(['settings'], ...)` child from `apply`: the child names the plugin fiber the policy belongs to, a late-loading or replaced Settings service picks the policy up, and the business plugin runs without Settings. The policy does not remove configuration reads or writes. ## Understand the implementation @@ -83,72 +44,34 @@ Every write rejects non-JSON-compatible data (a `Date`, `Map`, `BigInt`, non-fin
Implementation internals — click to expand -This section explains the design decisions behind the service and points at the code that realizes them; the observable behavior is fully covered in [Use this package](#use-this-package). +The [form projection](src/schema.ts) strips runtime references and ordinary fields. The [service](src/index.ts) supplies revisioned descriptors and validates edits against the full plugin Config before delegating persistence. Business plugins read their Config references directly. -### Design philosophy - -- **Layered resolution, single user layer.** A namespace's value is schema defaults, then the registrant's composition `base`, then the user document section; writes touch only the user layer, so `replace({})` is a true reset. -- **Commits are deep-equal gated.** `settings/updated` fires only when the resolved value moved; the raw-section event is separate because configuration surfaces must also learn "inherited became overridden". -- **Writes are queued and revision-checked.** Per-namespace write queues serialize in call order, and `expectedRevision` is judged at the front of the queue, where the service can tell a fresh writer from one holding a stale snapshot. -- **Observer and listener failures are contained.** Watcher invocations and event fan-out isolate sync throws and async rejections so one broken observer cannot wedge commits or a provider's reload loop; `INVARIANT`-coded failures rethrow after every listener ran. -- **Registrations are fiber effects.** Registering a namespace is an effect on the calling plugin's fiber: disposing that fiber removes the namespace and its observers. - -### Source map - -| File | Role | -|---|---| -| [`src/index.ts`](src/index.ts) | Service Definition: namespace validation, registration, resolution, write queue, describe/redaction, events, `installSection` | -| [`src/redact.ts`](src/redact.ts) | `redactSecrets` walker: strip `role('secret')` fields and enumerate their slots | -| [`src/types.ts`](src/types.ts) | Client-safe type surface: event declarations, `SettingsNamespace`, `SettingsUpdateSource` | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: `settings/updated` fires only for a registered namespace, only on a resolved-value change, with the authoritative value | - -### Resolution and write paths - -Each write snapshots its input at call time (detaching and validating JSON-shaped data), then queues on the namespace's serialized chain. At the front of the queue the service re-reads the section as it stands, checks `expectedRevision`, merges/replaces/mutates, resolves and validates the candidate through the schema plus the owner's optional `validate`, persists through the provider, and only then commits and emits. A write whose registrant fiber was disposed mid-flight still reaches storage but commits and notifies nobody; teardown refuses new writes and drains queued writes and started watcher invocations before disposal completes. - -### Change detection and events - -`commit` compares resolved values with the seam's `deepEqualJson` predicate and fans `settings/updated` out one listener at a time. `bumpRevision` compares raw sections and emits `settings/document-updated` with the new revision; it runs independently of the resolved-value check. Both fan-outs contain listener failures the same way. - -### Client-safe types - -The `./types` subpath export holds the event declarations together with the `SettingsNamespace` and `SettingsUpdateSource` types their signatures name, and the package root re-exports those types. A consumer outside the Host compilation face reads the exact signature the Host emits instead of restating it. +Secret roles are redacted from values, inherited values, profile overrides, and schema defaults; clients receive presence markers. Path edits preserve fields a client did not receive. No invariant companion is published because the service projects Loader configuration rather than maintaining an independent authoritative value.
------ - ## Further Exploration -Read these pages when the service-level contract is not enough. They move from the shared subsystem vocabulary to the shipped provider and the capability architecture. - -- [Settings subsystem reference](../../../docs/subsystems/settings.md) — namespaces, registration, owner scope, descriptors, change commits, and the generated cordis surface. -- [File-backed settings provider](../settings-file/README.md) — the shipped YAML/JSON provider: configuration, hot reload, comment-preserving writes. -- [Settings package map](../README.md) — the two packages of the user-settings capability and their roles. -- [Capability seams](../../../docs/capability-seams.md) — the Service Definition / Service Provider / Consumer split this service follows. - ------ +- [Settings reference](../../../docs/subsystems/settings.md) — form values and mutations. +- [Volatile configuration](../../../vendor/loader/README.md) — reference lifetime and notifications. +- [Configuration editor](../../boot/config-editor/README.md) — persistence and reload ordering. ## Model Experience -Indirectly, through consumer plugins, which own any model-facing content fed by a settings value; the service only stores and resolves user settings and registers nothing model-facing itself. +Indirectly, through configuration values consumed by model-facing plugins. #### KV Cache effect -No direct invalidation; a consumer that folds a settings value into the request prefix owns that change. +Consumers that change request prefixes determine cache effects. ## Known Limitations and Deferred Work - -These limits define when the service is a poor fit or needs special care. They are current package constraints, not a task backlog. - -- **Single user layer** — resolution knows schema defaults, one composition `base`, and one user document; it does not record which layer supplied each resolved value. -- **`redactSecrets` is not a proven wire boundary** — the walker follows `object`/`dict`/`array` containers, so a `role('secret')` field reachable only through a union, intersection, or transform is returned verbatim with an empty `secrets` list, and the serialized schema carries a secret field's default to every client. Neither case is rejected; a schema whose secrets are not reachable through the walked containers must not be registered on a wire-exposed namespace. A fail-closed `describeForWire()` — one that refuses a schema it cannot prove safe and sanitizes the serialized envelope and error text — is the deferred answer. -- **Cross-process concurrency is provider-defined** — the service serializes writes per namespace in-process only; concurrent processes converge by provider behavior (the file provider read-modify-writes under a writer lock, so namespaces survive concurrent writers and same-namespace conflicts resolve last-write-wins). +- Nested Includes own separate configurations and are not editable through the active profile’s form. +- Field-level resets restore inherited values; they cannot delete a value supplied by a lower configuration layer. Unsetting an array index removes that element. ### Dev Note @@ -156,6 +79,6 @@ These limits define when the service is a poor fit or needs special care. They a
Working context for maintainers — click to expand -This Dev Note is working context for maintainers: open design directions that are not decided. It is explicitly non-authoritative — shipped behavior, limits, and accepted rationale live in the sections above and the package code. Open directions, tracked in code TODOs: rename the public `ns` parameter to `namespace` across the API, provider contract, implementations, tests, and consumers; deactivate watchers and await their tails on registration disposal so callbacks cannot outlive the registrant fiber; re-resolve a replacement registration from its persisted section so an in-flight old write cannot leave it stale; and use property-safe object construction so valid JSON keys such as `__proto__` remain own data. The fail-closed `describeForWire()` sanitizer is the deferred answer to the redaction limitation above. +None.
diff --git a/packages/settings/settings/README.zh.md b/packages/settings/settings/README.zh.md index afa6d621a3..0f7df0dec0 100644 --- a/packages/settings/settings/README.zh.md +++ b/packages/settings/settings/README.zh.md @@ -1,5 +1,5 @@ --- -description: "面向插件作者与维护者的用户设置服务:注册可配置 namespace、读取解析值或接入配置界面。" +description: "通过 Config 派生表单查看和编辑插件的即时配置。" kind: "package-reference" --- @@ -9,153 +9,76 @@ kind: "package-reference" ## 概述 -当用户需要在运行时修改插件配置,而无需重启或重新读取 `cordis.yml` 时,请使用本包。每个 namespace 合并 schema 默认值、部署配置与用户覆盖;读取方会得到深冻结的解析值快照,并可观察已提交的变更。写入只影响用户覆盖、按 namespace 串行执行,并可拒绝陈旧 revision,避免覆盖较新的变更。持久化运行时编辑需要先配置设置存储;否则插件仍可继续使用组合配置。 +编辑插件通过 `.volatile()` 声明的字段,并查看其实际值。表单以 profile 条目 id 标识插件、保留秘密值,并拒绝过期写入。更改通过当前 profile 的 Cordis patch 持久化。 ## 目录 -- [使用本包](#use-this-package) +- [使用此包](#use-this-package) - [理解实现](#understand-the-implementation) - [进一步探索](#further-exploration) - [模型体验](#model-experience) -- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [已知限制与延后工作](#known-limitations-and-deferred-work) - [开发备注](#dev-note) ------ - -## 使用本包 +## 使用此包 -插件与配置界面通过 `ctx.settings` 在运行时读取并修改配置。常用路径:挂载提供方、用 schema 注册 namespace、读取并观察解析值,并通过 owner scope 写入。 - -### 何时选择 - -当插件的配置需要在运行时可变——用户编辑文档或配置界面修改——且无需重启或重读 `cordis.yml` 时,选择设置服务。它适合多个插件各拥有一个配置 namespace、以及配置界面需要渲染 schema、标记用户覆盖字段并持久化编辑的场景。当配置在加载时固定则没有必要:没有挂载提供方时一切照旧,配置保持组合原样。 - -### 挂载提供方 - -服务本身不存储任何内容;请挂载一个提供方,例如随附的文件型提供方: +将此插件与 Loader 和 [config-editor](../../boot/config-editor/README.zh.md) 一起挂载。基础组合包提供此组合。 ```yaml -- name: '@deepseek-ai/dsh-settings-file' - config: - path: /absolute/path/to/settings.yaml +- id: settings + name: '@deepseek-ai/dsh-settings' ``` -提供方上线后 `ctx.settings` 即出现。完整配置面由提供方 README 负责;生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-settings-file)列出每个受支持字段。 +此插件没有配置字段。表单只展示活动且可唯一定位的 profile 条目中的 volatile 字段。普通配置仍通过 Cordis 配置文件编辑。 -### 注册 namespace +Settings 启动后、Loader 完成所有条目的加载时,早期版本留在 harness home 中的 `settings.yaml` 会被导入一次:每个 section 写入同名条目(`ui-developer-tools` → `ui-settings`、`ui-onboarding` → `ui-settings-general`、`shell` → 当前平台的 shell 执行器条目),文件在第一次写入前改名为 `settings.yaml.imported`,被当前组合拒绝的 section 会记录日志并只保留在改名后的文件中。 -插件用 schemastery schema 注册自己的 namespace,并可选地把组合配置作为 `base` 层传入,让解析值从部署已配置的内容起步: +重置恢复 profile 覆盖层以下的值,包括 schema 默认值。Home patch 和命令行 overlay 优先级更高;表单写入若会被它们覆盖,则被拒绝。 -```text -const scope = ctx.settings.register('ui-theme', ThemeSchema, { - base: config, // composition entry config; the user layer resolves above it -}) -const theme = scope.get() // deep-frozen resolved snapshot -scope.update({ density: 'compact' }) // merges into the user section and persists -``` - -TypeScript 会按小写字母、数字与连字符文法检查字面量 namespace 参数;运行时动态传入的字符串接受相同校验。`ctx.settings.installSection(owner, ns, schema, entry, hooks)` 为消费方插件封装可选服务接线:只要设置服务存在,它就用插件的组合配置作为 `base` 注册 namespace;服务消失时插件回退到组合配置,行为与原先完全一致。 - -### 读取与观察值 - -`get(ns)` 以深冻结快照返回解析值,namespace 未注册时为 `undefined`。`watch(callback)` 在每次已提交变更后以 `(next, prev)` 调用回调:同一回调的调用按提交顺序逐个执行,异常被隔离并记入日志,因此慢或抛错的观察者绝不会阻塞或破坏其他观察者。 - -### 写入值 - -`update(ns, patch)` 把普通对象 patch 深合并进用户分节——绝不进 `base`——校验解析候选值、经提供方持久化后提交。`replace(ns, section)` 整体替换用户分节,是删除/重置路径:`replace({})` 重新继承 `base` 与 schema 默认值。`mutate(ns, ops)` 在写入排到队首那一刻的分节上按序施加 `{ op: 'set' | 'unset', path }` 编辑——这是持有不完整(例如脱敏后)视图的调用方的删除路径,因为按协议接口返回的内容重建分节再整体替换,会删掉协议从未回传的每个字段。 - -每次写入都会拒绝与 JSON 不兼容的数据(`Date`、`Map`、`BigInt`、非有限数或循环引用会在任何内容持久化前以 `$` 为根的路径报错)、拒绝只读提供方上的写入,并可接受可选的 `expectedRevision`:把 descriptor 中的 `revision` 传回,namespace 已越过该值时写入会被 `SettingsConflictError` 拒绝,而不是覆盖先完成写入的一方。 - -### 配置界面 - -`describe()` 为每个已注册 namespace 返回一条 descriptor:序列化 schema、解析值、分离的 `base` 与 `user` 层(字段出现在 `user` 中即标记为用户覆盖)、生效时机与 namespace 的 revision。每个协议接口都必须传入 `redactSecrets: true`:它从每一层剥离 `role('secret')` 字段,并把它们枚举为 `{ path, set }` slot,让页面可以渲染只写输入而不接触任何机密。`documentPath` 与 `prepareDocument()` 在提供方拥有用户可编辑文件时把它暴露给原生编辑器。 - -### 事件与失败 - -`settings/updated (ns, next, prev, source)` 在每次已提交变更后触发——进程内写入(`source: 'update'`)或外部观察到的编辑(`source: 'provider'`)——解析值深相等时绝不触发。`settings/document-updated (ns, revision)` 在原始用户分节发生变化时触发,即使解析值没有变——已打开的编辑器正需要它来得知字段从继承变为覆盖。schema 拒绝的存量分节在重载时保留该 namespace 的最后可用值并告警;注册时同样的失败会直接拒绝注册。 - ------ +每个表单报告 `autoGenerate`(默认开启),供按 schema 生成页面的客户端使用;目前没有已发布的客户端这样做。自带页面的插件在 `apply` 中于可选的 `ctx.inject(['settings'], ...)` 子级内以 effect 注册 `configure({ auto: false }, ctx.fiber)`:子级指明策略所属的插件 fiber,迟加载或被替换的 Settings 服务也会采用该策略,业务插件无需 Settings 即可运行。策略不移除配置读写。 ## 理解实现
-实现细节——点击展开 +实现内部细节——点击展开 -本节解释服务背后的设计决策并指出实现它们的代码位置;可观察行为已在[使用本包](#use-this-package)中完整说明。 +[表单投影](src/schema.ts) 去除运行时引用和普通字段。[服务](src/index.ts) 提供带修订号的描述符,在委托持久化前按完整插件 Config 验证编辑。业务插件直接读取自己的 Config 引用。 -### 设计理念 - -- **分层解析,单一用户层。** namespace 的值依次为 schema 默认值、注册方的组合 `base`、用户文档分节;写入只触碰用户层,因此 `replace({})` 是真正的重置。 -- **提交以深相等为门槛。** 只有解析值变化时 `settings/updated` 才触发;原始分节事件独立存在,因为配置界面还必须得知「继承变成了覆盖」。 -- **写入排队并做 revision 检查。** 每个 namespace 的写队列按调用顺序串行,`expectedRevision` 在队首判断——那里服务才能分辨持有新鲜快照的写入方与持有陈旧快照的写入方。 -- **观察者与监听器异常被隔离。** watcher 调用与事件扇出隔离同步抛出与异步拒绝,一个坏掉的观察者不会卡死提交或提供方的重载循环;`INVARIANT` 编码的失败在所有监听器执行完后重新抛出。 -- **注册是 fiber 上的 effect。** 注册 namespace 是调用方插件 fiber 上的 effect:dispose(资源释放)该 fiber 即移除 namespace 及其观察者。 - -### 源码地图 - -| 文件 | 职责 | -|---|---| -| [`src/index.ts`](src/index.ts) | Service Definition:namespace 校验、注册、解析、写队列、describe/脱敏、事件、`installSection` | -| [`src/redact.ts`](src/redact.ts) | `redactSecrets` 遍历器:剥离 `role('secret')` 字段并枚举其 slot | -| [`src/types.ts`](src/types.ts) | 客户端安全类型面:事件声明、`SettingsNamespace`、`SettingsUpdateSource` | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:`settings/updated` 只对已注册 namespace、只在解析值变化时、且携带权威值触发 | - -### 解析与写入路径 - -每次写入都在调用时对输入做快照(分离并校验 JSON 形状的数据),然后排上该 namespace 的串行链。在队首,服务按当前状态重读分节、检查 `expectedRevision`、合并/替换/编辑、经 schema 与 owner 的可选 `validate` 解析并校验候选值、经提供方持久化,然后才提交并发出事件。registrant fiber 在写入途中被 dispose 的写入仍到达存储,但不会提交、也不会通知任何人;卸载先拒绝新写入,并排干排队写入与已启动的 watcher 调用后才完成。 - -### 变更检测与事件 - -`commit` 用 seam 的 `deepEqualJson` 谓词比较解析值,并逐监听器扇出 `settings/updated`。`bumpRevision` 比较原始分节并携带新 revision 发出 `settings/document-updated`;它与解析值检查相互独立。两个扇出以相同方式隔离监听器异常。 - -### 客户端安全类型 - -`./types` 子路径出口持有事件声明及其签名点名的 `SettingsNamespace`、`SettingsUpdateSource` 类型,包根继续 re-export 这些类型。于是 Host 编译面之外的消费方读到的正是 Host 发射的那一份签名,而不必再写一遍。 +秘密角色从实际值、继承值、profile 覆盖值和 schema 默认值中隐藏;客户端接收存在性标记。路径编辑保留客户端未收到的字段。此服务投影 Loader 配置,不维护独立的权威值,因此不发布 invariant companion。
------ - ## 进一步探索 -当服务级约定不够用时阅读以下页面。它们从共享子系统词汇逐步进入随附提供方与能力架构。 - -- [设置子系统参考](../../../docs/subsystems/settings.zh.md)——namespace、注册、owner scope、descriptor、变更提交与生成的 cordis 接口面。 -- [文件型设置提供方](../settings-file/README.zh.md)——随附的 YAML/JSON 提供方:配置、热重载、保留注释的写入。 -- [设置包映射](../README.zh.md)——用户设置能力的两个包及其角色。 -- [能力 seam](../../../docs/capability-seams.zh.md)——本服务遵循的 Service Definition / Service Provider / Consumer 拆分。 - ------ +- [设置参考](../../../docs/subsystems/settings.zh.md)——表单值与修改。 +- [Volatile 配置](../../../vendor/loader/README.md)——引用生命周期与通知。 +- [配置编辑器](../../boot/config-editor/README.zh.md)——持久化与重载顺序。 ## 模型体验 -间接生效:由设置值提供的所有面向模型的内容均由消费方插件负责;本服务只存储并解析用户设置,自身不注册任何面向模型的内容。 +通过面向模型的插件读取的配置值间接影响模型。 #### KV Cache 影响 -无直接失效;把设置值纳入请求前缀的消费方负责该变更。 +改变请求前缀的消费者决定缓存影响。 -## 已知限制与延期工作 +## 已知限制与延后工作 - -这些限制说明本服务何时不合适或需要特别注意。它们是当前包约束,不是任务积压。 - -- **单一用户层**——解析只认识 schema 默认值、一个组合 `base` 与一个用户文档;它不记录每个解析值由哪一层提供。 -- **`redactSecrets` 并非一条可被证明的协议边界**——遍历器只跟随 `object`/`dict`/`array` 容器,因此只能经由 union、intersection 或 transform 抵达的 `role('secret')` 字段会被原样返回,且 `secrets` 列表为空;序列化 schema 还会把 secret 字段的默认值带给每个客户端。两种情况都不会被拒绝;机密无法经由被遍历的容器抵达的 schema,绝不可注册到暴露于协议的 namespace 上。fail-closed 的 `describeForWire()`——拒绝自己无法证明安全的 schema,并对序列化封装与错误文本做净化——是暂缓的答案。 -- **跨进程并发由提供方定义**——服务仅在进程内按 namespace 串行写入;跨进程并发按提供方行为收敛(文件提供方在写锁下读-改-写,因此并发写入者不会丢掉彼此的 namespace,同 namespace 冲突按后写胜出解决)。 +- 嵌套 Include 独立拥有配置,不能通过当前 profile 的表单编辑。 +- 字段重置恢复继承值,不能删除下层配置提供的值。取消设置数组索引会移除该元素。 ### 开发备注
-维护者的工作上下文——点击展开 +维护者工作上下文——点击展开 -本开发备注是维护者的工作上下文:尚未决定的开放设计方向。它明确非权威——已发布的行为、限制与已接受的理由见上文各节与包代码。代码 TODO 中记录的开放方向:把公开的 `ns` 参数更名为 `namespace`(API、提供方约定、实现、测试与消费方同步);释放注册项时停用所有 watcher 并等待其调用链完成,确保回调不会在 registrant fiber 释放后继续运行;替换注册从持久化分节重新解析,让进行中的旧写入不会把它留成陈旧值;改用属性安全的对象构造,让 `__proto__` 这类合法 JSON 键保持为自有数据。fail-closed 的 `describeForWire()` 净化器是上文脱敏限制的暂缓答案。 +无。
diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json index 30ad30c96b..cc061e8c10 100644 --- a/packages/settings/settings/package.json +++ b/packages/settings/settings/package.json @@ -18,10 +18,6 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" @@ -31,7 +27,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/types/**/*.js", "lib/types/**/*.d.ts" ], @@ -48,9 +43,18 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/cordis-plugin-timer": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-hmr": "workspace:^", + "@deepseek-ai/dsh-agent-default-model": "workspace:^", + "yaml": "^2.8.1" }, "dependencies": { - "@deepseek-ai/dsh-util-values": "workspace:^" + "@deepseek-ai/dsh-util-values": "workspace:^", + "@deepseek-ai/dsh-config-editor": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/cosmokit": "workspace:^", + "yaml": "^2.8.1" } } diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index 232364b4e8..e0586ecb24 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -1,156 +1,47 @@ -/** - * Service Definition for the user-settings capability seam (`ctx.settings`). Providers store one raw document of - * per-namespace sections; plugins register a namespace schema and read the - * resolved value, which layers schema defaults, the registrant's composition - * `base`, and the user document section, in that order. - * @module @deepseek-ai/dsh-settings - */ - -import { Context, Service } from '@deepseek-ai/cordis' +/** Config-schema projection and form edits over Cordis profile patches. */ +import { existsSync } from 'node:fs' +import { readFile, rename } from 'node:fs/promises' +import { join } from 'node:path' +import { parse } from 'yaml' +import { Context, FiberState, Service, resolveConfig, type Fiber } from '@deepseek-ai/cordis' import type z from '@deepseek-ai/schemastery' -import { deepEqualJson, deepFreeze } from '@deepseek-ai/dsh-util-values' -import { redactSecrets } from './redact.ts' -import type { RedactedSecret } from './redact.ts' -import type { SettingsNamespace, SettingsUpdateSource } from './types.ts' +import { interpolate, type Entry } from '@deepseek-ai/cordis-plugin-loader' +import type {} from '@deepseek-ai/dsh-config-editor' +import type {} from '@deepseek-ai/dsh-app-boot' +import { redactSecrets, type RedactedSecret } from './redact.ts' +import { isVolatilePath, plainConfig, projectForm, volatileForm } from './schema.ts' +import type { SettingsNamespace } from './types.ts' export { redactSecrets } from './redact.ts' export type { RedactedSecret, RedactedValue } from './redact.ts' -export type { SettingsNamespace, SettingsUpdateSource } from './types.ts' +export type { SettingsNamespace } from './types.ts' -const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/ -type LowercaseLetter = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm' - | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z' -type DecimalDigit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' -type NamespaceCharacter = LowercaseLetter | DecimalDigit | '-' -type ValidNamespaceTail = Value extends '' - ? true - : Value extends `${NamespaceCharacter}${infer Rest}` - ? ValidNamespaceTail - : false -type SettingsNamespaceInput = Value extends SettingsNamespace - ? Value - : string extends Value - ? string - : Value extends `${LowercaseLetter}${infer Rest}` - ? ValidNamespaceTail extends true ? Value : never - : never - -function parseSettingsNamespace(value: string): SettingsNamespace { - if (!NAMESPACE_PATTERN.test(value)) { - throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`) - } - return value as SettingsNamespace -} - -/** When a namespace's changes take effect for its owner. */ -export type SettingsApplies = 'live' | 'restart' - -/** Registration options beyond the namespace schema. */ -export interface SettingsRegisterOptions { - /** Composition-layer values resolved below the user layer (entry-config subset). */ - base?: Partial - /** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */ - applies?: SettingsApplies - /** - * Reject a resolved section the owner could not act on, for constraints its - * schema cannot express — a cross-field requirement, or one field's validity - * depending on another's. Throwing here refuses the *write* that produced the - * value, so a caller learns at `update`/`replace`/`mutate` instead of storing - * something that would silently disable the owner. - * - * Kept separate from the schema because the schema is also what a - * configuration surface renders and what an absent section resolves through; - * folding a cross-field check into it would change both. - * - * Once the owner is registered, a stored section that fails this keeps the - * namespace's last good value and warns, exactly as a schema failure does, - * so an externally edited document cannot strand a running owner. At - * registration there is no last good value yet, so a stored section that - * already fails rejects the registration itself — again exactly as a schema - * failure does. - * @param value - the resolved section, schema-valid by construction. - */ - validate?: (value: T) => void -} - -/** One registered namespace as surfaced to configuration UIs. */ +/** One Loader entry's live Config fields. */ export interface SettingsDescriptor { - // TODO(settings-namespace-vocabulary): Rename `ns` to `namespace` across the - // public API, provider contract, implementations, tests, and consumers. - /** The registered namespace. */ ns: SettingsNamespace - /** Serialized schemastery schema (`schema.toJSON()`). */ + /** Whether the UI may generate a page when no custom page exists. */ + autoGenerate: boolean schema: unknown - /** Current resolved value. */ value: unknown - /** - * Monotonic revision of the raw user section this descriptor was read at. - * Send it back as `expectedRevision` on a write to refuse a stale one. - */ revision: number - /** Registrant's composition `base` layer (detached), when one was declared. */ base?: unknown - /** - * Raw user section from the stored document (detached), when one exists and - * is well-formed; a field's presence here is what marks it user-overridden. - */ user?: unknown - /** Owner's declared effect timing. */ - applies: SettingsApplies - /** Schema-declared secret positions; present only under `redactSecrets`. */ + applies: 'live' secrets?: RedactedSecret[] } -/** Options for {@link SettingsProvider.describe}. */ +/** Wire readers always request secret redaction. */ export interface SettingsDescribeOptions { - /** - * Strip `role('secret')` fields from `value`/`base`/`user` and enumerate - * them in each descriptor's `secrets`. Every wire surface MUST pass this; - * the verbatim default exists for same-process configuration UIs only. - */ redactSecrets?: boolean } -/** Owner-facing handle for one registered namespace. */ -export interface SettingsScope { - /** Current resolved value: schema defaults, then `base`, then the user layer. */ - get(): T - /** - * Observe committed changes to this namespace's resolved value. Invocations - * of one callback run asynchronously, one at a time, in commit order; a - * rejection is contained and logged like a sync throw. After the disposer - * returns, no further invocation starts — one already queued is skipped; - * one already started still settles, and service disposal waits for it. - * @param callback - invoked after each commit with the next and previous values. - * @returns the disposer removing this observer. - */ - watch(callback: (next: T, prev: T) => void | Promise): () => void - /** - * Merge a partial patch into this namespace's user layer and persist it. - * @param patch - plain-object patch over the user section; JSON-compatible data - * only (non-JSON values reject with their path before anything persists). - */ - update(patch: object): Promise - /** - * Replace this namespace's user section wholesale; absent keys re-inherit - * the composition `base` and schema defaults (`replace({})` resets all). - * @param section - the complete next user section; JSON-compatible data only, - * as for {@link update}. - */ - replace(section: object): Promise -} - declare module '@deepseek-ai/cordis' { interface Context { - settings: SettingsProvider + /** Schema-derived plugin configuration forms. */ + settings: SettingsForms } } - -/** - * A write refused because the namespace moved since the caller read it. The - * Service Definition's serialized write queue orders writes; it cannot tell a fresh writer - * from one holding a stale snapshot, which is what this reports. - */ +/** Refusal to overwrite configuration changed since the form was read. */ export class SettingsConflictError extends Error { /** Stable machine code for wire layers mapping this to their own taxonomy. */ readonly code = 'SETTINGS_CONFLICT' @@ -192,29 +83,30 @@ export type SettingsPathOp = | { op: 'unset'; path: readonly string[] } /** Apply one path op to a detached section, returning the next section. */ -function applyPathOp(section: Record, op: SettingsPathOp): Record { - const [head, ...rest] = op.path - // The empty path addresses the section itself. - if (head === undefined) { - if (op.op === 'unset') return {} - if (!isPlainObject(op.value)) { - throw new TypeError('settings mutate: setting the section root requires a plain object') +function applyPathOp(section: Record, op: SettingsPathOp, schema: z): Record { + const edit = (input: unknown, path: readonly string[], node?: z): unknown => { + const [head, ...rest] = path + if (head === undefined) return op.op === 'set' ? op.value : undefined + const value: unknown = input === undefined ? node?.meta.default : input + if (Array.isArray(value)) { + if (!/^(0|[1-9][0-9]*)$/.test(head) || (Number(head) > value.length || Number(head) === value.length && (rest.length > 0 || op.op === 'unset'))) { + throw new TypeError(`Config array index "${head}" is out of range`) + } + const result: unknown[] = [...value as unknown[]] + const index = Number(head) + if (rest.length === 0 && op.op === 'unset') result.splice(index, 1) + else result[index] = edit(value[index], rest, node?.inner) + return result } - return { ...op.value } + const result = isPlainObject(value) ? { ...value } : {} + const child = edit(Object.hasOwn(result, head) ? result[head] : undefined, rest, node?.dict?.[head] ?? node?.inner) + if (child === undefined) Reflect.deleteProperty(result, head) + else Object.defineProperty(result, head, { value: child, enumerable: true, writable: true, configurable: true }) + return result } - if (rest.length === 0) { - if (op.op === 'set') return { ...section, [head]: op.value } - const { [head]: _removed, ...kept } = section - return kept - } - const child = section[head] - if (!isPlainObject(child)) { - // Unsetting through an absent path is already satisfied; setting through - // one creates the intermediate objects it needs. - if (op.op === 'unset') return section - return { ...section, [head]: applyPathOp({}, { ...op, path: rest }) } - } - return { ...section, [head]: applyPathOp(child, { ...op, path: rest }) } + const result = edit(section, op.path, schema) + if (!isPlainObject(result)) throw new TypeError('Config root must be a plain object') + return result } /** Human label for a value that lossless JSON cannot represent (numbers reject inline). */ @@ -236,14 +128,12 @@ function describeRejected(value: unknown): string { * silently distorts on the reload round-trip. `undefined` entries in objects * are skipped — the same sparse-patch semantics as {@link mergeLayers} — while * an `undefined` array entry is rejected rather than coerced. - * @param root - plain-object write input (caller-checked). - * @param reject - builds the validation error from a value label and its `$`-rooted path. + * @param root - write input to validate before merging. * @returns the detached JSON-compatible clone. */ -function cloneJsonShaped( - root: Record, - reject: (label: string, path: string) => TypeError, -): Record { +function cloneJsonShaped(root: object): Record { + const reject = (label: string, path: string): TypeError => new TypeError(`Config ${path} contains ${label}`) + if (!isPlainObject(root)) throw reject('a non-plain root', '$') const visiting = new WeakSet() const clone = (value: unknown, path: string): unknown => { if (value === null || typeof value === 'string' || typeof value === 'boolean') return value @@ -262,12 +152,10 @@ function cloneJsonShaped( if (isPlainObject(value)) { if (visiting.has(value)) throw reject('a circular reference', path) visiting.add(value) - // TODO(settings-json-properties): Use property-safe construction here and - // in mergeLayers so valid JSON keys such as "__proto__" remain own data. const out: Record = {} for (const [key, entry] of Object.entries(value)) { if (entry === undefined) continue - out[key] = clone(entry, `${path}.${key}`) + Object.defineProperty(out, key, { value: clone(entry, `${path}.${key}`), enumerable: true, configurable: true, writable: true }) } visiting.delete(value) return out @@ -285,609 +173,259 @@ function cloneJsonShaped( * cannot erase lower keys. */ function mergeLayers(under: unknown, over: unknown): unknown { - if (over === undefined) return under if (!isPlainObject(under) || !isPlainObject(over)) return over const merged: Record = { ...under } for (const [key, value] of Object.entries(over)) { - merged[key] = key in merged ? mergeLayers(merged[key], value) : value + Object.defineProperty(merged, key, { + value: Object.hasOwn(merged, key) ? mergeLayers(merged[key], value) : value, + enumerable: true, configurable: true, writable: true, + }) } return merged } -/** One registered watcher and its serialized invocation chain. */ -interface SettingsWatcher { - callback: (next: never, prev: never) => void | Promise - /** Settled tail: invocations of this callback run one at a time, in commit order. */ - tail: Promise - /** Cleared by the disposer: a queued invocation checks this before starting. */ - active: boolean -} -/** One live namespace registration owned by a registrant fiber. */ -interface SettingsRegistration { - ns: SettingsNamespace - schema: z - base: unknown - applies: SettingsApplies - /** Owner-supplied check for constraints the schema cannot express. */ - validate?: (value: unknown) => void - resolved: unknown - /** - * Monotonic counter over this namespace's RAW user section — bumped by any - * change to what is stored, including one whose resolved value is - * unchanged (adding an override equal to the composition base). Editors - * carry it as `expectedRevision` to detect a concurrent write, and the - * document event carries it so another tab learns a field went from - * inherited to overridden. - */ - revision: number - watchers: Set -} - -/** - * Abstract settings service. Providers implement raw-document storage - * (`load`/`persist`) and push external changes through {@link Settings.publish}; - * the base class owns namespace registration, resolution, validation, change - * detection, and the `settings/updated` commit event. +/** Read one member of a plain object or array; `own` limits the read to own properties. + * @param node Candidate container. + * @param key Member name. + * @param own Whether inherited members count as absent. + * @returns The member, or undefined when the node is not a container or lacks the member. */ -export abstract class SettingsProvider extends Service { - private readonly registrations = new Map() - /** Latest published raw document; empty until the provider's first publish. */ - private document: Record = {} - /** Per-namespace write chains; settled tails, so a failure never poisons the queue. */ - private readonly writeQueues = new Map>() - /** In-flight watcher invocation segments, drained by the dispose teardown. */ - private readonly pendingTails = new Set>() - /** Set at service dispose: refuse new writes while queued ones drain. */ - private stopped = false +function member(node: unknown, key: string, own = false): unknown { + if (!(isPlainObject(node) || Array.isArray(node)) || (own && !Object.hasOwn(node, key))) return undefined + const value: unknown = Reflect.get(node, key) + return value +} - /** Opaque read of {@link stopped}: control flow cannot narrow it across awaits. */ - private isStopped(): boolean { - return this.stopped +/** Entry ids of the removed `settings.yaml` sections whose owning entry carries another id. */ +const LEGACY_SECTION_ENTRIES: Record = { + 'ui-developer-tools': 'ui-settings', + 'ui-onboarding': 'ui-settings-general', + /* v8 ignore next -- the base bundle composes one shell executor per platform */ + shell: process.platform === 'win32' ? 'pwsh-sandbox' : 'bash-sandbox', +} + +/** Resolve the inherited layers alone, or keep their raw values when required fields arrive only through the profile. + * @param runtime Plugin runtime owning the Config schema. + * @param inherited Interpolated config beneath the profile override. + * @returns Values the profile override sits on. + */ +function inheritedConfig(runtime: Fiber['runtime'] & object, inherited: unknown): unknown { + try { + return resolveConfig(runtime, inherited) + } catch (_error) { + // The profile override supplies fields the inherited layers lack; the form shows their raw values as the base. + return inherited + } +} + +/** Project Config schemas into forms and own optional instance-level UI policy. */ +export class SettingsForms extends Service { + static inject = ['configEditor', 'profileContext'] + private revisions = new Map() + private closed = false + private scheduled = false + private readonly presentations = new Map() + + constructor(private readonly ownerContext: Context) { + super(ownerContext, 'settings') + const ctx = ownerContext + ctx.effect(() => () => { this.closed = true }) + ctx.on('app-boot/config-reload', () => { this.invalidate() }) + void ctx.root.loader.await().then(() => this.importLegacyDocument()).catch((error: unknown) => { ctx.logger.error(error) }) } - constructor(ctx: Context) { - super(ctx, 'settings') - } - - /** - * Load the provider's document once and publish it before the service - * becomes injectable, and register the write-drain teardown. Providers with - * their own init (watchers, connections) delegate here first via - * `yield* super[Service.init]()`; their disposers then run before the drain. - */ - async* [Service.init](): AsyncGenerator<() => Promise | void, void, void> { - yield async () => { - // Teardown: refuse new writes and new watcher starts, then wait until - // every queued write chain and every started watcher invocation settles - // so disposal completes only once storage and observers are quiescent. - // Invocations queued but not yet started skip via the stopped check. - this.stopped = true - await Promise.allSettled([...this.writeQueues.values(), ...this.pendingTails]) + /** Move the sections of the removed `settings.yaml` into the active profile once the Loader has settled every entry. + * The document is renamed before the first write, so a partial import never repeats; a section the running + * composition rejects is logged and remains only in the renamed file. */ + private async importLegacyDocument(): Promise { + const profile = this.ownerContext.profileContext + const path = join(profile.home, 'settings.yaml') + if (!existsSync(path)) return + const imported = `${path}.imported` + await rename(path, imported) + const sections = parse(await readFile(imported, 'utf8')) as Record | null + for (const [section, values] of Object.entries(sections ?? {})) { + const ns = LEGACY_SECTION_ENTRIES[section] ?? section + try { + await this.update(ns, values) + } catch (error) { + this.ownerContext.logger.warn('settings: section %s of %s was not imported into entry %s', section, imported, ns) + this.ownerContext.logger.warn(error) + } } - this.publish(await this.load()) + this.ownerContext.logger.info('settings: imported %s into profile %s', imported, profile.name) } - /** Whether {@link update} may persist through this provider. */ - abstract readonly writable: boolean - - /** - * Absolute path of the provider's user-editable document, when its storage - * is one local file. Configuration surfaces use this only as availability - * metadata; the guarded open operation resolves the path again Host-side. - * Non-file providers leave it undefined and expose no open-document affordance. - * @returns the absolute local document path, or undefined for non-file storage. + /** Register the calling plugin instance's page policy without changing its Config. + * @param presentation Automatic-page policy for this instance; `auto` defaults to true. + * @param owner Plugin instance the policy belongs to; defaults to the calling fiber. + * @returns Disposer; register it with the calling plugin's effects. + * @throws If this instance already has a registered policy. */ - get documentPath(): string | undefined { - return undefined - } - - /** - * Prepare the provider's user-editable document for a native editor. File - * providers may materialize an absent document before returning its path; - * non-file providers return undefined. - * @returns the absolute local document path, or undefined for non-file storage. - */ - prepareDocument(): Promise { - return Promise.resolve(this.documentPath) - } - - /** - * Read the provider's current raw document (namespace to raw section). - * @returns the detached raw document. - */ - protected abstract load(): Promise> - - /** - * Durably store one namespace's merged user section. - * @param ns - the namespace being written. - * @param section - the complete merged user section to store. - */ - protected abstract persist(ns: SettingsNamespace, section: Record): Promise - - /** - * Register a namespace schema and receive its owner scope. The registration - * is an effect on the calling plugin's fiber: disposing that fiber removes - * the namespace and its observers. An invalid stored section fails the - * registration itself — the earliest point where the schema can judge it. - * @param ns - unique namespace; duplicate registration fails loud. - * @param schema - schemastery schema resolving this namespace's value. - * @param options - composition `base` layer and effect timing. - * @returns the owner scope for reads, observation, and updates. - * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier. - */ - register( - ns: Namespace & SettingsNamespaceInput, - schema: z, - options?: SettingsRegisterOptions, - ): SettingsScope { - const parsedNs = parseSettingsNamespace(ns) - if (this.registrations.has(parsedNs)) { - throw new Error(`settings namespace "${parsedNs}" is already registered`) - } - const registration: SettingsRegistration = { - ns: parsedNs, - schema: schema as z, - base: options?.base, - applies: options?.applies ?? 'live', - ...options?.validate === undefined - ? {} - : { validate: options.validate as (value: unknown) => void }, - resolved: deepFreeze(this.resolve(schema, options?.base, this.section(parsedNs), options?.validate)), - revision: 0, - watchers: new Set(), - } - this.ctx.effect(() => { - this.registrations.set(parsedNs, registration) - // TODO(settings-registration-quiescence): Deactivate every watcher and await - // its tail on disposal so callbacks cannot outlive the registrant fiber. - return () => this.registrations.delete(parsedNs) - }, `settings.register(${JSON.stringify(String(parsedNs))})`) - return { - get: () => registration.resolved as T, - watch: (callback) => { - const watcher: SettingsWatcher = { callback: callback, tail: Promise.resolve(), active: true } - registration.watchers.add(watcher) - return () => { - watcher.active = false - registration.watchers.delete(watcher) - } - }, - update: patch => this.update(parsedNs, patch), - replace: section => this.replace(parsedNs, section), + configure(presentation: { auto?: boolean }, owner: Fiber = this.ctx.fiber): () => void { + const fiber = owner + if (this.presentations.has(fiber)) throw new Error('Settings presentation is already configured for this plugin instance') + const policy = { ...presentation } + this.presentations.set(fiber, policy) + this.invalidate() + return () => { + if (this.presentations.get(fiber) !== policy) return + this.presentations.delete(fiber) + this.invalidate() } } - /** - * Attach one optional-settings consumer to this provider. The consumer - * registers its composition entry as the base layer while this provider is - * present, then falls back to that entry if the provider detaches. - * @param owner - consumer context whose unload suppresses fallback work. - * @param ns - consumer-owned settings namespace. - * @param schema - schema resolving the namespace. - * @param entry - composition entry used as the base and fallback value. - * @param hooks - source sink, change notification, and optional validation. - * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier. - */ - installSection( - owner: Context, - ns: Namespace & SettingsNamespaceInput, - schema: z, - entry: T, - hooks: SettingsSectionHooks, - ): void { - const scope = this.register(ns, schema, { - base: entry, - ...hooks.validate === undefined ? {} : { validate: hooks.validate }, - }) - hooks.setSource(() => scope.get()) - this.ctx.effect(() => () => { - // Losing the provider leaves the consumer running; unloading the - // consumer does not, so only the former needs fallback work. - if (isUnloading(owner)) return - hooks.setSource(() => entry) - hooks.onChange() - }) - hooks.onChange() - scope.watch(() => { - if (isUnloading(owner)) return - hooks.onChange() + private invalidate(): void { + if (this.scheduled || this.closed) return + this.scheduled = true + queueMicrotask(() => { + this.scheduled = false + if (this.closed || this.ownerContext.fiber.state !== FiberState.ACTIVE) return + try { this.describe() } catch (error) { this.ownerContext.logger.error(error) } }) } - /** - * Describe every registered namespace for configuration surfaces, including - * the composition `base` and raw user layers so a form can mark which fields - * the user overrode (presence in `user`) and what a reset returns to. - * @param options - redaction switch; wire surfaces must redact. - * @returns one descriptor per registered namespace, in registration order. + /** Whether the active profile accepts form edits. */ + get writable(): boolean { return true } + /** Current profile patch shown by the native configuration editor. */ + get documentPath(): string { return this.ownerContext.configEditor.documentPath } + /** Locate the profile patch for native editing. + * @returns The existing profile patch path. + */ + prepareDocument(): Promise { return Promise.resolve(this.documentPath) } + + /** Read active plugin schemas and their live values. + * @param options Redaction required for remote callers. + * @returns Forms keyed by unique profile entry ids. */ describe(options?: SettingsDescribeOptions): SettingsDescriptor[] { - return [...this.registrations.values()].map((registration) => { - let user: Record | undefined - try { - user = this.section(registration.ns) - } catch { - // A malformed stored section already warned at publish and kept the - // last good resolved value; only that malformed shape can throw here, - // and describing it as "no user layer" keeps this read total. - user = undefined - } - const base = registration.base === undefined ? undefined : structuredClone(registration.base) - const detachedUser = user === undefined ? undefined : structuredClone(user) - const descriptor: SettingsDescriptor = { - ns: registration.ns, - schema: registration.schema.toJSON(), - value: registration.resolved, - revision: registration.revision, - ...base === undefined ? {} : { base }, - ...detachedUser === undefined ? {} : { user: detachedUser }, - applies: registration.applies, - } - if (options?.redactSecrets !== true) return descriptor - const schema = registration.schema as z - const redacted = redactSecrets(schema, registration.resolved) - return { - ...descriptor, - value: redacted.value, - ...base === undefined ? {} : { base: redactSecrets(schema, base).value }, - ...detachedUser === undefined ? {} : { user: redactSecrets(schema, detachedUser).value }, - secrets: redacted.secrets, + const active = new Set() + const descriptors = this.ownerContext.configEditor.configuration().flatMap(({ entry, inherited, override }) => { + const schema = this.schema(entry) + if (schema === undefined || entry.fiber === undefined + || entry.fiber.runtime === null || entry.fiber.state !== FiberState.ACTIVE) return [] + const form = volatileForm(schema) + if (form === undefined) return [] + active.add(entry.id) + const raw = JSON.stringify([entry.fiber.uid, schema.toJSON(), entry.options.config ?? {}]) + const autoGenerate = this.presentations.get(entry.fiber)?.auto ?? true + const previous = this.revisions.get(entry.id) + const revision = previous === undefined ? 0 : previous.revision + Number(previous.raw !== raw) + this.revisions.set(entry.id, { raw, revision, ns: entry.options.id as SettingsNamespace, autoGenerate }) + if (previous?.raw !== raw || previous.autoGenerate !== autoGenerate) { + this.ownerContext.emit('settings/document-updated', entry.options.id as SettingsNamespace, revision) } + const value = projectForm(form, plainConfig(entry.fiber.config)) + const resolved: unknown = interpolate(entry.fiber.ctx, inherited) + const base = projectForm(form, plainConfig(inheritedConfig(entry.fiber.runtime, resolved))) + const user = projectForm(form, override) + const redacted = redactSecrets(form as z, value) + return [{ + autoGenerate, + ns: entry.options.id as SettingsNamespace, schema: form.toJSON(), revision, applies: 'live' as const, + value: options?.redactSecrets ? redacted.value : value, + base: options?.redactSecrets ? redactSecrets(form as z, base).value : base, + user: options?.redactSecrets ? redactSecrets(form as z, user).value : user, + ...options?.redactSecrets ? { secrets: redacted.secrets } : {}, + }] }) + for (const [id, previous] of this.revisions) { + if (active.has(id) || previous.raw === undefined) continue + const revision = previous.revision + 1 + this.revisions.set(id, { ...previous, raw: undefined, revision }) + this.ownerContext.emit('settings/document-updated', previous.ns, revision) + } + return descriptors } - /** - * Read one registered namespace's resolved value. - * @param ns - the namespace to read. - * @returns the resolved value, or `undefined` while unregistered. - * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier. + /** Merge editable fields into an entry's config. + * @param ns Profile entry id. + * @param patch Fields to merge. + * @param expectedRevision Revision returned by describe. */ - get(ns: Namespace & SettingsNamespaceInput): unknown { - return this.registrations.get(parseSettingsNamespace(ns))?.resolved + async update(ns: string, patch: object, expectedRevision?: number): Promise { + const input = cloneJsonShaped(patch) + await this.write(ns, current => mergeLayers(current, input) as Record, expectedRevision) } - /** - * Merge a patch into one registered namespace's user layer, validate the - * resolved candidate, persist through the provider, then commit and emit. - * A validation failure rejects before anything is persisted. Writes to one - * namespace are serialized: concurrent updates apply in call order, each - * merging over the previous write's committed section. - * @param ns - the registered namespace to update. - * @param patch - plain-object patch over the user section. - * @param expectedRevision - the descriptor `revision` the caller read; a - * namespace that moved past it rejects with {@link SettingsConflictError}. - * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier. + /** Reset all live fields, then set the supplied fields; ordinary config is preserved. + * @param ns Profile entry id. + * @param section Complete form values. + * @param expectedRevision Revision returned by describe. */ - async update( - ns: Namespace & SettingsNamespaceInput, - patch: object, - expectedRevision?: number, - ): Promise { - return this.write(parseSettingsNamespace(ns), patch, 'merge', expectedRevision) + async replace(ns: string, section: object, expectedRevision?: number): Promise { + const input = cloneJsonShaped(section) + await this.write(ns, (_current, base) => mergeLayers(base, input) as Record, expectedRevision) } - /** - * Replace one registered namespace's user section wholesale, validate, - * persist, then commit and emit. Keys absent from `section` fall back to the - * composition `base` and schema defaults — this is the removal/reset path a - * merge-only patch cannot express (`replace({})` re-inherits everything). - * @param ns - the registered namespace to replace. - * @param section - the complete next user section. - * @param expectedRevision - the descriptor `revision` the caller read; a - * namespace that moved past it rejects with {@link SettingsConflictError}. - * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier. + /** Apply field edits without restating redacted secrets; unsetting an array index removes its element. + * @param ns Profile entry id. + * @param ops Ordered form edits. + * @param expectedRevision Revision returned by describe. */ - async replace( - ns: Namespace & SettingsNamespaceInput, - section: object, - expectedRevision?: number, - ): Promise { - return this.write(parseSettingsNamespace(ns), section, 'replace', expectedRevision) + async mutate(ns: string, ops: readonly SettingsPathOp[], expectedRevision?: number): Promise { + await this.write(ns, (current, base, schema) => ops.reduce((value, op) => { + if (op.op === 'set') return applyPathOp(value, op, schema) + const parent = op.path.slice(0, -1).reduce((node, key) => member(node, key), value) + if (Array.isArray(parent)) return applyPathOp(value, op, schema) + const inherited = op.path.reduce((node, key) => member(node, key, true), base) + return applyPathOp(value, inherited === undefined ? op : { op: 'set', path: op.path, value: inherited }, schema) + }, current), expectedRevision, ops.map(op => op.path)) } - /** - * Apply path-addressed edits to one registered namespace's user section, - * validate, persist, then commit and emit. The ops are applied to the - * section as it stands when the write reaches the front of the queue, so a - * caller never has to restate fields it did not touch — and, crucially, - * cannot delete fields it never saw. This is the write path for any caller - * holding a redacted view; `replace` remains the wholesale reset. - * @param ns - the registered namespace to edit. - * @param ops - ordered path edits; later ops observe earlier ones. - * @param expectedRevision - the descriptor `revision` the caller read; a - * namespace that moved past it rejects with {@link SettingsConflictError}. - * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier. - */ - async mutate( - ns: Namespace & SettingsNamespaceInput, - ops: readonly SettingsPathOp[], - expectedRevision?: number, + private async write( + ns: string, + change: (current: Record, base: Record, schema: z) => Record, + expected?: number, paths: readonly (readonly string[])[] = [], ): Promise { - const parsedNs = parseSettingsNamespace(ns) - if (!Array.isArray(ops)) throw new TypeError(`settings mutate for "${parsedNs}" must be an array of path ops`) - for (const op of ops) { - if (!isPlainObject(op) || (op['op'] !== 'set' && op['op'] !== 'unset')) { - throw new TypeError(`settings mutate for "${parsedNs}" ops must be {op:'set'|'unset', path}`) - } - if (!Array.isArray(op['path']) || (op['path'] as unknown[]).some(part => typeof part !== 'string')) { - throw new TypeError(`settings mutate for "${parsedNs}" op paths must be arrays of strings`) - } + const entry = this.ownerContext.configEditor.entries().find(row => row.options.id === ns) + const schema = entry === undefined ? undefined : this.schema(entry) + if (entry === undefined || schema === undefined) throw new Error(`No configurable plugin entry "${ns}"`) + const form = volatileForm(schema) + if (form === undefined) throw new Error(`Plugin entry "${ns}" has no volatile fields`) + for (const path of paths) { + if (path.length && !isVolatilePath(schema, path)) throw new Error(`Config field "${path.join('.')}" is not volatile`) } - return this.write(parsedNs, ops, 'mutate', expectedRevision) - } - - /** Validate a write, then queue it on the namespace's serialized write chain. */ - private write( - ns: SettingsNamespace, - input: object, - mode: 'merge' | 'replace' | 'mutate', - expectedRevision?: number, - ): Promise { - const verb = mode === 'merge' ? 'update' : mode === 'replace' ? 'replace' : 'mutate' - const registration = this.registrations.get(ns) - if (registration === undefined) { - throw new Error(`settings namespace "${ns}" is not registered`) - } - if (this.isStopped()) { - throw new Error(`settings service is disposed: "${ns}" cannot be written`) - } - if (!this.writable) { - throw new Error(`settings provider is read-only: "${ns}" cannot be updated in-process`) - } - // A mutate's ops array is wrapped so one JSON-shape walk covers both - // shapes; merge/replace carry the section itself. - let payload: Record - if (mode === 'mutate') { - payload = { ops: input } - } else { - if (!isPlainObject(input)) throw new TypeError(`settings ${verb} for "${ns}" must be a plain object`) - payload = input - } - // Snapshot at call time: the queue must never read a caller-owned object - // the caller may keep mutating while the write waits its turn. The same - // walk rejects values that JSON cannot preserve (see cloneJsonShaped). - const snapshot = cloneJsonShaped(payload, (label, path) => - new TypeError(`settings ${verb} for "${ns}" must contain only JSON-compatible data (found ${label} at ${path})`)) - const previous = this.writeQueues.get(ns) ?? Promise.resolve() - // Chain past a failed predecessor: one rejected write must not poison the - // namespace queue for every later caller. - const run = previous.catch(() => undefined).then(async () => { - if (this.isStopped()) { - throw new Error(`settings service was disposed before the queued "${ns}" ${verb} ran`) + await this.ownerContext.configEditor.edit(entry, (raw, inherited) => { + const descriptor = this.describe().find(row => row.ns === ns) + if (descriptor === undefined) throw new Error(`Plugin entry "${ns}" is no longer configurable`) + if (expected !== undefined && descriptor.revision !== expected) { + throw new SettingsConflictError(ns as SettingsNamespace, expected, descriptor.revision) } - if (this.registrations.get(ns) !== registration) { - throw new Error(`settings namespace "${ns}" registration was disposed before the queued ${verb} ran`) + const current = projectForm(form, raw) as Record + const base = projectForm(form, inherited) as Record + const next = cloneJsonShaped(change(current, base, schema)) + const validatePaths = (value: Record, node: z, path: string[] = []): void => { + for (const [key, child] of Object.entries(value)) { + const target = [...path, key] + if (isVolatilePath(schema, target)) continue + const fields = node.dict as Record + const field = Object.hasOwn(fields, key) ? fields[key] : undefined + if (isPlainObject(child) && field !== undefined) validatePaths(child, field, target) + else throw new Error(`Config field "${target.join('.')}" is not volatile`) + } } - // Every mode derives from the section as it stands NOW, at the front of - // the queue — never from whatever the caller last saw. - const current = this.section(ns) ?? {} - // The revision check belongs HERE, not at call time: the queue orders - // writes but cannot tell a fresh writer from one holding a snapshot - // that a predecessor already superseded. - if (expectedRevision !== undefined && expectedRevision !== registration.revision) { - throw new SettingsConflictError(ns, expectedRevision, registration.revision) - } - const section = mode === 'merge' - ? mergeLayers(current, snapshot) as Record - : mode === 'replace' - ? snapshot - : (snapshot['ops'] as SettingsPathOp[]).reduce(applyPathOp, current) - const next = deepFreeze(this.resolve(registration.schema, registration.base, section, registration.validate)) - await this.persist(ns, section) - // The write reached storage either way; the cache must say so. Commit - // only when this registration is still the namespace owner — a fiber - // disposed (or replaced) mid-persist must not receive the notification. - this.document[ns] = section - // TODO(settings-replacement-resync): Re-resolve any replacement registration - // from this persisted section so an old in-flight write cannot leave it stale. - if (this.registrations.get(ns) === registration && !this.isStopped()) { - this.bumpRevision(registration, current, section) - this.commit(registration, next, 'update') + validatePaths(next, form) + const strip = (value: Record, node: z, path: string[] = []): Record => { + if (isVolatilePath(schema, path)) return {} + const result = { ...value } + for (const [key, field] of Object.entries(node.dict as Record)) { + const target = [...path, key] + if (isVolatilePath(schema, target)) Reflect.deleteProperty(result, key) + else if (isPlainObject(result[key])) result[key] = strip(result[key], field, target) + } + return result } + return mergeLayers(strip(raw, form), next) as Record }) - this.writeQueues.set(ns, run) - return run + this.describe() } - /** - * Provider hook: commit a complete raw document observed in storage. Each - * registered namespace re-resolves; an invalid section keeps that - * namespace's last good value and warns, other namespaces still commit. - * @param doc - the detached raw document (unregistered sections preserved). - * @param source - change origin; defaults to `provider`. - */ - protected publish(doc: Record, source: SettingsUpdateSource = 'provider'): void { - // Read every raw section BEFORE swapping the document, so the revision - // bump below compares what was stored with what now is — an external edit - // moves the revision exactly like an in-process write. - const before = new Map() - for (const registration of this.registrations.values()) { - try { - before.set(registration.ns, this.section(registration.ns)) - } catch { - // A malformed stored section is not a readable "before"; treating it - // as absent still bumps against any well-formed replacement. - before.set(registration.ns, undefined) - } - } - this.document = doc - for (const registration of this.registrations.values()) { - let next: unknown - try { - next = deepFreeze(this.resolve(registration.schema, registration.base, this.section(registration.ns), registration.validate)) - } catch (error) { - this.ctx.logger.warn('settings: keeping last good "%s" after invalid stored section', registration.ns) - this.ctx.logger.warn(error) - continue - } - this.bumpRevision(registration, before.get(registration.ns), this.section(registration.ns)) - this.commit(registration, next, source) - } - } - - /** Read one namespace's raw user section, rejecting non-object sections. */ - private section(ns: SettingsNamespace): Record | undefined { - const section = this.document[ns] - if (section === undefined) return undefined - if (!isPlainObject(section)) { - throw new TypeError(`settings section "${ns}" must be an object of keys`) - } - return section - } - - /** Resolve one namespace value: schema defaults, then `base`, then the user layer. */ - private resolve( - schema: z, - base: unknown, - section: Record | undefined, - validate?: (value: T) => void, - ): T { - // The merged candidate is untyped by construction; the schema call is the - // runtime validation that admits it into T. - const value = schema(mergeLayers(base, section) as never) - // The owner's own check runs on the admitted value, so it sees defaults - // and the composition base exactly as the owner will. - validate?.(value) - return value - } - - /** - * Advance a namespace's revision when its RAW section changed, and announce - * it. Deliberately independent of {@link commit}'s resolved-value equality: - * storing an override equal to the composition base leaves the resolved - * value alone but changes what the document says, which is exactly what a - * configuration surface must re-read. - */ - private bumpRevision(registration: SettingsRegistration, before: unknown, after: unknown): void { - if (deepEqualJson(before, after)) return - registration.revision += 1 - this.emitDocumentUpdated(registration.ns, registration.revision) - } - - /** Contained fan-out of `settings/document-updated`, mirroring {@link commit}'s. */ - private emitDocumentUpdated(ns: SettingsNamespace, revision: number): void { - let invariantFailure: unknown - const args = ['settings/document-updated', ns, revision] - for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) { - try { - const returned = listener(ns, revision) - if (returned != null && typeof (returned as PromiseLike).then === 'function') { - void Promise.resolve(returned as PromiseLike).then(undefined, (error: unknown) => { - this.warnListenerFailure(ns, error) - }) - } - } catch (error) { - if ((error as { code?: unknown } | null)?.code === 'INVARIANT') { - invariantFailure ??= error - continue - } - this.warnListenerFailure(ns, error) - } - } - if (invariantFailure !== undefined) throw invariantFailure as Error - } - - /** Commit a resolved value when changed: swap, notify watchers, emit the event. */ - private commit(registration: SettingsRegistration, next: unknown, source: SettingsUpdateSource): void { - const prev = registration.resolved - if (deepEqualJson(next, prev)) return - registration.resolved = next - for (const watcher of [...registration.watchers]) { - // Serialize per watcher: invocations of one callback run one at a time - // in commit order, so a slow stale invocation can never apply after a - // newer one. Sync throws and async rejections land in the same handler. - // The activity check runs when the queued invocation would start, so a - // disposer (or service stop) that ran while it waited prevents the - // start entirely; started invocations drain at service dispose. - const segment = watcher.tail - .then(() => { - if (!watcher.active || this.isStopped()) return - return watcher.callback(next as never, prev as never) - }) - .then(() => undefined, (error: unknown) => { - this.warnWatcherFailure(registration.ns, error) - }) - watcher.tail = segment - this.pendingTails.add(segment) - void segment.then(() => this.pendingTails.delete(segment)) - } - // Fan the event out one listener at a time (the plain emit stops at the - // first throwing listener, starving the rest). Invariant violations are - // harness-fatal by design and rethrow after every listener ran; any other - // failure is contained so one broken observer cannot wedge the commit - // path (and, through it, a provider's reload loop). - let invariantFailure: unknown - const args = ['settings/updated', registration.ns, next, prev, source] - for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) { - try { - const returned = listener(registration.ns, next, prev, source) - if (returned != null && typeof (returned as PromiseLike).then === 'function') { - // An emit listener may still be an async function; its rejection - // cannot reach the synchronous INVARIANT rethrow below, so it is - // contained here instead of becoming an unhandled rejection. - void Promise.resolve(returned as PromiseLike).then(undefined, (error: unknown) => { - this.warnListenerFailure(registration.ns, error) - }) - } - } catch (error) { - if ((error as { code?: unknown } | null)?.code === 'INVARIANT') { - invariantFailure ??= error - continue - } - this.warnListenerFailure(registration.ns, error) - } - } - if (invariantFailure !== undefined) throw invariantFailure as Error - } - - /** Contained-watcher diagnostic shared by the sync and async failure paths. */ - private warnWatcherFailure(ns: SettingsNamespace, error: unknown): void { - this.ctx.logger.warn('settings: watcher for "%s" failed', ns) - this.ctx.logger.warn(error) - } - - /** Contained-listener diagnostic shared by the sync and async failure paths. */ - private warnListenerFailure(ns: SettingsNamespace, error: unknown): void { - this.ctx.logger.warn('settings: a settings/updated listener for "%s" failed', ns) - this.ctx.logger.warn(error) + private schema(entry: Entry): z | undefined { + const schema = entry.fiber?.runtime?.Config + return schema !== undefined && 'toJSON' in schema ? schema as z : undefined } } -/** - * Value mirror of the `FiberState` members {@link isUnloading} compares - * against: a const enum has no runtime object to import, and the value is - * needed at runtime (same rationale as the CLI boot driver's mirror). - */ -const FIBER_DISPOSED = 4 -const FIBER_UNLOADING = 5 - -/** Whether the consumer's own fiber is tearing down (not just losing the settings service). */ -function isUnloading(ctx: Context): boolean { - const state: number = ctx.fiber.state - return state === FIBER_UNLOADING || state === FIBER_DISPOSED -} - -/** Hooks a consumer hands to {@link SettingsProvider.installSection}. */ -export interface SettingsSectionHooks { - /** - * Receive the active configuration source: the resolved settings scope - * while one is attached, the composition entry otherwise. Called before - * the matching `onChange` at attach and at detach. - * @param current - thunk returning the currently authoritative value. - */ - setSource(current: () => T): void - /** - * Re-judge anything derived from the source — registration-level facts, - * memoized resolutions — after an attach, a detach, or a committed change. - */ - onChange(): void - /** - * Reject a resolved section this consumer could not act on, for constraints - * its schema cannot express. See {@link SettingsRegisterOptions.validate}. - * @param value - the resolved section, schema-valid by construction. - */ - validate?: (value: T) => void -} - -export default SettingsProvider +export default SettingsForms diff --git a/packages/settings/settings/src/invariant.ts b/packages/settings/settings/src/invariant.ts deleted file mode 100644 index 3056671a44..0000000000 --- a/packages/settings/settings/src/invariant.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-settings`. - * @module @deepseek-ai/dsh-settings/invariant - */ - -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' -import { deepEqualJson } from '@deepseek-ai/dsh-util-values' - -const PACKAGE_NAME = '@deepseek-ai/dsh-settings' - -/** Cordis companion plugin name. */ -export const name = 'settings-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * Install the commit-event contract: `settings/updated` fires only for a - * currently registered namespace, only when the resolved value changed, and - * only with the service's authoritative resolved value — all judged with the - * seam's own equality predicate. - */ -const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => { - ctx.on('settings/updated', (ns, next, prev) => { - const settings = ctx.get('settings') - if (settings === undefined) { - fail(`settings/updated for "${ns}" emitted without a live settings service`) - } - const current = settings.get(ns) - if (current === undefined) { - fail(`settings/updated for "${ns}" emitted while the namespace is unregistered`) - } - if (!deepEqualJson(current, next)) { - fail(`settings/updated for "${ns}" does not match the authoritative resolved value`) - } - if (deepEqualJson(next, prev)) { - fail(`settings/updated for "${ns}" emitted without a resolved-value change`) - } - }) -} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/settings/settings/src/redact.ts b/packages/settings/settings/src/redact.ts index 18664bcbbe..981e673194 100644 --- a/packages/settings/settings/src/redact.ts +++ b/packages/settings/settings/src/redact.ts @@ -20,6 +20,7 @@ interface SchemaNode { dict?: Record /** `dict`/`array` element schema. */ inner?: SchemaNode + list?: SchemaNode[] } /** One schema-declared secret position inside a redacted value. */ @@ -83,20 +84,20 @@ function walk(node: SchemaNode | undefined, value: unknown, path: string[], secr if (!Array.isArray(value)) return value return value.map((entry, index) => walk(node.inner, entry, [...path, String(index)], secrets)) } + case 'union': + case 'intersect': + return (node.list ?? []).reduce((current, child) => walk(child, current, path, secrets), value) + case 'transform': + return walk(node.inner, value, path, secrets) default: - // TODO(settings-wire-redaction): Fail closed instead — a secret reachable - // only through a union, intersection, or transform is returned verbatim - // here, with nothing recording that it was missed. return value } } /** * Remove every `role('secret')` field a schema declares from a value. The - * walker follows `object`, `dict`, and `array` containers; a secret must be - * declared directly on a field reachable through those containers (a secret - * buried inside a union branch or transform is not reachable and must not be - * modeled that way). The input is never mutated. + * walker visits every union branch, conservatively removing any field declared + * secret by a branch. The input is never mutated. * @param schema - live schemastery schema describing the value. * @param value - the value to strip; `undefined` yields an empty record with * object-property secret slots still enumerated. @@ -105,5 +106,11 @@ function walk(node: SchemaNode | undefined, value: unknown, path: string[], secr export function redactSecrets(schema: z, value: unknown): RedactedValue { const secrets: RedactedSecret[] = [] const stripped = walk(schema, value, [], secrets) - return { value: stripped, secrets } + const positions = new Map() + for (const secret of secrets) { + const key = JSON.stringify(secret.path) + const previous = positions.get(key) + positions.set(key, { ...secret, set: secret.set || previous?.set === true }) + } + return { value: stripped, secrets: [...positions.values()] } } diff --git a/packages/settings/settings/src/schema.ts b/packages/settings/settings/src/schema.ts new file mode 100644 index 0000000000..9d84e2e532 --- /dev/null +++ b/packages/settings/settings/src/schema.ts @@ -0,0 +1,79 @@ +/** Derive editable forms and plain values from plugin Config schemas. */ +import { redactSecrets } from './redact.ts' +import z from '@deepseek-ai/schemastery' +import { isVolatile } from '@deepseek-ai/cosmokit' + +/** Remove runtime references from a configuration snapshot. + * @param value Parsed Config output. + * @returns Detached ordinary values suitable for redaction and forms. + */ +export function plainConfig(value: unknown): unknown { + if (isVolatile(value)) return plainConfig(value.get()) + if (Array.isArray(value)) return value.map(plainConfig) + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, plainConfig(child)])) + } + return value +} + +function plainSchema(schema: z): z { + const result = new z(schema.toJSON()) + const walk = (node: z): void => { + delete node.meta.volatile + if (node.meta.role === 'secret') { delete node.meta.default; delete node.meta.required } + else if (node.meta.default !== undefined) node.meta.default = redactSecrets(node as z, node.meta.default).value + for (const child of Object.values(node.dict ?? {})) walk(child) + if (node.inner) walk(node.inner) + for (const child of node.list ?? []) walk(child) + } + walk(result) + return result +} + +/** Select fields whose nearest volatile ancestor makes them editable without remounting. + * @param schema The plugin's Config schema. + * @returns A plain form schema, or undefined when no field is live. + */ +export function volatileForm(schema: z): z | undefined { + if (schema.meta.volatile) return plainSchema(schema) + if (schema.type === 'object') { + const dict = Object.fromEntries(Object.entries(schema.dict ?? {}).flatMap(([key, child]) => { + const field = volatileForm(child) + return field === undefined ? [] : [[key, field]] + })) + return Object.keys(dict).length === 0 ? undefined : z.object(dict) + } + return undefined +} + +/** Whether a raw config node is an unevaluated `!!js` expression, kept whole rather than projected field by field. */ +function isExpression(value: object): boolean { + return Object.keys(value).length === 1 && typeof Reflect.get(value, '__jsExpr') === 'string' +} + +/** Project only schema-declared fields, excluding ordinary configuration. + * @param schema The filtered form schema. + * @param value Plain raw or resolved config. + * @returns The fields visible to this form. + */ +export function projectForm(schema: z, value: unknown): unknown { + if (schema.type === 'object' && value !== null && typeof value === 'object' && !isExpression(value)) { + return Object.fromEntries(Object.entries(schema.dict ?? {}).flatMap(([key, child]) => { + const field: unknown = Reflect.get(value, key) + return field === undefined ? [] : [[key, projectForm(child, field)]] + })) + } + return value +} + +/** Check that a field path lies beneath a declared volatile node. + * @param schema Complete plugin Config schema. + * @param path Field path addressed by a form edit. + * @returns Whether the path can be edited live. + */ +export function isVolatilePath(schema: z, path: readonly string[]): boolean { + if (schema.meta.volatile) return true + const [key, ...rest] = path + const child = key === undefined ? undefined : schema.dict?.[key] + return child !== undefined && isVolatilePath(child, rest) +} diff --git a/packages/settings/settings/src/types.ts b/packages/settings/settings/src/types.ts index 8068d4ef13..2c48cfd5e7 100644 --- a/packages/settings/settings/src/types.ts +++ b/packages/settings/settings/src/types.ts @@ -1,22 +1,11 @@ -/** - * Client-safe type surface of the user-settings seam: the namespace brand, the - * commit-origin union, the redacted views a configuration surface reads over - * the Remote wire, and the seam's Cordis event declarations. Types only — no - * runtime code, and nothing here reaches a Host-only symbol, so a Client - * compilation face reads exactly the signatures the Host emits. - * - * @module @deepseek-ai/dsh-settings/types - */ +/** Client-safe configuration form views and change notifications. */ import type { Branded } from '@deepseek-ai/dsh-brand' import type { JsonValue } from '@deepseek-ai/dsh-util-values' -/** Nominal id of one registered settings namespace. */ +/** Nominal id of one profile plugin entry. */ export type SettingsNamespace = Branded<'SettingsNamespace'> -/** Origin of one committed settings change. */ -export type SettingsUpdateSource = 'update' | 'provider' - /** One schema-declared secret slot inside a redacted namespace value. */ export interface SettingsSecretView { /** Path from the section root to the removed field. */ @@ -26,27 +15,29 @@ export interface SettingsSecretView { } /** - * Wire view of one registered namespace, always read under `redactSecrets`. The + * Wire view of one profile plugin entry, always read under `redactSecrets`. The * JSON-valued fields are `JsonValue` rather than the descriptor's `unknown` * because the Remote boundary admits no unconstrained data. */ export interface SettingsNamespaceView { + /** Generate a page if no custom page is registered for this instance. */ + autoGenerate: boolean /** Namespace key (`llm-deepseek`, `llm-pi-ai`, …). */ ns: string /** Serialized schemastery schema envelope (`schema.toJSON()`); rehydrate with `new Schema(json)`. */ schema: JsonValue /** Redacted resolved value (schema defaults → composition base → user layer). */ value: JsonValue - /** Redacted composition base layer, when the registrant declared one. */ + /** Redacted composition base layer, with defaults resolved. */ base?: JsonValue /** Redacted raw user section, when one exists; a field's presence here marks it user-overridden. */ user?: JsonValue /** When the owner applies changes. */ - applies: 'live' | 'restart' + applies: 'live' /** Every schema-declared secret slot with its configured state. */ secrets: SettingsSecretView[] /** - * Monotonic revision of the raw user section this view was read at. Send it + * Monotonic revision of the raw entry configuration this view was read at. Send it * back as `expectedRevision` on a write so a stale editor is refused rather * than silently overwriting a concurrent change. */ @@ -62,44 +53,23 @@ export type SettingsPathOpView = | { op: 'set'; path: string[]; value: JsonValue } | { op: 'unset'; path: string[] } -/** Every registered namespace with the deployment facts a configuration page renders around them. */ +/** Every profile plugin entry with the deployment facts a configuration page renders around them. */ export interface SettingsDescribeValue { - /** Whether the provider accepts writes; `false` disables every write control. */ + /** Whether the profile accepts writes; `false` disables every write control. */ writable: boolean - /** Whether a file-backed provider owns a local document, without exposing its Host path. */ + /** Whether the configuration editor owns a local document, without exposing its Host path. */ hasDocument: boolean - /** One view per registered namespace. */ + /** One view per profile plugin entry. */ namespaces: SettingsNamespaceView[] } declare module '@deepseek-ai/cordis' { interface Events { /** - * Committed change to one registered namespace's resolved value. Emitted - * after the provider persisted (for `update`) or published (`provider`) - * the change; never emitted when the resolved value is deep-equal. - * Listener failures are contained and logged — a sync throw and an async - * rejection alike — except `INVARIANT`-coded failures, which rethrow - * after every listener ran; that rethrow reaches the emitter only from - * synchronous listeners, so invariant checks on this event must not be - * async functions. - * @param ns - the namespace whose resolved value changed. - * @param next - the new resolved value. - * @param prev - the previous resolved value. - * @param source - whether the change entered through `update()` or the provider. - * @mode emit - */ - 'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void - - /** - * One registered namespace's RAW user section changed, whether or not the - * resolved value did. `settings/updated` is the consumer-facing event and - * stays deep-equal-gated; this one exists for configuration surfaces, - * which must learn that a field went from inherited to overridden (same - * resolved value, different meaning) and that their held revision is - * stale. Listener containment matches `settings/updated`. - * @param ns - the namespace whose stored section changed. - * @param revision - the namespace's new revision. + * One profile entry's form values, availability, or page policy changed. + * Form clients re-read its schema, resolved values, and revision. + * @param ns Profile entry id. + * @param revision The entry's new revision. * @mode emit */ 'settings/document-updated'(ns: SettingsNamespace, revision: number): void diff --git a/packages/settings/settings/tests/configuration-fixture.ts b/packages/settings/settings/tests/configuration-fixture.ts new file mode 100644 index 0000000000..4f81ec8d90 --- /dev/null +++ b/packages/settings/settings/tests/configuration-fixture.ts @@ -0,0 +1,58 @@ +/** Real profile patches, Loader updates, and consumers of live Config references. */ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { onTestFinished } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import Timer from '@deepseek-ai/cordis-plugin-timer' +import { boot, initProfile, readProfilePatches, type ProfileContext } from '@deepseek-ai/dsh-app-boot' +import ConfigEditor from '@deepseek-ai/dsh-config-editor' +import Hmr from '@deepseek-ai/dsh-hmr' +import DefaultModel from '@deepseek-ai/dsh-agent-default-model' +import Settings from '../src/index.ts' + +export async function configurationFixture(options: { schema?: z; apply?: (ctx: Context, config: unknown) => void; hmr?: boolean } = {}) { + const home = realpathSync(mkdtempSync(join(tmpdir(), 'settings-config-'))) + const dir = join(home, 'profiles', 'test') + onTestFinished(() => { rmSync(home, { recursive: true, force: true }) }) + initProfile(dir, ['test-bundle']) + const bundle = join(dir, 'node_modules', 'test-bundle') + mkdirSync(bundle, { recursive: true }) + writeFileSync(join(home, 'package.json'), '{"name":"test-installation"}\n') + writeFileSync(join(bundle, 'package.json'), JSON.stringify({ name: 'test-bundle', version: '1.0.0', dsh: { bundle: { patch: 'cordis.patch.yml' } } })) + writeFileSync(join(bundle, 'cordis.patch.yml'), JSON.stringify([{ insert: [ + { id: 'config-editor', name: 'cordis:editor' }, + { id: 'settings', name: 'cordis:settings' }, + { id: 'default-model', name: 'cordis:model', config: { provider: 'test', model: 'original' } }, + { id: 'first', name: 'cordis:probe', config: { ordinary: 'fixed', token: 'private' } }, + { id: 'second', name: 'cordis:probe', config: { ordinary: 'second' } }, + ] }])) + writeFileSync(join(dir, 'cordis.yml'), '[]\n') + const profile: ProfileContext = { + name: 'test', startedBundles: ['test-bundle'], dir, patchPath: join(dir, 'cordis.patch.yml'), + installAnchor: join(home, 'package.json'), cwd: home, home, overlays: [], telemetryDisabledEnv: undefined, + } + const Probe = { + Config: options.schema ?? z.object({ ordinary: z.string().required(), count: z.number().min(1).default(2).volatile(), token: z.string().role('secret').volatile(), list: z.array(z.object({ name: z.string().required(), token: z.string().role('secret') })).volatile() }), + apply: options.apply ?? (() => {}), + } + const start = async (): Promise => { + const ctx = await boot('test', join(dir, 'cordis.yml'), readProfilePatches('test', profile), (ctx) => { + ctx.provide('profileContext', profile) + ctx.provide('appReady', { onReady: (listener: () => void) => { listener(); return () => {} } }) + Object.assign(ctx.loader.builtins, { + editor: ConfigEditor, settings: Settings, model: DefaultModel, probe: Probe, + }) + }) + onTestFinished(async () => { await ctx.fiber.dispose() }) + if (options.hmr !== false) { + await ctx.plugin(Timer) + const hmr = ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) + await hmr.await() + await ctx.hmr.runExclusive(async () => {}) + } + return ctx + } + return { ctx: await start(), profile, home, start } +} diff --git a/packages/settings/settings/tests/configuration.spec.ts b/packages/settings/settings/tests/configuration.spec.ts new file mode 100644 index 0000000000..5bafea1e5b --- /dev/null +++ b/packages/settings/settings/tests/configuration.spec.ts @@ -0,0 +1,356 @@ +/** Real profile patch behavior and reference lifetime. */ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { expect, it, vi } from 'vitest' +import { parse } from 'yaml' +import z from '@deepseek-ai/schemastery' +import { configurationFixture as fixture } from './configuration-fixture.ts' + +it('persists a model edit, updates the real consumer without remounting, and restores it at restart', async () => { + const { ctx, profile, start } = await fixture() + const consumer = ctx.agentDefaultModel + const fiber = [...ctx.loader.entries()].find(entry => entry.options.id === 'default-model')!.fiber + await ctx.settings.mutate('default-model', [{ op: 'set', path: ['model'], value: 'changed' }]) + expect([...ctx.loader.entries()].find(entry => entry.options.id === 'default-model')!.fiber === fiber).toBe(true) + expect(consumer.currentSelection()).toEqual({ provider: 'test', model: 'changed' }) + expect(parse(readFileSync(profile.patchPath, 'utf8'))).toContainEqual({ id: 'default-model', name: 'cordis:model', config: { provider: 'test', model: 'changed' } }) + await ctx.fiber.dispose() + const restored = await start() + expect(restored.agentDefaultModel.currentSelection()).toEqual({ provider: 'test', model: 'changed' }) +}) + +it('isolates instances, hides ordinary fields, rejects invalid edits, and redacts secrets', async () => { + const { ctx, profile } = await fixture() + const before = readFileSync(profile.patchPath, 'utf8') + const first = ctx.settings.describe({ redactSecrets: true }).find(row => row.ns === 'first')! + expect(first.value).toEqual({ count: 2, list: [] }) + expect(first.secrets).toContainEqual({ path: ['token'], set: true }) + expect(JSON.stringify(first)).not.toContain('private') + await expect(ctx.settings.update('first', { count: 0 })).rejects.toThrow() + await expect(ctx.settings.update('first', { ordinary: 'changed' })).rejects.toThrow('not volatile') + expect(readFileSync(profile.patchPath, 'utf8')).toBe(before) + await ctx.settings.update('first', { count: 5 }) + expect(ctx.settings.describe({ redactSecrets: true }).find(row => row.ns === 'first')!.value).toEqual({ count: 5, list: [] }) + expect(ctx.settings.describe({ redactSecrets: true }).find(row => row.ns === 'second')!.value).toEqual({ count: 2, list: [] }) + await ctx.settings.mutate('first', [{ op: 'unset', path: ['count'] }]) + expect(ctx.settings.describe({ redactSecrets: true }).find(row => row.ns === 'first')!.value).toEqual({ count: 2, list: [] }) +}) + +it('refuses an edit shadowed by a higher home layer', async () => { + const { ctx, home, profile } = await fixture() + writeFileSync(join(home, 'cordis.patch.yml'), JSON.stringify([{ id: 'default-model', config: { provider: 'test', model: 'home' } }])) + const before = readFileSync(profile.patchPath, 'utf8') + await expect(ctx.settings.update('default-model', { model: 'profile' })).rejects.toThrow('overridden') + expect(readFileSync(profile.patchPath, 'utf8')).toBe(before) + expect(ctx.agentDefaultModel.currentSelection().model).toBe('home') +}, 15_000) + +it('serializes concurrent writes and refuses stale revisions after external changes', async () => { + const { ctx, profile } = await fixture() + const revision = ctx.settings.describe().find(row => row.ns === 'first')!.revision + const writes = await Promise.allSettled([ + ctx.settings.update('first', { count: 7 }, revision), + ctx.settings.update('first', { count: 8 }, revision), + ]) + expect(writes.filter(result => result.status === 'fulfilled')).toHaveLength(1) + expect(writes.filter(result => result.status === 'rejected')).toHaveLength(1) + const before = ctx.settings.describe().find(row => row.ns === 'first')!.revision + writeFileSync(profile.patchPath, JSON.stringify([{ id: 'first', config: { ordinary: 'fixed', count: 9 } }])) + await expect(ctx.settings.update('first', { count: 10 }, before)).rejects.toThrow('changed since it was read') + expect(ctx.settings.describe().find(row => row.ns === 'first')!.value).toEqual({ count: 9, list: [] }) +}) + +it('preserves configuration expressions while editing a different live field', async () => { + const { ctx, profile, start } = await fixture() + writeFileSync(profile.patchPath, '- id: first\n config:\n ordinary: !!js "\'expression-value\'"\n count: 3\n- id: second\n config:\n ordinary: !!js "\'untouched-expression\'"\n') + await ctx.settings.update('first', { count: 4 }) + const saved = readFileSync(profile.patchPath, 'utf8') + expect(saved).toContain('!!js') + expect(saved).toContain('expression-value') + await ctx.fiber.dispose() + const restored = await start() + const entry = [...restored.loader.entries()].find(row => row.options.id === 'first')! + expect((entry.fiber!.config as { ordinary: string }).ordinary).toBe('expression-value') + const second = [...restored.loader.entries()].find(entry => entry.options.id === 'second')! + expect((second.fiber!.config as { ordinary: string }).ordinary).toBe('untouched-expression') + expect(restored.settings.describe().find(row => row.ns === 'first')!.value).toEqual({ count: 4, list: [] }) +}) + +it('edits array elements and appends without replacing redacted secrets', async () => { + const { ctx } = await fixture() + await ctx.settings.update('first', { list: [{ name: 'first', token: 'hidden' }] }) + await ctx.settings.mutate('first', [ + { op: 'set', path: ['list', '0', 'name'], value: 'renamed' }, + { op: 'set', path: ['list', '1'], value: { name: 'second' } }, + ]) + expect(ctx.settings.describe().find(row => row.ns === 'first')!.value).toMatchObject({ list: [{ name: 'renamed', token: 'hidden' }, { name: 'second' }] }) + await ctx.settings.mutate('first', [{ op: 'unset', path: ['list', '1'] }]) + expect(ctx.settings.describe({ redactSecrets: true }).find(row => row.ns === 'first')!.value).toMatchObject({ list: [{ name: 'renamed' }] }) + await expect(ctx.settings.mutate('first', [{ op: 'unset', path: ['ordinary'] }])).rejects.toThrow('not volatile') +}) + +it('resets a whole form without losing ordinary fields or pinning inherited defaults', async () => { + const { ctx, profile } = await fixture({ hmr: false }) + await ctx.settings.update('first', { count: 7, list: [{ name: 'custom' }] }) + await ctx.settings.replace('first', {}) + expect(ctx.settings.describe().find(row => row.ns === 'first')!.value).toEqual({ count: 2, token: 'private', list: [] }) + expect(parse(readFileSync(profile.patchPath, 'utf8'))).toEqual([]) + await ctx.settings.update('first', { count: 9 }) + await ctx.settings.mutate('first', [{ op: 'unset', path: [] }]) + expect(parse(readFileSync(profile.patchPath, 'utf8'))).toEqual([]) +}) + +it('rejects non-JSON inputs and invalid array paths without changing the patch', async () => { + const { ctx, profile } = await fixture({ hmr: false }) + const before = readFileSync(profile.patchPath, 'utf8') + const circular: unknown[] = []; circular.push(circular) + const object: Record = {}; object.self = object + const custom = Object.create({ constructor: undefined }) as object + for (const value of [NaN, Infinity, 1n, Symbol('value'), () => {}, new Date(), custom, circular, object, [undefined]]) { + await expect(ctx.settings.update('first', { count: value })).rejects.toThrow('Config') + } + expect(readFileSync(profile.patchPath, 'utf8')).toBe(before) + await ctx.settings.update('first', { list: [{ name: 'one' }], count: undefined }) + for (const path of [['list', '-1'], ['list', '2'], ['list', '1', 'name']]) { + await expect(ctx.settings.mutate('first', [{ op: 'set', path, value: 'x' }])).rejects.toThrow('out of range') + } + await expect(ctx.settings.mutate('first', [{ op: 'unset', path: ['list', '1'] }])).rejects.toThrow('out of range') + await expect(ctx.settings.mutate('first', [{ op: 'set', path: [], value: [] }])).rejects.toThrow('plain object') +}) + +it('refuses missing entries and ordinary-only plugin forms', async () => { + const { ctx } = await fixture({ hmr: false }) + await expect(ctx.settings.update('missing', {})).rejects.toThrow('No configurable') + await expect(ctx.settings.update('config-editor', {})).rejects.toThrow('No configurable') + const entry = ctx.configEditor.entries().find(entry => entry.options.id === 'first')! + await entry.update({ disabled: true }) + await expect(ctx.configEditor.edit(entry, () => ({}))).rejects.toThrow() +}) + +it('edits and resets nested fields while retaining ordinary siblings', async () => { + const schema = z.object({ ordinary: z.string(), nested: z.object({ fixed: z.string().default('fixed'), live: z.number().default(3).volatile() }), count: z.number().default(2).volatile() }) + const { ctx } = await fixture({ schema, hmr: false }) + await ctx.settings.update('first', { nested: { live: 8 }, count: 7 }) + expect(ctx.settings.describe().find(row => row.ns === 'first')!.value).toEqual({ nested: { live: 8 }, count: 7 }) + await expect(ctx.settings.update('first', { nested: { fixed: 'different' } })).rejects.toThrow('not volatile') + await ctx.settings.update('first', { count: undefined }) + expect(ctx.settings.describe().find(row => row.ns === 'first')!.value).toMatchObject({ count: 7 }) + await ctx.settings.replace('first', {}) + expect(ctx.settings.describe().find(row => row.ns === 'first')!.value).toEqual({ nested: { live: 3 }, count: 2 }) +}) + +it('resets a volatile root and rejects editing ordinary-only schemas', async () => { + const { ctx } = await fixture({ schema: z.object({ ordinary: z.string(), count: z.number().default(2) }).volatile(), hmr: false }) + await ctx.settings.update('first', { count: 9 }) + await ctx.settings.replace('first', {}) + expect(ctx.settings.describe().find(row => row.ns === 'first')!.value).toMatchObject({ ordinary: 'fixed', count: 2 }) + const ordinary = await fixture({ schema: z.object({ ordinary: z.string() }), hmr: false }) + await expect(ordinary.ctx.settings.update('first', {})).rejects.toThrow('no volatile fields') +}) + +it('restores the profile patch when a plugin rejects activation after validation', async () => { + const { ctx, profile } = await fixture({ hmr: false, apply: (_ctx, config) => { + if ((config as { ordinary: string }).ordinary === 'rejected') throw new Error('activation refused') + } }) + const entry = ctx.configEditor.entries().find(entry => entry.options.id === 'first')! + const before = readFileSync(profile.patchPath, 'utf8') + await expect(ctx.configEditor.edit(entry, raw => ({ ...raw, ordinary: 'rejected' }))).rejects.toThrow() + expect(readFileSync(profile.patchPath, 'utf8')).toBe(before) + expect(ctx.settings.describe().find(row => row.ns === 'first')!.value).toMatchObject({ count: 2 }) +}) + +it('invalidates removed forms once and rejects writes held across entry removal', async () => { + const { ctx } = await fixture({ hmr: false }) + const original = ctx.settings.describe().find(row => row.ns === 'first')! + const updates: Array<[string, number]> = [] + ctx.on('settings/document-updated', (ns, revision) => { updates.push([ns, revision]) }) + const entry = ctx.configEditor.entries().find(entry => entry.options.id === 'first')! + entry.parent.remove(entry.options.id) + expect(ctx.settings.describe().some(row => row.ns === 'first')).toBe(false) + ctx.settings.describe() + expect(updates.filter(([ns]) => ns === 'first')).toEqual([['first', original.revision + 1]]) + await expect(ctx.configEditor.edit(entry, raw => raw)).rejects.toThrow('no longer available') +}) + +it('restores inherited array elements and preserves metadata during a full reset', async () => { + const { ctx, profile } = await fixture({ hmr: false }) + writeFileSync(profile.patchPath, '- id: first\n name: cordis:probe\n disabled: false\n config:\n ordinary: fixed\n token: private\n count: 8\n- id: second\n disabled: false\n') + await ctx.settings.replace('first', {}) + expect(parse(readFileSync(profile.patchPath, 'utf8'))).toEqual([ + { id: 'first', name: 'cordis:probe', disabled: false }, { id: 'second', disabled: false }, + ]) + await ctx.settings.mutate('first', [{ op: 'set', path: ['list', '0'], value: { name: 'one' } }]) + await ctx.settings.mutate('first', [{ op: 'unset', path: ['list', '0', 'token'] }]) + expect(ctx.settings.describe().find(row => row.ns === 'first')!.value).toMatchObject({ list: [{ name: 'one' }] }) + await expect(ctx.settings.update('first', [])).rejects.toThrow('non-plain root') +}) + +it('resolves group-owned entries and preserves defaults for an entry without raw config', async () => { + const { ctx, profile } = await fixture({ hmr: false, schema: z.object({ count: z.number().default(2).volatile() }) }) + writeFileSync(profile.patchPath, JSON.stringify([{ insert: [ + { id: 'group', name: 'cordis:group', group: true, config: [{ id: 'nested', name: 'cordis:probe' }] }, + { id: 'unconfigured', name: 'cordis:probe' }, + ] }])) + await ctx.settings.update('first', { count: 3 }) + expect(ctx.settings.describe().some(row => row.ns === 'nested')).toBe(true) + await ctx.settings.update('unconfigured', { count: 4 }) + await ctx.settings.replace('unconfigured', {}) + expect(ctx.settings.describe().find(row => row.ns === 'unconfigured')!.value).toEqual({ count: 2 }) + await ctx.settings.mutate('unconfigured', [{ op: 'unset', path: [] }]) +}) + +it('refuses an entry removed by an external bundle edit before commit', async () => { + const { ctx, profile } = await fixture({ hmr: false }) + const entry = ctx.configEditor.entries().find(row => row.options.id === 'first')! + const path = join(profile.dir, 'node_modules', 'test-bundle', 'cordis.patch.yml') + const document = JSON.parse(readFileSync(path, 'utf8')) as Array<{ insert: Array<{ id: string }> }> + document[0]!.insert = document[0]!.insert.filter(row => row.id !== 'first') + writeFileSync(path, JSON.stringify(document)) + await expect(ctx.configEditor.edit(entry, raw => raw)).rejects.toThrow('changed during reload') +}) + +it('refuses an entry disabled after a form was read', async () => { + const { ctx, profile } = await fixture({ hmr: false }) + const entry = ctx.configEditor.entries().find(row => row.options.id === 'first')! + writeFileSync(profile.patchPath, '- id: first\n disabled: true\n') + await expect(ctx.settings.update('first', { count: 8 })).rejects.toThrow('no longer configurable') + expect(ctx.settings.describe().some(row => row.ns === 'first')).toBe(false) + await expect(ctx.configEditor.edit(entry, raw => raw)).rejects.toThrow('no longer active') +}) + +it('edits schema-default array rows without dropping secrets and removes rows instead of restoring them', async () => { + const schema = z.object({ + ordinary: z.string(), + rows: z.array(z.object({ name: z.string(), token: z.string().role('secret') })) + .default([{ name: 'default', token: 'private-default' }]).volatile(), + }) + const { ctx } = await fixture({ schema, hmr: false }) + await ctx.settings.mutate('first', [{ op: 'set', path: ['rows', '0', 'name'], value: 'edited' }]) + expect(ctx.settings.describe().find(row => row.ns === 'first')!.value).toEqual({ rows: [{ name: 'edited', token: 'private-default' }] }) + await ctx.settings.mutate('first', [{ op: 'unset', path: ['rows', '0'] }]) + expect(ctx.settings.describe().find(row => row.ns === 'first')!.value).toEqual({ rows: [] }) +}) + +it('edits dictionary keys and creates optional live containers', async () => { + const { ctx } = await fixture({ hmr: false, schema: z.object({ + bag: z.any().volatile(), providers: z.dict(z.object({ endpoint: z.string() })).volatile(), + }) }) + await ctx.settings.mutate('first', [ + { op: 'set', path: ['bag', 'key'], value: 'value' }, + { op: 'set', path: ['providers', 'one', 'endpoint'], value: 'https://example.test' }, + ]) + expect(ctx.settings.describe().find(row => row.ns === 'first')!.value).toEqual({ + bag: { key: 'value' }, providers: { one: { endpoint: 'https://example.test' } }, + }) + await ctx.settings.mutate('first', [{ op: 'unset', path: ['bag', 'missing', 'nested', 'key'] }]) + const ordinary = await fixture({ schema: z.object({ ordinary: z.string() }), hmr: false }) + expect(ordinary.ctx.settings.describe().some(row => row.ns === 'first')).toBe(false) +}) + +it('restores an inherited secret without returning it to remote forms', async () => { + const { ctx } = await fixture({ hmr: false }) + await ctx.settings.update('first', { token: 'changed' }) + await ctx.settings.mutate('first', [{ op: 'unset', path: ['token'] }]) + expect(ctx.settings.describe().find(row => row.ns === 'first')!.value).toMatchObject({ token: 'private' }) + expect(JSON.stringify(ctx.settings.describe({ redactSecrets: true }))).not.toContain('private') +}) + +it('does not publish a queued form refresh after disposal', async () => { + const { ctx } = await fixture({ hmr: false }) + ctx.settings.describe() + const entries = ctx.configEditor.entries() + const settings = entries.find(row => row.options.id === 'settings')! + const updates: string[] = [] + ctx.on('settings/document-updated', (ns) => { updates.push(ns) }) + ctx.emit('app-boot/config-reload') + await settings.fiber!.dispose() + await Promise.resolve() + expect(updates).toEqual([]) +}) + +it('defaults to automatic pages and applies reversible presentation policy to only the caller instance', async () => { + const { ctx } = await fixture({ hmr: false }) + const first = ctx.configEditor.entries().find(entry => entry.options.id === 'first')! + const read = (id: string) => ctx.settings.describe().find(view => view.ns === id)! + expect(read('first').autoGenerate).toBe(true) + expect(read('second').autoGenerate).toBe(true) + const settings = first.fiber!.ctx.get('settings')! + const dispose = settings.configure({ auto: false }) + expect(read('first').autoGenerate).toBe(false) + expect(read('second').autoGenerate).toBe(true) + expect(() => settings.configure({ auto: true })).toThrow('already configured') + await ctx.settings.update('first', { count: 4 }) + expect(read('first').value).toMatchObject({ count: 4 }) + dispose() + expect(read('first').autoGenerate).toBe(true) + const disposeNext = settings.configure({ auto: false }) + dispose() + expect(read('first').autoGenerate).toBe(false) + disposeNext() +}) + +it('registers optional presentation policy after Settings loads and removes it with the caller', async () => { + const { ctx } = await fixture({ hmr: false, apply: (plugin) => { + plugin.inject(['settings'], (child) => { child.effect(() => child.settings.configure({ auto: false }, plugin.fiber)) }) + } }) + await ctx.loader.await() + await vi.waitFor(() => { expect(ctx.settings.describe().find(view => view.ns === 'first')!.autoGenerate).toBe(false) }) + const provider = ctx.configEditor.entries().find(entry => entry.options.id === 'settings')! + const entry = ctx.configEditor.entries().find(entry => entry.options.id === 'first')! + const owner = entry.fiber + await provider.update({ disabled: true }) + expect(ctx.get('settings')).toBeUndefined() + expect(entry.fiber).toBe(owner) + await provider.update({ disabled: false }) + await ctx.loader.await() + expect(entry.fiber).toBe(owner) + await vi.waitFor(() => { expect(ctx.settings.describe().find(view => view.ns === 'first')!.autoGenerate).toBe(false) }) + await entry.update({ disabled: true }) + expect(ctx.settings.describe().some(view => view.ns === 'first')).toBe(false) + await entry.update({ disabled: false }) + await ctx.loader.await() + await vi.waitFor(() => { expect(ctx.settings.describe().find(view => view.ns === 'first')!.autoGenerate).toBe(false) }) +}) + +it('imports the removed settings.yaml into the profile once and keeps rejected sections in the renamed file', async () => { + const { ctx, home, profile, start } = await fixture() + await ctx.fiber.dispose() + const legacy = join(home, 'settings.yaml') + writeFileSync(legacy, 'default-model:\n model: legacy\nfirst:\n ordinary: rejected\nmissing:\n count: 1\n') + const restored = await start() + await vi.waitFor(() => { expect(restored.agentDefaultModel.currentSelection().model).toBe('legacy') }) + expect(parse(readFileSync(profile.patchPath, 'utf8'))).toContainEqual({ id: 'default-model', name: 'cordis:model', config: { provider: 'test', model: 'legacy' } }) + expect(restored.settings.describe({ redactSecrets: true }).find(row => row.ns === 'first')!.value).toEqual({ count: 2, list: [] }) + expect(existsSync(legacy)).toBe(false) + expect(readFileSync(`${legacy}.imported`, 'utf8')).toContain('rejected') + // An empty document is renamed without writes; a document that cannot be renamed is reported, not rethrown into boot. + await restored.fiber.dispose() + writeFileSync(legacy, '') + const again = await start() + await vi.waitFor(() => { expect(readFileSync(`${legacy}.imported`, 'utf8')).toBe('') }) + await again.fiber.dispose() + writeFileSync(legacy, 'default-model:\n model: blocked\n') + rmSync(`${legacy}.imported`) + mkdirSync(join(home, 'settings.yaml.imported', 'occupied'), { recursive: true }) + const blocked = await start() + const failures = (): unknown[] => blocked.logger.buffer.filter(message => message.type === 'error').map((message): unknown => message.args[0]) + // The rename fails with EISDIR on POSIX and EPERM on Windows; the reported error names the rename either way. + await vi.waitFor(() => { expect(failures().some(failure => failure instanceof Error && failure.message.includes('rename'))).toBe(true) }) + expect(blocked.agentDefaultModel.currentSelection().model).toBe('legacy') +}) + +it('describes an entry whose required field only the profile supplies, and reports a failed refresh instead of crashing', async () => { + const { ctx, profile, start } = await fixture({ + schema: z.object({ ordinary: z.string(), required: z.string().required(), count: z.number().default(2).volatile() }), + }) + await ctx.fiber.dispose() + writeFileSync(profile.patchPath, JSON.stringify([{ id: 'first', config: { required: 'profile', count: 4 } }])) + const restored = await start() + const first = restored.settings.describe().find(row => row.ns === 'first')! + expect(first.value).toEqual({ count: 4 }) + expect(first.base).toEqual({}) + vi.spyOn(restored.settings, 'describe').mockImplementationOnce(() => { throw new Error('refresh failed') }) + restored.emit('app-boot/config-reload') + const failures = (): unknown[] => restored.logger.buffer.filter(message => message.type === 'error').map((message): unknown => message.args[0]) + await vi.waitFor(() => { expect(failures()).toContainEqual(expect.objectContaining({ message: 'refresh failed' })) }) +}) diff --git a/packages/settings/settings/tests/editor-failures.spec.ts b/packages/settings/settings/tests/editor-failures.spec.ts new file mode 100644 index 0000000000..7767189182 --- /dev/null +++ b/packages/settings/settings/tests/editor-failures.spec.ts @@ -0,0 +1,34 @@ +/** File failures between validation and commit must not persist partial edits. */ +import * as fs from 'node:fs/promises' +import { readFileSync } from 'node:fs' +import { afterEach, expect, it, vi } from 'vitest' +import { configurationFixture } from './configuration-fixture.ts' + +vi.mock('node:fs/promises', async (importOriginal) => { + const original = await importOriginal() + return { ...original, readFile: vi.fn(original.readFile) } +}) +afterEach(() => { vi.mocked(fs.readFile).mockReset() }) + +it.each([ + ['denied read', Object.assign(new Error('denied read'), { code: 'EACCES' })], + ['truncated YAML', '- id: ['], + ['wrong document', '{}'], +] as const)('refuses %s observed after initial validation', async (_name, result) => { + const { ctx, profile } = await configurationFixture({ hmr: false }) + const entry = ctx.configEditor.entries().find(row => row.options.id === 'first')! + const before = readFileSync(profile.patchPath, 'utf8') + const read = vi.mocked(fs.readFile) + if (typeof result === 'string') read.mockResolvedValueOnce(result) + else read.mockRejectedValueOnce(result) + await expect(ctx.configEditor.edit(entry, raw => ({ ...raw, count: 6 }))).rejects.toThrow() + expect(readFileSync(profile.patchPath, 'utf8')).toBe(before) +}) + +it('creates a patch removed between validation and reading the document', async () => { + const { ctx, profile } = await configurationFixture({ hmr: false }) + const entry = ctx.configEditor.entries().find(row => row.options.id === 'first')! + vi.mocked(fs.readFile).mockRejectedValueOnce(Object.assign(new Error('removed'), { code: 'ENOENT' })) + await ctx.configEditor.edit(entry, raw => ({ ...raw, count: 6 })) + expect(readFileSync(profile.patchPath, 'utf8')).toContain('count: 6') +}) diff --git a/packages/settings/settings/tests/invariant.spec.ts b/packages/settings/settings/tests/invariant.spec.ts deleted file mode 100644 index 31de9df12a..0000000000 --- a/packages/settings/settings/tests/invariant.spec.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' -import z from '@deepseek-ai/schemastery' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' -import * as SettingsInvariant from '../src/invariant.ts' -import { MemorySettings } from './memory.ts' - -async function setup(withProvider: boolean): Promise { - const ctx = new Context() - await ctx.plugin(InvariantRegistry) - await ctx.plugin(SettingsInvariant) - if (withProvider) await ctx.plugin(MemorySettings) - return ctx -} - -describe('settings invariants', () => { - it('fails a settings/updated emission without a live settings service', async () => { - const ctx = await setup(false) - expect(() => { - ctx.emit('settings/updated', 'ghost' as SettingsNamespace, { a: 1 }, { a: 2 }, 'provider') - }).toThrow(/without a live settings service/) - }) - - it('fails a settings/updated emission for an unregistered namespace', async () => { - const ctx = await setup(true) - expect(() => { - ctx.emit('settings/updated', 'ghost' as SettingsNamespace, { a: 1 }, { a: 2 }, 'provider') - }).toThrow(/unregistered/) - }) - - it('fails a settings/updated emission without a resolved-value change', async () => { - const ctx = await setup(true) - ctx.settings.register('ui-theme', z.object({ - theme: z.string().default('dark'), - })) - expect(() => { - ctx.emit('settings/updated', 'ui-theme' as SettingsNamespace, { theme: 'dark' }, { theme: 'dark' }, 'update') - }).toThrow(/without a resolved-value change/) - }) - - it('fails a settings/updated emission whose value diverges from the authoritative state', async () => { - const ctx = await setup(true) - ctx.settings.register('ui-theme', z.object({ - theme: z.string().default('dark'), - })) - // Fabricated next ≠ the service's current resolved value ({theme: 'dark'}). - expect(() => { - ctx.emit('settings/updated', 'ui-theme' as SettingsNamespace, { theme: 'forged' }, { theme: 'dark' }, 'update') - }).toThrow(/authoritative/) - }) -}) diff --git a/packages/settings/settings/tests/live-config.ts b/packages/settings/settings/tests/live-config.ts new file mode 100644 index 0000000000..1248487f55 --- /dev/null +++ b/packages/settings/settings/tests/live-config.ts @@ -0,0 +1,60 @@ +/** Test fixtures using the same Loader updates as profile reconciliation. */ +import { Context, resolveConfig, type Fiber, type Plugin } from '@deepseek-ai/cordis' +import { expect, vi } from 'vitest' +import Loader from '@deepseek-ai/cordis-plugin-loader' + +function merge(base: Record, patch: Record): Record { + const result = { ...base } + for (const [key, value] of Object.entries(patch)) { + const before = result[key] + result[key] = before && typeof before === 'object' && !Array.isArray(before) + && value && typeof value === 'object' && !Array.isArray(value) + ? merge(before as Record, value as Record) : value + } + return result +} + +/** Mount a consumer behind Loader and edit its raw configuration. */ +export async function liveConfig(ctx: Context, plugin: Plugin, initial: object = {}) { + if (ctx.get('loader') === undefined) { + await ctx.plugin(Loader) + } + const name = `live-${Object.keys(ctx.loader.builtins).length}` + ctx.loader.builtins[name] = plugin + const options = { id: plugin.name ?? name, name: `cordis:${name}`, config: initial } + const id = await ctx.loader.create(options) + const entry = ctx.loader.resolve(id) + await entry.fiber!.await() + const replace = async (next: Record) => { + const fiber = entry.fiber! + resolveConfig(fiber.runtime!, fiber.ctx.waterfall(fiber, 'internal/config', next, () => next)) + await entry.update({ config: next }) + await entry.fiber!.await() + ctx.emit('app-boot/config-reload') + } + return { + entry, + fiber: entry.fiber!, + update: (patch: Record) => replace(merge(entry.options.config as Record, patch)), + replace, + } +} + +/** Assert a plugin keeps its own fiber off the generated pages while Settings is mounted and withdraws on disposal. + * @param mount Starts the plugin under test in the supplied context and returns its fiber. + */ +export async function omitsGeneratedPage(mount: (ctx: Context) => Fiber | Promise): Promise { + const ctx = new Context() + const release = vi.fn() + const configure = vi.fn(() => release) + ctx.provide('settings', { configure } as never) + const fiber = await mount(ctx) + await ctx.fiber.await() + expect(configure).toHaveBeenCalledOnce() + const [policy, owner] = configure.mock.calls[0] as unknown[] + expect(policy).toEqual({ auto: false }) + // Identity only: printing a Fiber walks Context proxies. + expect(Object.is(owner, fiber)).toBe(true) + await fiber.dispose() + expect(release).toHaveBeenCalledOnce() +} diff --git a/packages/settings/settings/tests/memory.ts b/packages/settings/settings/tests/memory.ts deleted file mode 100644 index f4c23f62f5..0000000000 --- a/packages/settings/settings/tests/memory.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * In-memory settings provider fixture: the smallest real subclass of the Service Definition, - * used by the base-class behavior suite in place of a file- or network-backed - * provider. Kept in `tests/` because production providers live in their own - * packages. - */ - -import { SettingsProvider, type SettingsNamespace } from '../src/index.ts' - -/** In-memory provider exposing the protected provider hooks to tests. */ -export class MemorySettings extends SettingsProvider { - /** Raw document the provider "storage" currently holds. */ - doc: Record - /** Every persist() call observed, in order. */ - persisted: Array<{ ns: SettingsNamespace; section: Record }> = [] - /** When false, update() must reject before reaching persist(). */ - writableFlag: boolean - - /** Artificial persist latency so tests can interleave concurrent updates. */ - persistDelayMs: number - - constructor(ctx: ConstructorParameters[0], options?: { - doc?: Record - writable?: boolean - persistDelayMs?: number - }) { - super(ctx) - this.doc = structuredClone(options?.doc ?? {}) - this.writableFlag = options?.writable ?? true - this.persistDelayMs = options?.persistDelayMs ?? 0 - } - - get writable(): boolean { - return this.writableFlag - } - - protected load(): Promise> { - return Promise.resolve(structuredClone(this.doc)) - } - - protected async persist(ns: SettingsNamespace, section: Record): Promise { - if (this.persistDelayMs > 0) { - await new Promise(resolve => setTimeout(resolve, this.persistDelayMs)) - } - this.persisted.push({ ns, section: structuredClone(section) }) - this.doc[ns] = structuredClone(section) - } - - /** Simulate an external storage change reaching the provider. */ - pushExternal(doc: Record): void { - this.doc = structuredClone(doc) - this.publish(structuredClone(doc)) - } -} diff --git a/packages/settings/settings/tests/profile-composition.ts b/packages/settings/settings/tests/profile-composition.ts new file mode 100644 index 0000000000..b0ba588749 --- /dev/null +++ b/packages/settings/settings/tests/profile-composition.ts @@ -0,0 +1,40 @@ +/** Mount source modules through a real profile, patch file, and reload coordinator. */ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import yaml from 'js-yaml' +import type { Context } from '@deepseek-ai/cordis' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' +import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import Timer from '@deepseek-ai/cordis-plugin-timer' +import { initProfile, mountRootInclude, readProfilePatches, type ProfileContext } from '@deepseek-ai/dsh-app-boot' +import Hmr from '@deepseek-ai/dsh-hmr' +import ConfigEditor from '@deepseek-ai/dsh-config-editor' +import Settings from '../src/index.ts' + +/** Attach the profile runtime to an initialized Loader with a source-module importer. */ +export async function profileComposition(ctx: Context, home: string, baseFile: string): Promise { + const dir = join(home, 'profile') + initProfile(dir, ['test-profile-bundle']) + const bundle = join(dir, 'node_modules', 'test-profile-bundle') + await mkdir(bundle, { recursive: true }) + await writeFile(join(home, 'package.json'), '{"name":"test-installation"}\n') + await writeFile(join(bundle, 'package.json'), JSON.stringify({ name: 'test-profile-bundle', version: '1.0.0', dsh: { bundle: { patch: 'cordis.patch.yml' } } })) + const rows = yaml.load(await readFile(baseFile, 'utf8'), { schema: entryListSchema }) as EntryOptions[] + for (const row of rows) row.id ??= row.name.replace('@deepseek-ai/dsh-', '') + await writeFile(join(bundle, 'cordis.patch.yml'), yaml.dump([{ insert: rows }], { schema: entryListSchema })) + await writeFile(join(dir, 'cordis.yml'), '[]\n') + const profile: ProfileContext = { + name: 'test', startedBundles: ['test-profile-bundle'], dir, patchPath: join(dir, 'cordis.patch.yml'), + installAnchor: join(home, 'package.json'), cwd: home, home, overlays: [], telemetryDisabledEnv: undefined, + } + ctx.provide('profileContext', profile) + ctx.provide('appReady', { onReady: (listener: () => void) => { listener(); return () => {} } }) + await ctx.plugin(ConfigEditor) + await ctx.plugin(Settings) + await mountRootInclude(ctx, join(dir, 'cordis.yml'), readProfilePatches('test', profile)) + await ctx.loader.await() + await ctx.plugin(Timer) + await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) + await ctx.hmr.runExclusive(async () => {}) + return profile.patchPath +} diff --git a/packages/settings/settings/tests/redact.spec.ts b/packages/settings/settings/tests/redact.spec.ts index dc8530058f..60e732adf5 100644 --- a/packages/settings/settings/tests/redact.spec.ts +++ b/packages/settings/settings/tests/redact.spec.ts @@ -1,8 +1,6 @@ import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { redactSecrets } from '../src/index.ts' -import { MemorySettings } from './memory.ts' const Profile = z.object({ apiKey: z.string().role('secret'), @@ -101,68 +99,22 @@ describe('redactSecrets', () => { expect(redactSecrets({ type: 'object' } as never, { k: 'v' })).toEqual({ value: { k: 'v' }, secrets: [] }) expect(redactSecrets({ type: 'array' } as never, ['v'])).toEqual({ value: ['v'], secrets: [] }) }) + it('removes secrets declared by any union branch, intersection, or transform', () => { + const schema = z.object({ + union: z.union([z.object({ token: z.string().role('secret') }), z.object({ token: z.string() })]), + intersection: z.intersect([z.object({ token: z.string().role('secret') }), z.object({ name: z.string() })]), + transformed: z.transform(z.object({ token: z.string().role('secret') }), value => value), + }) + const { value, secrets } = redactSecrets(schema as z, { + union: { token: 'union-secret' }, intersection: { token: 'intersection-secret', name: 'visible' }, + transformed: { token: 'transformed-secret' }, + }) + expect(value).toEqual({ union: {}, intersection: { name: 'visible' }, transformed: {} }) + expect(secrets).toEqual(['union', 'intersection', 'transformed'].map(key => ({ path: [key, 'token'], set: true }))) + }) + }) -describe('describe() layers and redaction', () => { - const NS = 'adapter' - - async function boot(doc?: Record) { - const ctx = new Context() - await ctx.plugin(MemorySettings, doc === undefined ? undefined : { doc }) - return ctx - } - - it('exposes detached base and user layers beside the resolved value', async () => { - const ctx = await boot({ adapter: { baseURL: 'https://user' } }) - const base = { apiKey: 'entry-key', baseURL: 'https://base' } - ctx.settings.register(NS, Profile, { base }) - const [descriptor] = ctx.settings.describe() - expect(descriptor?.base).toEqual(base) - expect(descriptor?.base).not.toBe(base) - expect(descriptor?.user).toEqual({ baseURL: 'https://user' }) - expect(descriptor?.value).toEqual({ apiKey: 'entry-key', baseURL: 'https://user' }) - ;(descriptor?.user as Record).baseURL = 'mutated' - expect(ctx.settings.describe()[0]?.user).toEqual({ baseURL: 'https://user' }) - expect(descriptor?.secrets).toBeUndefined() - }) - - it('omits the layers when neither a base nor a user section exists', async () => { - const ctx = await boot() - ctx.settings.register(NS, Profile) - const [descriptor] = ctx.settings.describe() - expect(descriptor).not.toHaveProperty('base') - expect(descriptor).not.toHaveProperty('user') - }) - - it('describes a section that became malformed after registration as having no user layer', async () => { - const ctx = await boot({ adapter: { baseURL: 'https://user' } }) - const provider = ctx.get('settings') as MemorySettings - ctx.settings.register(NS, Profile, { base: { baseURL: 'https://base' } }) - provider.pushExternal({ adapter: 5 }) - const [descriptor] = ctx.settings.describe() - expect(descriptor).not.toHaveProperty('user') - // The malformed publish kept the last good resolved value. - expect(descriptor?.value).toEqual({ baseURL: 'https://user' }) - }) - - it('redacts a descriptor that has neither base nor user layer', async () => { - const ctx = await boot() - ctx.settings.register(NS, Profile) - const [descriptor] = ctx.settings.describe({ redactSecrets: true }) - expect(descriptor).not.toHaveProperty('base') - expect(descriptor).not.toHaveProperty('user') - expect(descriptor?.secrets).toEqual([{ path: ['apiKey'], set: false }]) - }) - - it('redacts every layer and enumerates secret slots under redactSecrets', async () => { - const ctx = await boot({ adapter: { apiKey: 'user-key', baseURL: 'https://user' } }) - ctx.settings.register(NS, Profile, { base: { apiKey: 'entry-key' } }) - const [descriptor] = ctx.settings.describe({ redactSecrets: true }) - expect(descriptor?.value).toEqual({ baseURL: 'https://user' }) - expect(descriptor?.base).toEqual({}) - expect(descriptor?.user).toEqual({ baseURL: 'https://user' }) - expect(descriptor?.secrets).toEqual([{ path: ['apiKey'], set: true }]) - const [verbatim] = ctx.settings.describe() - expect(verbatim?.value).toEqual({ apiKey: 'user-key', baseURL: 'https://user' }) - }) +it('preserves values when an unspecified union declares no secret alternatives', () => { + expect(redactSecrets(new z({ type: 'union' }) as z, 'visible')).toEqual({ value: 'visible', secrets: [] }) }) diff --git a/packages/settings/settings/tests/schema.spec.ts b/packages/settings/settings/tests/schema.spec.ts new file mode 100644 index 0000000000..bd511eb5d5 --- /dev/null +++ b/packages/settings/settings/tests/schema.spec.ts @@ -0,0 +1,42 @@ +/** Form projection preserves live containers and removes secret defaults. */ +import { expect, it } from 'vitest' +import z from '@deepseek-ai/schemastery' +import { isVolatilePath, plainConfig, projectForm, volatileForm } from '../src/schema.ts' + +it('exposes nested live fields while omitting ordinary fields', () => { + const schema = z.object({ + ordinary: z.string(), nested: z.object({ fixed: z.string(), live: z.number().volatile() }), enabled: z.boolean().volatile(), + }) + const form = volatileForm(schema)! + expect(Object.keys(form.dict!)).toEqual(['nested', 'enabled']) + expect(projectForm(form, { ordinary: 'hidden', nested: { fixed: 'hidden', live: 3 }, enabled: true })).toEqual({ nested: { live: 3 }, enabled: true }) + expect(isVolatilePath(schema, ['nested', 'live'])).toBe(true) + expect(isVolatilePath(schema, ['nested', 'fixed'])).toBe(false) + expect(isVolatilePath(schema, [])).toBe(false) + expect(volatileForm(z.object({ fixed: z.string() }))).toBeUndefined() + // A raw `!!js` expression is one node; projecting it field by field would replace it with `{}` on the next write. + expect(projectForm(form, { nested: { __jsExpr: 'ctx.live' } })).toEqual({ nested: { __jsExpr: 'ctx.live' } }) +}) + +it('detaches references and strips secret defaults throughout a live container', () => { + const schema = z.object({ + secret: z.string().role('secret').default('hidden').required(), + rows: z.array(z.object({ name: z.string(), token: z.string().role('secret') })).default([{ name: 'one', token: 'hidden' }]), + mode: z.union(['one', 'two']).default('one'), + }).volatile() + const form = volatileForm(schema)! + expect(JSON.stringify(form.toJSON())).not.toContain('hidden') + expect(form.meta.volatile).toBeUndefined() + expect(form.dict!.secret!.meta.required).toBeUndefined() + const parsed = schema({ secret: 'private', rows: [{ name: 'two', token: 'private' }] }) + expect(plainConfig(parsed)).toEqual({ secret: 'private', rows: [{ name: 'two', token: 'private' }], mode: 'one' }) + expect(isVolatilePath(schema, ['rows', '0', 'token'])).toBe(true) + expect(projectForm(form, null)).toBeNull() + expect(projectForm(form, {})).toEqual({}) +}) + +it('omits an object schema with no declared fields', () => { + const schema = new z({ type: 'object' }) + expect(volatileForm(schema)).toBeUndefined() + expect(projectForm(schema, { undeclared: true })).toEqual({}) +}) diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts deleted file mode 100644 index a0d2f8928e..0000000000 --- a/packages/settings/settings/tests/settings.spec.ts +++ /dev/null @@ -1,1058 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import z from '@deepseek-ai/schemastery' -import { SettingsProvider, SettingsConflictError, type SettingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts' -import { deepEqualJson } from '@deepseek-ai/dsh-util-values' -import { MemorySettings } from './memory.ts' - -/** A provider implementing only the three primitives: the Service Definition owns initialization. */ -class BareProvider extends SettingsProvider { - doc: Record - - constructor(ctx: ConstructorParameters[0], options?: { doc?: Record }) { - super(ctx) - this.doc = structuredClone(options?.doc ?? {}) - } - - get writable(): boolean { - return true - } - - protected load(): Promise> { - return Promise.resolve(structuredClone(this.doc)) - } - - protected persist(ns: SettingsNamespace, section: Record): Promise { - this.doc[ns] = structuredClone(section) - return Promise.resolve() - } -} - -interface ThemeConfig { - theme: 'dark' | 'light' - fontSize: number -} - -const ThemeSchema: z = z.object({ - theme: z.union(['dark', 'light']).default('dark'), - fontSize: z.number().default(14), -}) - -interface NestedConfig { - retry: { attempts: number; delayMs: number } - tags: string[] -} - -const NestedSchema: z = z.object({ - retry: z.object({ - attempts: z.number().default(2), - delayMs: z.number().default(100), - }), - tags: z.array(z.string()).default(['default']), -}) - -async function boot(options?: ConstructorParameters[1]) { - const ctx = new Context() - const fiber = ctx.plugin(MemorySettings, options) - await fiber - const provider = ctx.get('settings') as MemorySettings - return { ctx, provider, fiber } -} - -describe('provider metadata', () => { - it('does not advertise a local document unless the provider overrides it', async () => { - const { ctx } = await boot() - expect(ctx.settings.documentPath).toBeUndefined() - await expect(ctx.settings.prepareDocument()).resolves.toBeUndefined() - }) -}) - -/** Record every settings/updated emission. */ -function recordUpdates(ctx: Context) { - const events: Array<{ ns: string; next: unknown; prev: unknown; source: SettingsUpdateSource }> = [] - ctx.on('settings/updated', (ns, next, prev, source) => { - events.push({ ns, next, prev, source }) - }) - return events -} - -describe('settings namespace validation', () => { - it.each(['', 'UI', '9lives', 'a_b', '-lead'])('rejects %j at the service', async (value) => { - const { ctx } = await boot() - expect(() => ctx.settings.register(value, ThemeSchema)).toThrow(TypeError) - }) -}) - -describe('registration', () => { - it('resolves schema defaults, then composition base, then the user layer', async () => { - const { ctx } = await boot({ doc: { 'ui-theme': { theme: 'light' } } }) - const scope = ctx.settings.register('ui-theme', ThemeSchema, { - base: { fontSize: 16 }, - }) - // theme: user layer wins; fontSize: base wins over the schema default. - expect(scope.get()).toEqual({ theme: 'light', fontSize: 16 }) - }) - - it('refuses a write its owner could not act on, and keeps the last good value for a stored one', async () => { - const { ctx } = await boot() - const ns = 'ui-theme' - // A constraint the schema cannot express: this owner cannot serve a size - // it considers unreadable, whatever the schema admits. - const scope = ctx.settings.register(ns, ThemeSchema, { - validate: (value) => { - if (value.fontSize < 10) throw new Error(`font size ${String(value.fontSize)} is unreadable`) - }, - }) - const before = scope.get() - - await expect(ctx.settings.update(ns, { fontSize: 4 })).rejects.toThrow(/unreadable/) - expect(scope.get()).toEqual(before) - - // An externally edited document must not strand the owner: the namespace - // keeps its last good value, exactly as a schema failure would. - ;(ctx.settings as unknown as { publish(doc: Record): void }) - .publish({ 'ui-theme': { fontSize: 4 } }) - expect(scope.get()).toEqual(before) - - await ctx.settings.update(ns, { fontSize: 18 }) - expect(scope.get()).toMatchObject({ fontSize: 18 }) - }) - - it('fails the registration itself when the already-stored section is unserviceable', async () => { - // The other direction of the same contract: `register` resolves inline, so - // at cold start there is no last good value to keep. A stored section the - // owner cannot serve therefore refuses the registration rather than - // mounting an owner over configuration it rejects. - const { ctx } = await boot({ doc: { 'ui-theme': { fontSize: 4 } } }) - expect(() => ctx.settings.register('ui-theme', ThemeSchema, { - validate: (value) => { - if (value.fontSize < 10) throw new Error(`font size ${String(value.fontSize)} is unreadable`) - }, - })).toThrow(/unreadable/) - }) - - it('rejects a duplicate namespace loud', async () => { - const { ctx } = await boot() - ctx.settings.register('ui-theme', ThemeSchema) - expect(() => ctx.settings.register('ui-theme', ThemeSchema)) - .toThrow(/already registered/) - }) - - it('fails registration when the stored section is invalid for the schema', async () => { - const { ctx } = await boot({ doc: { 'ui-theme': { fontSize: 'big' } } }) - expect(() => ctx.settings.register('ui-theme', ThemeSchema)).toThrow() - }) - - it('fails registration when the stored section is not an object', async () => { - const { ctx } = await boot({ doc: { 'ui-theme': 'dark' } }) - expect(() => ctx.settings.register('ui-theme', ThemeSchema)) - .toThrow(/must be an object/) - }) - - it('describes registered namespaces with schema JSON, value, and applies', async () => { - const { ctx } = await boot() - ctx.settings.register('ui-theme', ThemeSchema) - ctx.settings.register('workspace', NestedSchema, { applies: 'restart' }) - const descriptors = ctx.settings.describe() - expect(descriptors.map(entry => [entry.ns, entry.applies])).toEqual([ - ['ui-theme', 'live'], - ['workspace', 'restart'], - ]) - expect(descriptors[0]!.value).toEqual({ theme: 'dark', fontSize: 14 }) - // schemastery's canonical wire form: a { uid, refs } envelope whose root ref - // is the object schema — the shape schema-driven form UIs reconstruct from. - const serialized = descriptors[0]!.schema as { uid: number; refs: Record } - expect(serialized.refs[String(serialized.uid)]?.type).toBe('object') - }) - - it('reads undefined for an unregistered namespace', async () => { - const { ctx } = await boot() - expect(ctx.settings.get('missing')).toBeUndefined() - }) - - it('hands out frozen resolved values', async () => { - const { ctx } = await boot({ doc: { workspace: { retry: { attempts: 5 } } } }) - const scope = ctx.settings.register('workspace', NestedSchema) - const value = scope.get() - expect(Object.isFrozen(value)).toBe(true) - expect(Object.isFrozen(value.retry)).toBe(true) - expect(() => { (value.retry as { attempts: number }).attempts = 0 }).toThrow(TypeError) - }) - - it('removes the namespace and its observers when the registrant fiber disposes', async () => { - const { ctx, provider } = await boot() - const seen: unknown[] = [] - let scope: SettingsScope | undefined - const fiber = ctx.plugin({ - inject: ['settings'], - apply: (child: Context) => { - scope = child.settings.register('ui-theme', ThemeSchema) - scope.watch((next) => { seen.push(next) }) - }, - }) - await fiber - expect(ctx.settings.get('ui-theme')).toEqual({ theme: 'dark', fontSize: 14 }) - - await fiber.dispose() - expect(ctx.settings.get('ui-theme')).toBeUndefined() - expect(ctx.settings.describe()).toEqual([]) - provider.pushExternal({ 'ui-theme': { theme: 'light' } }) - expect(seen).toEqual([]) - - // The namespace is free again, and re-registration resolves the user layer - // that kept living in storage while nobody owned the namespace. - const again = ctx.settings.register('ui-theme', ThemeSchema) - expect(again.get()).toEqual({ theme: 'light', fontSize: 14 }) - }) -}) - -describe('update', () => { - it('persists the merged user section without baking in the base layer', async () => { - const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } }) - const scope = ctx.settings.register('ui-theme', ThemeSchema, { - base: { fontSize: 16 }, - }) - await scope.update({ theme: 'dark' }) - expect(provider.persisted).toEqual([ - { ns: 'ui-theme', section: { theme: 'dark' } }, - ]) - expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 }) - }) - - it('deep-merges nested objects and replaces arrays wholesale', async () => { - const { ctx, provider } = await boot({ - doc: { workspace: { retry: { attempts: 5, delayMs: 300 }, tags: ['a', 'b'] } }, - }) - const scope = ctx.settings.register('workspace', NestedSchema) - await scope.update({ retry: { attempts: 7 }, tags: ['c'] }) - expect(provider.persisted[0]!.section).toEqual({ - retry: { attempts: 7, delayMs: 300 }, - tags: ['c'], - }) - expect(scope.get()).toEqual({ retry: { attempts: 7, delayMs: 300 }, tags: ['c'] }) - }) - - it('commits, notifies watchers, and emits with source update', async () => { - const { ctx } = await boot() - const events = recordUpdates(ctx) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - const watcher = vi.fn() - scope.watch(watcher) - await scope.update({ theme: 'light' }) - expect(watcher).toHaveBeenCalledWith( - { theme: 'light', fontSize: 14 }, - { theme: 'dark', fontSize: 14 }, - ) - expect(events).toEqual([{ - ns: 'ui-theme', - next: { theme: 'light', fontSize: 14 }, - prev: { theme: 'dark', fontSize: 14 }, - source: 'update', - }]) - }) - - it('rejects an invalid patch before persisting anything', async () => { - const { ctx, provider } = await boot() - const events = recordUpdates(ctx) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - await expect(scope.update({ fontSize: 'big' })).rejects.toThrow() - expect(provider.persisted).toEqual([]) - expect(events).toEqual([]) - expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 }) - // The failed write must not poison the namespace queue for later writers. - await scope.update({ fontSize: 18 }) - expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 }) - }) - - it('ignores explicit undefined entries so a sparse patch cannot erase keys', async () => { - const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - await scope.update({ theme: undefined, fontSize: 18 }) - expect(provider.persisted[0]!.section).toEqual({ theme: 'light', fontSize: 18 }) - expect(scope.get()).toEqual({ theme: 'light', fontSize: 18 }) - }) - - it('rejects a non-object patch', async () => { - const { ctx } = await boot() - const scope = ctx.settings.register('ui-theme', ThemeSchema) - await expect(scope.update([1])).rejects.toThrow(TypeError) - await expect(scope.update(new Date() as object)).rejects.toThrow(TypeError) - await expect(scope.replace([1])).rejects.toThrow(/replace for "ui-theme"/) - }) - - it('accepts a null-prototype patch object', async () => { - const { ctx } = await boot() - const scope = ctx.settings.register('ui-theme', ThemeSchema) - const patch: { fontSize?: number } = Object.create(null) as { fontSize?: number } - patch.fontSize = 18 - await scope.update(patch) - expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 }) - }) - - it('rejects an unregistered namespace', async () => { - const { ctx } = await boot() - await expect(ctx.settings.update('missing', {})) - .rejects.toThrow(/not registered/) - }) - - it('rejects on a read-only provider before reaching persist', async () => { - const { ctx, provider } = await boot({ writable: false }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - await expect(scope.update({ theme: 'light' })).rejects.toThrow(/read-only/) - expect(provider.persisted).toEqual([]) - }) -}) - -describe('deepEqualJson', () => { - it.each([ - [{ a: [1, 2] }, { a: [1, 2] }, true], - [{ a: [1, 2] }, { a: [1] }, false], - [{ a: [1] }, { a: { 0: 1 } }, false], - [{ a: 1 }, { b: 1 }, false], - [{ a: 1 }, {}, false], - [{ a: null }, { a: null }, true], - [{ a: null }, { a: {} }, false], - ])('compares %j vs %j as %s', (a, b, equal) => { - expect(deepEqualJson(a, b)).toBe(equal) - }) -}) - -describe('review regressions', () => { - it('propagates an invariant-coded listener failure instead of containing it', async () => { - const { ctx, provider } = await boot() - ctx.on('settings/updated', () => { - throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' }) - }) - ctx.settings.register('ui-theme', ThemeSchema) - expect(() => { provider.pushExternal({ 'ui-theme': { theme: 'light' } }) }) - .toThrow(/forged relation/) - }) - - it('serializes concurrent updates so neither patch is lost', async () => { - const { ctx, provider } = await boot({ persistDelayMs: 10 }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - await Promise.all([ - scope.update({ theme: 'light' }), - scope.update({ fontSize: 20 }), - ]) - expect(provider.doc['ui-theme']).toEqual({ theme: 'light', fontSize: 20 }) - expect(scope.get()).toEqual({ theme: 'light', fontSize: 20 }) - }) - - it('contains a throwing settings/updated listener and keeps later commits alive', async () => { - const { ctx, provider } = await boot() - ctx.on('settings/updated', () => { - throw new Error('listener boom') - }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - expect(() => { provider.pushExternal({ 'ui-theme': { theme: 'light' } }) }).not.toThrow() - expect(scope.get().theme).toBe('light') - provider.pushExternal({ 'ui-theme': { theme: 'dark' } }) - expect(scope.get().theme).toBe('dark') - }) - - it('contains an async watcher rejection', async () => { - const { ctx, provider } = await boot() - const scope = ctx.settings.register('ui-theme', ThemeSchema) - scope.watch(async () => { - throw new Error('async watcher boom') - }) - provider.pushExternal({ 'ui-theme': { theme: 'light' } }) - expect(scope.get().theme).toBe('light') - // Give the rejected watcher promise a microtask turn; containment means - // vitest observes no unhandled rejection out of this test. - await new Promise(resolve => setTimeout(resolve, 10)) - }) - - it('loads the provider document through the base init without provider boilerplate', async () => { - const ctx = new Context() - await ctx.plugin(BareProvider, { doc: { 'ui-theme': { fontSize: 7 } } }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - expect(scope.get()).toEqual({ theme: 'dark', fontSize: 7 }) - }) - - it('replaces the user section wholesale so overrides can be removed', async () => { - const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light', fontSize: 20 } } }) - const scope = ctx.settings.register('ui-theme', ThemeSchema, { - base: { fontSize: 16 }, - }) - await scope.replace({ theme: 'light' }) - // fontSize override is gone: resolution falls back to the base layer. - expect(scope.get()).toEqual({ theme: 'light', fontSize: 16 }) - expect(provider.doc['ui-theme']).toEqual({ theme: 'light' }) - await scope.replace({}) - expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 }) - expect(provider.doc['ui-theme']).toEqual({}) - }) -}) - -describe('second review regressions', () => { - it('runs every settings/updated listener even when an earlier one throws', async () => { - const { ctx, provider } = await boot() - ctx.on('settings/updated', () => { - throw new Error('first listener boom') - }) - const second = vi.fn() - ctx.on('settings/updated', second) - ctx.settings.register('ui-theme', ThemeSchema) - provider.pushExternal({ 'ui-theme': { theme: 'light' } }) - expect(second).toHaveBeenCalledTimes(1) - }) - - it('rejects an update queued after the registrant fiber disposed', async () => { - const { ctx } = await boot() - let scope: SettingsScope | undefined - const fiber = ctx.plugin({ - inject: ['settings'], - apply: (child: Context) => { - scope = child.settings.register('ui-theme', ThemeSchema) - }, - }) - await fiber - await fiber.dispose() - await expect(scope!.update({ theme: 'light' })).rejects.toThrow(/disposed|not registered/) - }) - - it('does not notify a registrant disposed while its update was in flight', async () => { - const { ctx, provider } = await boot({ persistDelayMs: 30 }) - const events = recordUpdates(ctx) - let scope: SettingsScope | undefined - const watcher = vi.fn() - const fiber = ctx.plugin({ - inject: ['settings'], - apply: (child: Context) => { - scope = child.settings.register('ui-theme', ThemeSchema) - scope.watch(watcher) - }, - }) - await fiber - const pending = scope!.update({ theme: 'light' }) - await new Promise(resolve => setTimeout(resolve, 5)) - await fiber.dispose() - await pending.catch(() => undefined) - await new Promise(resolve => setTimeout(resolve, 10)) - expect(watcher).not.toHaveBeenCalled() - expect(events).toEqual([]) - // The persist was already in flight, so storage keeps the write — but no - // commit reached the disposed registration. - expect(provider.doc['ui-theme']).toEqual({ theme: 'light' }) - }) - - it('drains in-flight writes at service dispose and rejects later ones', async () => { - const { ctx, provider, fiber } = await boot({ persistDelayMs: 20 }) - const service = ctx.settings - const scope = service.register('ui-theme', ThemeSchema) - const pending = scope.update({ theme: 'light' }) - await new Promise(resolve => setTimeout(resolve, 5)) - await fiber.dispose() - // The teardown drained the in-flight write before completing… - await pending.catch(() => undefined) - const persistedAtDispose = provider.persisted.length - expect(persistedAtDispose).toBe(1) - // …and afterwards nothing writes and new writes reject. - await expect(service.update('ui-theme', { theme: 'dark' })) - .rejects.toThrow(/disposed|not registered/) - await new Promise(resolve => setTimeout(resolve, 40)) - expect(provider.persisted.length).toBe(persistedAtDispose) - }) - - it('serializes invocations of one async watcher in commit order', async () => { - const { ctx, provider } = await boot() - const scope = ctx.settings.register('ui-theme', ThemeSchema) - const applied: number[] = [] - let firstCall = true - scope.watch(async (next) => { - // The first (stale) invocation is slow; unserialised it would finish - // last and clobber the newer applied state. - const delay = firstCall ? 30 : 0 - firstCall = false - await new Promise(resolve => setTimeout(resolve, delay)) - applied.push(next.fontSize) - }) - provider.pushExternal({ 'ui-theme': { fontSize: 1 } }) - provider.pushExternal({ 'ui-theme': { fontSize: 2 } }) - await vi.waitFor(() => { - expect(applied).toHaveLength(2) - }) - expect(applied).toEqual([1, 2]) - }) - - it('rejects a function value as not JSON-compatible', async () => { - const { ctx } = await boot() - const scope = ctx.settings.register('ui-theme', ThemeSchema) - await expect(scope.update({ theme: () => 'dark' })) - .rejects.toThrow(/JSON-compatible.*function at \$\.theme/) - }) - - it('rejects a write still queued when the service disposes', async () => { - const { ctx, fiber } = await boot({ persistDelayMs: 20 }) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - const first = scope.update({ theme: 'light' }) - const second = scope.update({ fontSize: 20 }) - await new Promise(resolve => setTimeout(resolve, 5)) - await fiber.dispose() - await first - await expect(second).rejects.toThrow(/disposed before the queued/) - }) - - it('rejects a write still queued when the registrant disposes', async () => { - const { ctx } = await boot({ persistDelayMs: 20 }) - let scope: SettingsScope | undefined - const fiber = ctx.plugin({ - inject: ['settings'], - apply: (child: Context) => { - scope = child.settings.register('ui-theme', ThemeSchema) - }, - }) - await fiber - const first = scope!.update({ theme: 'light' }) - const second = scope!.update({ fontSize: 20 }) - await new Promise(resolve => setTimeout(resolve, 5)) - await fiber.dispose() - await first - await expect(second).rejects.toThrow(/registration was disposed before the queued/) - }) - - it('snapshots the patch at call time so caller mutation cannot leak in', async () => { - const { ctx } = await boot() - const scope = ctx.settings.register('ui-theme', ThemeSchema) - const patch = { fontSize: 18 } - const pending = scope.update(patch) - patch.fontSize = 99 - await pending - expect(scope.get().fontSize).toBe(18) - }) -}) - -describe('publish', () => { - it('notifies watchers of an external change with source provider', async () => { - const { ctx, provider } = await boot() - const events = recordUpdates(ctx) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - const watcher = vi.fn() - scope.watch(watcher) - provider.pushExternal({ 'ui-theme': { theme: 'light' } }) - await vi.waitFor(() => { - expect(watcher).toHaveBeenCalledWith( - { theme: 'light', fontSize: 14 }, - { theme: 'dark', fontSize: 14 }, - ) - }) - expect(events[0]!.source).toBe('provider') - }) - - it('stays silent when the resolved value is deep-equal', async () => { - const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } }) - const events = recordUpdates(ctx) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - const watcher = vi.fn() - scope.watch(watcher) - provider.pushExternal({ 'ui-theme': { theme: 'light' } }) - expect(watcher).not.toHaveBeenCalled() - expect(events).toEqual([]) - }) - - it('keeps the last good value for an invalid section while other namespaces commit', async () => { - const { ctx, provider } = await boot() - const events = recordUpdates(ctx) - const theme = ctx.settings.register('ui-theme', ThemeSchema) - const workspace = ctx.settings.register('workspace', NestedSchema) - provider.pushExternal({ - 'ui-theme': { fontSize: 'broken' }, - workspace: { retry: { attempts: 9 } }, - }) - expect(theme.get()).toEqual({ theme: 'dark', fontSize: 14 }) - expect(workspace.get()).toEqual({ retry: { attempts: 9, delayMs: 100 }, tags: ['default'] }) - expect(events.map(event => event.ns)).toEqual(['workspace']) - }) - - it('recovers from a bad section once storage turns valid again', async () => { - const { ctx, provider } = await boot() - const scope = ctx.settings.register('ui-theme', ThemeSchema) - provider.pushExternal({ 'ui-theme': { fontSize: 'broken' } }) - expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 }) - provider.pushExternal({ 'ui-theme': { fontSize: 18 } }) - expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 }) - }) -}) - -describe('third review regressions', () => { - it('skips a queued watch invocation whose disposer ran before it started', async () => { - const { ctx, provider } = await boot() - const scope = ctx.settings.register('ui-theme', ThemeSchema) - const watcher = vi.fn() - const dispose = scope.watch(watcher) - // The commit chains the invocation as a microtask; the disposer runs in - // the same synchronous frame, before that invocation could start. - provider.pushExternal({ 'ui-theme': { theme: 'light' } }) - dispose() - await new Promise(resolve => setTimeout(resolve, 10)) - expect(watcher).not.toHaveBeenCalled() - }) - - it('waits for an in-flight watch invocation at service dispose', async () => { - const { ctx, provider, fiber } = await boot() - const scope = ctx.settings.register('ui-theme', ThemeSchema) - let release: (() => void) | undefined - let finished = false - scope.watch(async () => { - await new Promise((resolve) => { release = resolve }) - finished = true - }) - provider.pushExternal({ 'ui-theme': { theme: 'light' } }) - await vi.waitFor(() => { expect(release).toBeDefined() }) - let disposed = false - const disposal = fiber.dispose().then(() => { disposed = true }) - await new Promise(resolve => setTimeout(resolve, 15)) - expect(disposed).toBe(false) - release!() - await disposal - expect(finished).toBe(true) - }) - - it('rejects a Date at its path before anything persists', async () => { - const { ctx, provider } = await boot() - const scope = ctx.settings.register('ui-theme', z.object({ value: z.any() })) - await expect(scope.update({ value: { at: new Date(0) } })) - .rejects.toThrow(/JSON-compatible.*Date at \$\.value\.at/) - expect(provider.persisted).toEqual([]) - }) - - it.each([ - ['a Map', { value: new Map() }, /Map at \$\.value/], - ['a bigint', { value: [10n] }, /bigint at \$\.value\[0\]/], - ['a symbol', { value: Symbol('x') }, /symbol at \$\.value/], - ['a non-finite number', { value: Number.NaN }, /non-finite number at \$\.value/], - ['an undefined array entry', { value: [undefined] }, /undefined at \$\.value\[0\]/], - ['a class instance', { value: Object.create({ marker: true }) as object }, /non-plain object at \$\.value/], - ])('rejects %s that structuredClone would admit', async (_label, patch, message) => { - const { ctx } = await boot() - const scope = ctx.settings.register('ui-theme', z.object({ value: z.any() })) - await expect(scope.update(patch)).rejects.toThrow(message) - }) - - it('rejects a circular patch instead of storing an alias-looped document', async () => { - const { ctx } = await boot() - const scope = ctx.settings.register('ui-theme', z.object({ value: z.any() })) - const cyclic: Record = {} - cyclic['self'] = cyclic - await expect(scope.update({ value: cyclic })).rejects.toThrow(/circular reference at \$\.value\.self/) - const loop: unknown[] = [] - loop.push(loop) - await expect(scope.update({ value: loop })).rejects.toThrow(/circular reference at \$\.value\[0\]/) - }) - - it('accepts one object referenced twice without a cycle', async () => { - const { ctx } = await boot() - const scope = ctx.settings.register('ui-theme', z.object({ value: z.any() })) - const shared = { leaf: 1 } - await scope.update({ value: { left: shared, right: shared } }) - expect(scope.get()).toEqual({ value: { left: { leaf: 1 }, right: { leaf: 1 } } }) - }) - - it('contains an async settings/updated listener rejection and keeps other listeners running', async () => { - const { ctx, provider } = await boot() - // An async listener violates the event's synchronous signature, but an - // unlinted JS plugin can still register one. Declaring the return as - // unknown keeps this file's typed surface legal (unknown-returning - // functions are assignable to void positions) while the runtime value is - // still the rejected promise the containment guard must handle. - const boom = (): unknown => Promise.reject(new Error('async listener boom')) - ctx.on('settings/updated', boom) - const second = vi.fn() - ctx.on('settings/updated', second) - ctx.settings.register('ui-theme', ThemeSchema) - provider.pushExternal({ 'ui-theme': { theme: 'light' } }) - expect(second).toHaveBeenCalledTimes(1) - // Containment gives the rejection a handler; vitest observes no unhandled - // rejection out of this test. - await new Promise(resolve => setTimeout(resolve, 10)) - }) -}) - -describe('watch', () => { - it('stops after its disposer runs', async () => { - const { ctx, provider } = await boot() - const scope = ctx.settings.register('ui-theme', ThemeSchema) - const watcher = vi.fn() - const dispose = scope.watch(watcher) - dispose() - provider.pushExternal({ 'ui-theme': { theme: 'light' } }) - expect(watcher).not.toHaveBeenCalled() - }) - - it('contains a throwing watcher without blocking the commit or other watchers', async () => { - const { ctx, provider } = await boot() - const events = recordUpdates(ctx) - const scope = ctx.settings.register('ui-theme', ThemeSchema) - scope.watch(() => { throw new Error('watcher boom') }) - const second = vi.fn() - scope.watch(second) - provider.pushExternal({ 'ui-theme': { theme: 'light' } }) - await vi.waitFor(() => { - expect(second).toHaveBeenCalledTimes(1) - }) - expect(events).toHaveLength(1) - expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 }) - }) -}) - -describe('SettingsProvider.installSection', () => { - const HelperSchema: z<{ theme: string }> = z.object({ - theme: z.string().default('default'), - }) - - it('drives the source through attach, live commits, and detach', async () => { - const ctx = new Context() - const entry = { theme: 'entry' } - let current: () => { theme: string } = () => entry - let changes = 0 - ctx.inject(['settings'], (settingsCtx) => { - settingsCtx.settings.installSection(ctx, 'helper-ns', HelperSchema, entry, { - setSource: (source) => { - current = source - }, - onChange: () => { - changes += 1 - }, - }) - }) - // No settings service mounted: nothing ran, the entry stays authoritative. - expect(current()).toEqual({ theme: 'entry' }) - expect(changes).toBe(0) - - const fiber = ctx.plugin(MemorySettings, { doc: { 'helper-ns': { theme: 'user' } } }) - await fiber - await vi.waitFor(() => { - expect(current()).toEqual({ theme: 'user' }) - }) - expect(changes).toBe(1) - - await ctx.settings.update('helper-ns', { theme: 'live' }) - await vi.waitFor(() => { - expect(changes).toBe(2) - }) - expect(current()).toEqual({ theme: 'live' }) - - await fiber.dispose() - await vi.waitFor(() => { - expect(changes).toBe(3) - }) - expect(current()).toEqual({ theme: 'entry' }) - }) - - it('stays silent when the consumer itself unloads', async () => { - const { ctx } = await boot({ doc: { 'helper-ns': { theme: 'user' } } }) - const entry = { theme: 'entry' } - let current: () => { theme: string } = () => entry - const changes: string[] = [] - const consumer = ctx.plugin({ - inject: ['settings'], - apply: (child: Context) => { - child.settings.installSection(child, 'helper-ns', HelperSchema, entry, { - setSource: (source) => { - current = source - }, - onChange: () => { - changes.push(current().theme) - }, - }) - }, - }) - await consumer - await vi.waitFor(() => { - expect(changes).toEqual(['user']) - }) - - // The consumer's own teardown must not re-derive anything: an onChange - // here would re-register routes and touch resources being released. - await consumer.dispose() - await new Promise(resolve => setTimeout(resolve, 20)) - expect(changes).toEqual(['user']) - }) - - it('stays silent for a stored change that lands while the consumer unloads', async () => { - // The watcher outlives the start of teardown by the width of the unload, - // so a document change arriving in that window reaches it. Notifying then - // is exactly as harmful as notifying from the disposer. - const { ctx, provider } = await boot({ doc: { 'helper-ns': { theme: 'user' } } }) - const entry = { theme: 'entry' } - let current: () => { theme: string } = () => entry - const changes: string[] = [] - const consumer = ctx.plugin({ - inject: ['settings'], - apply: (child: Context) => { - child.settings.installSection(child, 'helper-ns', HelperSchema, entry, { - setSource: (source) => { - current = source - }, - onChange: () => { - changes.push(current().theme) - }, - }) - }, - }) - await consumer - await vi.waitFor(() => { - expect(changes).toEqual(['user']) - }) - - const unloading = consumer.dispose() - provider.pushExternal({ 'helper-ns': { theme: 'racing' } }) - await unloading - expect(changes).toEqual(['user']) - }) -}) - -describe('mutate (path-addressed writes)', () => { - interface KeyedConfig { - apiKey: string - baseURL: string - reasoning: string - } - - const KeyedSchema: z = z.object({ - apiKey: z.string().role('secret'), - baseURL: z.string(), - reasoning: z.string(), - }) - - const KEYED = 'keyed' - const NESTED = 'workspace' - - async function mounted(doc: Record) { - const ctx = new Context() - await ctx.plugin(BareProvider, { doc }) - ctx.settings.register(KEYED, KeyedSchema) - return ctx - } - - it('removes one field without touching a secret the caller never saw', async () => { - // The data-loss shape this exists to prevent: a configuration UI reads the - // REDACTED descriptor (no apiKey), the user resets baseURL, and the client - // rebuilds the section from what it holds. A wholesale replace of that - // rebuild deletes the stored literal key; a path unset cannot. - const ctx = await mounted({ keyed: { apiKey: 'sk-stored', baseURL: 'https://user', reasoning: 'high' } }) - const redacted = ctx.settings.describe({ redactSecrets: true }).find(d => d.ns === KEYED)! - expect(redacted.user).toEqual({ baseURL: 'https://user', reasoning: 'high' }) - - await ctx.settings.mutate(KEYED, [{ op: 'unset', path: ['baseURL'] }]) - - const raw = ctx.settings.describe().find(d => d.ns === KEYED)! - expect(raw.user).toEqual({ apiKey: 'sk-stored', reasoning: 'high' }) - }) - - it('applies set and unset in one write, in order', async () => { - const ctx = await mounted({ keyed: { apiKey: 'sk-stored', baseURL: 'https://old' } }) - await ctx.settings.mutate(KEYED, [ - { op: 'set', path: ['baseURL'], value: 'https://new' }, - { op: 'set', path: ['reasoning'], value: 'low' }, - { op: 'unset', path: ['reasoning'] }, - ]) - expect(ctx.settings.describe().find(d => d.ns === KEYED)!.user) - .toEqual({ apiKey: 'sk-stored', baseURL: 'https://new' }) - }) - - it('reads the section as it stands at the front of the queue, not at call time', async () => { - // Two concurrent writers: the mutate is issued against the pre-update - // section but must observe the update that ran before it. - const ctx = await mounted({ keyed: { apiKey: 'sk-stored' } }) - const first = ctx.settings.update(KEYED, { baseURL: 'https://first', reasoning: 'high' }) - const second = ctx.settings.mutate(KEYED, [{ op: 'unset', path: ['reasoning'] }]) - await Promise.all([first, second]) - expect(ctx.settings.describe().find(d => d.ns === KEYED)!.user) - .toEqual({ apiKey: 'sk-stored', baseURL: 'https://first' }) - }) - - it('creates intermediate objects for a nested set and leaves an absent unset alone', async () => { - const ctx = new Context() - await ctx.plugin(BareProvider, { doc: {} }) - ctx.settings.register(NESTED, NestedSchema) - await ctx.settings.mutate(NESTED, [{ op: 'set', path: ['retry', 'attempts'], value: 5 }]) - expect(ctx.settings.describe().find(d => d.ns === NESTED)!.user).toEqual({ retry: { attempts: 5 } }) - await ctx.settings.mutate(NESTED, [{ op: 'unset', path: ['missing', 'deep'] }]) - expect(ctx.settings.describe().find(d => d.ns === NESTED)!.user).toEqual({ retry: { attempts: 5 } }) - }) - - it('edits one leaf of an existing nested object without replacing its siblings', async () => { - const ctx = new Context() - await ctx.plugin(BareProvider, { doc: { workspace: { retry: { attempts: 5, delayMs: 250 } } } }) - ctx.settings.register(NESTED, NestedSchema) - await ctx.settings.mutate(NESTED, [{ op: 'set', path: ['retry', 'delayMs'], value: 900 }]) - expect(ctx.settings.describe().find(d => d.ns === NESTED)!.user) - .toEqual({ retry: { attempts: 5, delayMs: 900 } }) - }) - - it('addresses the section itself through the empty path', async () => { - const ctx = await mounted({ keyed: { apiKey: 'sk-stored', baseURL: 'https://user' } }) - await ctx.settings.mutate(KEYED, [{ op: 'set', path: [], value: { reasoning: 'low' } }]) - expect(ctx.settings.describe().find(d => d.ns === KEYED)!.user).toEqual({ reasoning: 'low' }) - await ctx.settings.mutate(KEYED, [{ op: 'unset', path: [] }]) - expect(ctx.settings.describe().find(d => d.ns === KEYED)!.user).toEqual({}) - }) - - it('refuses a non-object at the section root, leaving the stored section alone', async () => { - const ctx = await mounted({ keyed: { apiKey: 'sk-stored' } }) - await expect(ctx.settings.mutate(KEYED, [{ op: 'set', path: [], value: 'a whole section' }])) - .rejects.toThrow(/setting the section root requires a plain object/) - expect(ctx.settings.describe().find(d => d.ns === KEYED)!.user).toEqual({ apiKey: 'sk-stored' }) - }) - - it('rejects ops that are not an array at all', async () => { - const ctx = await mounted({ keyed: { apiKey: 'sk-stored' } }) - await expect(ctx.settings.mutate(KEYED, { op: 'unset', path: ['apiKey'] } as never)) - .rejects.toThrow(/must be an array of path ops/) - expect(ctx.settings.describe().find(d => d.ns === KEYED)!.user).toEqual({ apiKey: 'sk-stored' }) - }) - - it('rejects a malformed op before anything is queued', async () => { - const ctx = await mounted({ keyed: { apiKey: 'sk-stored' } }) - await expect(ctx.settings.mutate(KEYED, [{ op: 'delete' } as never])) - .rejects.toThrow(/must be \{op:'set'\|'unset', path\}/) - await expect(ctx.settings.mutate(KEYED, [{ op: 'unset', path: ['a', 1] as never }])) - .rejects.toThrow(/op paths must be arrays of strings/) - expect(ctx.settings.describe().find(d => d.ns === KEYED)!.user).toEqual({ apiKey: 'sk-stored' }) - }) - - it('rejects a value that lossless JSON cannot represent', async () => { - const ctx = await mounted({ keyed: {} }) - await expect(ctx.settings.mutate(KEYED, [{ op: 'set', path: ['baseURL'], value: new Date() }])) - .rejects.toThrow(/must contain only JSON-compatible data/) - }) -}) - -describe('revision and conflict detection', () => { - const REV = 'rev' - const RevSchema: z<{ a: string; b: string }> = z.object({ - a: z.string().default('base-a'), - b: z.string(), - }) - - async function mounted(doc: Record = {}) { - const ctx = new Context() - await ctx.plugin(BareProvider, { doc }) - return ctx - } - - it('refuses a write whose expected revision is stale, leaving the winner in place', async () => { - // Two editors open the same namespace, both holding revision 0. The first - // to land wins; the second must be told rather than overwrite it. - const ctx = await mounted() - ctx.settings.register(REV, RevSchema) - const opened = ctx.settings.describe().find(d => d.ns === REV)!.revision - - await ctx.settings.update(REV, { b: 'from-tab-B' }, opened) - await expect(ctx.settings.update(REV, { a: 'from-tab-A' }, opened)) - .rejects.toThrow(/changed since it was read \(expected revision 0, now 1\)/) - expect(ctx.settings.describe().find(d => d.ns === REV)!.user).toEqual({ b: 'from-tab-B' }) - }) - - it('carries the machine code and both revisions on the refusal', async () => { - const ctx = await mounted() - ctx.settings.register(REV, RevSchema) - await ctx.settings.update(REV, { b: 'first' }) - const error = await ctx.settings.update(REV, { b: 'second' }, 0).catch((e: unknown) => e) - expect(error).toBeInstanceOf(SettingsConflictError) - expect(error).toMatchObject({ code: 'SETTINGS_CONFLICT', expected: 0, actual: 1 }) - }) - - it('accepts a write that carries no expectation at all', async () => { - const ctx = await mounted() - ctx.settings.register(REV, RevSchema) - await ctx.settings.update(REV, { b: 'one' }) - await ctx.settings.update(REV, { b: 'two' }) - expect(ctx.settings.describe().find(d => d.ns === REV)!.revision).toBe(2) - }) - - it('announces a raw change whose resolved value is unchanged', async () => { - // Storing an override equal to the schema default leaves `value` alone but - // changes what the document says: the field is now overridden, not - // inherited, and another tab has to learn that. - const ctx = await mounted() - ctx.settings.register(REV, RevSchema) - const documents: Array<[string, number]> = [] - const resolved: string[] = [] - ctx.on('settings/document-updated', (ns, revision) => { documents.push([String(ns), revision]) }) - ctx.on('settings/updated', (ns) => { resolved.push(String(ns)) }) - - await ctx.settings.update(REV, { a: 'base-a' }) - - expect(documents).toEqual([['rev', 1]]) - expect(resolved).toEqual([]) - expect(ctx.settings.describe().find(d => d.ns === REV)!.user).toEqual({ a: 'base-a' }) - }) - - it('does not move the revision when a write stores an identical section', async () => { - const ctx = await mounted({ rev: { b: 'same' } }) - ctx.settings.register(REV, RevSchema) - const documents: unknown[] = [] - ctx.on('settings/document-updated', (ns, revision) => { documents.push([String(ns), revision]) }) - await ctx.settings.update(REV, { b: 'same' }) - expect(documents).toEqual([]) - expect(ctx.settings.describe().find(d => d.ns === REV)!.revision).toBe(0) - }) - - it('moves the revision for an external edit the provider publishes', async () => { - const ctx = await mounted() - ctx.settings.register(REV, RevSchema) - const documents: Array<[string, number]> = [] - ctx.on('settings/document-updated', (ns, revision) => { documents.push([String(ns), revision]) }) - ;(ctx.settings as unknown as { publish(doc: Record): void }) - .publish({ rev: { b: 'edited on disk' } }) - expect(documents).toEqual([['rev', 1]]) - // An editor that opened before the external edit is now refused. - await expect(ctx.settings.update(REV, { b: 'stale' }, 0)).rejects.toThrow(SettingsConflictError) - }) - - it('moves the revision past a stored section that was not an object', async () => { - // A hand-edited file can leave a namespace holding a scalar. The resolved - // value keeps its last good reading, and the repair that follows still has - // to announce itself — an open editor is reading a document it cannot see. - const ctx = await mounted() - ctx.settings.register(REV, RevSchema) - const settings = ctx.settings as unknown as { publish(doc: Record): void } - settings.publish({ rev: 'not a section' }) - const documents: Array<[string, number]> = [] - ctx.on('settings/document-updated', (ns, revision) => { documents.push([String(ns), revision]) }) - settings.publish({ rev: { b: 'repaired by hand' } }) - expect(documents).toEqual([['rev', 1]]) - }) - - it('contains a throwing document listener and keeps the rest of the fan-out running', async () => { - const ctx = await mounted() - ctx.settings.register(REV, RevSchema) - const seen: number[] = [] - ctx.on('settings/document-updated', () => { throw new Error('document listener boom') }) - ctx.on('settings/document-updated', (_ns, revision) => { seen.push(revision) }) - await ctx.settings.update(REV, { b: 'one' }) - await ctx.settings.update(REV, { b: 'two' }) - expect(seen).toEqual([1, 2]) - }) - - it('contains an async document listener rejection', async () => { - const ctx = await mounted() - ctx.settings.register(REV, RevSchema) - // Same shape as the `settings/updated` case above: the unknown return type - // keeps an async listener legal at this file's typed surface while the - // runtime value stays the rejected promise the containment guard handles. - const boom = (): unknown => Promise.reject(new Error('async document boom')) - ctx.on('settings/document-updated', boom) - await ctx.settings.update(REV, { b: 'one' }) - expect(ctx.settings.describe().find(d => d.ns === REV)!.revision).toBe(1) - // Give the rejected listener promise a microtask turn; containment means - // vitest observes no unhandled rejection out of this test. - await new Promise(resolve => setTimeout(resolve, 10)) - }) - - it('propagates an invariant-coded document listener failure instead of containing it', async () => { - const ctx = await mounted() - ctx.settings.register(REV, RevSchema) - ctx.on('settings/document-updated', () => { - throw Object.assign(new Error('forged revision'), { code: 'INVARIANT' }) - }) - expect(() => { - (ctx.settings as unknown as { publish(doc: Record): void }) - .publish({ rev: { b: 'edited on disk' } }) - }).toThrow(/forged revision/) - }) -}) diff --git a/packages/settings/settings/tsconfig.json b/packages/settings/settings/tsconfig.json index c06688eecf..674969c82f 100644 --- a/packages/settings/settings/tsconfig.json +++ b/packages/settings/settings/tsconfig.json @@ -24,7 +24,10 @@ "path": "../../util/values" }, { - "path": "../../runtime-diagnostics/invariants" + "path": "../../boot/config-editor" + }, + { + "path": "../../../vendor/loader" } ] } diff --git a/packages/settings/settings/tsdown.config.ts b/packages/settings/settings/tsdown.config.ts deleted file mode 100644 index e92275a7f5..0000000000 --- a/packages/settings/settings/tsdown.config.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { defineConfig } from 'tsdown' - -/** Build the package root and optional invariant companion as independent bundles. */ -export default defineConfig([ - { - entry: ['lib/types/index.js'], - outDir: 'lib', - format: ['esm'], - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, - }, - { - entry: ['lib/types/invariant.js'], - outDir: 'lib', - format: ['esm'], - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, - }, -]) diff --git a/packages/shell/bash-local/README.i18n.yaml b/packages/shell/bash-local/README.i18n.yaml index 6908d47ff4..51d9a7fa8a 100644 --- a/packages/shell/bash-local/README.i18n.yaml +++ b/packages/shell/bash-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/bash-local/README.md -README.md: cc53845f382d933a9062a6e9ff10e5ec6416e036 -README.zh.md: 04f69abadbe298952ef7fa1dcd6f577189fe35c5 +README.md: 578e5284e22d6ec6dda1590848734ac63d2399fc +README.zh.md: 4bac3b6cceced30935108ffd14665df51a0c23ce diff --git a/packages/shell/bash-local/README.md b/packages/shell/bash-local/README.md index cc53845f38..578e5284e2 100644 --- a/packages/shell/bash-local/README.md +++ b/packages/shell/bash-local/README.md @@ -67,7 +67,7 @@ Resolve with `onExpiry: 'none'` and await `execute` to run a command in the back ### Adjusting budgets at runtime -When a settings provider is composed, this executor registers the capability's shared `shell` settings namespace with the composition entry as its base, so a user section in `settings.yaml` layers over it and the next command runs with the new budgets. Values the schema cannot judge — positive and finite numbers, and the `graceMs` timer bound — are refused at the write, leaving the running executor on its last good section; without a provider, the composition entry is what runs. +Execution budgets are volatile Config fields sampled when resolving each command. The Plugins page edits the active executor’s profile entry. Complete Config validation rejects invalid numbers and timer limits before a form write reaches disk. ----- diff --git a/packages/shell/bash-local/README.zh.md b/packages/shell/bash-local/README.zh.md index 04f69abadb..4bac3b6cce 100644 --- a/packages/shell/bash-local/README.zh.md +++ b/packages/shell/bash-local/README.zh.md @@ -67,7 +67,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs) ### 运行时调整预算 -当组合了设置提供方时,本执行器以组合条目为 base 注册该能力共享的 `shell` 设置命名空间,因此 `settings.yaml` 中的用户段会叠加其上,下一条命令即按新预算运行。schema 无法判定的值——正有限数字与 `graceMs` 的定时器上界——会在写入时被拒绝,运行中的执行器保持它最后一份可用的段;没有提供方时,运行的就是组合条目。 +执行预算是解析每条命令时读取的 volatile Config 字段。插件页面编辑当前执行器的 profile 条目。完整 Config 验证在表单写入磁盘前拒绝无效数字和定时器上限。 ----- diff --git a/packages/shell/bash-local/package.json b/packages/shell/bash-local/package.json index f94025292a..32e853f646 100644 --- a/packages/shell/bash-local/package.json +++ b/packages/shell/bash-local/package.json @@ -30,8 +30,7 @@ "@deepseek-ai/dsh-shell": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^" @@ -41,7 +40,6 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/shell/bash-local/src/index.ts b/packages/shell/bash-local/src/index.ts index 762410922e..373c739760 100644 --- a/packages/shell/bash-local/src/index.ts +++ b/packages/shell/bash-local/src/index.ts @@ -9,12 +9,12 @@ * @module @deepseek-ai/dsh-bash-local */ +import type { Volatile } from '@deepseek-ai/cordis' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { SHELL_SETTINGS_NAMESPACE, ShellExecutor } from '@deepseek-ai/dsh-shell' +import { ShellExecutor } from '@deepseek-ai/dsh-shell' import type { ShellExecRequest, ShellExecSpec, ShellExecution, ShellProcess, ShellProcessRead, ShellRunResult, CollectedOutput } from '@deepseek-ai/dsh-shell' import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' -import type {} from '@deepseek-ai/dsh-settings' import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout' /** @@ -37,25 +37,22 @@ const DEFAULT_GRACE_MS = 3_000 /** Default per-stream spill cap (the `maxSpillBytes` config). */ const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024 -/** Plugin config (all optional — `static Config` supplies the defaults). */ +/** Validated plugin configuration with live command budgets. */ export interface Config { /** Default working directory for commands (default: process.cwd()). */ - cwd?: string + cwd: Volatile /** Default foreground timeout in milliseconds. */ - timeoutMs?: number + timeoutMs: Volatile /** Upper bound for per-call timeout overrides. */ - maxTimeoutMs?: number + maxTimeoutMs: Volatile /** Per-stream in-memory output cap; overflow spills to a temp file. */ - maxOutputBytes?: number + maxOutputBytes: Volatile /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ - maxSpillBytes?: number + maxSpillBytes: Volatile /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ - graceMs?: number + graceMs: Volatile } -/** The shape after schemastery applied the defaults (cwd has none). */ -type ResolvedConfig = Required> & Pick - /** Project a settled collect-mode reader into the final CollectedOutput shape. */ function finalOutput(reader: SubprocessOutputReader): CollectedOutput { const read = reader.readFrom(0) @@ -75,19 +72,17 @@ function assertPositiveFinite(name: string, value: number): void { /** * Reject a resolved section this executor could not run with. The schema * expresses neither "positive and finite" nor the timer bound `graceMs` has to - * fit, so a stored value is refused where it is written instead of failing at - * the next command. - * @param config - the resolved section, schema-valid by construction. + * fit, so a stored value that cannot be used fails at the next command. + * @param config - the live configuration, schema-valid by construction. * @throws Error naming the field that cannot be used. */ export function assertServiceableBashConfig(config: Config): void { - const resolved = config as ResolvedConfig - assertPositiveFinite('timeoutMs', resolved.timeoutMs) - assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs) - assertPositiveFinite('maxOutputBytes', resolved.maxOutputBytes) - assertPositiveFinite('maxSpillBytes', resolved.maxSpillBytes) - assertPositiveFinite('graceMs', resolved.graceMs) - if (resolved.graceMs > MAX_TIMER_DELAY_MS) { + assertPositiveFinite('timeoutMs', config.timeoutMs.get()) + assertPositiveFinite('maxTimeoutMs', config.maxTimeoutMs.get()) + assertPositiveFinite('maxOutputBytes', config.maxOutputBytes.get()) + assertPositiveFinite('maxSpillBytes', config.maxSpillBytes.get()) + assertPositiveFinite('graceMs', config.graceMs.get()) + if (config.graceMs.get() > MAX_TIMER_DELAY_MS) { throw new Error(`bash-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`) } } @@ -102,40 +97,17 @@ export function assertServiceableBashConfig(config: Config): void { export class LocalBashExecutor extends ShellExecutor { static inject = ['subprocess'] - static Config: z = z.object({ - cwd: z.string(), - timeoutMs: z.number().default(120_000), - maxTimeoutMs: z.number().default(600_000), - maxOutputBytes: z.number().default(64_000), - maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES), - graceMs: z.number().default(DEFAULT_GRACE_MS), + static Config = z.object({ + cwd: z.string().volatile(), + timeoutMs: z.number().default(120_000).volatile(), + maxTimeoutMs: z.number().default(600_000).volatile(), + maxOutputBytes: z.number().default(64_000).volatile(), + maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES).volatile(), + graceMs: z.number().default(DEFAULT_GRACE_MS).volatile(), }) - /** The currently authoritative config: the settings section, or the composition entry. */ - private source: () => ResolvedConfig - - /** Validated config (schemastery applied the defaults before construction). */ - get config(): ResolvedConfig { - return this.source() - } - - constructor(ctx: Context, config: Config) { + constructor(ctx: Context, readonly config: Config) { super(ctx) - // Schemastery fills these fields before construction; the type does not encode that step. - const entry = config as ResolvedConfig - assertServiceableBashConfig(entry) - this.source = () => entry - ctx.inject(['settings'], (settingsCtx) => { - settingsCtx.settings.installSection(ctx, SHELL_SETTINGS_NAMESPACE, LocalBashExecutor.Config, entry, { - validate: assertServiceableBashConfig, - setSource: (current) => { - this.source = current as () => ResolvedConfig - }, - // Every field is read through the getter at each command, so nothing - // derived from the source needs rebuilding when the document changes. - onChange: () => {}, - }) - }) } /** @@ -146,17 +118,18 @@ export class LocalBashExecutor extends ShellExecutor { * re-defaults. */ resolve(request: ShellExecRequest): ShellExecSpec { + assertServiceableBashConfig(this.config) const timeoutMs = clampTimeout( request.timeoutMs, - this.config.timeoutMs, - this.config.maxTimeoutMs, + this.config.timeoutMs.get(), + this.config.maxTimeoutMs.get(), 'bash-local: request.timeoutMs', ) - const stdoutMaxBytes = request.stdoutMaxBytes ?? this.config.maxOutputBytes + const stdoutMaxBytes = request.stdoutMaxBytes ?? this.config.maxOutputBytes.get() assertPositiveFinite('request.stdoutMaxBytes', stdoutMaxBytes) return { command: request.command, - workdir: request.workdir ?? this.config.cwd ?? process.cwd(), + workdir: request.workdir ?? this.config.cwd.get() ?? process.cwd(), timeoutMs, onExpiry: request.onExpiry ?? 'kill', stdoutMaxBytes, @@ -182,16 +155,16 @@ export class LocalBashExecutor extends ShellExecutor { signal: AbortSignal | undefined, ): SubprocessSpawnSpec { const collect = (maxBytes: number): SubprocessCollect => - ({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } }) + ({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes.get() } }) return { argv, cwd: spec.workdir, stdio: { stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore', stdout: collect(stdoutMaxBytes), - stderr: collect(this.config.maxOutputBytes), + stderr: collect(this.config.maxOutputBytes.get()), }, - graceMs: this.config.graceMs, + graceMs: this.config.graceMs.get(), signal, // One explicit env map for the seam, layered so the trusted dshEnv // snapshot beats both the caller's env and the terminal overrides; the diff --git a/packages/shell/bash-local/tests/executor.spec.ts b/packages/shell/bash-local/tests/executor.spec.ts index 4e7e5fe709..84ce13814f 100644 --- a/packages/shell/bash-local/tests/executor.spec.ts +++ b/packages/shell/bash-local/tests/executor.spec.ts @@ -27,7 +27,7 @@ afterAll(() => { rmSync(spillDir, { recursive: true, force: true }) }) -async function setup(config: ConstructorParameters[1] = {}) { +async function setup(config: Parameters[0] = {}) { const ctx = new Context() await ctx.plugin(LocalSubprocessRuntime) ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } @@ -82,14 +82,15 @@ describe('LocalBashExecutor.run', () => { expect(result.timeoutMs).toBe(2_000) }) - it('rejects invalid numeric config and timeout overrides', async () => { - await expect(setup({ timeoutMs: Number.NaN })).rejects.toThrow(/timeoutMs/) - await expect(setup({ maxTimeoutMs: 0 })).rejects.toThrow(/maxTimeoutMs/) - await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/) - await expect(setup({ maxSpillBytes: 0 })).rejects.toThrow(/maxSpillBytes/) - await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/) - await expect(setup({ graceMs: MAX_TIMER_DELAY_MS + 1 })) - .rejects.toThrow(`graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`) + it('rejects unusable numeric config at the next command and invalid timeout overrides at resolve', async () => { + for (const [config, field] of [ + [{ timeoutMs: Number.NaN }, /timeoutMs/], [{ maxTimeoutMs: 0 }, /maxTimeoutMs/], [{ maxOutputBytes: -1 }, /maxOutputBytes/], + [{ maxSpillBytes: 0 }, /maxSpillBytes/], [{ graceMs: 0 }, /graceMs/], + [{ graceMs: MAX_TIMER_DELAY_MS + 1 }, `graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`], + ] as const) { + const { bash: unusable } = await setup(config) + expect(() => unusable.resolve({ command: 'true' })).toThrow(field) + } const { bash } = await setup() expect(() => bash.resolve({ command: 'true', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/) diff --git a/packages/shell/bash-local/tests/settings.spec.ts b/packages/shell/bash-local/tests/settings.spec.ts index 5f6464b1c8..417eb8d1fb 100644 --- a/packages/shell/bash-local/tests/settings.spec.ts +++ b/packages/shell/bash-local/tests/settings.spec.ts @@ -1,115 +1,26 @@ -/** The `bash` settings section layered over the executor's composition entry. */ - -import { describe, expect, it } from 'vitest' +/** Live executor configuration through Loader updates. */ +import { expect, it, onTestFinished } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import type { Fiber } from '@deepseek-ai/cordis' -import { SettingsProvider } from '@deepseek-ai/dsh-settings' -import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' -import { SHELL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-shell' -import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import { LocalBashExecutor } from '../src/index.ts' +import { liveConfig } from '../../../settings/settings/tests/live-config.ts' -/** The smallest real provider: one in-memory document, always writable. */ -class MemorySettings extends SettingsProvider { - doc: Record = {} - - get writable(): boolean { - return true - } - - protected load(): Promise> { - return Promise.resolve(structuredClone(this.doc)) - } - - protected persist(ns: SettingsNamespace, section: Record): Promise { - this.doc = { ...this.doc, [ns]: structuredClone(section) } - return Promise.resolve() - } -} - -async function boot(config: ConstructorParameters[1] = {}): Promise<{ - ctx: Context - settingsFiber: Fiber - executorFiber: Fiber - bash: LocalBashExecutor -}> { +it('uses changed budgets for later commands without remounting the executor', async () => { const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(LocalSubprocessRuntime) - const settingsFiber = ctx.plugin(MemorySettings) - await settingsFiber.await() - const executorFiber = ctx.plugin(LocalBashExecutor, { timeoutMs: 60_000, ...config }) - await executorFiber.await() - return { ctx, settingsFiber, executorFiber, bash: ctx.shell as LocalBashExecutor } -} - -describe('bash settings section', () => { - it('resolves the user layer over the composition entry', async () => { - const bench = await boot() - expect(bench.bash.config.timeoutMs).toBe(60_000) - - await bench.ctx.settings.update(SHELL_SETTINGS_NAMESPACE, { timeoutMs: 5_000 }) - - expect(bench.bash.config.timeoutMs).toBe(5_000) - await bench.ctx.fiber.dispose() - }) - - it('refuses a stored value the constructor would have rejected', async () => { - const bench = await boot() - - await expect(bench.ctx.settings.update(SHELL_SETTINGS_NAMESPACE, { timeoutMs: 0 })) - .rejects.toThrow(/positive finite/) - - expect(bench.bash.config.timeoutMs).toBe(60_000) - await bench.ctx.fiber.dispose() - }) - - it('refuses a grace period longer than a timer can carry', async () => { - const bench = await boot() - - await expect(bench.ctx.settings.update(SHELL_SETTINGS_NAMESPACE, { graceMs: Number.MAX_SAFE_INTEGER })) - .rejects.toThrow(/graceMs must be no greater than/) - - await bench.ctx.fiber.dispose() - }) - - it('serves the stored section to every later read', async () => { - const bench = await boot() - await bench.ctx.settings.update(SHELL_SETTINGS_NAMESPACE, { maxOutputBytes: 1_024, cwd: '/tmp' }) - - const spec = bench.bash.resolve({ command: 'true' }) - - expect(spec.stdoutMaxBytes).toBe(1_024) - expect(spec.workdir).toBe('/tmp') - await bench.ctx.fiber.dispose() - }) - - it('falls back to the composition entry when the settings provider detaches', async () => { - const bench = await boot() - await bench.ctx.settings.update(SHELL_SETTINGS_NAMESPACE, { timeoutMs: 5_000 }) - expect(bench.bash.config.timeoutMs).toBe(5_000) - - await bench.settingsFiber.dispose() - - expect(bench.bash.config.timeoutMs).toBe(60_000) - await bench.ctx.fiber.dispose() - }) - - it('keeps the composition entry when no settings provider is mounted', async () => { - const ctx = new Context() - await ctx.plugin(LocalSubprocessRuntime) - await ctx.plugin(LocalBashExecutor, { timeoutMs: 1_234 }) - - expect((ctx.shell as LocalBashExecutor).config.timeoutMs).toBe(1_234) - await ctx.fiber.dispose() - }) - - it('releases the namespace when the executor unloads', async () => { - const bench = await boot() - expect(bench.ctx.settings.describe().map(row => String(row.ns))).toContain('shell') - - await bench.executorFiber.dispose() - - expect(bench.ctx.settings.describe().map(row => String(row.ns))).not.toContain('shell') - await bench.ctx.fiber.dispose() - }) + const live = await liveConfig(ctx, LocalBashExecutor, { timeoutMs: 60_000 }) + const before = live.fiber + await live.update({ timeoutMs: 5_000, maxOutputBytes: 1_024 }) + expect(live.entry.fiber === before).toBe(true) + expect(ctx.shell.resolve({ command: 'echo ok' })).toMatchObject({ timeoutMs: 5_000, stdoutMaxBytes: 1_024 }) + // A stored budget the executor cannot use fails the next command, not the profile write. + await live.update({ timeoutMs: 0 }) + expect(() => ctx.shell.resolve({ command: 'echo ok' })).toThrow('timeoutMs must be a positive finite number') + await live.update({ timeoutMs: 5_000, graceMs: Number.MAX_SAFE_INTEGER }) + expect(() => ctx.shell.resolve({ command: 'echo ok' })).toThrow('graceMs must be no greater') + await live.update({ graceMs: 200 }) + expect(ctx.shell.resolve({ command: 'echo ok' }).timeoutMs).toBe(5_000) + await live.replace({}) + expect(ctx.shell.resolve({ command: 'echo ok' }).timeoutMs).toBe(120_000) }) diff --git a/packages/shell/bash-local/tsconfig.json b/packages/shell/bash-local/tsconfig.json index 5097714846..3b9b48ce79 100644 --- a/packages/shell/bash-local/tsconfig.json +++ b/packages/shell/bash-local/tsconfig.json @@ -28,9 +28,6 @@ }, { "path": "../../subprocess/subprocess" - }, - { - "path": "../../settings/settings" } ] } diff --git a/packages/shell/bash-sandbox/tests/sandbox.spec.ts b/packages/shell/bash-sandbox/tests/sandbox.spec.ts index 1cd24bcee4..09d81bd4eb 100644 --- a/packages/shell/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/shell/bash-sandbox/tests/sandbox.spec.ts @@ -19,7 +19,6 @@ import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' import { classifyDenial } from '../src/helpers.ts' -import type { Config } from '@deepseek-ai/dsh-bash-sandbox' /** Historical foreground shorthand over the unified execute() seam. */ async function run(x: { execute(spec: ShellExecSpec): Promise }, spec: ShellExecSpec): Promise { @@ -66,7 +65,7 @@ const passthrough = (argv: readonly string[]): ConfinedArgv => * per test) and the executor under test on top of it. */ async function setup( - config: { mode?: SandboxMode; workspaceRoot?: string } & Config = {}, + config: { mode?: SandboxMode; workspaceRoot?: string } & NonNullable[0]> = {}, behavior: (argv: readonly string[], policy: SandboxPolicy, signal?: AbortSignal) => ConfinedArgv | Promise = passthrough, ) { const { mode, workspaceRoot, ...execConfig } = config diff --git a/packages/shell/pwsh-local/README.i18n.yaml b/packages/shell/pwsh-local/README.i18n.yaml index 2d7defe986..c90a53c620 100644 --- a/packages/shell/pwsh-local/README.i18n.yaml +++ b/packages/shell/pwsh-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/pwsh-local/README.md -README.md: 558da2c4a74194a5822eb17ba01853efd021d3dd -README.zh.md: 9cde01a0880a93c2689f37c357cfad49482d5b77 +README.md: f4001263ba06d02a4b4c3b801d9a518488a97327 +README.zh.md: e9e1c3246bef62f55c965d4974202646b5fc2096 diff --git a/packages/shell/pwsh-local/README.md b/packages/shell/pwsh-local/README.md index 558da2c4a7..f4001263ba 100644 --- a/packages/shell/pwsh-local/README.md +++ b/packages/shell/pwsh-local/README.md @@ -72,7 +72,7 @@ Resolve with `onExpiry: 'none'` and await `execute` to run a command in the back ### Adjusting budgets at runtime -When a settings provider is composed, this executor registers the capability's shared `shell` settings namespace — the same one the POSIX family uses, because a host composes exactly one provider of `ctx.shell` — so a user section in `settings.yaml` layers over the composition entry and the next command runs with the new budgets. Values the schema cannot judge — positive and finite numbers, and the `graceMs` timer bound — are refused at the write, leaving the running executor on its last good section. +Execution budgets are volatile Config fields sampled when resolving each command. The Plugins page edits the active executor’s profile entry. Complete Config validation rejects invalid numbers and timer limits before a form write reaches disk. ----- diff --git a/packages/shell/pwsh-local/README.zh.md b/packages/shell/pwsh-local/README.zh.md index 9cde01a088..e9e1c3246b 100644 --- a/packages/shell/pwsh-local/README.zh.md +++ b/packages/shell/pwsh-local/README.zh.md @@ -72,7 +72,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs) ### 运行时调整预算 -当组合了设置提供方时,本执行器注册该能力共享的 `shell` 设置命名空间——与 POSIX 家族共用同一个,因为一个宿主只组装一个 `ctx.shell` 提供方——因此 `settings.yaml` 中的用户段会叠加在组合条目之上,下一条命令即按新预算运行。schema 无法判定的值——正有限数字与 `graceMs` 的定时器上界——会在写入时被拒绝,运行中的执行器保持它最后一份可用的段。 +执行预算是解析每条命令时读取的 volatile Config 字段。插件页面编辑当前执行器的 profile 条目。完整 Config 验证在表单写入磁盘前拒绝无效数字和定时器上限。 ----- diff --git a/packages/shell/pwsh-local/package.json b/packages/shell/pwsh-local/package.json index e09bff4f6a..1e3c486d14 100644 --- a/packages/shell/pwsh-local/package.json +++ b/packages/shell/pwsh-local/package.json @@ -30,8 +30,7 @@ "@deepseek-ai/dsh-shell": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^" @@ -41,7 +40,6 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/shell/pwsh-local/src/index.ts b/packages/shell/pwsh-local/src/index.ts index 661e8b7783..06b7c32d2e 100644 --- a/packages/shell/pwsh-local/src/index.ts +++ b/packages/shell/pwsh-local/src/index.ts @@ -15,12 +15,12 @@ /* jscpd:ignore-start -- this executor mirrors dsh-bash-local call-for-call by design (see this package's README), so the two import the same seam surface */ +import type { Volatile } from '@deepseek-ai/cordis' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { SHELL_SETTINGS_NAMESPACE, ShellExecutor } from '@deepseek-ai/dsh-shell' +import { ShellExecutor } from '@deepseek-ai/dsh-shell' import type { CollectedOutput, ShellExecRequest, ShellExecSpec, ShellExecution, ShellProcess, ShellProcessRead, ShellRunResult } from '@deepseek-ai/dsh-shell' import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' -import type {} from '@deepseek-ai/dsh-settings' import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout' /* jscpd:ignore-end */ import { resolvePwshPath } from './resolve.ts' @@ -54,32 +54,29 @@ const DEFAULT_GRACE_MS = 3_000 /** Default per-stream spill cap (the `maxSpillBytes` config). */ const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024 -/** Plugin config (all optional — `static Config` supplies the defaults). */ +/** Validated plugin configuration with live command budgets. */ export interface Config { /** Default working directory for commands (default: process.cwd()). */ - cwd?: string + cwd: Volatile /** Default foreground timeout in milliseconds. */ - timeoutMs?: number + timeoutMs: Volatile /** Upper bound for per-call timeout overrides. */ - maxTimeoutMs?: number + maxTimeoutMs: Volatile /** Per-stream in-memory output cap; overflow spills to a temp file. */ - maxOutputBytes?: number + maxOutputBytes: Volatile /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ - maxSpillBytes?: number + maxSpillBytes: Volatile /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ - graceMs?: number + graceMs: Volatile /** * Explicit pwsh executable. When omitted, well-known Windows install * locations and PATH entries are probed in order (PowerShell 7 install, * PATH entries such as the Microsoft Store install, then Windows * PowerShell 5.1), falling back to a bare `pwsh` resolved through PATH. */ - pwshPath?: string + pwshPath: Volatile } -/** The shape after schemastery applied the defaults (cwd/pwshPath have none). */ -type ResolvedConfig = Required> & Pick - // Resolution lives in its own dependency-free module so the repository's // coverage-gate probe shares the exact definition the suites use. export { candidatePwshPaths, resolvePwshPath } from './resolve.ts' @@ -103,19 +100,17 @@ function assertPositiveFinite(name: string, value: number): void { /** * Reject a resolved section this executor could not run with. The schema * expresses neither "positive and finite" nor the timer bound `graceMs` has to - * fit, so a stored value is refused where it is written instead of failing at - * the next command. - * @param config - the resolved section, schema-valid by construction. + * fit, so a stored value that cannot be used fails at the next command. + * @param config - the live configuration, schema-valid by construction. * @throws Error naming the field that cannot be used. */ export function assertServiceablePwshConfig(config: Config): void { - const resolved = config as ResolvedConfig - assertPositiveFinite('timeoutMs', resolved.timeoutMs) - assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs) - assertPositiveFinite('maxOutputBytes', resolved.maxOutputBytes) - assertPositiveFinite('maxSpillBytes', resolved.maxSpillBytes) - assertPositiveFinite('graceMs', resolved.graceMs) - if (resolved.graceMs > MAX_TIMER_DELAY_MS) { + assertPositiveFinite('timeoutMs', config.timeoutMs.get()) + assertPositiveFinite('maxTimeoutMs', config.maxTimeoutMs.get()) + assertPositiveFinite('maxOutputBytes', config.maxOutputBytes.get()) + assertPositiveFinite('maxSpillBytes', config.maxSpillBytes.get()) + assertPositiveFinite('graceMs', config.graceMs.get()) + if (config.graceMs.get() > MAX_TIMER_DELAY_MS) { throw new Error(`pwsh-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`) } } @@ -128,59 +123,36 @@ export function assertServiceablePwshConfig(config: Config): void { export class PwshLocalExecutor extends ShellExecutor { static inject = ['subprocess'] - static Config: z = z.object({ - cwd: z.string(), - timeoutMs: z.number().default(120_000), - maxTimeoutMs: z.number().default(600_000), - maxOutputBytes: z.number().default(64_000), - maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES), - graceMs: z.number().default(DEFAULT_GRACE_MS), - pwshPath: z.string(), + static Config = z.object({ + cwd: z.string().volatile(), + timeoutMs: z.number().default(120_000).volatile(), + maxTimeoutMs: z.number().default(600_000).volatile(), + maxOutputBytes: z.number().default(64_000).volatile(), + maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES).volatile(), + graceMs: z.number().default(DEFAULT_GRACE_MS).volatile(), + pwshPath: z.string().volatile(), }) - /** The currently authoritative config: the settings section, or the composition entry. */ - private source: () => ResolvedConfig - /** The declared executable the current {@link pwshPath} was resolved from. */ private declaredPwshPath: string | undefined /** The pwsh executable resolved from the current config. */ private resolvedPwshPath: string - /** Validated config (schemastery applied the defaults before construction). */ - get config(): ResolvedConfig { - return this.source() - } - - /** The pwsh executable every command runs through. */ + /** The pwsh executable every command runs through; a changed declared path is probed again on the next read. */ get pwshPath(): string { + const declared = this.config.pwshPath.get() + if (declared !== this.declaredPwshPath) { + this.resolvedPwshPath = resolvePwshPath(declared) + this.declaredPwshPath = declared + } return this.resolvedPwshPath } - constructor(ctx: Context, config: Config) { + constructor(ctx: Context, readonly config: Config) { super(ctx) - // Schemastery fills these fields before construction; the type does not encode that step. - const entry = config as ResolvedConfig - assertServiceablePwshConfig(entry) - this.source = () => entry - this.declaredPwshPath = entry.pwshPath - this.resolvedPwshPath = resolvePwshPath(entry.pwshPath) - ctx.inject(['settings'], (settingsCtx) => { - settingsCtx.settings.installSection(ctx, SHELL_SETTINGS_NAMESPACE, PwshLocalExecutor.Config, entry, { - validate: assertServiceablePwshConfig, - setSource: (current) => { - this.source = current as () => ResolvedConfig - }, - // Probing the filesystem is the one fact derived from the source: every - // other field is read through the getter at each command. - onChange: () => { - const declared = this.source().pwshPath - if (declared === this.declaredPwshPath) return - this.declaredPwshPath = declared - this.resolvedPwshPath = resolvePwshPath(declared) - }, - }) - }) + this.declaredPwshPath = config.pwshPath.get() + this.resolvedPwshPath = resolvePwshPath(this.declaredPwshPath) } /** @@ -189,17 +161,18 @@ export class PwshLocalExecutor extends ShellExecutor { * `config.timeoutMs`, capped at `config.maxTimeoutMs`. */ resolve(request: ShellExecRequest): ShellExecSpec { + assertServiceablePwshConfig(this.config) const timeoutMs = clampTimeout( request.timeoutMs, - this.config.timeoutMs, - this.config.maxTimeoutMs, + this.config.timeoutMs.get(), + this.config.maxTimeoutMs.get(), 'pwsh-local: request.timeoutMs', ) - const stdoutMaxBytes = request.stdoutMaxBytes ?? this.config.maxOutputBytes + const stdoutMaxBytes = request.stdoutMaxBytes ?? this.config.maxOutputBytes.get() assertPositiveFinite('request.stdoutMaxBytes', stdoutMaxBytes) return { command: request.command, - workdir: request.workdir ?? this.config.cwd ?? process.cwd(), + workdir: request.workdir ?? this.config.cwd.get() ?? process.cwd(), timeoutMs, onExpiry: request.onExpiry ?? 'kill', stdoutMaxBytes, @@ -229,16 +202,16 @@ export class PwshLocalExecutor extends ShellExecutor { argv: readonly string[], ): SubprocessSpawnSpec { const collect = (maxBytes: number): SubprocessCollect => - ({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } }) + ({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes.get() } }) return { argv: [...argv], cwd: spec.workdir, stdio: { stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore', stdout: collect(stdoutMaxBytes), - stderr: collect(this.config.maxOutputBytes), + stderr: collect(this.config.maxOutputBytes.get()), }, - graceMs: this.config.graceMs, + graceMs: this.config.graceMs.get(), signal, env: { ...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv }, } diff --git a/packages/shell/pwsh-local/tests/executor.spec.ts b/packages/shell/pwsh-local/tests/executor.spec.ts index 31716e0b2d..d9adffa612 100644 --- a/packages/shell/pwsh-local/tests/executor.spec.ts +++ b/packages/shell/pwsh-local/tests/executor.spec.ts @@ -84,7 +84,7 @@ function samePath(actual: string, expected: string): boolean { return norm(actual) === norm(expected) } -async function setup(config: ConstructorParameters[1] = {}) { +async function setup(config: Parameters[0] = {}) { const ctx = createContext() await ctx.plugin(LocalSubprocessRuntime) ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } @@ -466,14 +466,15 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => { expect(result.timeoutMs).toBe(2_000) }) - it('rejects invalid numeric config and timeout overrides', async () => { - await expect(setup({ timeoutMs: Number.NaN })).rejects.toThrow(/timeoutMs/) - await expect(setup({ maxTimeoutMs: 0 })).rejects.toThrow(/maxTimeoutMs/) - await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/) - await expect(setup({ maxSpillBytes: 0 })).rejects.toThrow(/maxSpillBytes/) - await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/) - await expect(setup({ graceMs: MAX_TIMER_DELAY_MS + 1 })) - .rejects.toThrow(`graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`) + it('rejects unusable numeric config at the next command and invalid timeout overrides at resolve', async () => { + for (const [config, field] of [ + [{ timeoutMs: Number.NaN }, /timeoutMs/], [{ maxTimeoutMs: 0 }, /maxTimeoutMs/], [{ maxOutputBytes: -1 }, /maxOutputBytes/], + [{ maxSpillBytes: 0 }, /maxSpillBytes/], [{ graceMs: 0 }, /graceMs/], + [{ graceMs: MAX_TIMER_DELAY_MS + 1 }, `graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`], + ] as const) { + const { bash: unusable } = await setup(config) + expect(() => unusable.resolve({ command: 'Write-Output ok' })).toThrow(field) + } const { bash } = await setup() expect(() => bash.resolve({ command: 'Write-Output ok', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/) diff --git a/packages/shell/pwsh-local/tests/settings.spec.ts b/packages/shell/pwsh-local/tests/settings.spec.ts index 22a4effe0a..c384780d8a 100644 --- a/packages/shell/pwsh-local/tests/settings.spec.ts +++ b/packages/shell/pwsh-local/tests/settings.spec.ts @@ -1,108 +1,41 @@ -/** The shared `bash` settings section as the pwsh executor family resolves it. */ - -import { describe, expect, it } from 'vitest' +/** Live executor configuration through Loader updates. */ +import { expect, it, onTestFinished } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import type { Fiber } from '@deepseek-ai/cordis' -import { SettingsProvider } from '@deepseek-ai/dsh-settings' -import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' -import { SHELL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-shell' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' -import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local' +import { PwshLocalExecutor } from '../src/index.ts' +import { liveConfig } from '../../../settings/settings/tests/live-config.ts' -/** The smallest real provider: one in-memory document, always writable. */ -class MemorySettings extends SettingsProvider { - doc: Record = {} - - get writable(): boolean { - return true - } - - protected load(): Promise> { - return Promise.resolve(structuredClone(this.doc)) - } - - protected persist(ns: SettingsNamespace, section: Record): Promise { - this.doc = { ...this.doc, [ns]: structuredClone(section) } - return Promise.resolve() - } -} - -async function boot(config: ConstructorParameters[1] = {}): Promise<{ - ctx: Context - settingsFiber: Fiber - executorFiber: Fiber - pwsh: PwshLocalExecutor -}> { +it('uses changed budgets for later commands without remounting the executor', async () => { const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(LocalSubprocessRuntime) - const settingsFiber = ctx.plugin(MemorySettings) - await settingsFiber.await() - const executorFiber = ctx.plugin(PwshLocalExecutor, { timeoutMs: 60_000, ...config }) - await executorFiber.await() - return { ctx, settingsFiber, executorFiber, pwsh: ctx.shell as PwshLocalExecutor } -} - -describe('pwsh executor over the bash settings section', () => { - it('resolves the user layer over the composition entry', async () => { - const bench = await boot() - expect(bench.pwsh.config.timeoutMs).toBe(60_000) - - await bench.ctx.settings.update(SHELL_SETTINGS_NAMESPACE, { timeoutMs: 5_000 }) - - expect(bench.pwsh.config.timeoutMs).toBe(5_000) - await bench.ctx.fiber.dispose() - }) - - it('refuses a stored value the constructor would have rejected', async () => { - const bench = await boot() - - await expect(bench.ctx.settings.update(SHELL_SETTINGS_NAMESPACE, { timeoutMs: 0 })) - .rejects.toThrow(/pwsh-local: timeoutMs must be a positive finite number/) - - expect(bench.pwsh.config.timeoutMs).toBe(60_000) - await bench.ctx.fiber.dispose() - }) - - it('re-resolves the executable when the stored path changes', async () => { - const bench = await boot({ pwshPath: '/opt/first/pwsh' }) - expect(bench.pwsh.pwshPath).toBe('/opt/first/pwsh') - - await bench.ctx.settings.update(SHELL_SETTINGS_NAMESPACE, { pwshPath: '/opt/second/pwsh' }) - - expect(bench.pwsh.pwshPath).toBe('/opt/second/pwsh') - await bench.ctx.fiber.dispose() - }) - - it('keeps the resolved executable when an unrelated field changes', async () => { - const bench = await boot({ pwshPath: '/opt/first/pwsh' }) - const before = bench.pwsh.pwshPath - - await bench.ctx.settings.update(SHELL_SETTINGS_NAMESPACE, { timeoutMs: 5_000 }) - - expect(bench.pwsh.pwshPath).toBe(before) - await bench.ctx.fiber.dispose() - }) - - it('falls back to the composition entry when the settings provider detaches', async () => { - const bench = await boot({ pwshPath: '/opt/first/pwsh' }) - await bench.ctx.settings.update(SHELL_SETTINGS_NAMESPACE, { timeoutMs: 5_000, pwshPath: '/opt/second/pwsh' }) - expect(bench.pwsh.config.timeoutMs).toBe(5_000) - expect(bench.pwsh.pwshPath).toBe('/opt/second/pwsh') - - await bench.settingsFiber.dispose() - - expect(bench.pwsh.config.timeoutMs).toBe(60_000) - expect(bench.pwsh.pwshPath).toBe('/opt/first/pwsh') - await bench.ctx.fiber.dispose() - }) - - it('releases the namespace when the executor unloads', async () => { - const bench = await boot() - expect(bench.ctx.settings.describe().map(row => String(row.ns))).toContain('shell') - - await bench.executorFiber.dispose() - - expect(bench.ctx.settings.describe().map(row => String(row.ns))).not.toContain('shell') - await bench.ctx.fiber.dispose() - }) + const live = await liveConfig(ctx, PwshLocalExecutor, { timeoutMs: 60_000 }) + const before = live.fiber + await live.update({ timeoutMs: 5_000, maxOutputBytes: 1_024 }) + expect(live.entry.fiber === before).toBe(true) + expect(ctx.shell.resolve({ command: 'echo ok' })).toMatchObject({ timeoutMs: 5_000, stdoutMaxBytes: 1_024 }) + // A stored budget the executor cannot use fails the next command, not the profile write. + await live.update({ timeoutMs: 0 }) + expect(() => ctx.shell.resolve({ command: 'echo ok' })).toThrow('timeoutMs must be a positive finite number') + await live.update({ timeoutMs: 5_000, graceMs: Number.MAX_SAFE_INTEGER }) + expect(() => ctx.shell.resolve({ command: 'echo ok' })).toThrow('graceMs must be no greater') + await live.update({ graceMs: 200 }) + expect(ctx.shell.resolve({ command: 'echo ok' }).timeoutMs).toBe(5_000) + await live.replace({}) + expect(ctx.shell.resolve({ command: 'echo ok' }).timeoutMs).toBe(120_000) +}) + +it('resolves a changed pwsh path for later commands without remounting the executor', async () => { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(LocalSubprocessRuntime) + const live = await liveConfig(ctx, PwshLocalExecutor, {}) + const executor = ctx.shell as PwshLocalExecutor + const before = live.fiber + const initial = executor.pwshPath + await live.update({ pwshPath: '/opt/pwsh/pwsh' }) + expect(live.entry.fiber === before).toBe(true) + expect(executor.pwshPath).toBe('/opt/pwsh/pwsh') + await live.replace({}) + expect(executor.pwshPath).toBe(initial) }) diff --git a/packages/shell/pwsh-local/tsconfig.json b/packages/shell/pwsh-local/tsconfig.json index 5097714846..3b9b48ce79 100644 --- a/packages/shell/pwsh-local/tsconfig.json +++ b/packages/shell/pwsh-local/tsconfig.json @@ -28,9 +28,6 @@ }, { "path": "../../subprocess/subprocess" - }, - { - "path": "../../settings/settings" } ] } diff --git a/packages/shell/shell-env/README.i18n.yaml b/packages/shell/shell-env/README.i18n.yaml index 3810859b8d..e8589faa8a 100644 --- a/packages/shell/shell-env/README.i18n.yaml +++ b/packages/shell/shell-env/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/shell-env/README.md -README.md: f85fb0005e94dc59939cfd2e9f0d69e5cdc7d133 -README.zh.md: b9f763a247fc792335326d8945dfeb2db1690fc7 +README.md: 6557049c8287348507e2f5fc8a824366949c2861 +README.zh.md: 32bd5e744af98ca84e426490765d15dd1701851c diff --git a/packages/shell/shell-env/README.md b/packages/shell/shell-env/README.md index f85fb0005e..6557049c82 100644 --- a/packages/shell/shell-env/README.md +++ b/packages/shell/shell-env/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-shell-env` provides the trusted `DSH_*` environment that every model shell call — bash or pwsh — runs with: built-in facts such as `DSH_HOME`, `DSH_SHELL=1`, and the agent's `DSH_SESSION_ID`. Plugin authors can register their own facts with declared keys, collected per execution and disposed with their plugin; duplicate ownership or undeclared runtime keys fail loudly instead of silently overwriting. The registry changes nothing else the model sees — the shell tools own their own schemas and prompts. Choose it in any composition that mounts a model shell tool; configuration only picks the Harness home directory. +`dsh-shell-env` provides the trusted `DSH_*` environment that every model shell call — bash or pwsh — runs with: built-in facts such as `DSH_HOME`, `DSH_SHELL=1`, the agent's `DSH_SESSION_ID`, and the launched profile's `DSH_PROFILE` and `DSH_PROFILE_DIR`. Plugin authors can register their own facts with declared keys, collected per execution and disposed with their plugin; duplicate ownership or undeclared runtime keys fail loudly instead of silently overwriting. The registry changes nothing else the model sees — the shell tools own their own schemas and prompts. Choose it in any composition that mounts a model shell tool; configuration only picks the Harness home directory. ## Table of Contents @@ -29,7 +29,7 @@ Load this plugin in any composition that mounts a model shell tool (`dsh-tool-ba ### What every shell call receives -Every call receives `DSH_HOME` (the absolute Harness home), `DSH_SHELL=1`, and, for agent calls, `DSH_SESSION_ID` (the calling session's id). +Every call receives `DSH_HOME` (the absolute Harness home), `DSH_SHELL=1`, and, for agent calls, `DSH_SESSION_ID` (the calling session's id). When the launcher provided a profile context, every call also receives `DSH_PROFILE` (the profile name) and `DSH_PROFILE_DIR` (its absolute directory; its `node_modules` holds only profile-installed packages, while the harness's own bundles resolve from the dsh installation); compositions booted without a profile omit both. ### Adding your own environment facts @@ -64,7 +64,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### What can go wrong -Two contributors declaring the same key, or a contributor claiming a reserved built-in (`DSH_HOME`, `DSH_SHELL`, `DSH_SESSION_ID`), fails plugin load loudly. A `DSH_*` key must be all-caps with underscores (for example `DSH_REGION`), and a missing description fails registration. +Two contributors declaring the same key, or a contributor claiming a reserved built-in (`DSH_HOME`, `DSH_SHELL`, `DSH_SESSION_ID`, `DSH_PROFILE`, `DSH_PROFILE_DIR`), fails plugin load loudly. A `DSH_*` key must be all-caps with underscores (for example `DSH_REGION`), and a missing description fails registration. ----- @@ -80,7 +80,7 @@ This section explains the design decisions behind the registry and points at the - **Trusted namespace, rebuilt per call.** The environment is a Harness-owned `DSH_*` namespace: the shell executor discards inherited `DSH_*` values and merges the registry's current snapshot for each execution, so nested harnesses and concurrent parent/child agents cannot leak stale identities, and `process.env` is never modified. - **Declared ownership, loud conflicts.** Contributors declare their keys up front so duplicate ownership is detected before the first command; resolvers may only return declared keys. -- **Built-ins stay here.** `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID` are reserved for the registry; contributors cannot claim them. +- **Built-ins stay here.** `DSH_HOME`, `DSH_SHELL`, `DSH_SESSION_ID`, `DSH_PROFILE`, and `DSH_PROFILE_DIR` are reserved for the registry; contributors cannot claim them. ### Source map @@ -127,7 +127,7 @@ The managed environment never enters the request prefix, so it does not invalida These limits define when the registry is a poor fit or needs care. They are current package constraints, not a task backlog. -- **`list()` enumerates plugin-contributed variables only** — registry-owned built-ins (`DSH_HOME`, `DSH_SHELL`, `DSH_SESSION_ID`) are not included, so diagnostics, prompt, or UI code must not treat `list()` as an exhaustive environment catalog. +- **`list()` enumerates plugin-contributed variables only** — registry-owned built-ins (`DSH_HOME`, `DSH_SHELL`, `DSH_SESSION_ID`, `DSH_PROFILE`, `DSH_PROFILE_DIR`) are not included, so diagnostics, prompt, or UI code must not treat `list()` as an exhaustive environment catalog. ### Dev Note diff --git a/packages/shell/shell-env/README.zh.md b/packages/shell/shell-env/README.zh.md index b9f763a247..32bd5e744a 100644 --- a/packages/shell/shell-env/README.zh.md +++ b/packages/shell/shell-env/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-shell-env` 提供每次模型 shell 调用——bash 或 pwsh——所运行的受信 `DSH_*` 环境:内置事实如 `DSH_HOME`、`DSH_SHELL=1` 与 agent(智能体)的 `DSH_SESSION_ID`。插件作者可以注册自己的事实,带声明键、按每次执行收集,并随插件释放;重复所有权或未声明的运行时键会明确报错,而不是静默覆盖。注册表不会改变模型看到的其他任何内容——shell 工具拥有各自的 schema 与提示词。任何挂载了模型 shell 工具的组合都适合选择它;配置只决定 Harness 主目录。 +`dsh-shell-env` 提供每次模型 shell 调用——bash 或 pwsh——所运行的受信 `DSH_*` 环境:内置事实如 `DSH_HOME`、`DSH_SHELL=1`、agent(智能体)的 `DSH_SESSION_ID`,以及所启动 profile 的 `DSH_PROFILE` 与 `DSH_PROFILE_DIR`。插件作者可以注册自己的事实,带声明键、按每次执行收集,并随插件释放;重复所有权或未声明的运行时键会明确报错,而不是静默覆盖。注册表不会改变模型看到的其他任何内容——shell 工具拥有各自的 schema 与提示词。任何挂载了模型 shell 工具的组合都适合选择它;配置只决定 Harness 主目录。 ## 目录 @@ -29,7 +29,7 @@ kind: "package-reference" ### 每次 shell 调用都会收到什么 -每次调用都会收到 `DSH_HOME`(Harness 主目录的绝对路径)、`DSH_SHELL=1`,agent 调用还会收到 `DSH_SESSION_ID`(调用方会话的 id)。 +每次调用都会收到 `DSH_HOME`(Harness 主目录的绝对路径)、`DSH_SHELL=1`,agent 调用还会收到 `DSH_SESSION_ID`(调用方会话的 id)。启动器提供了 profile 上下文时,每次调用还会收到 `DSH_PROFILE`(profile 名)与 `DSH_PROFILE_DIR`(其绝对目录;其 `node_modules` 只存放 profile 自行安装的包,harness 自带的组合包从 dsh 安装目录解析);不带 profile 启动的组合则两者都没有。 ### 添加你自己的环境事实 @@ -64,7 +64,7 @@ contributor 必须声明它返回的每个键;返回未声明或非字符串 ### 可能出什么问题 -两个 contributor 声明同一个键,或 contributor 声称拥有保留内置键(`DSH_HOME`、`DSH_SHELL`、`DSH_SESSION_ID`),都会导致插件加载时明确报错。`DSH_*` 键必须全大写并带下划线(例如 `DSH_REGION`),缺少描述也会让注册失败。 +两个 contributor 声明同一个键,或 contributor 声称拥有保留内置键(`DSH_HOME`、`DSH_SHELL`、`DSH_SESSION_ID`、`DSH_PROFILE`、`DSH_PROFILE_DIR`),都会导致插件加载时明确报错。`DSH_*` 键必须全大写并带下划线(例如 `DSH_REGION`),缺少描述也会让注册失败。 ----- @@ -80,7 +80,7 @@ contributor 必须声明它返回的每个键;返回未声明或非字符串 - **受信命名空间,每次调用重建。** 环境是归 Harness 所有的 `DSH_*` 命名空间:shell 执行器丢弃继承的 `DSH_*` 值,并为每次执行合并注册表的当前快照,因此嵌套 harness 与并发的父子 agent 无法泄漏陈旧身份,`process.env` 也永不被修改。 - **声明所有权,冲突明确报错。** contributor 预先声明键,使重复所有权在第一条命令之前就被发现;resolver 只能返回已声明的键。 -- **内置键留在这里。** `DSH_HOME`、`DSH_SHELL` 与 `DSH_SESSION_ID` 为注册表保留;contributor 不能声称拥有它们。 +- **内置键留在这里。** `DSH_HOME`、`DSH_SHELL`、`DSH_SESSION_ID`、`DSH_PROFILE` 与 `DSH_PROFILE_DIR` 为注册表保留;contributor 不能声称拥有它们。 ### 源码地图 @@ -127,7 +127,7 @@ contributor 必须声明它返回的每个键;返回未声明或非字符串 这些限制说明注册表何时不合适或需要小心使用。它们是当前包约束,不是任务积压。 -- **`list()` 只枚举插件贡献的变量**——注册表自有的内置键(`DSH_HOME`、`DSH_SHELL`、`DSH_SESSION_ID`)不包含在内,因此诊断、提示词或 UI 代码不得把 `list()` 当作完整的环境目录。 +- **`list()` 只枚举插件贡献的变量**——注册表自有的内置键(`DSH_HOME`、`DSH_SHELL`、`DSH_SESSION_ID`、`DSH_PROFILE`、`DSH_PROFILE_DIR`)不包含在内,因此诊断、提示词或 UI 代码不得把 `list()` 当作完整的环境目录。 ### 开发备注 diff --git a/packages/shell/shell-env/package.json b/packages/shell/shell-env/package.json index e9ebe245f4..aaea7d341e 100644 --- a/packages/shell/shell-env/package.json +++ b/packages/shell/shell-env/package.json @@ -27,20 +27,22 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-shell": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-shell": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-shell": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-shell": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^" } } diff --git a/packages/shell/shell-env/src/index.ts b/packages/shell/shell-env/src/index.ts index 4b52f28c01..36752364dd 100644 --- a/packages/shell/shell-env/src/index.ts +++ b/packages/shell/shell-env/src/index.ts @@ -14,6 +14,8 @@ import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-shell' import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-shell' import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-home-paths' import type { ToolExecution } from '@deepseek-ai/dsh-tools' +// Declares `Context.profileContext`, the launcher-provided profile the built-ins read. +import type {} from '@deepseek-ai/dsh-app-boot' declare module '@deepseek-ai/cordis' { interface Context { @@ -69,10 +71,14 @@ export interface BashEnvVariableInfo extends BashEnvVariable { const DSH_SHELL_KEY = `${DSH_ENV_PREFIX}SHELL` as const const DSH_SESSION_ID_KEY = `${DSH_ENV_PREFIX}SESSION_ID` as const +const DSH_PROFILE_KEY = `${DSH_ENV_PREFIX}PROFILE` as const +const DSH_PROFILE_DIR_KEY = `${DSH_ENV_PREFIX}PROFILE_DIR` as const const RESERVED_BASH_ENV_KEYS = new Set([ DSH_HOME_ENV, DSH_SHELL_KEY, DSH_SESSION_ID_KEY, + DSH_PROFILE_KEY, + DSH_PROFILE_DIR_KEY, ]) const BASH_ENV_KEY_SUFFIX = /^[A-Z][A-Z0-9_]*$/ @@ -155,6 +161,11 @@ export class ShellEnvRegistry extends Service { if (execution.agent !== undefined) { values[DSH_SESSION_ID_KEY] = execution.agent.session.header.id } + const profile = this.ctx.get('profileContext') + if (profile !== undefined) { + values[DSH_PROFILE_KEY] = profile.name + values[DSH_PROFILE_DIR_KEY] = profile.dir + } for (const contributor of [...this.contributors.values()].sort((left, right) => left.name.localeCompare(right.name))) { const resolved = contributor.resolve(execution) diff --git a/packages/shell/shell-env/tests/shell-env.spec.ts b/packages/shell/shell-env/tests/shell-env.spec.ts index d9fc219b21..ad0cf49102 100644 --- a/packages/shell/shell-env/tests/shell-env.spec.ts +++ b/packages/shell/shell-env/tests/shell-env.spec.ts @@ -55,6 +55,21 @@ describe('ShellEnvRegistry', () => { }) }) + it('collects the launcher-provided profile name and directory when a profile context exists', () => { + const ctx = new Context() + ctx.provide('profileContext', { + name: 'web', dir: '/profiles/web', patchPath: '/profiles/web/cordis.patch.yml', installAnchor: '/dsh/package.json', + cwd: '/work', home: '/home', startedBundles: [], overlays: [], telemetryDisabledEnv: undefined, + }) + const registry = new ShellEnvRegistry(ctx, { dshHome: './test-dsh-home' }) + expect(registry.collect(execution())).toMatchObject({ DSH_PROFILE: 'web', DSH_PROFILE_DIR: '/profiles/web' }) + expect(() => registry.register({ + name: 'profile-claimer', + variables: { DSH_PROFILE: { description: 'Reserved key.' } }, + resolve: () => ({}), + })).toThrow(/reserved key "DSH_PROFILE"/) + }) + it('resolves DSH_HOME from the ambient override or the user-home default', () => { vi.stubEnv('DSH_HOME', './ambient-dsh-home') const fromEnvironment = new ShellEnvRegistry(new Context()) diff --git a/packages/shell/shell-env/tsconfig.json b/packages/shell/shell-env/tsconfig.json index 6db2523824..fc6a34b571 100644 --- a/packages/shell/shell-env/tsconfig.json +++ b/packages/shell/shell-env/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../core/tools" + }, + { + "path": "../../boot/app-boot" } ] } diff --git a/packages/shell/shell/package.json b/packages/shell/shell/package.json index 4580b5a75e..4600d165e4 100644 --- a/packages/shell/shell/package.json +++ b/packages/shell/shell/package.json @@ -29,13 +29,11 @@ "peerDependencies": { "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/shell/shell/src/index.ts b/packages/shell/shell/src/index.ts index e328cfc0c8..939aff271c 100644 --- a/packages/shell/shell/src/index.ts +++ b/packages/shell/shell/src/index.ts @@ -9,17 +9,6 @@ import { Context, Service } from '@deepseek-ai/cordis' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { ShellExecRequest, ShellExecSpec, ShellExecution } from './types.ts' -/** - * Settings namespace of this capability, owned here rather than by either - * executor family because it names the capability, not an implementation: a - * host composes exactly one provider of `ctx.shell` (the win32 layer swaps the - * POSIX rows for the pwsh ones, and mounting both fails loud on a duplicate - * service registration), so the providers share one namespace without ever - * registering it twice, and a settings document carried between platforms - * keeps resolving on both. - */ -export const SHELL_SETTINGS_NAMESPACE = 'shell' - export { DSH_ENV_PREFIX } from './types.ts' export type { ShellExecRequest, diff --git a/packages/shell/shell/tsconfig.json b/packages/shell/shell/tsconfig.json index 380470c4b2..a94e59d03d 100644 --- a/packages/shell/shell/tsconfig.json +++ b/packages/shell/shell/tsconfig.json @@ -19,9 +19,6 @@ }, { "path": "../../sandbox/sandbox" - }, - { - "path": "../../settings/settings" } ] } diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index fb26e9f540..97a07c3e7b 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md -README.md: 4ed03104d3d8ab70816ee0cff7334a253196aef2 -README.zh.md: 92acf7498f282cdefea4f440d59e17f6dfffc81a +README.md: adffd0edfdc55177afee0390c8d1c2f7fbee7999 +README.zh.md: 79d39ca879c8a15385e1e97c6556178b04897fca diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 4ed03104d3..adffd0edfd 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -100,6 +100,7 @@ This section explains how the service is built and where the observable behavior | [`src/list-children.ts`](src/list-children.ts) | Direct parent-catalog reads and complete descendant-corpus reads | | [`src/control.ts`](src/control.ts) | Browser control request validation and stable failure codes | | [`src/control-types.ts`](src/control-types.ts) | Client-safe catalog row, control requests, receipts, and failures | +| [`src/archive-admission.ts`](src/archive-admission.ts) | The `subagent` family of the Workspace registry's archive admission: running descendants and their parent-cause cancel | ### One-shot flow @@ -117,6 +118,7 @@ Successful local child creation appends a `subagent/catalog` fact to the parent - **Registration is effect-scoped** — removing a provider blocks new starts but never revokes accepted runs. - **Agent-message authority is exact adjacency** — `sendMessage()` requires the exact live sender; every sender may target a direct continuable child, while only a sender with a resident continuable Activation may target its direct parent. - **The descriptor is log-only** — a session event absent from model history and retained across compaction; a continuable descriptor records the resolved child provider, model, and reasoning effort explicitly for cold resume. +- **This runtime answers archive admission for children** ([seam](../../workspace/workspace/README.md)) — `workspace/session-activity` reports the live subagent descendants inside a turn as the `subagent` family, found by the durable lineage this package records (`parentSession` with the subagent origin, any depth, never a fork) and labelled from each child's descriptor through a live Session observation when the Session query service is composed, otherwise by id; `workspace/session-stop` cancels each of them with the parent cause, one at a time, so one child refusing its cancel is logged while its siblings still stop. The parent's own turn, its jobs, and the archived-lineage step gate belong to the API Session Controller. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 92acf7498f..79d39ca879 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -100,6 +100,7 @@ kind: "package-reference" | [`src/list-children.ts`](src/list-children.ts) | 直接 parent 目录读取与完整后代语料读取 | | [`src/control.ts`](src/control.ts) | 浏览器控制请求校验与稳定失败分码 | | [`src/control-types.ts`](src/control-types.ts) | client-safe 的目录行、控制面请求、回执与失败 | +| [`src/archive-admission.ts`](src/archive-admission.ts) | Workspace 注册表归档准入中的 `subagent` 族:运行中的子孙及其父级取消 | ### 一次性流程 @@ -117,6 +118,7 @@ kind: "package-reference" - **注册受 effect 作用域约束**——移除提供方会阻止新启动,但绝不撤销已接受的运行。 - **Agent 消息权限基于确切相邻关系**——`sendMessage()` 要求确切在线 sender;每个 sender 都可以指定直接可继续 child,只有具备驻留可继续 Activation 的 sender 可以指定自己的直接 parent。 - **描述符仅进日志**——它是会话事件,不进入模型历史,并跨压缩(compaction)保留;可继续描述符会显式记录解析后的子级提供方、模型与推理强度,用于冷恢复。 +- **本 runtime 为子代理回答归档准入**([接缝](../../workspace/workspace/README.zh.md))——`workspace/session-activity` 把回合中的在线子代理子孙作为 `subagent` 族报告:按本包记录的持久化血缘查找(带 subagent 来源的 `parentSession`,任意深度,从不包括 fork),组合了 Session query 服务时经一次活会话 observation 从各 child 的描述符取名称,否则只报 id;`workspace/session-stop` 以父级原因逐个取消它们,一个拒绝取消的 child 只记日志,其兄弟仍会停止。父级自身的回合、它的任务以及已归档血缘的步骤门禁归 API Session Controller。 diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index c95f7069c2..f9d2b7b6bc 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -81,7 +81,7 @@ "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-util-time": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-workspace": "workspace:^" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-agent-preset-registry": { @@ -108,6 +108,9 @@ "@deepseek-ai/dsh-session-query": { "optional": true }, + "@deepseek-ai/dsh-workspace": { + "optional": true + }, "@deepseek-ai/dsh-jobs": { "optional": true }, @@ -144,6 +147,6 @@ "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-util-time": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-workspace": "workspace:^" } } diff --git a/packages/subagent/subagent/src/archive-admission.ts b/packages/subagent/subagent/src/archive-admission.ts new file mode 100644 index 0000000000..f1ff751b5f --- /dev/null +++ b/packages/subagent/subagent/src/archive-admission.ts @@ -0,0 +1,90 @@ +/** + * The `subagent` family of the Workspace registry's archive admission: which + * subagent descendants of a Session are still inside a turn, and how they + * stop when the Session is archived with its work. + * + * @module @deepseek-ai/dsh-subagent + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-query' +import type { SessionActivity, SessionActivityItem } from '@deepseek-ai/dsh-workspace' +import { foldSubagentDescriptor } from './descriptor.ts' + +/** + * Answer `workspace/session-activity` with the running subagent descendants of + * the asked Session, and `workspace/session-stop` by cancelling each of them + * as their parent would. Both listeners live as long as `ctx`'s fiber. + * @param ctx - context carrying the Agent registry; the Session query service is optional and only supplies labels. + */ +export function installSubagentArchiveAdmission(ctx: Context): void { + ctx.on('workspace/session-activity', async ({ sessionId }, next) => { + const running = runningDescendants(ctx, sessionId) + const rest = await next() + if (running.length === 0) return rest + const own: SessionActivity = { kind: 'subagent', items: await Promise.all(running.map(child => describe(ctx, child))) } + return [own, ...rest] + }) + ctx.on('workspace/session-stop', ({ sessionId }) => { + for (const child of runningDescendants(ctx, sessionId)) { + try { + child.cancel({ kind: 'parent' }) + } catch (error: unknown) { + // One child refusing its cancel must not keep its siblings running for an archived parent. + ctx.logger.warn(`subagent: cancelling "${child.id}" for an archived Session failed: ${String(error)}`) + } + } + }) +} + +/** + * Live subagent descendants inside a turn, by durable lineage: a child whose + * header names its parent and carries the subagent origin this package + * records, at any depth. A fork shares the lineage field without the origin + * and is an independent conversation, so it never holds its source. Lineage + * is read as data, so a damaged header chain that loops is visited once. + */ +function runningDescendants(ctx: Context, rootId: SessionId): Agent[] { + const childrenOf = new Map() + for (const agent of ctx.agents.list()) { + const { parentSession, origin } = agent.session.header + if (parentSession === undefined || origin !== 'subagent') continue + const siblings = childrenOf.get(parentSession) ?? [] + siblings.push(agent) + childrenOf.set(parentSession, siblings) + } + const running: Agent[] = [] + const pending = [rootId] + const visited = new Set() + while (pending.length > 0) { + const parentId = pending.shift() as SessionId + if (visited.has(parentId)) continue + visited.add(parentId) + for (const child of childrenOf.get(parentId) ?? []) { + if (child.status === 'running') running.push(child) + pending.push(child.id) + } + } + return running +} + +/** + * The child's activity item: its id, plus the durable creation label its + * descriptor carries, read through a live Session observation. Without the + * Session query service, or with a descriptor that is absent or unreadable, + * the item names the child by id alone. + */ +async function describe(ctx: Context, child: Agent): Promise { + const query = ctx.get('sessionQuery') + if (query === undefined) return { id: child.id } + try { + using observation = await query.observeSession(child.id, { projectionMode: 'none' }) + const label = foldSubagentDescriptor(observation.events.slice(observation.inheritedEventCount))?.label + return label === undefined ? { id: child.id } : { id: child.id, label } + } catch { + // A damaged descriptor is data damage in the child; the report still names the child. + return { id: child.id } + } +} diff --git a/packages/subagent/subagent/src/control-types.ts b/packages/subagent/subagent/src/control-types.ts index 965f4d70dc..c721574fc6 100644 --- a/packages/subagent/subagent/src/control-types.ts +++ b/packages/subagent/subagent/src/control-types.ts @@ -9,6 +9,8 @@ import type { PromptContentPart } from '@deepseek-ai/dsh-attachment/types' import type { Branded } from '@deepseek-ai/dsh-brand' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' +// Type-only: the Workspace registry's archive-admission family map this runtime merges `subagent` into. +import type {} from '@deepseek-ai/dsh-workspace/types' /** * Client-minted identity of one browser prompt, persisted on the exact accepted @@ -117,6 +119,13 @@ export interface SubagentInterruptReceipt { * Failure details the control surface answers with. Prompts and interrupts * share these failures with the Client Remote result. */ +declare module '@deepseek-ai/dsh-workspace/types' { + interface SessionActivityKindMap { + /** A subagent session delegated from this session (at any depth) is inside a turn. */ + subagent: true + } +} + declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { /** A browser-supplied zone is neither UTC nor a canonical IANA name. */ diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 0262c37476..2a79339563 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -28,10 +28,10 @@ * * @module @deepseek-ai/dsh-subagent */ +import type { Volatile } from '@deepseek-ai/cordis' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import type {} from '@deepseek-ai/dsh-settings' import type {} from '@deepseek-ai/dsh-attachment' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' @@ -73,6 +73,7 @@ import SubagentContinuationManager from './continuation.ts' import type { SubagentDelivery } from './inbox.ts' import { listChildren as listSubagentChildren, listDescendants as listSubagentDescendants } from './list-children.ts' import type { SubagentDescendantListEntry } from './list-children.ts' +import { installSubagentArchiveAdmission } from './archive-admission.ts' import { snapshotSubagentDescriptor } from './descriptor.ts' import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts' import { establishCatalogChild, subagentCatalogProjectionDefinition } from './catalog.ts' @@ -190,18 +191,17 @@ interface BrowserPromptSource { /** Host configuration for continuable subagent capacity. */ export interface Config { /** Maximum live children sharing uninterrupted continuable parent links; defaults to 8. */ - maxActiveSubagents?: number + maxActiveSubagents: Volatile /** Default delegation depth for tools without an explicit limit; defaults to 1. */ - maxDepth?: number + maxDepth: Volatile } /** Named provider registry with one-shot runs, durable discovery, and continuable-child operations. */ export class SubagentRuntime extends TypertRemoteService { - static Config: z = z.object({ - maxDepth: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(1), - maxActiveSubagents: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(8), + static Config = z.object({ + maxDepth: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(1).volatile(), + maxActiveSubagents: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(8).volatile(), }) - private settingsSource: () => Config private providers = new Map() private continuations: SubagentContinuationManager | undefined /** @@ -211,23 +211,14 @@ export class SubagentRuntime extends TypertRemoteService { */ private readonly emitLifecycle: LifecycleEmitter - constructor(ctx: Context, config: Config) { + constructor(ctx: Context, private config: Config) { super(ctx, 'subagents') - assertSubagentMaxDepth(config.maxDepth) - this.settingsSource = () => config - ctx.inject(['settings'], (settingsCtx) => { - settingsCtx.settings.installSection(ctx, 'subagent', SubagentRuntime.Config, config, { - validate: (value) => { assertSubagentMaxDepth(value.maxDepth) }, - setSource: (source) => { this.settingsSource = source }, - onChange: () => {}, - }) - }) this.emitLifecycle = createLifecycleEmitter(this.ctx, parent => scopeTarget(this, parent)) ctx.inject(['agents'], (childCtx: Context) => { const manager = new SubagentContinuationManager(childCtx, { prepareContinuable: (name, request) => this.prepareContinuable(name, request), observeActivation: (provider, childId, parent) => this.observeActivation(provider, childId, parent), - }, () => (this.settingsSource() as Required).maxActiveSubagents) + }, () => this.config.maxActiveSubagents.get()) this.continuations = manager childCtx.effect(() => () => { /* v8 ignore else -- one injected binding owns the slot until its fiber disposes. */ @@ -240,6 +231,9 @@ export class SubagentRuntime extends TypertRemoteService { projections.register(subagentTimingProjectionDefinition) projections.register(subagentIdentityProjectionDefinition) }) + // Archive admission: this runtime is the owner that knows which live + // children descend from a Session and how a parent stops them. + ctx.inject(['agents'], (agentsCtx: Context) => { installSubagentArchiveAdmission(agentsCtx) }) } /** @@ -249,7 +243,10 @@ export class SubagentRuntime extends TypertRemoteService { */ resolveMaxDepth(configured?: number | 'provider-managed'): number | undefined { if (configured === 'provider-managed') return undefined - return configured ?? (this.settingsSource() as Required).maxDepth + if (configured !== undefined) return configured + const depth = this.config.maxDepth.get() + assertSubagentMaxDepth(depth) + return depth } /** diff --git a/packages/subagent/subagent/tests/archive-admission.spec.ts b/packages/subagent/subagent/tests/archive-admission.spec.ts new file mode 100644 index 0000000000..c279884a67 --- /dev/null +++ b/packages/subagent/subagent/tests/archive-admission.spec.ts @@ -0,0 +1,182 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent, AgentCancelCause, AgentStatus, CancelOptions } from '@deepseek-ai/dsh-agent' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import type { SessionActivity } from '@deepseek-ai/dsh-workspace' +import SubagentRuntime, { SUBAGENT_DESCRIPTOR_VERSION } from '../src/index.ts' +import { TestSessionQuery } from './test-session-query.ts' + +type CancelCall = [AgentCancelCause, CancelOptions | undefined] + +/** A registered Agent double whose status the test flips and whose cancels it records. */ +type LiveAgent = Agent & { cancels: CancelCall[]; setStatus(status: AgentStatus): void } + +async function harness(options: { sessionQuery?: boolean } = {}): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) + if (options.sessionQuery !== false) await ctx.plugin(TestSessionQuery) + await ctx.plugin(SubagentRuntime) + return ctx +} + +async function liveAgent(ctx: Context, session: Session, initial: AgentStatus): Promise { + const cancels: CancelCall[] = [] + let status = initial + const agent: LiveAgent = { + id: session.id, + options: {}, + session, + inbox: unsupportedInbox(), + get status() { return status }, + ctx, + send: () => {}, + followup: () => {}, + steer: () => {}, + inject: () => {}, + cancel: (cause, options) => { cancels.push([cause, options]) }, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), + cancels, + setStatus: (next) => { status = next }, + } + await ctx.agents.register(agent) + return agent +} + +/** A child session in the durable lineage this package records at delegation. */ +function childOf(ctx: Context, id: string, parent: SessionId, label?: string): Session { + const child = ctx.sessions.create(SessionId(id), { meta: { parentSession: parent, origin: 'subagent' } }) + if (label !== undefined) { + child.append('subagent/descriptor', { version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 'spawn', label }) + } + return child +} + +function ask(ctx: Context, sessionId: SessionId): Promise { + return ctx.waterfall('workspace/session-activity', { sessionId }, () => Promise.resolve([])) +} + +function stop(ctx: Context, sessionId: SessionId): Promise { + return ctx.parallel('workspace/session-stop', { sessionId }) +} + +describe('Subagent archive admission: reporting', () => { + it('reports running descendants by durable lineage at any depth, with their labels, but never forks', async () => { + const ctx = await harness() + const parent = ctx.sessions.create(SessionId('parent')) + await liveAgent(ctx, parent, 'idle') + const child = childOf(ctx, 'child', parent.id, 'reviewer') + const childAgent = await liveAgent(ctx, child, 'idle') + const grandchild = childOf(ctx, 'grandchild', child.id) + await liveAgent(ctx, grandchild, 'running') + const fork = ctx.sessions.create(SessionId('fork'), { meta: { parentSession: parent.id } }) + await liveAgent(ctx, fork, 'running') + ctx.on('workspace/session-activity', async (_request, next) => [...(await next()), { kind: 'probe' }]) + + expect(await ask(ctx, parent.id)).toEqual([{ kind: 'subagent', items: [{ id: 'grandchild' }] }, { kind: 'probe' }]) + childAgent.setStatus('running') + expect(await ask(ctx, parent.id)).toEqual([ + { kind: 'subagent', items: [{ id: 'child', label: 'reviewer' }, { id: 'grandchild' }] }, + { kind: 'probe' }, + ]) + // A fork is an independent conversation: its own turn (reported by the + // composed Agent registry) does not hold its source. + expect(await ask(ctx, fork.id)).toEqual([{ kind: 'turn' }, { kind: 'probe' }]) + expect(await ask(ctx, SessionId('cold-or-unknown'))).toEqual([{ kind: 'probe' }]) + }) + + it('names a child by id alone without the Session query service or with a damaged descriptor', async () => { + const queryless = await harness({ sessionQuery: false }) + const parent = queryless.sessions.create(SessionId('parent')) + await liveAgent(queryless, parent, 'idle') + await liveAgent(queryless, childOf(queryless, 'child', parent.id, 'reviewer'), 'running') + expect(await ask(queryless, parent.id)).toEqual([{ kind: 'subagent', items: [{ id: 'child' }] }]) + + const ctx = await harness() + const damagedParent = ctx.sessions.create(SessionId('damaged-parent')) + await liveAgent(ctx, damagedParent, 'idle') + const damaged = ctx.sessions.create(SessionId('damaged-child'), { meta: { parentSession: damagedParent.id, origin: 'subagent' } }) + // A current-version payload whose label is not a string fails the descriptor parse. + damaged.append('subagent/descriptor', { version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 'spawn', label: 7 } as never) + await liveAgent(ctx, damaged, 'running') + expect(await ask(ctx, damagedParent.id)).toEqual([{ kind: 'subagent', items: [{ id: 'damaged-child' }] }]) + }) + + it('visits a damaged lineage that loops only once', async () => { + const ctx = await harness() + // Two headers naming each other as subagent parents cannot be created by + // delegation; a report over such data terminates instead of hanging. + const first = ctx.sessions.create(SessionId('loop-a'), { meta: { parentSession: SessionId('loop-b'), origin: 'subagent' } }) + const second = ctx.sessions.create(SessionId('loop-b'), { meta: { parentSession: first.id, origin: 'subagent' } }) + await liveAgent(ctx, first, 'idle') + await liveAgent(ctx, second, 'running') + expect(await ask(ctx, first.id)).toEqual([{ kind: 'subagent', items: [{ id: 'loop-b' }] }]) + }) +}) + +describe('Subagent archive admission: stopping', () => { + it('cancels running descendants as their parent and leaves idle children and forks alone', async () => { + const ctx = await harness() + const parent = ctx.sessions.create(SessionId('stop-me')) + const parentAgent = await liveAgent(ctx, parent, 'running') + const childAgent = await liveAgent(ctx, childOf(ctx, 'stop-child', parent.id), 'running') + const idleChildAgent = await liveAgent(ctx, childOf(ctx, 'idle-child', parent.id), 'idle') + const fork = ctx.sessions.create(SessionId('stop-fork'), { meta: { parentSession: parent.id } }) + const forkAgent = await liveAgent(ctx, fork, 'running') + + await stop(ctx, parent.id) + await stop(ctx, SessionId('nobody')) + expect(childAgent.cancels).toEqual([[{ kind: 'parent' }, undefined]]) + expect(idleChildAgent.cancels).toEqual([]) + expect(forkAgent.cancels).toEqual([]) + // The parent's own turn is the composed Agent registry's family, cancelled with the user cause. + expect(parentAgent.cancels).toEqual([[{ kind: 'user' }, undefined]]) + }) + + it('keeps asking the remaining descendants to stop when one cancel throws', async () => { + const ctx = await harness() + const parent = ctx.sessions.create(SessionId('stubborn')) + await liveAgent(ctx, parent, 'running') + const stubborn = await liveAgent(ctx, childOf(ctx, 'stubborn-child', parent.id), 'running') + stubborn.cancel = () => { throw new Error('child refuses cancel') } + const quiet = await liveAgent(ctx, childOf(ctx, 'quiet-child', parent.id), 'running') + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + + await stop(ctx, parent.id) + expect(quiet.cancels).toEqual([[{ kind: 'parent' }, undefined]]) + expect(warn.mock.calls.map(([message]) => String(message))).toEqual([ + expect.stringContaining('cancelling "stubborn-child"'), + ]) + }) +}) + +describe('Subagent archive admission: lifetime', () => { + it('stops answering once the runtime fiber is disposed', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) + const fiber = await ctx.plugin(SubagentRuntime) + const parent = ctx.sessions.create(SessionId('watched')) + await liveAgent(ctx, parent, 'idle') + const child = await liveAgent(ctx, childOf(ctx, 'watched-child', parent.id), 'running') + expect(await ask(ctx, parent.id)).toEqual([{ kind: 'subagent', items: [{ id: 'watched-child' }] }]) + await fiber.dispose() + expect(await ask(ctx, parent.id)).toEqual([]) + await stop(ctx, parent.id) + expect(child.cancels).toEqual([]) + }) +}) + +// The runtime knows only its own family; this suite merges a trailing one to observe ordering. +declare module '@deepseek-ai/dsh-workspace/types' { + interface SessionActivityKindMap { + probe: true + } +} diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 9749eaf2f5..2fdea25a1a 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' -import { SettingsProvider, type SettingsNamespace } from '@deepseek-ai/dsh-settings' +import { liveConfig } from '../../../settings/settings/tests/live-config.ts' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -33,12 +33,7 @@ import { dropContinuationActivation, } from './continuation-internals.ts' -/** Writable settings isolated to one test Context. */ -class MemorySettings extends SettingsProvider { - get writable(): boolean { return true } - protected load(): Promise> { return Promise.resolve({}) } - protected persist(_ns: SettingsNamespace, _section: Record): Promise { return Promise.resolve() } -} +const subagentConfigs = new WeakMap>>() type Script = ConstructorParameters[0] @@ -103,7 +98,8 @@ async function setupWith( await ctx.plugin(AgentLoop, { agents: [] }) if (options.schedule) await ctx.plugin(toolSchedule) if (options.sessionQuery !== false) await ctx.plugin(TestSessionQuery) - await ctx.plugin(SubagentRuntime, options.maxActiveSubagents === undefined ? {} : { maxActiveSubagents: options.maxActiveSubagents }) + subagentConfigs.set(ctx, await liveConfig(ctx, SubagentRuntime, + options.maxActiveSubagents === undefined ? {} : { maxActiveSubagents: options.maxActiveSubagents })) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(SubagentFork, { providerName: 'fork' }) ctx.llm.registerAdapter(['mock'], adapter) @@ -272,22 +268,23 @@ describe('continuable activation capacity', () => { it('layers editable depth over composition and removes the section on disposal', async () => { const ctx = new Context() try { - await ctx.plugin(MemorySettings) - const fiber = await ctx.plugin(SubagentRuntime, { maxDepth: 4 }) + + const live = await liveConfig(ctx, SubagentRuntime, { maxDepth: 4 }) + subagentConfigs.set(ctx, live) + const fiber = live.fiber expect(ctx.subagents.resolveMaxDepth()).toBe(4) - await ctx.settings.update('subagent', { maxDepth: 0 }) + await subagentConfigs.get(ctx)!.update({ maxDepth: 0 }) expect(ctx.subagents.resolveMaxDepth()).toBe(0) expect(ctx.subagents.resolveMaxDepth(2)).toBe(2) expect(ctx.subagents.resolveMaxDepth('provider-managed')).toBeUndefined() - await expect(ctx.settings.update('subagent', { maxDepth: -0 })).rejects.toThrow() - await expect(ctx.settings.update('subagent', { maxDepth: -1 })).rejects.toThrow() - await expect(ctx.settings.update('subagent', { maxDepth: 1.5 })).rejects.toThrow() - await expect(ctx.settings.update('subagent', { maxActiveSubagents: 0 })).rejects.toThrow() + await expect(subagentConfigs.get(ctx)!.update({ maxDepth: -1 })).rejects.toThrow() + await expect(subagentConfigs.get(ctx)!.update({ maxDepth: 1.5 })).rejects.toThrow() + await expect(subagentConfigs.get(ctx)!.update({ maxActiveSubagents: 0 })).rejects.toThrow() expect(ctx.subagents.resolveMaxDepth()).toBe(0) - await ctx.settings.replace('subagent', {}) + await subagentConfigs.get(ctx)!.replace({ maxDepth: 4 }) expect(ctx.subagents.resolveMaxDepth()).toBe(4) await fiber.dispose() - expect(ctx.settings.describe().some(section => section.ns === 'subagent')).toBe(false) + expect(ctx.get('subagents')).toBeUndefined() } finally { await ctx.fiber.dispose() } @@ -299,16 +296,16 @@ describe('continuable activation capacity', () => { const { ctx, parent } = await setupWith(adapter, { maxActiveSubagents: 1 }) parkParent(ctx, parent) try { - await ctx.plugin(MemorySettings) + const first = await ctx.subagents.startContinuable(startSpec(parent)) await expect(ctx.subagents.startContinuable(startSpec(parent))).rejects.toMatchObject({ code: 'ACTIVATION_LIMIT_REACHED' }) - await ctx.settings.update('subagent', { maxActiveSubagents: 2 }) + await subagentConfigs.get(ctx)!.update({ maxActiveSubagents: 2 }) const second = await ctx.subagents.startContinuable(startSpec(parent)) - await ctx.settings.update('subagent', { maxActiveSubagents: 1 }) + await subagentConfigs.get(ctx)!.update({ maxActiveSubagents: 1 }) expect(ctx.agents.get(first.childId)).toBeDefined() expect(ctx.agents.get(second.childId)).toBeDefined() await expect(ctx.subagents.startContinuable(startSpec(parent))).rejects.toMatchObject({ code: 'ACTIVATION_LIMIT_REACHED' }) - await ctx.settings.update('subagent', { maxActiveSubagents: 3 }) + await subagentConfigs.get(ctx)!.update({ maxActiveSubagents: 3 }) const third = await ctx.subagents.startContinuable(startSpec(parent)) release.resolve(undefined) await Promise.all([first, second, third].map(child => waitNoActivation(ctx, child.childId))) diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index 9def319863..a2e591d99c 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -59,6 +59,9 @@ { "path": "../../session-query/session-query" }, + { + "path": "../../workspace/workspace" + }, { "path": "../../jobs/jobs" }, @@ -76,9 +79,6 @@ }, { "path": "../../util/chunked-list" - }, - { - "path": "../../settings/settings" } ] } diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index 3d81c154d1..41abc00a2d 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -43,7 +43,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", @@ -63,7 +62,6 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/subagent/tool-subagent/src/model-selection-settings.ts b/packages/subagent/tool-subagent/src/model-selection-settings.ts index e3c4564782..e03d5d2b49 100644 --- a/packages/subagent/tool-subagent/src/model-selection-settings.ts +++ b/packages/subagent/tool-subagent/src/model-selection-settings.ts @@ -1,8 +1,8 @@ /** Host-owned opt-in setting for model-selectable subagent delegation. */ +import type { Volatile } from '@deepseek-ai/cordis' import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import type {} from '@deepseek-ai/dsh-settings' import { AllowedModelRouteSchema, assertAllowedModelRoutes, @@ -16,9 +16,6 @@ declare module '@deepseek-ai/cordis' { } } -/** User-settings section for model-selectable subagent delegation. */ -export const SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE = 'subagent-model-selection' - /** Stored user preference; the shipped composition defaults it off. */ export interface SubagentModelSelectionSettings { /** Whether newly composed top-level Sessions receive model selection. */ @@ -27,54 +24,23 @@ export interface SubagentModelSelectionSettings { allowedModels: AllowedModelRoute[] } -/** Schema served to settings clients for the opt-in preference. */ -export const SUBAGENT_MODEL_SELECTION_SETTINGS_SCHEMA: z = z.object({ - enabled: z.boolean().default(false), - allowedModels: z.array(AllowedModelRouteSchema).default([]), -}) - /** Optional deployment base for the preference. */ export interface Config { /** Initial enabled state inherited when the user document does not override it. */ - enabled?: boolean + enabled: Volatile /** Initial route list inherited when the user document does not override it. */ - allowedModels?: AllowedModelRoute[] + allowedModels: Volatile } /** Singleton settings owner read when delegation tools are composed for a Session. */ export class SubagentModelSelectionConfig extends Service { - static Config: z = z.object({ - enabled: z.boolean().default(false), - allowedModels: z.array(AllowedModelRouteSchema).default([]), + static Config = z.object({ + enabled: z.boolean().default(false).volatile(), + allowedModels: z.array(AllowedModelRouteSchema).default([]).volatile(), }) - private source: () => SubagentModelSelectionSettings - - constructor(ctx: Context, config: Config = {}) { + constructor(ctx: Context, private config: Config) { super(ctx, 'subagentModelSelection') - // Cordis supplies the schema default; the fallback also covers direct construction. - /* v8 ignore next */ - const entry: SubagentModelSelectionSettings = { - enabled: config.enabled ?? false, - allowedModels: config.allowedModels ?? [], - } - this.validate(entry) - this.source = () => entry - ctx.inject(['settings'], (settingsCtx) => { - settingsCtx.settings.installSection( - ctx, - SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, - SUBAGENT_MODEL_SELECTION_SETTINGS_SCHEMA, - entry, - { - setSource: (source) => { this.source = source }, - validate: (value) => { this.validate(value) }, - // Consumers snapshot per Session, so a settings update never rebuilds - // the tool definitions of a Session that is already running. - onChange: () => {}, - }, - ) - }) } /** @@ -82,19 +48,15 @@ export class SubagentModelSelectionConfig extends Service { * @returns the enabled state and exact allowed routes. */ current(): SubagentModelSelectionSettings { - const current = this.source() - return { - enabled: current.enabled, - allowedModels: current.allowedModels.map(route => ({ ...route })), - } - } - - private validate(value: SubagentModelSelectionSettings): void { - assertAllowedModelRoutes(value.allowedModels) - if (value.enabled && value.allowedModels.length === 0) { + const enabled = this.config.enabled.get() + const allowedModels = this.config.allowedModels.get() + assertAllowedModelRoutes(allowedModels) + if (enabled && allowedModels.length === 0) { throw new Error('enabled subagent model selection requires at least one allowed model') } + return { enabled, allowedModels: allowedModels.map(route => ({ ...route })) } } + } export const name = 'subagent-model-selection-settings' diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index a2d56548ce..0907efed8f 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -6,8 +6,7 @@ import { ToolCallId } from '@deepseek-ai/dsh-llm' import { Session, SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { bindScopeParent, createScope, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' -import { SettingsProvider } from '@deepseek-ai/dsh-settings' -import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' +import { liveConfig } from '../../../settings/settings/tests/live-config.ts' import InvariantRegistry from '@deepseek-ai/dsh-invariants' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -16,9 +15,7 @@ import SubagentRuntime from '@deepseek-ai/dsh-subagent' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import * as tool from '../src/index.ts' import * as ToolInvariant from '../src/invariant.ts' -import SubagentModelSelectionConfig, { - SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, -} from '../src/model-selection-settings.ts' +import SubagentModelSelectionConfig from '../src/model-selection-settings.ts' import { subagentModelSelectionPolicy, subagentModelSelectionProjectionDefinition, @@ -27,23 +24,8 @@ import { callSubagent, text } from './harness.ts' const ALLOWED_MODELS = [{ provider: 'alpha', model: 'fast-model' }] -/** Writable in-memory settings provider for the package integration. */ -class MemorySettings extends SettingsProvider { - doc: Record = {} - - get writable(): boolean { - return true - } - - protected load(): Promise> { - return Promise.resolve(structuredClone(this.doc)) - } - - protected persist(ns: SettingsNamespace, section: Record): Promise { - this.doc = { ...this.doc, [ns]: structuredClone(section) } - return Promise.resolve() - } -} +const selectionConfigs = new WeakMap>>() +const subagentConfigs = new WeakMap>>() /** Read whether one Agent's delegation definition contains route fields. */ function selectable(ctx: Context, agent: Awaited>['agent']): boolean { @@ -60,11 +42,11 @@ const modelSelectionPresets = new WeakMap { const ctx = new Context() - await ctx.plugin(MemorySettings) - await ctx.plugin(SubagentModelSelectionConfig) + + selectionConfigs.set(ctx, await liveConfig(ctx, SubagentModelSelectionConfig)) await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(SubagentRuntime) + subagentConfigs.set(ctx, await liveConfig(ctx, SubagentRuntime)) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) if (withPreset) { const preset = createScope(ctx, { preset: 'model-selection-test' }) @@ -106,11 +88,11 @@ describe('SubagentModelSelectionConfig', () => { it('defaults off and follows the validated user layer', async () => { const ctx = new Context() - await ctx.plugin(MemorySettings) - await ctx.plugin(SubagentModelSelectionConfig) + + selectionConfigs.set(ctx, await liveConfig(ctx, SubagentModelSelectionConfig)) expect(ctx.subagentModelSelection.current()).toEqual({ enabled: false, allowedModels: [] }) - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + await selectionConfigs.get(ctx)!.update({ enabled: true, allowedModels: ALLOWED_MODELS, }) @@ -120,25 +102,23 @@ describe('SubagentModelSelectionConfig', () => { it('rejects duplicate routes, enabled empty settings, and an empty durable policy', async () => { const ctx = new Context() - await ctx.plugin(MemorySettings) - await ctx.plugin(SubagentModelSelectionConfig) + + selectionConfigs.set(ctx, await liveConfig(ctx, SubagentModelSelectionConfig)) await ctx.plugin(SessionProjectionRegistry) ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) - await expect(ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { - allowedModels: [...ALLOWED_MODELS, ...ALLOWED_MODELS], - })).rejects.toThrow('repeats route "alpha/fast-model"') + // A stored policy the service cannot apply fails when it is read, not when the profile row is written. + await selectionConfigs.get(ctx)!.update({ allowedModels: [...ALLOWED_MODELS, ...ALLOWED_MODELS] }) + expect(() => ctx.subagentModelSelection.current()).toThrow('repeats route "alpha/fast-model"') - await expect(ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { - enabled: true, - allowedModels: [], - })).rejects.toThrow('enabled subagent model selection requires at least one allowed model') - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + await selectionConfigs.get(ctx)!.update({ enabled: true, allowedModels: [] }) + expect(() => ctx.subagentModelSelection.current()).toThrow('enabled subagent model selection requires at least one allowed model') + await selectionConfigs.get(ctx)!.update({ enabled: false, allowedModels: ALLOWED_MODELS, }) expect(ctx.subagentModelSelection.current()).toEqual({ enabled: false, allowedModels: ALLOWED_MODELS }) - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: [] }) + await selectionConfigs.get(ctx)!.update({ allowedModels: [] }) expect(ctx.subagentModelSelection.current()).toEqual({ enabled: false, allowedModels: [] }) const invalid = Session.create(SessionId('empty-policy')) @@ -160,7 +140,7 @@ describe('SubagentModelSelectionConfig', () => { expect(selectable(ctx, disabled)).toBe(false) expect(subagentModelSelectionPolicy(ctx.sessionProjections, disabled.session)).toBeUndefined() - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + await selectionConfigs.get(ctx)!.update({ enabled: true, allowedModels: ALLOWED_MODELS, }) @@ -169,7 +149,7 @@ describe('SubagentModelSelectionConfig', () => { expect(selectable(ctx, enabled)).toBe(true) expect(selectable(ctx, disabled)).toBe(false) - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) + await selectionConfigs.get(ctx)!.update({ enabled: false }) const disabledAgain = await createAgent(ctx, 'disabled-again') expect(selectable(ctx, disabledAgain)).toBe(false) expect(selectable(ctx, enabled)).toBe(true) @@ -178,7 +158,7 @@ describe('SubagentModelSelectionConfig', () => { it('installs a direct Agent setup before Session publication', async () => { const ctx = await boot(false) - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + await selectionConfigs.get(ctx)!.update({ enabled: true, allowedModels: ALLOWED_MODELS, }) @@ -212,7 +192,7 @@ describe('SubagentModelSelectionConfig', () => { it('installs one tool when recording the Session policy triggers a registry refresh', async () => { const ctx = await boot() try { - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + await selectionConfigs.get(ctx)!.update({ enabled: true, allowedModels: ALLOWED_MODELS, }) @@ -235,7 +215,7 @@ describe('SubagentModelSelectionConfig', () => { it('rejects a forced route outside the Session policy before child creation', async () => { const ctx = await boot() - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + await selectionConfigs.get(ctx)!.update({ enabled: true, allowedModels: ALLOWED_MODELS, }) @@ -282,7 +262,7 @@ describe('SubagentModelSelectionConfig', () => { const disabled = await createComposed('preset-disabled') expect(selectable(ctx, disabled.agent)).toBe(false) - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + await selectionConfigs.get(ctx)!.update({ enabled: true, allowedModels: ALLOWED_MODELS, }) @@ -352,12 +332,12 @@ describe('SubagentModelSelectionConfig', () => { it('inherits the parent decision and preserves seeded decisions across composition', async () => { const ctx = await boot() - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + await selectionConfigs.get(ctx)!.update({ enabled: true, allowedModels: ALLOWED_MODELS, }) const parent = await createAgent(ctx, 'parent') - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) + await selectionConfigs.get(ctx)!.update({ enabled: false }) const child = await createAgent(ctx, 'child', { meta: { parentSession: parent.id, origin: 'subagent' }, }) @@ -375,7 +355,7 @@ describe('SubagentModelSelectionConfig', () => { expect(selectable(ctx, resumedEnabled)).toBe(true) const oldSeed = Session.create(SessionId('old-seed'), []) - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + await selectionConfigs.get(ctx)!.update({ enabled: true, allowedModels: ALLOWED_MODELS, }) @@ -417,9 +397,9 @@ describe('SubagentModelSelectionConfig', () => { it('requires the Session registry when a child inherits its parent policy', async () => { const ctx = new Context() try { - await ctx.plugin(SubagentModelSelectionConfig) + selectionConfigs.set(ctx, await liveConfig(ctx, SubagentModelSelectionConfig)) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(SubagentRuntime) + subagentConfigs.set(ctx, await liveConfig(ctx, SubagentRuntime)) const childId = SessionId('child-without-session-registry') const child = Session.create(childId, undefined, { version: SESSION_FORMAT_VERSION, @@ -463,7 +443,7 @@ describe('SubagentModelSelectionConfig', () => { await expect(ctx.waterfall(ctx as never, 'agent/pre-step', payload, next)) .resolves.toEqual({ kind: 'enter', messages: [] }) - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + await selectionConfigs.get(ctx)!.update({ enabled: true, allowedModels: ALLOWED_MODELS, }) @@ -478,7 +458,7 @@ describe('SubagentModelSelectionConfig', () => { .rejects.toThrow('require a durable policy, route fields, and list_subagent_models') schemas.mockReturnValue(enabledSchemas) - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) + await selectionConfigs.get(ctx)!.update({ enabled: false }) const withoutPolicy = await createAgent(ctx, 'invariant-without-policy') await expect(ctx.waterfall(ctx as never, 'agent/pre-step', { ...payload, agent: withoutPolicy }, next)) .rejects.toThrow('require a durable policy, route fields, and list_subagent_models') @@ -503,9 +483,9 @@ it('reads the saved default depth at each delegation without remounting the tool }) await ctx.plugin(tool, { provider: 'capture-depth' }) await callSubagent(ctx, { description: 'first', prompt: 'work' }) - await ctx.settings.update('subagent', { maxDepth: 5 }) + await subagentConfigs.get(ctx)!.update({ maxDepth: 5 }) await callSubagent(ctx, { description: 'second', prompt: 'work' }) - await ctx.settings.update('subagent', { maxDepth: 0 }) + await subagentConfigs.get(ctx)!.update({ maxDepth: 0 }) await callSubagent(ctx, { description: 'disabled', prompt: 'work' }) expect(depths).toEqual([1, 5, 0]) } finally { diff --git a/packages/subagent/tool-subagent/tsconfig.json b/packages/subagent/tool-subagent/tsconfig.json index bb755f39a0..84feade601 100644 --- a/packages/subagent/tool-subagent/tsconfig.json +++ b/packages/subagent/tool-subagent/tsconfig.json @@ -29,9 +29,6 @@ { "path": "../../core/scope" }, - { - "path": "../../settings/settings" - }, { "path": "../../llm/llm" }, diff --git a/packages/test-support/client-runtime/README.i18n.yaml b/packages/test-support/client-runtime/README.i18n.yaml index 4a16509fa8..aa99103e9c 100644 --- a/packages/test-support/client-runtime/README.i18n.yaml +++ b/packages/test-support/client-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/test-support/client-runtime/README.md -README.md: e22d3b180f32706a2dd487ea98b7846aeebc34a5 -README.zh.md: b003b02cbf6950a1396511eb7bd1afa203652600 +README.md: 06455888a87fa03b1960574d25cbc9ce69e2c9aa +README.zh.md: edad3ae12b76fef8ed230953866341d345e0cdc6 diff --git a/packages/test-support/client-runtime/README.md b/packages/test-support/client-runtime/README.md index e22d3b180f..06455888a8 100644 --- a/packages/test-support/client-runtime/README.md +++ b/packages/test-support/client-runtime/README.md @@ -11,6 +11,8 @@ English | [中文](README.zh.md) `SlotTestRuntime.create()` lets Vitest suites drive production slots, stores, typed Session and Workspace fixtures, and local DOM assertions in jsdom. For plugin activation, reload, reconnect, and cleanup tests, `createClientTest` starts the web profile's bundle roster with endpoint-named Remote mocks, without a business Host. Missing services and unstubbed calls fail explicitly. The whole-client fixture owns startup and disposal; the local runtime provides idempotent disposal. Use this package through `devDependencies` for client tests; it is not a product plugin. +Linked bundles resolve dependencies from their real package directories; dependencies next to any bundle take precedence over packages inherited from ancestor directories. + ## Table of Contents - [Use this package](#use-this-package) @@ -113,7 +115,7 @@ This section explains the design of the bench; the observable behavior is fully ### Design -The bench copies no production logic: it mounts the production `SlotRegistry`, production renderer, and `UiSession` adapter. `TestSessions` and `TestWorkspaces` implement the owner interfaces that features consume through Cordis, each fixture Session implements `SessionFace`, and `stubSettingsScope` implements `SettingsScope`. `UiSession` derives standard renderer sources from those Controller bindings. Unstubbed `ISession` behavior fails with the missing method name. +The bench copies no production logic: it mounts the production `SlotRegistry`, production renderer, and `UiSession` adapter. `TestSessions` and `TestWorkspaces` implement the owner interfaces that features consume through Cordis, each fixture Session implements `SessionFace`, and `stubConfigForm` implements `ConfigForm`. `UiSession` derives standard renderer sources from those Controller bindings. Unstubbed `ISession` behavior fails with the missing method name. ### Source map @@ -125,7 +127,7 @@ The bench copies no production logic: it mounts the production `SlotRegistry`, p | [`src/snapshot.ts`](src/snapshot.ts) | DOM snapshot serializer (class-hash folding, `` fingerprint) | | [`src/remote.ts`](src/remote.ts) | `TestRemote` double for host RPC, `RemoteError` value re-export | | [`src/translate.ts`](src/translate.ts) + [`src/locale-env.ts`](src/locale-env.ts) | Translation and pinned-browser-language test helpers | -| [`src/settings-scope.ts`](src/settings-scope.ts) | `stubSettingsScope` with test-driven publications and write spies | +| [`src/config-form.ts`](src/config-form.ts) | `stubConfigForm` with test-driven publications and write spies | | [`src/assembly/roster.ts`](src/assembly/roster.ts) | `ClientRosterRow`, `ClientRoster` (`of`/`closure`/`pick`/`without`), the `AssemblyPlan` it annotates, and `graphFromRoster` | | [`src/assembly/modules.ts`](src/assembly/modules.ts) | Source `/client` imports and replacements, registered through the production module facade's pending factory queue | | [`src/assembly/test-client.ts`](src/assembly/test-client.ts) | `TestClient`: instance-bound Connection, shared jsdom shims, `bootClient`, mount, readiness wait, `reload`/`unload`/`dispose` | diff --git a/packages/test-support/client-runtime/README.zh.md b/packages/test-support/client-runtime/README.zh.md index b003b02cbf..edad3ae12b 100644 --- a/packages/test-support/client-runtime/README.zh.md +++ b/packages/test-support/client-runtime/README.zh.md @@ -11,6 +11,8 @@ kind: "package-library" `SlotTestRuntime.create()` 让 Vitest 套件在 jsdom 中驱动生产 slot、store、带类型的 Session 与 Workspace fixture,并对局部 DOM 断言。面向插件激活、重载、重连与清理的测试,`createClientTest` 使用具名端点 Remote mock 启动 web profile 的 bundle roster,无需业务 Host。缺失服务与未打桩调用会明确失败。整机 fixture 拥有启动和销毁,局部 runtime 提供幂等销毁。通过 `devDependencies` 将本包用于客户端测试;它不是产品插件。 +链接到工作区的 bundle 从实际包目录解析依赖;各 bundle 自身目录中的依赖优先于祖先目录中继承的包。 + ## 目录 - [使用本包](#use-this-package) @@ -113,7 +115,7 @@ test('registers into the sidebar', async ({ remote, start }) => { ### 设计 -测试台不复制生产逻辑:它挂载生产 `SlotRegistry`、生产渲染器与 `UiSession` 适配器。`TestSessions` 与 `TestWorkspaces` 实现功能通过 Cordis 消费的 owner 接口,每个 fixture Session 实现 `SessionFace`,`stubSettingsScope` 实现 `SettingsScope`。`UiSession` 从这些控制器绑定派生标准渲染器数据源。未 stub 的 `ISession` 行为会携缺失方法名失败。 +测试台不复制生产逻辑:它挂载生产 `SlotRegistry`、生产渲染器与 `UiSession` 适配器。`TestSessions` 与 `TestWorkspaces` 实现功能通过 Cordis 消费的 owner 接口,每个 fixture Session 实现 `SessionFace`,`stubConfigForm` 实现 `ConfigForm`。`UiSession` 从这些控制器绑定派生标准渲染器数据源。未 stub 的 `ISession` 行为会携缺失方法名失败。 ### 源码地图 @@ -125,7 +127,7 @@ test('registers into the sidebar', async ({ remote, start }) => { | [`src/snapshot.ts`](src/snapshot.ts) | DOM 快照序列化器(类名哈希折叠、`` 指纹) | | [`src/remote.ts`](src/remote.ts) | 用于 host RPC 的 `TestRemote` 替身、`RemoteError` 值转出 | | [`src/translate.ts`](src/translate.ts) + [`src/locale-env.ts`](src/locale-env.ts) | 翻译与固定浏览器语言测试辅助 | -| [`src/settings-scope.ts`](src/settings-scope.ts) | 带测试驱动发布与写入 spy 的 `stubSettingsScope` | +| [`src/config-form.ts`](src/config-form.ts) | 带测试驱动发布与写入 spy 的 `stubConfigForm` | | [`src/assembly/roster.ts`](src/assembly/roster.ts) | `ClientRosterRow`、`ClientRoster`(`of`/`closure`/`pick`/`without`)、它所标注的 `AssemblyPlan`,以及 `graphFromRoster` | | [`src/assembly/modules.ts`](src/assembly/modules.ts) | 源码 `/client` 导入及替换,通过生产模块 facade 的待注册工厂队列登记 | | [`src/assembly/test-client.ts`](src/assembly/test-client.ts) | `TestClient`:绑定实例的 Connection、共享 jsdom 桩、`bootClient`、挂载、等就绪、`reload`/`unload`/`dispose` | diff --git a/packages/test-support/client-runtime/src/assembly/bundle-roster.ts b/packages/test-support/client-runtime/src/assembly/bundle-roster.ts index 3650afe792..6b362b187d 100644 --- a/packages/test-support/client-runtime/src/assembly/bundle-roster.ts +++ b/packages/test-support/client-runtime/src/assembly/bundle-roster.ts @@ -13,7 +13,7 @@ * repository. * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/bundle-roster */ -import { existsSync, readFileSync } from 'node:fs' +import { existsSync, readFileSync, realpathSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -130,12 +130,15 @@ function readManifest(path: string): PackageManifest { return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest } -/** Locate `/package.json` on the resolution paths of any anchor, without requiring a `./package.json` export. */ +/** Resolve package manifests to real paths so linked bundles use their own dependency directories. */ function locateManifest(anchors: readonly string[], name: string): string | undefined { - for (const anchor of anchors) { - for (const searchPath of createRequire(anchor).resolve.paths(name) ?? []) { - const candidate = join(searchPath, name, 'package.json') - if (existsSync(candidate)) return candidate + const paths = anchors.map(anchor => createRequire(anchor).resolve.paths(name) ?? []) + for (let depth = 0; depth < Math.max(...paths.map(search => search.length)); depth++) { + for (const search of paths) { + const directory = search[depth] + if (directory === undefined) continue + const candidate = join(directory, name, 'package.json') + if (existsSync(candidate)) return realpathSync(candidate) } } return undefined diff --git a/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts b/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts index f5baa25eaa..2ad61c31e9 100644 --- a/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts +++ b/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts @@ -34,6 +34,9 @@ export const remoteDefaultResponses: RemoteTable = { 'credentials/describe': ok({}), // ui-permission-presets `PermissionCatalogDirectory` on its first read for a connection generation. 'permissionPresets/catalog': ok({ options: [] }), + // ui-settings-account refreshes details after a stored-grant snapshot. + 'account/getProfile': ok(null), + 'account/getBalance': ok(null), }, // Stream endpoints the roster opens later than boot; declared so a spec that forgets the script gets a stream miss. streams: [ @@ -43,6 +46,8 @@ export const remoteDefaultResponses: RemoteTable = { stream: { // api-session-controller client `apply`: the control stream's opening baseline, then open. 'session/control': openStream([{ type: 'baseline', value: { projections: {} } }]), + // ui-settings-account shares the account snapshot across settings and the sidebar menu. + 'account/watch': openStream([{ status: 'signed-out', attempt: null, links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } }]), // api-workspace-controller client `apply`: the follow stream's opening baseline, then open. 'workspace/follow': openStream([{ type: 'baseline', value: { items: [], archivedSessionIds: [], pinnedSessionIds: [] } }]), }, diff --git a/packages/test-support/client-runtime/src/settings-scope.ts b/packages/test-support/client-runtime/src/config-form.ts similarity index 81% rename from packages/test-support/client-runtime/src/settings-scope.ts rename to packages/test-support/client-runtime/src/config-form.ts index 04d4063e70..df1f6eea01 100644 --- a/packages/test-support/client-runtime/src/settings-scope.ts +++ b/packages/test-support/client-runtime/src/config-form.ts @@ -1,17 +1,17 @@ /** Test doubles for settings transport. */ import { vi } from 'vitest' import type { - SettingsScope, SettingsScopeSnapshot, + ConfigForm, ConfigFormSnapshot, } from '@deepseek-ai/dsh-client-ui-settings/client' /** Handle over one stubbed scope: the scope, its write spy, and publication controls. */ -export interface StubSettingsScope { +export interface StubConfigForm { /** The scope face handed to the service under test. */ - scope: SettingsScope + scope: ConfigForm /** Spy behind `scope.set`; resolves immediately. */ set: ReturnType /** Spy behind `scope.mutate`; resolves immediately. */ - mutate: ReturnType + mutate: ReturnType['mutate']>> /** Spy behind `scope.unset`; resolves immediately. */ unset: ReturnType /** @returns how many listeners are currently subscribed (disposal assertions). */ @@ -21,7 +21,7 @@ export interface StubSettingsScope { * acceptance would. * @param next - snapshot fields to replace. */ - publish(next: Partial>): void + publish(next: Partial>): void } /** @@ -29,14 +29,14 @@ export interface StubSettingsScope { * loading state, records writes, and lets the test publish Host acceptances. * @returns the stub handle. */ -export function stubSettingsScope(): StubSettingsScope { - let snapshot: SettingsScopeSnapshot = { +export function stubConfigForm(): StubConfigForm { + let snapshot: ConfigFormSnapshot = { status: 'loading', value: undefined, base: undefined, user: undefined, revision: undefined, writable: false, mode: 'host', } const listeners = new Set<() => void>() const set = vi.fn(() => Promise.resolve(true)) - const mutate = vi.fn(() => Promise.resolve(true)) + const mutate = vi.fn['mutate']>(() => Promise.resolve(true)) const unset = vi.fn(() => Promise.resolve(true)) return { scope: { diff --git a/packages/test-support/client-runtime/src/index.ts b/packages/test-support/client-runtime/src/index.ts index 880148b5cf..67c37d8e1a 100644 --- a/packages/test-support/client-runtime/src/index.ts +++ b/packages/test-support/client-runtime/src/index.ts @@ -46,8 +46,8 @@ import type { Stabilizer } from './fixtures.ts' export type { UseSession } from '@deepseek-ai/dsh-client-ui-session/client' export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot.ts' export { FixtureSession, TestSessions } from './sessions.ts' -export { stubSettingsScope } from './settings-scope.ts' -export type { StubSettingsScope } from './settings-scope.ts' +export { stubConfigForm } from './config-form.ts' +export type { StubConfigForm } from './config-form.ts' export { TestWorkspaces } from './workspaces.ts' export { RemoteError, TestRemote } from './remote.ts' export { diff --git a/packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts index e29ebf0027..bc3d567c4d 100644 --- a/packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts +++ b/packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts @@ -1,9 +1,9 @@ /** bundleRoster: the real web profile read from its bundles, and every reader decision on a scratch installation. */ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { getStaticModules } from '@deepseek-ai/dsh-client-web/src/seed.ts' -import { afterAll, describe, expect, it } from 'vitest' +import { afterAll, describe, expect, it, onTestFinished } from 'vitest' import { MODULES_PACKAGE } from '../src/assembly/modules.ts' import { WEB_PROFILE_BUNDLES, bundleRoster, webApp } from '../src/assembly/bundle-roster.ts' @@ -74,6 +74,21 @@ describe('bundleRoster on a scratch installation', () => { const scratch = new Scratch() afterAll(() => { rmSync(scratch.root, { recursive: true, force: true }) }) + it('resolves a linked bundle dependency before an unrelated ancestor package', () => { + const linked = new Scratch() + onTestFinished(() => { rmSync(linked.root, { recursive: true, force: true }) }) + const bundle = join(linked.root, 'workspace', 'bundle') + const dependency = join(bundle, 'node_modules', '@t', 'theme') + mkdirSync(dependency, { recursive: true }) + writeFileSync(join(bundle, 'package.json'), JSON.stringify({ name: '@t/linked', dsh: { bundle: { patch: './cordis.patch.yml' } } })) + writeFileSync(join(bundle, 'cordis.patch.yml'), "- insert:\n - id: theme\n name: '@t/theme'\n") + writeFileSync(join(dependency, 'package.json'), JSON.stringify({ name: '@t/theme', dsh: { client: { platform: 'web' } } })) + linked.pkg('@t/theme', {}) + symlinkSync(bundle, join(linked.root, 'app', 'node_modules', '@t', 'linked'), 'junction') + linked.bundle('@t/base', '- insert: []\n') + expect(linked.roster(['@t/base', '@t/linked'])).toEqual(['@t/theme']) + }) + it('applies the layers in order and keeps enabled browser rows once, with their dsh.client declaration', () => { scratch.web('@t/a', { inject: ['@t/b'], immediately: true }) scratch.web('@t/b') @@ -290,3 +305,16 @@ describe('bundleRoster on a scratch installation', () => { expect(() => scratch.roster(['@t/misnamed'])).toThrow('names "@t/real", expected @t/alias') }) }) + + +it('exhausts uneven linked-bundle search paths before reporting a missing plugin', () => { + const scratch = new Scratch() + onTestFinished(() => { rmSync(scratch.root, { recursive: true, force: true }) }) + const bundle = join(scratch.root, 'workspace', 'nested', 'deeper', 'bundle') + mkdirSync(bundle, { recursive: true }) + writeFileSync(join(bundle, 'package.json'), JSON.stringify({ name: '@t/deep', dsh: { bundle: { patch: './cordis.patch.yml' } } })) + writeFileSync(join(bundle, 'cordis.patch.yml'), "- insert:\n - id: missing\n name: '@t/absent'\n") + scratch.bundle('@t/base', '- insert: []\n') + symlinkSync(bundle, join(scratch.root, 'app', 'node_modules', '@t', 'deep'), 'junction') + expect(() => scratch.roster(['@t/base', '@t/deep'])).toThrow('cannot resolve plugin package @t/absent') +}) diff --git a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx index ca9523edbe..f826a11af8 100644 --- a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx +++ b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx @@ -8,7 +8,7 @@ */ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { stubSettingsScope } from '../src/settings-scope.ts' +import { stubConfigForm } from '../src/config-form.ts' import { act, cleanup } from '@testing-library/react' import { useSyncExternalStore } from 'react' import { createSnapshotStore, defineStore } from '@deepseek-ai/dsh-client-store' @@ -912,7 +912,7 @@ describe('single-slot mounting edge arms', () => { describe('stubbed settings scope', () => { it('records both write kinds and publishes a Host acceptance to its listeners', async () => { - const host = stubSettingsScope<{ preference: string }>() + const host = stubConfigForm<{ preference: string }>() let notified = 0 const stop = host.scope.subscribe(() => { notified += 1 }) expect(host.listenerCount()).toBe(1) diff --git a/packages/test-support/client-runtime/tsconfig.json b/packages/test-support/client-runtime/tsconfig.json index d73de457a3..5503b5e71a 100644 --- a/packages/test-support/client-runtime/tsconfig.json +++ b/packages/test-support/client-runtime/tsconfig.json @@ -43,10 +43,10 @@ "path": "../../client/store" }, { - "path": "../../client/ui-chat" + "path": "../../client/ui-chat/tsconfig.client.json" }, { - "path": "../../client/ui-conversation" + "path": "../../client/ui-conversation/tsconfig.client.json" }, { "path": "../../client/ui-layout" @@ -61,7 +61,7 @@ "path": "../../client/ui-session" }, { - "path": "../../client/ui-settings" + "path": "../../client/ui-settings/tsconfig.client.json" }, { "path": "../../subagent/subagent" diff --git a/packages/test-support/loader-smoke/tests/fixtures/production-profile.ts b/packages/test-support/loader-smoke/tests/fixtures/production-profile.ts index 588ff2d443..415dc2c74a 100644 --- a/packages/test-support/loader-smoke/tests/fixtures/production-profile.ts +++ b/packages/test-support/loader-smoke/tests/fixtures/production-profile.ts @@ -4,6 +4,7 @@ import { writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' +import { homedir } from 'node:os' import type { Context } from '@deepseek-ai/cordis' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' @@ -13,6 +14,7 @@ import { loadOverlayPatches, loadProfile, PluginPackages, + type ProfileContext, type ProfileLayer, } from '@deepseek-ai/dsh-app-boot' @@ -98,6 +100,14 @@ export async function bootProductionProfile(options: ProductionProfileOptions): ...overlays.flat(), ], async (ctx) => { + // The launcher's own profile facts, so profile-backed services activate as in production. + const profileContext: ProfileContext = { + name: options.profile, dir: profile.dir, patchPath: profile.patchPath, installAnchor, + cwd: process.cwd(), home: process.env['DSH_HOME'] ?? join(homedir(), '.dsh'), + startedBundles: profile.layers.map(layer => layer.packageName), + overlays: overlays.flat(), telemetryDisabledEnv: process.env['DSH_TELEMETRY_DISABLED'], + } + ctx.provide('profileContext', profileContext) await ctx.plugin(PluginPackages, { resolution }) await options.prepare?.(ctx) }, diff --git a/packages/test-support/remote-mock/tests/proxy.client.spec.ts b/packages/test-support/remote-mock/tests/proxy.client.spec.ts index bef18eb4c8..696ce71dbe 100644 --- a/packages/test-support/remote-mock/tests/proxy.client.spec.ts +++ b/packages/test-support/remote-mock/tests/proxy.client.spec.ts @@ -58,7 +58,7 @@ describe('RemoteMock.remote unary proxies', () => { it('records every business argument while the carrier keeps its signal out of positional args', async () => { const mock = RemoteMock.create() const mutate = mock.remote.settings.mutate - const result = ok({ ns: 'locale', schema: {}, value: { preference: 'en' }, applies: 'live' as const, secrets: [], revision: 8 }) + const result = ok({ ns: 'locale', schema: {}, value: { preference: 'en' }, autoGenerate: true, applies: 'live' as const, secrets: [], revision: 8 }) const ops = [{ op: 'set' as const, path: ['preference'], value: 'en' }] mutate.mockResolvedValue(result) diff --git a/packages/test-support/session-snapshot/tests/fixtures/subagent-activation-limit.ts b/packages/test-support/session-snapshot/tests/fixtures/subagent-activation-limit.ts index 34c40de83d..4e05ddd997 100644 --- a/packages/test-support/session-snapshot/tests/fixtures/subagent-activation-limit.ts +++ b/packages/test-support/session-snapshot/tests/fixtures/subagent-activation-limit.ts @@ -2,7 +2,7 @@ import type { Context } from '@deepseek-ai/cordis' export const name = 'subagent-activation-limit' -export const inject = ['agents', 'settings', 'subagents'] +export const inject = ['agents', 'loader', 'subagents'] /** Order parent admission and child completion without elapsed-time assumptions. */ export function apply(ctx: Context): void { @@ -13,7 +13,11 @@ export function apply(ctx: Context): void { }) ctx.on('agent/pre-step', async ({ agent }, next) => { if (agent.session.header.parentSession !== undefined) await parentClosed.promise - else await ctx.settings.update('subagent', { maxActiveSubagents: 1 }) + else { + const entry = [...ctx.loader.entries()].find(entry => entry.options.id === 'subagent') + if (entry === undefined) throw new Error('Missing subagent fixture entry') + await entry.update({ config: { maxActiveSubagents: 1 } }) + } return next() }) } diff --git a/packages/util/atomic-write/README.i18n.yaml b/packages/util/atomic-write/README.i18n.yaml index af44e02999..31a0a8a4b9 100644 --- a/packages/util/atomic-write/README.i18n.yaml +++ b/packages/util/atomic-write/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/atomic-write/README.md -README.md: 89770a6d318da778e9dcb7a462dc7754244a0e41 -README.zh.md: a64771ae1266f132254c82aaa71f193dbf33cd06 +README.md: fa2a77c8ba11e1dd80fecef6da09fa32a608b523 +README.zh.md: 020d379c876733d5de11c63887de8d3168eac3b3 diff --git a/packages/util/atomic-write/README.md b/packages/util/atomic-write/README.md index 89770a6d31..fa2a77c8ba 100644 --- a/packages/util/atomic-write/README.md +++ b/packages/util/atomic-write/README.md @@ -33,7 +33,7 @@ Use `writeFileAtomic` when a file-backed store must replace one already-rendered import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' declare const text: string -await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 }) +await writeFileAtomic('/home/u/.dsh/cordis.patch.yml', text, { mode: 0o600 }) ``` Parent directories are created as needed, and readers observe either the old or the new complete content. On Windows, transient replacement interference reported as `EACCES`, `EBUSY`, or `EPERM` is retried for a bounded interval; any remaining failure removes the temporary file and leaves the target untouched. @@ -48,9 +48,9 @@ import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' declare const render: (previous: string) => string declare const readCurrent: () => Promise -await withFileLock('/home/u/.dsh/settings.yaml', async () => { +await withFileLock('/home/u/.dsh/cordis.patch.yml', async () => { const previous = await readCurrent() - await writeFileAtomic('/home/u/.dsh/settings.yaml', render(previous), { mode: 0o600 }) + await writeFileAtomic('/home/u/.dsh/cordis.patch.yml', render(previous), { mode: 0o600 }) }) ``` @@ -100,7 +100,7 @@ The package is built on one separation: the atomic commit owns the swap, and the Read these pages when you need the consuming stores or the family this primitive belongs to. -- [User-settings file store](../../settings/settings-file/README.md) — the settings document every write replaces through this package. +- [Profile configuration editor](../../boot/config-editor/README.md) — the profile patch every edit replaces through this package. - [Credentials store](../../credentials/credentials-local/README.md) — the credentials file this package locks and replaces. - [util group map](../README.md) — the zero-dependency utility family this package belongs to. diff --git a/packages/util/atomic-write/README.zh.md b/packages/util/atomic-write/README.zh.md index a64771ae12..020d379c87 100644 --- a/packages/util/atomic-write/README.zh.md +++ b/packages/util/atomic-write/README.zh.md @@ -33,7 +33,7 @@ kind: "package-library" import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' declare const text: string -await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 }) +await writeFileAtomic('/home/u/.dsh/cordis.patch.yml', text, { mode: 0o600 }) ``` 父目录会按需创建,读取方只会观察到旧内容或完整的新内容。在 Windows 上,报告为 `EACCES`、`EBUSY` 或 `EPERM` 的瞬时替换干扰会在有界时间内重试;任何剩余失败都会移除临时文件,并保持目标文件不变。 @@ -48,9 +48,9 @@ import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' declare const render: (previous: string) => string declare const readCurrent: () => Promise -await withFileLock('/home/u/.dsh/settings.yaml', async () => { +await withFileLock('/home/u/.dsh/cordis.patch.yml', async () => { const previous = await readCurrent() - await writeFileAtomic('/home/u/.dsh/settings.yaml', render(previous), { mode: 0o600 }) + await writeFileAtomic('/home/u/.dsh/cordis.patch.yml', render(previous), { mode: 0o600 }) }) ``` @@ -100,7 +100,7 @@ Windows 在无法观察到锁时会对 `EPERM` 重试一次,因为持锁方可 当你需要了解使用本原语的存储或它所属的工具家族时,阅读以下页面。 -- [用户设置文件存储](../../settings/settings-file/README.zh.md)——每次写入都通过本包替换的设置文档。 +- [用户设置文件存储](../../boot/config-editor/README.zh.md)——每次写入都通过本包替换的设置文档。 - [凭据存储](../../credentials/credentials-local/README.zh.md)——本包加锁并替换的凭据文件。 - [util 组映射](../README.zh.md)——本包所属的零依赖工具家族。 diff --git a/packages/web/web-search-deepseek/README.i18n.yaml b/packages/web/web-search-deepseek/README.i18n.yaml index f0d546d923..78947cae70 100644 --- a/packages/web/web-search-deepseek/README.i18n.yaml +++ b/packages/web/web-search-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/web/web-search-deepseek/README.md -README.md: 0617204fdddb9273f75bd9ad4028026c7ffc8060 -README.zh.md: b45b449f2d379f730a6a4f341e8359ed28aaeb0b +README.md: 6ac35d899ebfc281a3d830bd6050be46309f4ec9 +README.zh.md: 7693a7470872b59065e66cdf2d71e41bb9a1e016 diff --git a/packages/web/web-search-deepseek/README.md b/packages/web/web-search-deepseek/README.md index 0617204fdd..6ac35d899e 100644 --- a/packages/web/web-search-deepseek/README.md +++ b/packages/web/web-search-deepseek/README.md @@ -53,7 +53,7 @@ Load the web service and the provider; the key resolves from `ctx.credentials` w | `maxTokens` | `4096` | Positive-integer upper bound on generated tokens for the Messages request | | `maxUses` | `5` | Positive-integer maximum `web_search` server-tool uses per request | -The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web-search-deepseek) is the exhaustive source for every accepted field and its JSDoc. The entry above is the base layer of the provider's Settings section; a user layer over it reaches the next search, because the provider projects the section per call rather than capturing it at registration. +The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web-search-deepseek) lists every accepted field. Each search captures options from the live Config references. ### What a search returns @@ -88,14 +88,14 @@ The provider is built on two commitments: | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | Plugin entry: config schema, Settings section installation, per-search option projection | +| [`src/index.ts`](src/index.ts) | Config schema and per-search option capture | | [`src/provider.ts`](src/provider.ts) | The `DeepSeekSearchProvider`: Messages request dispatch, block parsing, citation joining, credential resolution | | [`src/types.ts`](src/types.ts) | Anthropic wire types for the search response | | — | No runtime invariant companion is published; the package emits a pre-dispatch log event but owns no later authoritative dispatch event to relate it to. Exact envelope equality is pinned at the provider boundary instead. | ### Request flow -Each search projects the current Settings section into provider options — endpoint, model, key reference, limits — then resolves the credential reference through `ctx.credentials` (or the environment), appends the log-only session event, and dispatches the Messages request with the native `web_search` server tool. The response's `web_search_tool_result` blocks become `sources[]`; `cited_text` entries from text blocks are joined to their URLs as snippets; results are deduplicated by URL; and the service enforces the requested source bound on the way back. +Each search captures the current Config values into provider options — endpoint, model, key reference, limits — then resolves the credential reference through `ctx.credentials` (or the environment), appends the log-only session event, and dispatches the Messages request with the native `web_search` server tool. The response's `web_search_tool_result` blocks become `sources[]`; `cited_text` entries from text blocks are joined to their URLs as snippets; results are deduplicated by URL; and the service enforces the requested source bound on the way back. diff --git a/packages/web/web-search-deepseek/README.zh.md b/packages/web/web-search-deepseek/README.zh.md index b45b449f2d..7693a74708 100644 --- a/packages/web/web-search-deepseek/README.zh.md +++ b/packages/web/web-search-deepseek/README.zh.md @@ -53,7 +53,7 @@ kind: "package-reference" | `maxTokens` | `4096` | Messages 请求生成 token 的正整数上限 | | `maxUses` | `5` | 每次请求使用 `web_search` 服务器工具的正整数上限 | -生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web-search-deepseek)是每个受支持字段及其 JSDoc 的穷尽式真源。上面的条目是提供方 Settings 段的 base 层;叠加其上的用户层会作用于下一次搜索,因为提供方是按次投影该段,而不是在注册时固化它。 +生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web-search-deepseek) 列出所有接受的字段。每次搜索从即时 Config 引用捕获选项。 ### 搜索返回什么 @@ -88,14 +88,14 @@ kind: "package-reference" | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | 插件入口:配置 schema、Settings 段安装、逐次选项投影 | +| [`src/index.ts`](src/index.ts) | Config schema 与每次搜索的选项捕获 | | [`src/provider.ts`](src/provider.ts) | `DeepSeekSearchProvider`:Messages 请求分发、块解析、引用拼接、凭据解析 | | [`src/types.ts`](src/types.ts) | 搜索响应的 Anthropic 协议类型 | | — | 不发布运行时不变量配套入口;本包会在分发前发出日志事件,但没有后续的权威分发事件可与之关联;精确的请求包络相等性改由提供方边界保障。 | ### 请求流程 -每次搜索先把当前 Settings 段投影为提供方选项——端点、模型、密钥引用、上限——然后通过 `ctx.credentials`(或环境)解析凭据引用,追加仅用于日志的会话事件,并以原生 `web_search` 服务器工具分发 Messages 请求。响应中的 `web_search_tool_result` 块变为 `sources[]`;文本块中的 `cited_text` 条目按其 URL 拼接为 snippet;结果按 URL 去重;服务在返回路径上强制执行请求的来源上限。 +每次搜索先把当前 Config 段投影为提供方选项——端点、模型、密钥引用、上限——然后通过 `ctx.credentials`(或环境)解析凭据引用,追加仅用于日志的会话事件,并以原生 `web_search` 服务器工具分发 Messages 请求。响应中的 `web_search_tool_result` 块变为 `sources[]`;文本块中的 `cited_text` 条目按其 URL 拼接为 snippet;结果按 URL 去重;服务在返回路径上强制执行请求的来源上限。 diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index c6cbc0b1e5..f2af653622 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -32,8 +32,7 @@ "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^" @@ -46,7 +45,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-http-proxy": "workspace:^" } } diff --git a/packages/web/web-search-deepseek/src/index.ts b/packages/web/web-search-deepseek/src/index.ts index c6c82c6d4c..f404a404ad 100644 --- a/packages/web/web-search-deepseek/src/index.ts +++ b/packages/web/web-search-deepseek/src/index.ts @@ -4,12 +4,12 @@ * `DEEPSEEK_BASE_URL`; auxiliary search has its own endpoint configuration. * @module @deepseek-ai/dsh-web-search-deepseek */ +import type { Volatile } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-agent' import { credentialRef } from '@deepseek-ai/dsh-credentials' -import type {} from '@deepseek-ai/dsh-settings' import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' import type {} from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-web' @@ -45,32 +45,32 @@ const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY' /** Plugin config (all optional — `apply` fills env-var and constant defaults). */ export interface Config { /** Literal DeepSeek API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */ - apiKey?: string + apiKey: Volatile /** Credential reference resolved for each search; defaults to `DEEPSEEK_API_KEY`. */ - apiKeyEnv?: string + apiKeyEnv: Volatile /** Anthropic-compatible endpoint base; `/messages` is appended. */ - baseURL?: string + baseURL: Volatile /** Anthropic-format model name. Defaults to `deepseek-v4-flash`. */ - model?: string + model: Volatile /** `anthropic-version` header value. Defaults to `2023-06-01`. */ - apiVersion?: string + apiVersion: Volatile /** Upper bound on generated tokens for the Messages request. Defaults to 4096. */ - maxTokens?: number + maxTokens: Volatile /** Maximum `web_search` server-tool uses per request. Defaults to 5. */ - maxUses?: number + maxUses: Volatile } -export const Config: z = z.object({ - apiKey: z.string().role('secret'), - apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV), +export const Config = z.object({ + apiKey: z.string().role('secret').volatile(), + apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV).volatile(), // Declared here rather than only at the use site: a configuration surface // renders the resolved section, so a default the schema does not carry reads // there as no value at all. - baseURL: z.string(), - model: z.string().default(DEEPSEEK_DEFAULT_MODEL), - apiVersion: z.string().default(DEEPSEEK_DEFAULT_API_VERSION), - maxTokens: z.number().step(1).min(1).default(DEEPSEEK_DEFAULT_MAX_TOKENS), - maxUses: z.number().step(1).min(1).default(DEEPSEEK_DEFAULT_MAX_USES), + baseURL: z.string().volatile(), + model: z.string().default(DEEPSEEK_DEFAULT_MODEL).volatile(), + apiVersion: z.string().default(DEEPSEEK_DEFAULT_API_VERSION).volatile(), + maxTokens: z.number().step(1).min(1).default(DEEPSEEK_DEFAULT_MAX_TOKENS).volatile(), + maxUses: z.number().step(1).min(1).default(DEEPSEEK_DEFAULT_MAX_USES).volatile(), }) /** @@ -90,8 +90,10 @@ export const WEB_SEARCH_DEEPSEEK_SETTINGS_NAMESPACE = 'web-search-deepseek' * @param config - the currently authoritative section. * @returns options for one search. */ -function resolveOptions(ctx: Context, config: Config): DeepSeekSearchProviderOptions { - const apiKeyEnv = credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV) +function resolveOptions( + ctx: Context, config: { [K in keyof Config]: ReturnType }, +): DeepSeekSearchProviderOptions { + const apiKeyEnv = credentialRef(config.apiKeyEnv) const literalApiKey = config.apiKey !== undefined && config.apiKey.length > 0 ? config.apiKey : undefined @@ -108,10 +110,10 @@ function resolveOptions(ctx: Context, config: Config): DeepSeekSearchProviderOpt baseURL: config.baseURL ?? launchEnvironmentOf(ctx).get(SEARCH_BASE_URL_ENV)?.value ?? DEEPSEEK_DEFAULT_BASE_URL, - model: config.model ?? DEEPSEEK_DEFAULT_MODEL, - apiVersion: config.apiVersion ?? DEEPSEEK_DEFAULT_API_VERSION, - maxTokens: config.maxTokens ?? DEEPSEEK_DEFAULT_MAX_TOKENS, - maxUses: config.maxUses ?? DEEPSEEK_DEFAULT_MAX_USES, + model: config.model, + apiVersion: config.apiVersion, + maxTokens: config.maxTokens, + maxUses: config.maxUses, recordRequest: (request) => { ctx.get('agents')?.currentInitiator()?.session.append( 'web/deepseek-search-llm-request', @@ -123,16 +125,8 @@ function resolveOptions(ctx: Context, config: Config): DeepSeekSearchProviderOpt /** Register the DeepSeek search provider with `ctx.web`. */ export function apply(ctx: Context, config: Config): void { - let current: () => Config = () => config - ctx.inject(['settings'], (settingsCtx) => { - settingsCtx.settings.installSection(ctx, WEB_SEARCH_DEEPSEEK_SETTINGS_NAMESPACE, Config, config, { - setSource: (source) => { - current = source - }, - // The registration carries no resolved value: the provider projects the - // section per search, so a committed change needs no re-registration. - onChange: () => {}, - }) - }) - ctx.web.registerSearchProvider(new DeepSeekSearchProvider(() => resolveOptions(ctx, current()))) + ctx.web.registerSearchProvider(new DeepSeekSearchProvider(() => resolveOptions(ctx, { + apiKey: config.apiKey.get(), apiKeyEnv: config.apiKeyEnv.get(), baseURL: config.baseURL.get(), model: config.model.get(), + apiVersion: config.apiVersion.get(), maxTokens: config.maxTokens.get(), maxUses: config.maxUses.get(), + }))) } diff --git a/packages/web/web-search-deepseek/tests/deepseek.spec.ts b/packages/web/web-search-deepseek/tests/deepseek.spec.ts index 2bed3147a1..77654ea851 100644 --- a/packages/web/web-search-deepseek/tests/deepseek.spec.ts +++ b/packages/web/web-search-deepseek/tests/deepseek.spec.ts @@ -489,7 +489,7 @@ describe('web-search-deepseek plugin registration', () => { vi.stubGlobal('fetch', fetchMock) const ctx = new Context() await ctx.plugin(WebRuntime, { searchProvider: DEEPSEEK_PROVIDER_ID }) - deepseekPlugin.apply(ctx, {}) + deepseekPlugin.apply(ctx, deepseekPlugin.Config({})) await ctx.web.search({ query: 'q' }) const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] expect(url).toBe('https://api.deepseek.com/anthropic/v1/messages') diff --git a/packages/web/web-search-deepseek/tests/settings.spec.ts b/packages/web/web-search-deepseek/tests/settings.spec.ts index b39fe966ec..d47aa60d67 100644 --- a/packages/web/web-search-deepseek/tests/settings.spec.ts +++ b/packages/web/web-search-deepseek/tests/settings.spec.ts @@ -2,30 +2,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import type { Fiber } from '@deepseek-ai/cordis' -import { SettingsProvider } from '@deepseek-ai/dsh-settings' -import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' +import { liveConfig } from '../../../settings/settings/tests/live-config.ts' import WebRuntime from '@deepseek-ai/dsh-web' import * as deepseekPlugin from '@deepseek-ai/dsh-web-search-deepseek' -import { WEB_SEARCH_DEEPSEEK_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-web-search-deepseek' - -/** The smallest real provider: one in-memory document, always writable. */ -class MemorySettings extends SettingsProvider { - doc: Record = {} - - get writable(): boolean { - return true - } - - protected load(): Promise> { - return Promise.resolve(structuredClone(this.doc)) - } - - protected persist(ns: SettingsNamespace, section: Record): Promise { - this.doc = { ...this.doc, [ns]: structuredClone(section) } - return Promise.resolve() - } -} function jsonResponse(body: unknown): Response { return new Response(JSON.stringify(body), { @@ -45,14 +24,12 @@ const ONE_RESULT = { ], } -async function boot(): Promise<{ ctx: Context; settingsFiber: Fiber; pluginFiber: Fiber }> { +async function boot() { const ctx = new Context() await ctx.plugin(WebRuntime, {}) - const settingsFiber = ctx.plugin(MemorySettings) - await settingsFiber.await() - const pluginFiber = ctx.plugin(deepseekPlugin, { apiKey: 'ds-key', baseURL: 'https://search.entry.test/v1' }) - await pluginFiber.await() - return { ctx, settingsFiber, pluginFiber } + const live = await liveConfig(ctx, deepseekPlugin, { apiKey: 'ds-key', baseURL: 'https://search.entry.test/v1' }) + return { ctx, live } + } afterEach(() => { @@ -79,7 +56,7 @@ describe('web-search-deepseek settings section', () => { const bench = await boot() expect(await searchOnce(bench.ctx)).toContain('https://search.entry.test/v1') - await bench.ctx.settings.update(WEB_SEARCH_DEEPSEEK_SETTINGS_NAMESPACE, { + await bench.live.update({ baseURL: 'https://search.stored.test/v1', }) @@ -87,38 +64,4 @@ describe('web-search-deepseek settings section', () => { await bench.ctx.fiber.dispose() }) - it('keeps the literal key out of every described layer', async () => { - const bench = await boot() - await bench.ctx.settings.update(WEB_SEARCH_DEEPSEEK_SETTINGS_NAMESPACE, { apiKey: 'ds-stored-secret' }) - - const [descriptor] = bench.ctx.settings.describe({ redactSecrets: true }) - .filter(row => String(row.ns) === 'web-search-deepseek') - - expect(JSON.stringify(descriptor)).not.toContain('ds-stored-secret') - expect(descriptor?.secrets).toEqual([{ path: ['apiKey'], set: true }]) - await bench.ctx.fiber.dispose() - }) - - it('falls back to the composition entry when the settings provider detaches', async () => { - const bench = await boot() - await bench.ctx.settings.update(WEB_SEARCH_DEEPSEEK_SETTINGS_NAMESPACE, { - baseURL: 'https://search.stored.test/v1', - }) - expect(await searchOnce(bench.ctx)).toContain('https://search.stored.test/v1') - - await bench.settingsFiber.dispose() - - expect(await searchOnce(bench.ctx)).toContain('https://search.entry.test/v1') - await bench.ctx.fiber.dispose() - }) - - it('releases the namespace when the plugin unloads', async () => { - const bench = await boot() - expect(bench.ctx.settings.describe().map(row => String(row.ns))).toContain('web-search-deepseek') - - await bench.pluginFiber.dispose() - - expect(bench.ctx.settings.describe().map(row => String(row.ns))).not.toContain('web-search-deepseek') - await bench.ctx.fiber.dispose() - }) }) diff --git a/packages/web/web-search-deepseek/tsconfig.json b/packages/web/web-search-deepseek/tsconfig.json index 0d41c51e4d..998420ec9e 100644 --- a/packages/web/web-search-deepseek/tsconfig.json +++ b/packages/web/web-search-deepseek/tsconfig.json @@ -32,9 +32,6 @@ { "path": "../../credentials/credentials" }, - { - "path": "../../settings/settings" - }, { "path": "../../util/http-proxy" } diff --git a/packages/workspace/workspace/README.i18n.yaml b/packages/workspace/workspace/README.i18n.yaml index 7f50ab3f55..74ba415914 100644 --- a/packages/workspace/workspace/README.i18n.yaml +++ b/packages/workspace/workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/workspace/workspace/README.md -README.md: 897aca8eb725bc9c3e0808e394e5c976886dd877 -README.zh.md: dc7a81340c9b134110975e700d43d5569fa6f03d +README.md: 59b314a7302c5cf83314d08f6e8bdfe2dccba37f +README.zh.md: 78d563ee59048d5b1b6f976ac26f64907174645d diff --git a/packages/workspace/workspace/README.md b/packages/workspace/workspace/README.md index 897aca8eb7..59b314a730 100644 --- a/packages/workspace/workspace/README.md +++ b/packages/workspace/workspace/README.md @@ -74,7 +74,7 @@ A session joins the project of the directory it runs in: create a session in a p ### Hiding and restoring sessions, and removing projects -Hide a session from the grouping when it should stop appearing there: it disappears from the visible list, while its session, history, and place in the project stay intact. Restore a hidden session when it should appear again: it returns to its recorded position under its project, or to the ungrouped sessions when it belongs to none. Remove a project when it is no longer needed: it leaves the list, and its folder, files, and session histories are never touched — those sessions become ungrouped. Adding the same directory again afterwards starts a fresh project without the old sessions. +Hide a session from the grouping when it should stop appearing there: it disappears from the visible list, while its session, history, and place in the project stay intact. A session with running work — its own turn, a running subagent, a background job, or an active reminder — is not hidden underneath that work: the registry refuses with the list of what runs, and a caller that asks to stop the work first has it stopped the way the user's own stop actions do, then hidden. Restore a hidden session when it should appear again: it returns to its recorded position under its project, or to the ungrouped sessions when it belongs to none, and continues the conversation from a regularly ended log. Remove a project when it is no longer needed: it leaves the list, and its folder, files, and session histories are never touched — those sessions become ungrouped. Adding the same directory again afterwards starts a fresh project without the old sessions. ----- @@ -98,6 +98,8 @@ This section explains the design decisions behind the feature and points at the The API has two owners: `WorkspaceRegistry` creates, orders, and deletes projects, manages their Session accounting, and pins, unpins, archives, or restores Sessions; the `Workspace` entity exposes the display title, directory status, and Session projection. Pinning requires a known, unarchived Session; archiving clears its pin in the same durable write, and restoring does not restore that pin. Per-method contracts live in [src/index.ts](src/index.ts) and [src/entity.ts](src/entity.ts). +Archive admission is a capability seam over two Host events this package declares and dispatches: `workspace/session-activity` (waterfall) asks the composed providers what still runs for a Session, and `workspace/session-stop` (parallel) asks them to stop it. `archiveSession(sessionId)` asks the activity waterfall once and rejects a non-empty answer with `WorkspaceActiveSessionError`, whose `activity` lists each family with its items — the keys are the providers' own, merged into `SessionActivityKindMap`, which this package leaves empty; `archiveSession(sessionId, { stopActivity: true })` skips the activity check, writes the archive, and then dispatches the stop event, so the durable archive set already gates every wake the stops induce; a rejecting provider is logged and the archive stays. The call resolves once every provider's stop request was issued, while the stopped work settles on its own. Both questions come after the existence check and never for an already archived id. The shipped providers are the Agent registry (the running turn), the job registry seam (owned jobs), the Subagent runtime (running descendants), and the Schedule plugin (active reminders); a composition without providers archives freely. + ### Source map | File | Role | @@ -170,6 +172,7 @@ These limits define when the project list is a poor fit or needs special operati - **A session joins only with a recorded directory** — a session belongs to a project only when its record carries a directory that resolves to the project's path; sessions without one stay ungrouped, and a session from another directory cannot be moved in. - **External changes are seen late** — if another process deletes or damages a directory, the project reflects it only at the next refresh or restart. - **Archive and unarchive enforce different session checks** — a restore only drops an id from the archive set, so an entry whose session is gone still unarchives and leaves no unknown referent; a restore of an id that is not archived resolves without writing, while `archiveSession` rejects a session that is neither live nor persisted. +- **The activity check and the archive write are not one atomic step** — a turn that starts between the providers' answer and the durable write is hidden while running, and every model step whose `agent/pre-step` precedes the write still runs with its tool calls; the API Session Controller's gate ends the first step proposed after the write as `blocked`, so the exposure is bounded by that write's latency, in practice one model step. - **Re-adding a directory starts fresh** — after removal, adding the same directory again creates a new project with an empty session list; the old sessions do not come back automatically. diff --git a/packages/workspace/workspace/README.zh.md b/packages/workspace/workspace/README.zh.md index dc7a81340c..78d563ee59 100644 --- a/packages/workspace/workspace/README.zh.md +++ b/packages/workspace/workspace/README.zh.md @@ -74,7 +74,7 @@ ctx.workspaceRegistry.list() // shows the project, newest first ### 隐藏、恢复会话与移除项目 -当会话不应再出现在分组中时隐藏它:它会从可见列表中消失,但其会话、历史与在项目中的位置都保持不变。当被隐藏的会话应重新出现时恢复它:它会回到其项目下记录的位置;不属于任何项目时则回到 Ungrouped。项目不再需要时移除它:它离开列表,而其文件夹、文件与会话历史绝不受影响——这些会话变成 Ungrouped。之后再次添加同一目录会从空项目开始,不会带回旧会话。 +当会话不应再出现在分组中时隐藏它:它会从可见列表中消失,但其会话、历史与在项目中的位置都保持不变。仍有工作在跑的会话——它自己的回合、运行中的子代理、后台任务或活跃提醒——不会被藏在这些工作之下:注册表会拒绝并列出还在跑的内容;调用方若要求先停止这些工作,注册表会按用户自己的停止操作同样的方式停掉它们,然后再隐藏。当被隐藏的会话应重新出现时恢复它:它会回到其项目下记录的位置;不属于任何项目时则回到 Ungrouped,并从一份正常收尾的日志继续对话。项目不再需要时移除它:它离开列表,而其文件夹、文件与会话历史绝不受影响——这些会话变成 Ungrouped。之后再次添加同一目录会从空项目开始,不会带回旧会话。 ----- @@ -94,10 +94,13 @@ ctx.workspaceRegistry.list() // shows the project, newest first - **两次写入的变更带显式标记。** 创建与删除在记录/顺序对可能分叉之前先持久化 `pendingMutation` 标记,因此启动只补全被中断的操作,未标记的分叉作为损坏明确报错。 - **串行化写入。** 注册表操作跑在同一条操作链上;实体变更通过领域写链上的 `table.update` 执行,写入 `updatedAt`,并在其所在的链位置决定成员资格。 + ### API 行为 该 API 由两个对象负责:`WorkspaceRegistry` 创建、排序与删除项目,管理会话记账,并置顶、取消置顶、归档或恢复会话;`Workspace` 实体暴露显示标题、目录状态与会话投影。置顶要求会话已知且未归档;归档在同一次持久化写入中清除置顶,恢复会话不会恢复置顶。各方法的精确约定见 [src/index.ts](src/index.ts) 与 [src/entity.ts](src/entity.ts)。 +归档准入是本包声明并派发的两个宿主事件之上的能力接缝:`workspace/session-activity`(waterfall)向已组合的提供方询问某会话还有什么在跑,`workspace/session-stop`(parallel)请它们停止这些工作。`archiveSession(sessionId)` 只询问一次活动 waterfall,对非空答案以 `WorkspaceActiveSessionError` 拒绝,其 `activity` 按族列出各项——键由各提供方自己合并进本包留空的 `SessionActivityKindMap`;`archiveSession(sessionId, { stopActivity: true })` 跳过活动检查,先写入归档,再派发停止事件,因此持久化的归档集合已经拦住停止所引发的每一次唤醒;提供方抛错只记日志,归档保留。调用在每个提供方的停止请求都已发出后返回,被停止的工作自行收敛。两种询问都在存在性检查之后进行,对已归档 id 从不发生。随附的提供方是 Agent 注册表(运行中的回合)、任务注册表接缝(所属任务)、Subagent runtime(运行中的子孙)与 Schedule 插件(活跃提醒);没有提供方的组合可自由归档。 + ### 源码地图 | 文件 | 职责 | @@ -170,6 +173,7 @@ ctx.workspaceRegistry.list() // shows the project, newest first - **只有带记录目录的会话才能加入**——只有记录中带有可解析为项目路径的目录的会话才属于项目;没有目录的会话保持 Ungrouped,来自其他目录的会话无法移入。 - **外部变更延迟可见**——如果另一进程删除或损坏目录,项目只能在下次刷新或重启后反映出来。 - **归档与取消归档执行不同的会话校验**——恢复只是从归档集合中移除 id,因此会话已不存在的条目仍能取消归档,也不会留下未知引用;对未归档 id 执行恢复不写盘即完成,而 `archiveSession` 会拒绝既非实时也未持久化的会话。 +- **活动检查与归档写入不是一个原子步骤**——在提供方作答与持久化写入之间开始的回合会在隐藏状态下运行,`agent/pre-step` 先于该写入的每个模型步连同其工具调用照常执行;API Session Controller 的门禁把写入之后提出的第一步以 `blocked` 收口,因此暴露面以该写入的时延为界,实际上是一个模型步。 - **重新添加目录从空开始**——移除后再次添加同一目录会创建空会话列表的新项目;旧会话不会自动回来。 diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index e2f67d948a..00eaf0030c 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -18,9 +18,11 @@ export { WorkspaceMoveInvalidError } from './entity.ts' import { defaultWorkspaceTitle, fullyQualifiedWorkspacePath, realpathNormalize } from './paths.ts' import { workspaceDomainSpec } from './spec.ts' import type { WorkspaceDomainState, WorkspaceRecord } from './spec.ts' -import type { Workspace, WorkspaceId as WorkspaceIdBrand } from './types.ts' +import type { SessionActivity, Workspace, WorkspaceId as WorkspaceIdBrand } from './types.ts' -export type { Workspace } from './types.ts' +export type { + SessionActivity, SessionActivityItem, SessionActivityKind, SessionActivityKindMap, Workspace, +} from './types.ts' export { workspaceDomainState, workspaceRecord, workspaceDomainSpec } from './spec.ts' export type { WorkspaceDomainState, WorkspaceRecord } from './spec.ts' export { realpathNormalize } from './paths.ts' @@ -53,6 +55,22 @@ export class WorkspaceUnknownSessionError extends Error { } } +/** + * An archiveSession request named a session that at least one + * `workspace/session-activity` listener reported active. Nothing was written; + * `activity` names what must stop before the session can be archived. + */ +export class WorkspaceActiveSessionError extends Error { + /** + * @param sessionId - The active session id. + * @param activity - The reported activity, in listener order. + */ + constructor(readonly sessionId: SessionId, readonly activity: readonly SessionActivity[]) { + super(`cannot archive session '${sessionId}': the session is active (${activity.map(entry => entry.kind).join(', ')})`) + this.name = 'WorkspaceActiveSessionError' + } +} + /** A pinSession request named a session currently in the archive set; pinning and archival are mutually exclusive. */ export class WorkspaceArchivedSessionPinError extends Error { /** @@ -76,10 +94,58 @@ export class WorkspaceOrderInvalidError extends Error { } +/** The session an archive request is about to write into the archive set. */ +export interface SessionActivityRequest { + readonly sessionId: SessionId +} + +/** Caller choices for {@link WorkspaceRegistry.archiveSession}. */ +export interface ArchiveSessionOptions { + /** + * Ask the composed providers to stop the session's running work instead of + * refusing the archive because of it. The archive is written first, then + * the stops are requested; running work is never awaited to settlement, and + * a provider failure is logged without undoing the archive. + */ + readonly stopActivity?: boolean +} + declare module '@deepseek-ai/cordis' { interface Context { workspaceRegistry: WorkspaceRegistry } + + interface Events { + /** + * Ask the composed providers what still runs for a session before it is + * archived. A listener prepends its own {@link SessionActivity} entries to + * the result of `next()`; the registry's innermost callback returns an + * empty list, so a composition without providers archives freely. Any + * non-empty result refuses the archive without a write. + * @param request - the session about to be archived. + * @param next - delegate to the remaining providers. + * @mode waterfall + */ + 'workspace/session-activity'( + request: SessionActivityRequest, + next: () => Promise, + ): Promise + /** + * Stop a session's running work because the caller archived it with + * `stopActivity`; the archive set is durable when this dispatches. Each + * provider stops its own families — cancelling a turn, its subagent + * descendants, owned jobs, or active schedules — through the same cancel + * paths the user's own stop actions use, so the session log ends every + * open turn regularly and a later unarchive can continue the + * conversation. Listeners issue their stop requests without waiting for + * running work to settle; a listener may await its own durability + * barrier. A rejection is logged by the registry and does not undo the + * archive. + * @param request - the session being archived. + * @mode parallel + */ + 'workspace/session-stop'(request: SessionActivityRequest): Promise | void + } } interface BootstrapGroup { @@ -278,13 +344,21 @@ export class WorkspaceRegistry extends Service { /** * Archive one session durably. The session must exist (live or in session * persistence); its workspace accounting — or lack of one — is irrelevant. - * Archiving drops the session's pin in the same durable write (pinning and - * archival are mutually exclusive). An already archived id resolves without - * writing. + * Without `stopActivity` the session must also be inactive: the + * `workspace/session-activity` waterfall is asked once, and any reported + * activity rejects with {@link WorkspaceActiveSessionError} before anything + * is written. With `stopActivity` the archive is written without an + * activity check, and the `workspace/session-stop` providers are then asked + * to stop the session's work: the durable archive set is what a provider's + * `agent/pre-step` gate reads, so every wake the stops induce is already + * blocked. Archiving drops the session's pin in the same durable write + * (pinning and archival are mutually exclusive). An already archived id + * resolves without writing, asking, or stopping. * @param sessionId - The session to archive. - * @returns resolution after durability. + * @param options - Whether running work is stopped instead of refusing. + * @returns resolution after durability and, with `stopActivity`, after every stop request was issued. */ - archiveSession(sessionId: SessionId): Promise { + archiveSession(sessionId: SessionId, options: ArchiveSessionOptions = {}): Promise { return this.enqueueOperation(async () => { // The chain slot serializes against every other registry write, so this // check-then-write pair cannot interleave with another archive. @@ -292,12 +366,19 @@ export class WorkspaceRegistry extends Service { if (!(await this.sessionKnown(sessionId))) { throw new WorkspaceUnknownSessionError(sessionId, 'archive') } + if (options.stopActivity !== true) { + const activity = await this.ctx.waterfall( + 'workspace/session-activity', { sessionId }, () => Promise.resolve([]), + ) + if (activity.length > 0) throw new WorkspaceActiveSessionError(sessionId, activity) + } const state = this.requireState() await this.setState({ ...state, archivedSessionIds: [...state.archivedSessionIds, sessionId], pinnedSessionIds: state.pinnedSessionIds.filter(id => id !== sessionId), }) + if (options.stopActivity === true) await this.stopSessionActivity(sessionId) }) } @@ -393,6 +474,20 @@ export class WorkspaceRegistry extends Service { return this.headers.has(id) } + /** Request every provider's stop; a failing provider is logged, never a reason to keep the session visible. */ + private async stopSessionActivity(sessionId: SessionId): Promise { + try { + await this.ctx.parallel('workspace/session-stop', { sessionId }) + } catch (error: unknown) { + // ctx.parallel settles every listener and rejects with one AggregateError. + /* v8 ignore next -- the plain arm guards a rethrowing dispatcher. */ + const failures = error instanceof AggregateError ? error.errors : [error] + for (const failure of failures) { + this.ctx.logger.warn(`workspace: stopping session '${sessionId}' for archive failed: ${String(failure)}`) + } + } + } + /** * Resolve by canonical directory path without creating or mutating a * workspace. A missing path rejects during `realpath`; an existing unowned diff --git a/packages/workspace/workspace/src/types.ts b/packages/workspace/workspace/src/types.ts index 6343d37f57..6355aee02c 100644 --- a/packages/workspace/workspace/src/types.ts +++ b/packages/workspace/workspace/src/types.ts @@ -22,6 +22,38 @@ declare module '@deepseek-ai/dsh-typert-protocol' { } } +/** + * Activity families a `workspace/session-activity` listener may report. This + * package declares none: each provider merges its own key from a module both + * its Host and Client faces import, so a consumer that renders the families + * sees exactly the keys its program compiled and falls through to a generic + * description for any other. The shipped providers merge `turn` (the Agent + * registry), `job` (the job registry seam), `subagent` (the Subagent + * runtime), and `schedule` (the Schedule plugin). + */ +export interface SessionActivityKindMap {} + +/** One activity family key. */ +export type SessionActivityKind = keyof SessionActivityKindMap + +/** One active item of a family that has per-item identity. */ +export interface SessionActivityItem { + /** Family-specific identity: a session id, a job id, or a schedule id. */ + readonly id: string + /** Display label when the family carries one (a job label, a subagent label). */ + readonly label?: string +} + +/** + * One reason a session counts as active for archive admission. Families with + * per-item identity list their items so a caller can name what must stop. + */ +export interface SessionActivity { + readonly kind: SessionActivityKind + /** Active items of the family; absent for a family without per-item identity (`turn`). */ + readonly items?: readonly SessionActivityItem[] +} + /** * One workspace: a stable id over an existing directory, a display title, and * an ordered candidate account of sessions. Membership requires both an id in diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index 6eafae9f2e..ef3116b3d7 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -957,6 +957,85 @@ describe('registry-global session archive', () => { expect(storedState(result.pool).archivedSessionIds).toEqual([]) }) + it('refuses a session the activity waterfall reports active and writes nothing', async () => { + const dir = await makeDir('archive-active') + const result = await harness({ sessions: [header('busy', dir, 100), header('quiet', dir, 200)] }) + const asked: SessionId[] = [] + result.ctx.on('workspace/session-activity', async ({ sessionId }, next) => { + asked.push(sessionId) + const rest = await next() + return sessionId === 'busy' + ? [{ kind: 'probe' }, { kind: 'probe-items', items: [{ id: 'item-1', label: 'build' }] }, ...rest] + : rest + }) + + await expect(result.registry.archiveSession(SessionId('busy'))).rejects.toMatchObject({ + name: 'WorkspaceActiveSessionError', + sessionId: 'busy', + activity: [{ kind: 'probe' }, { kind: 'probe-items', items: [{ id: 'item-1', label: 'build' }] }], + }) + expect(result.registry.archivedSessionIds).toEqual([]) + expect(storedState(result.pool).archivedSessionIds).toEqual([]) + expect(result.changes.filter(change => change.table === '')).toEqual([]) + + await result.registry.archiveSession(SessionId('quiet')) + expect(result.registry.archivedSessionIds).toEqual(['quiet']) + expect(asked).toEqual(['busy', 'quiet']) + + // The idempotent repeat resolves before any activity question is asked. + await result.registry.archiveSession(SessionId('quiet')) + expect(asked).toEqual(['busy', 'quiet']) + }) + + it('with stopActivity, writes the archive and then asks providers to stop, even when one of them fails', async () => { + const dir = await makeDir('archive-stop') + const result = await harness({ sessions: [header('busy', dir, 100)] }) + const order: string[] = [] + result.ctx.on('workspace/session-activity', async ({ sessionId }, next) => { + order.push(`activity:${sessionId}`) + return [{ kind: 'probe' }, ...(await next())] + }) + result.ctx.on('workspace/session-stop', ({ sessionId }) => { order.push(`stop:${sessionId}`) }) + result.ctx.on('workspace/session-stop', () => { throw new Error('job kill exploded') }) + result.ctx.on('domain/changed', (change) => { if (change.table === '') order.push('write') }) + const warn = vi.spyOn(result.ctx.logger, 'warn').mockImplementation(() => {}) + + await result.registry.archiveSession(SessionId('busy'), { stopActivity: true }) + expect(result.registry.archivedSessionIds).toEqual(['busy']) + expect(storedState(result.pool).archivedSessionIds).toEqual(['busy']) + // The archive write lands first, so a provider's gate sees the archived + // set before any stop-induced wake; the activity question is not asked + // because the caller already chose to stop what runs. + expect(order).toEqual(['write', 'stop:busy']) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('job kill exploded')) + + // A repeat resolves before stopping anything again. + order.length = 0 + await result.registry.archiveSession(SessionId('busy'), { stopActivity: true }) + expect(order).toEqual([]) + // An unknown session is still refused before any provider is asked to stop. + await expect(result.registry.archiveSession(SessionId('ghost'), { stopActivity: true })) + .rejects.toThrow(/cannot archive session 'ghost'/) + expect(order).toEqual([]) + }) + + it('asks about activity only after the session is known, and archives when every listener delegates', async () => { + const dir = await makeDir('archive-quiet') + const result = await harness({ sessions: [header('known', dir, 100)] }) + const asked: SessionId[] = [] + result.ctx.on('workspace/session-activity', ({ sessionId }, next) => { + asked.push(sessionId) + return next() + }) + await expect(result.registry.archiveSession(SessionId('ghost'))) + .rejects.toThrow(/cannot archive session 'ghost'/) + expect(asked).toEqual([]) + + await result.registry.archiveSession(SessionId('known')) + expect(asked).toEqual(['known']) + expect(result.registry.archivedSessionIds).toEqual(['known']) + }) + it('restores the archive set across restarts and defaults it for pre-field media', async () => { const dir = await makeDir('archive-restart') const pool = new MemoryMediaPool() @@ -1298,3 +1377,11 @@ describe('first-use Workspace preparation', () => { expect(await h.registry.initializeDefault(h.resolveDirectory)).toBeDefined() }) }) + +// The registry knows no family: the providers merge theirs, and this suite merges its own. +declare module '@deepseek-ai/dsh-workspace/types' { + interface SessionActivityKindMap { + probe: true + 'probe-items': true + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6176baca7..bc57a69165 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,10 +68,10 @@ importers: version: 5.10.0(eslint@10.5.0(jiti@2.7.0)) '@testing-library/dom': specifier: ^10.4.1 - version: 10.4.1 + version: 10.4.2 '@testing-library/react': specifier: ^16.3.2 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 @@ -514,9 +514,6 @@ importers: '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../packages/settings/settings - '@deepseek-ai/dsh-settings-file': - specifier: workspace:^ - version: link:../../packages/settings/settings-file '@deepseek-ai/dsh-shell-env': specifier: workspace:^ version: link:../../packages/shell/shell-env @@ -547,6 +544,9 @@ importers: '@types/ws': specifier: 8.18.1 version: 8.18.1 + ajv: + specifier: ^8.20.0 + version: 8.20.0 execa: specifier: ^10.0.0 version: 10.0.0 @@ -556,22 +556,43 @@ importers: apps/desktop: dependencies: + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:^ + version: link:../../packages/api/gateway electron-updater: specifier: ^6.8.9 version: 6.8.9 semver: specifier: ^7.8.5 version: 7.8.5 + ws: + specifier: ^8.21.0 + version: 8.21.0 devDependencies: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../packages/boot/app-boot + '@deepseek-ai/dsh-atomic-write': + specifier: workspace:^ + version: link:../../packages/util/atomic-write + '@deepseek-ai/dsh-client-shortcuts': + specifier: workspace:^ + version: link:../../packages/client/shortcuts + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../../packages/client/ui-primitives '@deepseek-ai/dsh-client-ui-settings-general': specifier: workspace:^ version: link:../../packages/client/ui-settings-general '@deepseek-ai/dsh-client-ui-sidebar-browser': specifier: workspace:^ version: link:../../packages/client/ui-sidebar-browser + '@deepseek-ai/dsh-client-ui-theme': + specifier: workspace:^ + version: link:../../packages/client/ui-theme + '@deepseek-ai/dsh-deepseek-account': + specifier: workspace:^ + version: link:../../packages/credentials/deepseek-account '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../packages/util/home-paths @@ -590,9 +611,18 @@ importers: '@types/node': specifier: ^22.20.0 version: 22.20.0 + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) '@types/semver': specifier: ^7.8.0 version: 7.8.0 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 app-builder-lib: specifier: 26.15.3 version: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) @@ -617,9 +647,18 @@ importers: pnpm: specifier: 11.7.0 version: 11.7.0 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) typescript: specifier: ^6.0.3 version: 6.0.3 + vite: + specifier: ^6.0.0 + version: 6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) apps/desktop-host: dependencies: @@ -638,6 +677,9 @@ importers: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../packages/client/connection + '@deepseek-ai/dsh-deepseek-account': + specifier: workspace:^ + version: link:../../packages/credentials/deepseek-account '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../packages/util/home-paths @@ -668,6 +710,9 @@ importers: '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../packages/test-support/client-runtime + '@deepseek-ai/dsh-client-ui-deliverables': + specifier: workspace:^ + version: link:../../packages/client/ui-deliverables '@deepseek-ai/dsh-client-ui-dockkit': specifier: workspace:^ version: link:../../packages/client/ui-dockkit @@ -945,6 +990,22 @@ importers: specifier: workspace:^ version: link:../../interaction/user-approval + packages/api/account-controller: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-deepseek-account': + specifier: workspace:^ + version: link:../../credentials/deepseek-account + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol + packages/api/gateway: dependencies: '@deepseek-ai/dsh-deque': @@ -1052,6 +1113,9 @@ importers: '@deepseek-ai/dsh-agent-preset-registry': specifier: workspace:^ version: link:../../preset/agent-preset-registry + '@deepseek-ai/dsh-api-account-controller': + specifier: workspace:^ + version: link:../account-controller '@deepseek-ai/dsh-api-gateway': specifier: workspace:^ version: link:../gateway @@ -1511,6 +1575,12 @@ importers: '@deepseek-ai/dsh-package-manifest': specifier: workspace:^ version: link:../../util/package-manifest + '@eslint-community/regexpp': + specifier: ^4.12.2 + version: 4.12.2 + ajv: + specifier: ^8.20.0 + version: 8.20.0 js-yaml: specifier: ^4.2.0 version: 4.2.0 @@ -1545,6 +1615,9 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 @@ -1564,6 +1637,37 @@ importers: specifier: ^15.0.0 version: 15.0.0 + packages/boot/config-editor: + dependencies: + '@deepseek-ai/dsh-atomic-write': + specifier: workspace:^ + version: link:../../util/atomic-write + js-yaml: + specifier: ^4.1.0 + version: 4.3.1 + yaml: + specifier: ^2.8.1 + version: 2.9.0 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../app-boot + '@deepseek-ai/dsh-hmr': + specifier: workspace:^ + version: link:../hmr + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 + packages/boot/hmr: dependencies: '@babel/code-frame': @@ -1737,6 +1841,9 @@ importers: '@deepseek-ai/dsh-attachment-local': specifier: workspace:^ version: link:../../attachment/attachment-local + '@deepseek-ai/dsh-authorization': + specifier: workspace:^ + version: link:../../credentials/authorization '@deepseek-ai/dsh-bash-sandbox': specifier: workspace:^ version: link:../../shell/bash-sandbox @@ -1761,9 +1868,15 @@ importers: '@deepseek-ai/dsh-compaction-tool-result-pruner': specifier: workspace:^ version: link:../../compaction/compaction-tool-result-pruner + '@deepseek-ai/dsh-config-editor': + specifier: workspace:^ + version: link:../../boot/config-editor '@deepseek-ai/dsh-credentials-local': specifier: workspace:^ version: link:../../credentials/credentials-local + '@deepseek-ai/dsh-deepseek-account-platform': + specifier: workspace:^ + version: link:../../credentials/deepseek-account-platform '@deepseek-ai/dsh-deepseek-llm-api-extensions': specifier: workspace:^ version: link:../../llm/deepseek-llm-api-extensions @@ -1860,9 +1973,9 @@ importers: '@deepseek-ai/dsh-session-title-first-prompt-llm': specifier: workspace:^ version: link:../../session/session-title-first-prompt-llm - '@deepseek-ai/dsh-settings-file': + '@deepseek-ai/dsh-settings': specifier: workspace:^ - version: link:../../settings/settings-file + version: link:../../settings/settings '@deepseek-ai/dsh-shell-env': specifier: workspace:^ version: link:../../shell/shell-env @@ -2167,6 +2280,9 @@ importers: '@deepseek-ai/dsh-agent-tool-presentation': specifier: workspace:^ version: link:../../core/agent-tool-presentation + '@deepseek-ai/dsh-api-account-controller': + specifier: workspace:^ + version: link:../../api/account-controller '@deepseek-ai/dsh-api-job-controller': specifier: workspace:^ version: link:../../api/job-controller @@ -2209,6 +2325,9 @@ importers: '@deepseek-ai/dsh-client-resources': specifier: workspace:^ version: link:../../client/resources + '@deepseek-ai/dsh-client-shortcuts': + specifier: workspace:^ + version: link:../../client/shortcuts '@deepseek-ai/dsh-client-ui-agent-preset': specifier: workspace:^ version: link:../../client/ui-agent-preset @@ -2287,6 +2406,12 @@ importers: '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../../client/ui-settings + '@deepseek-ai/dsh-client-ui-settings-account': + specifier: workspace:^ + version: link:../../client/ui-settings-account + '@deepseek-ai/dsh-client-ui-settings-agent-loop': + specifier: workspace:^ + version: link:../../client/ui-settings-agent-loop '@deepseek-ai/dsh-client-ui-settings-general': specifier: workspace:^ version: link:../../client/ui-settings-general @@ -2299,6 +2424,18 @@ importers: '@deepseek-ai/dsh-client-ui-settings-plugins': specifier: workspace:^ version: link:../../client/ui-settings-plugins + '@deepseek-ai/dsh-client-ui-settings-shell': + specifier: workspace:^ + version: link:../../client/ui-settings-shell + '@deepseek-ai/dsh-client-ui-settings-subagent': + specifier: workspace:^ + version: link:../../client/ui-settings-subagent + '@deepseek-ai/dsh-client-ui-settings-web-search': + specifier: workspace:^ + version: link:../../client/ui-settings-web-search + '@deepseek-ai/dsh-client-ui-shortcuts': + specifier: workspace:^ + version: link:../../client/ui-shortcuts '@deepseek-ai/dsh-client-ui-sidebar': specifier: workspace:^ version: link:../../client/ui-sidebar @@ -2505,7 +2642,7 @@ importers: version: 15.0.0 open: specifier: ^11.0.0 - version: 11.0.0 + version: 11.0.4 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2731,6 +2868,37 @@ importers: specifier: workspace:^ version: link:../../typert/protocol + packages/client/shortcuts: + dependencies: + '@deepseek-ai/dsh-util-crypto': + specifier: workspace:^ + version: link:../../util/crypto + '@deepseek-ai/dsh-util-values': + specifier: workspace:^ + version: link:../../util/values + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../../host/webserver + packages/client/store: devDependencies: '@deepseek-ai/cordis': @@ -2817,6 +2985,9 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale + '@deepseek-ai/dsh-client-shortcuts': + specifier: workspace:^ + version: link:../shortcuts '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation @@ -2909,7 +3080,7 @@ importers: version: link:../ui-sidebar '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -2947,6 +3118,9 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -3140,6 +3314,9 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale + '@deepseek-ai/dsh-client-shortcuts': + specifier: workspace:^ + version: link:../shortcuts '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../store @@ -3371,7 +3548,7 @@ importers: version: link:../ui-workspace '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3401,7 +3578,7 @@ importers: version: link:../ui-workspace '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3425,7 +3602,7 @@ importers: version: link:../ui-primitives '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3491,7 +3668,7 @@ importers: version: link:../../typert/protocol '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3606,6 +3783,9 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale + '@deepseek-ai/dsh-client-shortcuts': + specifier: workspace:^ + version: link:../shortcuts '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../store @@ -3686,7 +3866,7 @@ importers: version: link:../../typert/protocol '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3783,6 +3963,9 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale + '@deepseek-ai/dsh-client-shortcuts': + specifier: workspace:^ + version: link:../shortcuts '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../store @@ -3795,6 +3978,9 @@ importers: '@deepseek-ai/dsh-client-ui-deliverables': specifier: workspace:^ version: link:../ui-deliverables + '@deepseek-ai/dsh-client-ui-layout': + specifier: workspace:^ + version: link:../ui-layout '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives @@ -3987,6 +4173,9 @@ importers: '@deepseek-ai/dsh-client-ui-renderer': specifier: workspace:^ version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings '@deepseek-ai/dsh-client-ui-sidebar': specifier: workspace:^ version: link:../ui-sidebar @@ -4004,7 +4193,7 @@ importers: version: link:../../util/crypto '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -4026,6 +4215,12 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store + '@deepseek-ai/dsh-util-workspace-path': + specifier: workspace:^ + version: link:../../util/workspace-path '@shikijs/langs': specifier: ^4.3.1 version: 4.3.1 @@ -4166,7 +4361,7 @@ importers: version: 18.3.7(@types/react@18.3.31) '@types/use-sync-external-store': specifier: ^1.5.0 - version: 1.5.0 + version: 1.7.0 react: specifier: ^18.2.0 version: 18.3.1 @@ -4214,7 +4409,7 @@ importers: version: link:../../core/session '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -4301,6 +4496,106 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-settings-account: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-settings-models': + specifier: workspace:^ + version: link:../ui-settings-models + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-deepseek-account': + specifier: workspace:^ + version: link:../../credentials/deepseek-account + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../../host/webserver + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/big.js': + specifier: ^6.2.2 + version: 6.2.2 + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) + big.js: + specifier: ^7.0.1 + version: 7.0.1 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + + packages/client/ui-settings-agent-loop: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-plugin-manager': + specifier: workspace:^ + version: link:../ui-plugin-manager + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-settings-general: dependencies: '@deepseek-ai/schemastery': @@ -4319,6 +4614,9 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale + '@deepseek-ai/dsh-client-shortcuts': + specifier: workspace:^ + version: link:../shortcuts '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../store @@ -4352,14 +4650,24 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) clsx: specifier: ^2.0.0 version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) packages/client/ui-settings-models: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -4388,6 +4696,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../../host/webserver '@deepseek-ai/dsh-remote-mock': specifier: workspace:^ version: link:../../test-support/remote-mock @@ -4444,7 +4755,7 @@ importers: version: link:../../util/package-manifest '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -4456,6 +4767,36 @@ importers: version: 18.3.1(react@18.3.1) packages/client/ui-settings-plugins: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-util-values': + specifier: workspace:^ + version: link:../../util/values + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + + packages/client/ui-settings-shell: devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -4490,8 +4831,128 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + + packages/client/ui-settings-subagent: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-plugin-manager': + specifier: workspace:^ + version: link:../ui-plugin-manager + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + + packages/client/ui-settings-web-search: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-plugin-manager': + specifier: workspace:^ + version: link:../ui-plugin-manager + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + + packages/client/ui-shortcuts: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-shortcuts': + specifier: workspace:^ + version: link:../shortcuts + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-layout': + specifier: workspace:^ + version: link:../ui-layout + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 clsx: - specifier: ^2.0.0 + specifier: ^2.1.1 version: 2.1.1 react: specifier: ^18.2.0 @@ -4508,6 +4969,9 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale + '@deepseek-ai/dsh-client-shortcuts': + specifier: workspace:^ + version: link:../shortcuts '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../store @@ -4556,6 +5020,9 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale + '@deepseek-ai/dsh-client-shortcuts': + specifier: workspace:^ + version: link:../shortcuts '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../store @@ -4579,7 +5046,7 @@ importers: version: link:../ui-slots '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -4667,7 +5134,7 @@ importers: version: 1.0.4(patch_hash=c17549e117c8fb99f408497c1e84292c21f25516fb762a32518f91b4d9ab0f04)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/papaparse': specifier: ^5.3.16 version: 5.5.2 @@ -4722,6 +5189,9 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale + '@deepseek-ai/dsh-client-shortcuts': + specifier: workspace:^ + version: link:../shortcuts '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../store @@ -4757,7 +5227,7 @@ importers: version: link:../../util/workspace-path '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -4782,12 +5252,18 @@ importers: '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ version: link:../../api/session-controller + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale '@deepseek-ai/dsh-client-resources': specifier: workspace:^ version: link:../resources + '@deepseek-ai/dsh-client-shortcuts': + specifier: workspace:^ + version: link:../shortcuts '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../store @@ -4823,7 +5299,7 @@ importers: version: link:../../util/crypto '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/picomatch': specifier: ^4.0.2 version: 4.0.3 @@ -4863,6 +5339,9 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale + '@deepseek-ai/dsh-client-shortcuts': + specifier: workspace:^ + version: link:../shortcuts '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../store @@ -4901,7 +5380,7 @@ importers: version: link:../../core/session '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -4970,7 +5449,7 @@ importers: version: link:../../util/workspace-path '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -5196,7 +5675,7 @@ importers: version: link:../../util/workspace-path '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -5407,6 +5886,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes @@ -5422,6 +5904,9 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale + '@deepseek-ai/dsh-client-shortcuts': + specifier: workspace:^ + version: link:../shortcuts '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../store @@ -5449,6 +5934,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs '@deepseek-ai/dsh-schedule': specifier: workspace:^ version: link:../../schedule/schedule @@ -6015,9 +6503,15 @@ importers: '@deepseek-ai/dsh-util-values': specifier: workspace:^ version: link:../../util/values + '@deepseek-ai/dsh-workspace': + specifier: workspace:^ + version: link:../../workspace/workspace packages/core/agent-default-model: dependencies: + '@deepseek-ai/dsh-config-editor': + specifier: workspace:^ + version: link:../../boot/config-editor '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -6080,9 +6574,6 @@ importers: '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../session/session-projection - '@deepseek-ai/dsh-settings': - specifier: workspace:^ - version: link:../../settings/settings '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../system-prompt @@ -6279,6 +6770,40 @@ importers: specifier: workspace:^ version: link:../../util/launch-environment + packages/credentials/deepseek-account: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + + packages/credentials/deepseek-account-platform: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-authorization': + specifier: workspace:^ + version: link:../authorization + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../credentials + '@deepseek-ai/dsh-deepseek-account': + specifier: workspace:^ + version: link:../deepseek-account + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../../host/webserver + packages/deliverables/tool-present: dependencies: '@deepseek-ai/schemastery': @@ -6852,7 +7377,7 @@ importers: version: link:../../typert/protocol '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -6921,7 +7446,7 @@ importers: version: link:../../typert/protocol '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -7110,6 +7635,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings @@ -7304,6 +7832,9 @@ importers: '@deepseek-ai/dsh-api-gateway': specifier: workspace:^ version: link:../../api/gateway + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../../boot/app-boot '@deepseek-ai/dsh-bash-sandbox': specifier: workspace:^ version: link:../../shell/bash-sandbox @@ -7428,12 +7959,18 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-group': + specifier: workspace:^ + version: link:../../../vendor/group '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../../boot/app-boot '@deepseek-ai/dsh-cordis-host-runner': specifier: workspace:^ version: link:../cordis-host-runner @@ -7443,6 +7980,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery packages/extensions/ui-cordis: devDependencies: @@ -8271,6 +8811,46 @@ importers: specifier: workspace:^ version: link:../../typert/protocol + packages/host/product-telemetry-otel: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + '@opentelemetry/api': + specifier: ^1.9.1 + version: 1.9.1 + '@opentelemetry/api-logs': + specifier: ^0.220.0 + version: 0.220.0 + '@opentelemetry/core': + specifier: 2.9.0 + version: 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': + specifier: ^0.220.0 + version: 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': + specifier: ^0.220.0 + version: 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': + specifier: ^2.9.0 + version: 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': + specifier: ^0.220.0 + version: 0.220.0(@opentelemetry/api@1.9.1) + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../../boot/app-boot + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../test-support/loader-smoke + packages/host/webserver: dependencies: '@deepseek-ai/schemastery': @@ -8278,10 +8858,10 @@ importers: version: link:../../../vendor/schemastery compression: specifier: ^1.8.1 - version: 1.8.1(supports-color@9.4.0) + version: 1.8.2(supports-color@9.4.0) negotiator: specifier: ^1.0.0 - version: 1.0.0 + version: 1.1.0 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -8469,6 +9049,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-workspace': + specifier: workspace:^ + version: link:../../workspace/workspace packages/jobs/jobs-local: dependencies: @@ -8583,6 +9166,9 @@ importers: packages/llm/llm-deepseek: dependencies: + '@deepseek-ai/cosmokit': + specifier: link:../../../vendor/cosmokit + version: link:../../../vendor/cosmokit '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -8629,6 +9215,9 @@ importers: '@deepseek-ai/dsh-credentials-local': specifier: workspace:^ version: link:../../credentials/credentials-local + '@deepseek-ai/dsh-deepseek-account': + specifier: workspace:^ + version: link:../../credentials/deepseek-account '@deepseek-ai/dsh-deepseek-llm-api-extensions': specifier: workspace:^ version: link:../deepseek-llm-api-extensions @@ -8662,9 +9251,6 @@ importers: '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings - '@deepseek-ai/dsh-settings-file': - specifier: workspace:^ - version: link:../../settings/settings-file '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -8693,6 +9279,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment @@ -9104,12 +9693,27 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent-preset-registry': specifier: workspace:^ version: link:../agent-preset-registry + '@deepseek-ai/dsh-compaction-tool-result-pruner': + specifier: workspace:^ + version: link:../../compaction/compaction-tool-result-pruner + '@deepseek-ai/dsh-skill': + specifier: workspace:^ + version: link:../../skill/skill + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 + js-yaml: + specifier: ^4.2.0 + version: 4.3.1 packages/preset/agent-preset-registry: dependencies: @@ -9174,9 +9778,6 @@ importers: '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings - '@deepseek-ai/dsh-settings-file': - specifier: workspace:^ - version: link:../../settings/settings-file '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -9422,6 +10023,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-workspace': + specifier: workspace:^ + version: link:../../workspace/workspace packages/sdk/client: dependencies: @@ -10265,19 +10869,43 @@ importers: packages/settings/settings: dependencies: + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/cosmokit': + specifier: link:../../../vendor/cosmokit + version: link:../../../vendor/cosmokit + '@deepseek-ai/dsh-config-editor': + specifier: workspace:^ + version: link:../../boot/config-editor '@deepseek-ai/dsh-util-values': specifier: workspace:^ version: link:../../util/values '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + yaml: + specifier: ^2.8.1 + version: 2.9.0 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-timer': + specifier: workspace:^ + version: link:../../../vendor/timer + '@deepseek-ai/dsh-agent-default-model': + specifier: workspace:^ + version: link:../../core/agent-default-model + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../../boot/app-boot '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-hmr': + specifier: workspace:^ + version: link:../../boot/hmr '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -10285,34 +10913,6 @@ importers: specifier: workspace:^ version: link:../../core/session - packages/settings/settings-file: - dependencies: - '@deepseek-ai/dsh-util-values': - specifier: workspace:^ - version: link:../../util/values - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - chokidar: - specifier: ^4.0.3 - version: 4.0.3 - yaml: - specifier: ^2.9.0 - version: 2.9.0 - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-atomic-write': - specifier: workspace:^ - version: link:../../util/atomic-write - '@deepseek-ai/dsh-home-paths': - specifier: workspace:^ - version: link:../../util/home-paths - '@deepseek-ai/dsh-settings': - specifier: workspace:^ - version: link:../settings - packages/shell/bash-local: dependencies: '@deepseek-ai/schemastery': @@ -10322,9 +10922,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-settings': - specifier: workspace:^ - version: link:../../settings/settings '@deepseek-ai/dsh-shell': specifier: workspace:^ version: link:../shell @@ -10377,9 +10974,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-settings': - specifier: workspace:^ - version: link:../../settings/settings '@deepseek-ai/dsh-shell': specifier: workspace:^ version: link:../shell @@ -10428,9 +11022,6 @@ importers: '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox - '@deepseek-ai/dsh-settings': - specifier: workspace:^ - version: link:../../settings/settings '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../../subprocess/subprocess @@ -10447,6 +11038,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../../boot/app-boot '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths @@ -11201,9 +11795,6 @@ importers: '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../../session-query/session-query - '@deepseek-ai/dsh-settings': - specifier: workspace:^ - version: link:../../settings/settings '@deepseek-ai/dsh-storage': specifier: workspace:^ version: link:../../storage/storage @@ -11228,6 +11819,9 @@ importers: '@deepseek-ai/dsh-util-time': specifier: workspace:^ version: link:../../util/time + '@deepseek-ai/dsh-workspace': + specifier: workspace:^ + version: link:../../workspace/workspace packages/subagent/subagent-acp: dependencies: @@ -11698,9 +12292,6 @@ importers: '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../session/session-projection - '@deepseek-ai/dsh-settings': - specifier: workspace:^ - version: link:../../settings/settings '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent @@ -11981,10 +12572,10 @@ importers: dependencies: '@testing-library/dom': specifier: ^10.4.1 - version: 10.4.1 + version: 10.4.2 '@testing-library/react': specifier: ^16.3.2 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) js-yaml: specifier: ^4.2.0 version: 4.3.1 @@ -12586,9 +13177,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - '@deepseek-ai/dsh-settings': - specifier: workspace:^ - version: link:../../settings/settings '@deepseek-ai/dsh-web': specifier: workspace:^ version: link:../web @@ -13005,15 +13593,27 @@ importers: '@deepseek-ai/dsh-authorization': specifier: workspace:^ version: link:../../packages/credentials/authorization + '@deepseek-ai/dsh-base': + specifier: workspace:^ + version: link:../../packages/bundle/base '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../packages/shell/bash-local '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../packages/util/brand + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../packages/client/connection + '@deepseek-ai/dsh-cmdline': + specifier: workspace:^ + version: link:../../packages/boot/cmdline '@deepseek-ai/dsh-command-compact': specifier: workspace:^ version: link:../../packages/compaction/command-compact + '@deepseek-ai/dsh-command-feedback': + specifier: workspace:^ + version: link:../../packages/feedback/command-feedback '@deepseek-ai/dsh-command-goal': specifier: workspace:^ version: link:../../packages/goal/command-goal @@ -13032,12 +13632,18 @@ importers: '@deepseek-ai/dsh-compaction-tool-result-pruner': specifier: workspace:^ version: link:../../packages/compaction/compaction-tool-result-pruner + '@deepseek-ai/dsh-config-editor': + specifier: workspace:^ + version: link:../../packages/boot/config-editor '@deepseek-ai/dsh-cordis-host-runner': specifier: workspace:^ version: link:../../packages/extensions/cordis-host-runner '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../packages/credentials/credentials + '@deepseek-ai/dsh-deepseek-account': + specifier: workspace:^ + version: link:../../packages/credentials/deepseek-account '@deepseek-ai/dsh-deepseek-llm-api-extensions': specifier: workspace:^ version: link:../../packages/llm/deepseek-llm-api-extensions @@ -13074,6 +13680,9 @@ importers: '@deepseek-ai/dsh-host-plugin-inventory': specifier: workspace:^ version: link:../../packages/host/plugin-inventory + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../../packages/host/webserver '@deepseek-ai/dsh-http-proxy': specifier: workspace:^ version: link:../../packages/util/http-proxy @@ -13104,6 +13713,9 @@ importers: '@deepseek-ai/dsh-mcp-client': specifier: workspace:^ version: link:../../packages/mcp/mcp-client + '@deepseek-ai/dsh-message-feedback': + specifier: workspace:^ + version: link:../../packages/feedback/message-feedback '@deepseek-ai/dsh-output-retention': specifier: workspace:^ version: link:../../packages/util/output-retention @@ -13209,6 +13821,12 @@ importers: '@deepseek-ai/dsh-spill': specifier: workspace:^ version: link:../../packages/spill/spill + '@deepseek-ai/dsh-storage': + specifier: workspace:^ + version: link:../../packages/storage/storage + '@deepseek-ai/dsh-storage-domain': + specifier: workspace:^ + version: link:../../packages/storage/storage-domain '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../packages/subagent/subagent @@ -13314,6 +13932,9 @@ importers: '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../../packages/typert/protocol + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../packages/typert/registry '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../packages/interaction/user-approval @@ -16088,8 +16709,8 @@ packages: '@tanstack/virtual-core@3.17.7': resolution: {integrity: sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==} - '@testing-library/dom@10.4.1': - resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + '@testing-library/dom@10.4.2': + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} engines: {node: '>=18'} '@testing-library/react@16.3.2': @@ -16163,6 +16784,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/big.js@6.2.2': + resolution: {integrity: sha512-e2cOW9YlVzFY2iScnGBBkplKsrn2CsObHQ2Hiw4V1sSyiGbgWL8IyqE3zFi1Pt5o1pdAtYkDAIsF3KKUPjdzaA==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -16426,8 +17050,8 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@types/use-sync-external-store@1.5.0': - resolution: {integrity: sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==} + '@types/use-sync-external-store@1.7.0': + resolution: {integrity: sha512-v8435kzWm3FuxyUa4r8o4sG6b0D+r+uOrzF5MXHxZKW4Haoo6txu/lq56Vio45e2i1nkmFPA+R7myWcmV7Z7Bg==} '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} @@ -16963,6 +17587,9 @@ packages: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} + big.js@7.0.1: + resolution: {integrity: sha512-iFgV784tD8kq4ccF1xtNMZnXeZzVuXWWM+ERFzKQjv+A5G9HC8CY3DuV45vgzFFcW+u2tIvmF95+AzWgs6BjCg==} + bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} @@ -17220,8 +17847,8 @@ packages: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} - compression@1.8.1: - resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + compression@1.8.2: + resolution: {integrity: sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==} engines: {node: '>= 0.8.0'} concat-map@0.0.1: @@ -17235,8 +17862,8 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} - content-type@2.0.0: - resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} engines: {node: '>=18'} convert-source-map@2.0.0: @@ -17519,8 +18146,8 @@ packages: resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} engines: {node: '>=18'} - default-browser@5.5.0: - resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + default-browser@5.5.1: + resolution: {integrity: sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==} engines: {node: '>=18'} defer-to-connect@2.0.1: @@ -17557,6 +18184,10 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -18898,8 +19529,8 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} - media-typer@1.1.0: - resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} engines: {node: '>= 0.8'} merge-descriptors@2.0.0: @@ -19101,9 +19732,9 @@ packages: resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} engines: {node: '>= 0.6'} - negotiator@1.0.0: - resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} - engines: {node: '>= 0.6'} + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} node-abi@3.96.0: resolution: {integrity: sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==} @@ -19266,8 +19897,8 @@ packages: oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} - open@11.0.0: - resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + open@11.0.4: + resolution: {integrity: sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==} engines: {node: '>=20'} openai@6.40.0: @@ -19478,6 +20109,10 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + powershell-utils@0.2.1: + resolution: {integrity: sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==} + engines: {node: '>=20'} + preact@10.29.7: resolution: {integrity: sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==} peerDependencies: @@ -19533,8 +20168,8 @@ packages: resolution: {integrity: sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==} engines: {node: '>=12.0.0'} - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} engines: {node: '>= 0.10'} psl@1.15.0: @@ -20715,8 +21350,8 @@ packages: utf-8-validate: optional: true - wsl-utils@0.3.1: - resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + wsl-utils@1.0.0: + resolution: {integrity: sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==} engines: {node: '>=20'} xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: @@ -23138,7 +23773,7 @@ snapshots: '@tanstack/virtual-core@3.17.7': {} - '@testing-library/dom@10.4.1': + '@testing-library/dom@10.4.2': dependencies: '@babel/code-frame': 7.29.7 '@babel/runtime': 7.29.7 @@ -23149,10 +23784,10 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@testing-library/react@16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.29.7 - '@testing-library/dom': 10.4.1 + '@testing-library/dom': 10.4.2 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: @@ -23219,6 +23854,8 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@types/big.js@6.2.2': {} + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -23520,7 +24157,7 @@ snapshots: '@types/unist@3.0.3': {} - '@types/use-sync-external-store@1.5.0': {} + '@types/use-sync-external-store@1.7.0': {} '@types/web-bluetooth@0.0.21': {} @@ -23885,7 +24522,7 @@ snapshots: accepts@2.0.0: dependencies: mime-types: 3.0.2 - negotiator: 1.0.0 + negotiator: 1.1.0 acorn-jsx@5.3.2(acorn@8.17.0): dependencies: @@ -24137,6 +24774,8 @@ snapshots: big-integer@1.6.52: {} + big.js@7.0.1: {} + bignumber.js@9.3.1: {} binary@0.3.0: @@ -24161,7 +24800,7 @@ snapshots: body-parser@2.3.0: dependencies: bytes: 3.1.2 - content-type: 2.0.0 + content-type: 2.1.0 debug: 4.4.3(supports-color@9.4.0) http-errors: 2.0.1 iconv-lite: 0.7.3 @@ -24390,11 +25029,12 @@ snapshots: dependencies: mime-db: 1.54.0 - compression@1.8.1(supports-color@9.4.0): + compression@1.8.2(supports-color@9.4.0): dependencies: bytes: 3.1.2 compressible: 2.0.18 debug: 2.6.9(supports-color@9.4.0) + destroy: 1.2.0 negotiator: 0.6.4 on-headers: 1.1.0 safe-buffer: 5.2.1 @@ -24408,7 +25048,7 @@ snapshots: content-type@1.0.5: {} - content-type@2.0.0: {} + content-type@2.1.0: {} convert-source-map@2.0.0: {} @@ -24720,7 +25360,7 @@ snapshots: default-browser-id@5.0.1: {} - default-browser@5.5.0: + default-browser@5.5.1: dependencies: bundle-name: 4.1.0 default-browser-id: 5.0.1 @@ -24755,6 +25395,8 @@ snapshots: dequal@2.0.3: {} + destroy@1.2.0: {} + detect-libc@2.1.2: {} detect-node@2.1.0: @@ -25219,7 +25861,7 @@ snapshots: on-finished: 2.4.1 once: 1.4.0 parseurl: 1.3.3 - proxy-addr: 2.0.7 + proxy-addr: 2.0.8 qs: 6.15.3 range-parser: 1.3.0 router: 2.2.0 @@ -26349,7 +26991,7 @@ snapshots: mdn-data@2.27.1: {} - media-typer@1.1.0: {} + media-typer@1.1.1: {} merge-descriptors@2.0.0: {} @@ -26653,7 +27295,9 @@ snapshots: negotiator@0.6.4: {} - negotiator@1.0.0: {} + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 node-abi@3.96.0: dependencies: @@ -26804,14 +27448,14 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 - open@11.0.0: + open@11.0.4: dependencies: - default-browser: 5.5.0 + default-browser: 5.5.1 define-lazy-prop: 3.0.0 is-in-ssh: 1.0.0 is-inside-container: 1.0.0 - powershell-utils: 0.1.0 - wsl-utils: 0.3.1 + powershell-utils: 0.2.1 + wsl-utils: 1.0.0 openai@6.40.0(ws@8.21.0)(zod@4.4.3): optionalDependencies: @@ -27019,6 +27663,8 @@ snapshots: powershell-utils@0.1.0: {} + powershell-utils@0.2.1: {} + preact@10.29.7: {} prebuild-install@7.1.3: @@ -27083,7 +27729,7 @@ snapshots: '@types/node': 22.20.0 long: 5.3.2 - proxy-addr@2.0.7: + proxy-addr@2.0.8: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 @@ -27940,8 +28586,8 @@ snapshots: type-is@2.1.0: dependencies: - content-type: 2.0.0 - media-typer: 1.1.0 + content-type: 2.1.0 + media-typer: 1.1.1 mime-types: 3.0.2 typebox@1.3.7: {} @@ -28415,7 +29061,7 @@ snapshots: ws@8.21.0: {} - wsl-utils@0.3.1: + wsl-utils@1.0.0: dependencies: is-wsl: 3.1.1 powershell-utils: 0.1.0 diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 716e9cf3e8..1dbf19890a 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -23,8 +23,15 @@ "@deepseek-ai/dsh-atomic-write": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-authorization": "workspace:^", + "@deepseek-ai/dsh-base": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-cmdline": "workspace:^", + "@deepseek-ai/dsh-command-feedback": "workspace:^", + "@deepseek-ai/dsh-message-feedback": "workspace:^", + "@deepseek-ai/dsh-ptc-runtime": "workspace:^", + "@deepseek-ai/dsh-ptc-runtime-node": "workspace:^", "@deepseek-ai/dsh-command-compact": "workspace:^", "@deepseek-ai/dsh-command-goal": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", @@ -32,8 +39,10 @@ "@deepseek-ai/dsh-compaction-basic": "workspace:^", "@deepseek-ai/dsh-compaction-image-offload": "workspace:^", "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", + "@deepseek-ai/dsh-config-editor": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-deepseek-account": "workspace:^", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", @@ -46,6 +55,7 @@ "@deepseek-ai/dsh-hooks-claude-code": "workspace:^", "@deepseek-ai/dsh-hooks-codex": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-http-proxy": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", @@ -90,6 +100,8 @@ "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-filesystem": "workspace:^", "@deepseek-ai/dsh-skill-office": "workspace:^", + "@deepseek-ai/dsh-storage": "workspace:^", + "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:^", "@deepseek-ai/dsh-spill": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", @@ -126,6 +138,7 @@ "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-questions": "workspace:^", "@deepseek-ai/dsh-util-time": "workspace:^", diff --git a/scripts/check-workspace-constraints.spec.ts b/scripts/check-workspace-constraints.spec.ts index 1d57212f64..b8de5595c8 100644 --- a/scripts/check-workspace-constraints.spec.ts +++ b/scripts/check-workspace-constraints.spec.ts @@ -284,3 +284,16 @@ it('requires the local speech worker and locked runtime in the published payload .toEqual([expect.stringContaining('package.json files must be')]) } }) + +it('requires the standalone shortcut protocol and rejects unrelated runtime files', () => { + const dir = 'packages/client/shortcuts' + const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest'] + expect(checkWorkspaceManifest({ dir, manifest })).toEqual([]) + for (const files of [ + ['lib/index.js', 'lib/client.js', 'lib/types/**/*.d.ts'], + ['lib/index.js', 'lib/client.js', 'lib/protocol.js', 'lib/extra.js', 'lib/types/**/*.d.ts'], + ]) { + expect(checkWorkspaceManifest({ dir, manifest: { ...manifest, files } })) + .toEqual([expect.stringContaining('package.json files must be')]) + } +}) diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 179f002f3c..ca3dadffab 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -161,6 +161,8 @@ const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-client-ui-sidebar-terminal': ['lib/client.*.js'], '@deepseek-ai/dsh-client-web': ['lib/**/*.css', 'lib/apply-injections.js'], '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], + // The physical-key protocol is a public entry usable without the browser service. + '@deepseek-ai/dsh-client-shortcuts': ['lib/protocol.js'], // The CPython side ships as source .py files, published as-is rather than built. '@deepseek-ai/dsh-experimental-ptc-runtime-python': ['py/**/*.py'], '@deepseek-ai/dsh-experimental-speech-to-text-sensevoice': ['runtime/assets.json'], diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 454c58b734..1caa45f192 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -56,8 +56,10 @@ export { REGION_BEGIN, REGION_END } export const SERVICE_PAGE: Record = { speechToText: 'voice-input.md', speechController: 'voice-input.md', + productTelemetry: 'product-telemetry.md', connection: 'web-server.md', pluginManager: 'boot.md', + configEditor: 'boot.md', profileContext: 'boot.md', hmr: 'boot.md', mcpResources: 'mcp.md', @@ -78,6 +80,7 @@ export const SERVICE_PAGE: Record = { compaction: 'compaction.md', cordisInspect: 'extensions.md', authorization: 'credentials.md', + deepseekAccount: 'credentials.md', credentials: 'credentials.md', credentialsController: 'credentials.md', settingsController: 'settings.md', @@ -176,8 +179,9 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { uiConversation: 'client-side Conversation registries and assembler — packages/client/ui-conversation/README.md owns the API', uiWorkspace: 'client-side Workspace navigation adapter — packages/client/ui-workspace/README.md owns the API', settingsSchema: 'client-side schema introspection service — packages/client/ui-settings/README.md owns the API', - settingsScope: 'client-side settings-namespace transport service — packages/client/ui-settings/README.md owns the API', + configForms: 'client-side shared entry forms — packages/client/ui-settings/README.md owns the API', chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-chat/README.md owns the API', + shortcuts: 'client-side interface-typed keyboard service — packages/client/shortcuts/README.md owns the API', commandUi: 'client-side interface-typed browser service — packages/client/ui-commands/README.md owns the API', feedbackUi: 'client-side feedback dialog service — packages/client/ui-message-feedback/README.md owns the API', conversation: 'client-side interface-typed browser service — packages/client/ui-conversation/README.md owns the API', @@ -206,6 +210,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { * {@link EVENT_WALK_EXEMPTIONS} names each one with its documentation owner. */ export const EVENT_SCOPE_PAGE: Record = { + 'app-boot': 'boot.md', hmr: 'boot.md', 'plugin-manager': 'boot.md', 'agent': 'core.md', @@ -235,6 +240,7 @@ export const EVENT_SCOPE_PAGE: Record = { 'user-questions': 'user-questions.md', 'webserver': 'web-server.md', 'workflow': 'workflow.md', + 'workspace': 'workspace.md', } /** @@ -266,6 +272,8 @@ export const EVENT_WALK_EXEMPTIONS: Record = { * appear on more than one page. */ export const LINK_MAP: Readonly> = { + ProductTelemetryRecord: 'product-telemetry.md', + ProductTelemetryScalar: 'product-telemetry.md', WorkspaceChangesSummary: 'deliverables.md', WorkspaceFileDiff: 'deliverables.md', Reload: 'boot.md', @@ -661,10 +669,6 @@ export const LINK_MAP: Readonly> = { ToolRestriction: 'tools.md', ToolSchema: 'tools.md', SettingsNamespace: 'settings.md', - SettingsNamespaceInput: 'settings.md', - SettingsRegisterOptions: 'settings.md', - SettingsSectionHooks: 'settings.md', - SettingsScope: 'settings.md', SettingsDescriptor: 'settings.md', SettingsDescribeValue: 'settings.md', SettingsDocumentOpenValue: 'settings.md', @@ -674,7 +678,6 @@ export const LINK_MAP: Readonly> = { SettingsSecretView: 'settings.md', SettingsPathOp: 'settings.md', SettingsDescribeOptions: 'settings.md', - SettingsUpdateSource: 'settings.md', SkillListRequest: 'skills.md', SkillListValue: 'skills.md', AuthorizationEntry: 'credentials.md', @@ -683,6 +686,10 @@ export const LINK_MAP: Readonly> = { AuthorizationMethod: 'credentials.md', AuthorizationNotice: 'credentials.md', AuthorizationOutcome: 'credentials.md', + AccountView: 'credentials.md', + AccountDetails: 'credentials.md', + PlatformSession: 'credentials.md', + SignInAttemptId: 'credentials.md', AuthorizationPrompt: 'credentials.md', AuthorizationRequest: 'credentials.md', AuthorizationSession: 'credentials.md', @@ -720,6 +727,9 @@ export const LINK_MAP: Readonly> = { DomainChanged: 'storage.md', DomainFacility: 'storage.md', Workspace: 'workspace.md', + ArchiveSessionOptions: 'workspace.md', + SessionActivity: 'workspace.md', + SessionActivityRequest: 'workspace.md', WorkspaceArchiveSessionRequest: 'workspace.md', WorkspaceArchiveValue: 'workspace.md', WorkspaceCreateRequest: 'workspace.md', @@ -763,12 +773,14 @@ export const LINK_MAP: Readonly> = { /** TypeScript lib and pinned framework types with no repository-owned data page. */ export const FOUNDATION_TYPE_NAMES: ReadonlySet = new Set([ + 'Entry', 'Array', 'Plugin', 'AbortSignal', 'AsyncIterable', 'Context', 'Error', 'EntryTree', + 'Fiber', 'EntryOptions', 'Exclude', 'Extract', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 24b67fb9a6..fddee879ac 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -31,7 +31,7 @@ interface ServiceRole { key: string pkg: string title: string - mode: 'core' | 'seam' | 'bundle' + mode: 'core' | 'seam' | 'bundle' | 'service' implementations?: string[] consumers?: string[] companions?: string[] @@ -338,14 +338,21 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['agent-loop', 'tool-bash', 'hooks-claude-code', 'hooks-codex', 'session-query', 'session-query-sqlite', 'message-feedback'], note: 'The JSONL backend persists the SessionEvent vocabulary as one artifact per Session.', }, + { + key: 'configEditor', + pkg: 'config-editor', + title: 'Profile configuration edits', + mode: 'core', + consumers: ['settings', 'agent-default-model'], + note: 'Persists profile config patches under the application file lock and HMR queue, then reconciles Loader entries.', + }, { key: 'settings', pkg: 'settings', - title: 'User-settings seam', - mode: 'seam', - implementations: ['settings-file'], - consumers: ['api-settings-controller', 'llm-deepseek', 'llm-pi-ai'], - note: 'Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the settings controller serves redacted layered descriptors and writes the user layer.', + title: 'Plugin configuration forms', + mode: 'core', + consumers: ['api-settings-controller'], + note: 'Forms project volatile Config fields from active profile entries and delegate validated edits to config-editor. Plugins consume their own Config references.', }, { key: 'subagentModelSelection', @@ -364,6 +371,15 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['api-settings-controller', 'llm-deepseek', 'llm-pi-ai'], note: 'Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the settings controller exposes value-free views and write-only storage.', }, + { + key: 'deepseekAccount', + pkg: 'deepseek-account', + title: 'DeepSeek account', + mode: 'seam', + implementations: ['deepseek-account-platform'], + consumers: ['api-account-controller', 'llm-deepseek'], + note: 'The Host owns browser authorization and local credentials; UI consumers receive state without tokens.', + }, { key: 'authorization', pkg: 'authorization', @@ -373,6 +389,14 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['llm-pi-ai'], note: 'Flows are registered by the plugin that knows how to obtain one credential and keyed by the record they write; the seam owns the conversation and the one-attempt-per-key lifecycle, never the protocol.', }, + { + key: 'productTelemetry', + pkg: 'host-product-telemetry-otel', + title: 'Product usage event sender', + mode: 'service', + note: 'Exports explicitly submitted analytics events through OTLP/HTTP; mounting alone collects nothing.', + }, + { key: 'sessionTelemetry', pkg: 'session-telemetry', @@ -538,7 +562,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Default Agent model selection', mode: 'core', consumers: ['api-session-controller', 'headless'], - note: 'Layers the default ModelSelection through settings so direct and Host-backed Agent entry points share one state owner.', + note: 'Reads the default ModelSelection from volatile Config and saves selections through the profile editor.', }, { key: 'agentLoop', @@ -863,7 +887,7 @@ function renderCapabilitySeams(pkgs: Pkg[], services: readonly ServiceEntry[]): const addEdge = (from: string, to: string): void => { edges.add(` ${from} --> ${to}`) } const lines = generatedHeader('Capability Seams And Core Services') lines.push( - 'A service can be a core spine service, a swappable capability seam, or a bundle/composition point. The graph shows the package that owns the service declaration, known implementation packages, and packages that consume the service directly.', + 'A service can be a core spine service, a swappable capability seam, a bundle/composition point, or a standalone service. The graph shows the package that owns the service declaration, known implementation packages, and packages that consume the service directly.', '', '```mermaid', 'flowchart LR', diff --git a/scripts/gen-plugin-packages.ts b/scripts/gen-plugin-packages.ts new file mode 100644 index 0000000000..49d34c56d9 --- /dev/null +++ b/scripts/gen-plugin-packages.ts @@ -0,0 +1,83 @@ +/** + * Generate the loadable-plugin package list shipped inside the + * `cordis-composition-reference` skill from the workspace manifests and the + * config-catalog classification. `--check` exits 1 when the committed file is stale. + */ + +import { readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { collectConfigCatalog, type CatalogEntry } from './gen-config-catalog.ts' + +const root = resolve(import.meta.dirname, '..') +const OUT = 'packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md' + +interface Row { + pkg: string + group: string + config: boolean + description: string +} + +function rows(entries: readonly CatalogEntry[]): Row[] { + const result: Row[] = [] + for (const entry of entries) { + // Seams are abstract Service Definitions and libraries export no plugin; neither is a Loader row. + if (entry.kind !== 'config' && entry.kind !== 'no-config') continue + const manifest = JSON.parse(readFileSync(resolve(root, entry.dir, 'package.json'), 'utf8')) as { description: string } + // Catalog dirs are `packages//`, so the group segment is always present. + const group = entry.dir.split('/')[1] ?? entry.dir + result.push({ pkg: entry.pkg, group, config: entry.kind === 'config', description: manifest.description }) + } + return result.sort((left, right) => left.group.localeCompare(right.group) || left.pkg.localeCompare(right.pkg)) +} + +/** + * Render the package list as one Markdown document grouped by package group. + * @param entries - config-catalog entries for every workspace package. + * @returns the complete file content. + */ +export function render(entries: readonly CatalogEntry[]): string { + const lines = [ + '# Loadable Harness plugin packages', + '', + 'This file is GENERATED from workspace manifests (`scripts/gen-plugin-packages.ts`) and verified fresh by `pnpm run verify-plugin-packages` (part of `doc-sync`); do not edit it by hand.', + '', + 'Every package below exports a Cordis plugin that a bundle patch can name in a Loader row. `Config` marks packages whose row accepts a `config` mapping; query `Config.listConfigs` through `cordis_inspect_query` (filter by `name`, then query the `entry` id) for the mounted schema. Packages under `experimental` are pre-stable.', + '', + ] + let group = '' + for (const row of rows(entries)) { + if (row.group !== group) { + if (group !== '') lines.push('') + group = row.group + lines.push(`## ${group}`, '', '| Package | Config | Description |', '|---|---|---|') + } + lines.push(`| \`${row.pkg}\` | ${row.config ? 'yes' : 'no'} | ${row.description} |`) + } + return `${lines.join('\n')}\n` +} + +function main(): void { + const content = render(collectConfigCatalog()) + if (process.argv.includes('--check')) { + let committed: string | null + try { + committed = readFileSync(resolve(root, OUT), 'utf8') + } catch { + // A missing or unreadable file has the same remedy as a stale one: regenerate. + committed = null + } + if (committed === content) { + console.log(`gen-plugin-packages: ${OUT} is up to date.`) + process.exit(0) + } + console.error(`gen-plugin-packages: ${OUT} is stale. Run \`pnpm run gen-plugin-packages\` and commit ${OUT}.`) + process.exit(1) + } + writeFileSync(resolve(root, OUT), content) + console.log(`gen-plugin-packages: wrote ${OUT}.`) +} + +if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) { + main() +} diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index 6cdd9d9122..b16de71f9c 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -311,9 +311,6 @@ "ac0a86d16dd1fa30322d4ca2becc1864f716b08efd5826d74b271f07d0f3d605": 1, "dc2f3b946d1994a23ad82426c87326a6c50a477719f9ae1b4687087522d9571b": 1 }, - "packages/api/settings-controller/tests/settings-controller.host.spec.ts": { - "378e52325f972299c6e61e0393a39b49090f20d350a8030c32ae17b0c2360bbc": 1 - }, "packages/api/terminal-controller/src/index.ts": { "04bc8a44533574365dfedf563e2356458bcf95f990a00906938c977c1d683356": 2 }, @@ -506,7 +503,6 @@ "12a4fcbcbe9194fe012835a6c0346eab98441db62b74da62f80a8cd510774c5a": 1 }, "packages/client/ui-chat/tests/chat-view.client.spec.tsx": { - "5e81d253ded2bb3e281d730022707bacaa131618fcdfc55faa04be9a22f32956": 1, "6061de0a1e01547eb1abf9d4e1b5fa79fda112fa35dda980775491123413dcce": 3, "6787497d471fc8faaa78ffcbd497c36ceba82c1b8ee16d0d450b405ad4dddc25": 1 }, @@ -624,13 +620,10 @@ "b1db56322d2f3e549d59e65fd4b0a61e7c5d959f9f9bdfa5157a33e3ed1f3e9b": 1 }, "packages/client/ui-plan/tests/plan-preview.client.spec.tsx": { - "28772c6dac133ff70e4a6888fe587ace22036e384f547f65e8fa26f4cbf7e687": 1, "3b2258b8a75570daf0fa8c497d23f274d8e414f35508a6275932c3806c4c4484": 1, - "3cc0b750774bc690cd330554d82a673b6a5012af6da86c299dff0ecb6403dde9": 1, "3d9d4fce115bbdf1ad3ed4af51c49b19e7e1bcefefe25c373a7270dec8d73de3": 1, "41f50f1679049edf1f4a18e936df5a2ceb2de04f1e43a4fad07d8e93578d2cc6": 1, "53f1197af19938d444b06ebf666a8751240d0b5e5ea13edf6100316d1bc5ede2": 5, - "6b37aaa6c00e0f7146f39b3667a66eddcdb51601262fcf855e5a859783f2963b": 1, "9bb8efa3dd59e674ef31f5af43d4aac13ca59d96dd418fe3bf07199c8963c31c": 2, "bd4e8147a13bee681bd192d1558f4a79de23d5edeafb616e8acccd725465ef98": 1, "e01c2c40d53d557b621628de47a0a6677e11b4f151fbefd865c97be99a7b6c48": 1, @@ -688,25 +681,9 @@ "79c53da50aa08004e201cebca842267ae2650a543b435a7906960f6492c2398b": 1, "a3c1509d552d0102e118f3071a234002a5c83c30ca6923f8a87e9e3733ce0dcf": 1 }, - "packages/client/ui-settings-plugins/tests/apply.client.spec.ts": { - "3d455f54ba188122f29085e9d462bebbca24c517057052f346229efd175e6e3c": 1 - }, - "packages/client/ui-settings-plugins/tests/section.client.spec.tsx": { - "0d8472724f1363e2891409f0a3a4add1cff1ca68b49e3c553457ad69deb9743c": 2, - "203a19d9e599f039376a58265a2e59a5350752ca21ed2502095b3bc73cd45c6a": 1, - "878e5d009eb310a1e5574351c795dfa691449b162d027f7639220075957a5b18": 1, - "9f2da941345134a519a3be6744a11f5e6a1fcaa70ab3ac6ca40f8ddec52e172f": 1, - "ab5c523f935fc83dfa5acb5919ea34b578739a4703cf30de5382aaba06250af7": 1, - "fc819a8f00497253d2231099c726434b4ed85f9677af7d287111e52c09f005a7": 1, - "fd37ca50cf574f86f8640694f4e2bc7466e97e9d54869402a08cceab69dee52c": 1 - }, "packages/client/ui-settings/tests/schema.client.spec.ts": { "0584be4cde4cd33049ef7c02e9d1562a30ec48ef2176f2f22b328b5be2903407": 1 }, - "packages/client/ui-settings/tests/settings-scope.client.spec.ts": { - "14a0dc9ccde2879c016f779e0e43f3aa71792759126530b418c9a31a5a2e7a53": 1, - "ad8886dedfafd8e5f4bf7cb55525bb961822ad0c4fb0ff4a643bfb336fb9d94b": 1 - }, "packages/client/ui-sidebar-documentpreview/tests/document-toolbar.client.spec.tsx": { "fb9804cc2774409af6935d5a1369497b433c34cd9305cac88e3400eae0f70936": 1 }, @@ -764,12 +741,6 @@ "packages/client/ui-sidebar-right/src/client/persistence.ts": { "58c93ff6942e5ed7b892ff2e537c4304dbf2d7366e55b5c08559452c9a25bbd8": 1 }, - "packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx": { - "0f8ea23b013318ec4b8d64e4ae2bd9d54a57b7a7d4be5b88ed0eb332ee42e76c": 1 - }, - "packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx": { - "8ba56a6ed5e4e5fa21ddca80c6bcd30e7c7f2117b8949e4175e3723e66c9d8ed": 1 - }, "packages/client/ui-sidebar-right/tests/seat.client.spec.tsx": { "1e3017afba65017e855900ad51f65999e712b08beb1b33bfe996d3cdb137a105": 1 }, @@ -779,9 +750,6 @@ "packages/client/ui-sidebar-terminal/tests/terminal-cursor.client.spec.ts": { "c17c30832b2cde55b57c2c94083562feb1e941ade2d01268f1d49d31a6c6e2ca": 1 }, - "packages/client/ui-sidebar-terminal/tests/terminal-guide.client.spec.tsx": { - "303c4744be6bc68cb05cd1c303a49602cac074bfaf34cf4d8e2b324a6acf45ff": 1 - }, "packages/client/ui-sidebar-terminal/tests/terminal-theme.client.spec.ts": { "1fa946c54b497010d85a36dcb586ae0a99991d8eb4eab8ef709eb34af373fdc5": 1 }, @@ -810,38 +778,17 @@ "packages/client/ui-theme/tests/theme.client.spec.ts": { "6adc289cf963470891b9b4aea9e09db4c98d96d09a245bf41bf679f8f608ba49": 1 }, - "packages/client/ui-tool/tests/ask-question-row.client.spec.tsx": { - "6c5d515baefa7ae94d299082c4608ebf1baccf92b3dc05cc97cf34951628cf34": 1 - }, - "packages/client/ui-tool/tests/coverage-tails.client.spec.tsx": { - "7f912fda4081d62dbde16769dca200121033ea3b5fc93593ce6ede98cd3a4400": 1 - }, - "packages/client/ui-tool/tests/diff-card.client.spec.tsx": { - "31f883ce7d40cb93793940d43c9b4b9c68b4aa805511ef875c42c29847aacc5a": 1 - }, "packages/client/ui-tool/tests/image-card.client.spec.tsx": { "1a5ae4c2528dae3d43a3decbe30410647b6e60104341e070fd4de2c6a668a607": 1, "1d34efc5aadced56f018a7f1b453e5b2117ab13ab6e414a7bf32584a9b168af4": 1, "7266b2c682605ac318dad266d9371b6061a73746beac20f2fd94e9391eca5d4a": 1, - "9994235ebbd3c4d4d72805505a6d36e41399f4951237b25402a11dcf0ca925b1": 1, "a4968fcd795d3444b0896dcd7e9688e84335d4fcc72e138c46f951d157a421c7": 1 }, "packages/client/ui-tool/tests/read-card.client.spec.tsx": { - "ad325101d9586299adfd569b444e203060197b4613a9ca3b36671b96a635c569": 1, - "d321ccf1d92d73552e648e4573d724b9d2eacc7891293d59a99eb7d35bfe1e4e": 1 - }, - "packages/client/ui-tool/tests/search-card.client.spec.tsx": { - "10fd8811b2062bc69e6f8ee0a71a3445f52d6ea8f0eb3c7b3199032e61633ef0": 1 - }, - "packages/client/ui-tool/tests/terminal-card.client.spec.tsx": { - "dbb68ed1ba48d383c92929b0478380d0b06c7caa8e07f9d19ba921a33d78c890": 1 - }, - "packages/client/ui-tool/tests/todo-row.client.spec.tsx": { - "2de567342e3ab05a9bbb5956c42bfeea3ea95934b16a07bb1ff85965abdc7859": 1 + "ad325101d9586299adfd569b444e203060197b4613a9ca3b36671b96a635c569": 1 }, "packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx": { - "0579b8a3ceb3401991284810b465c683c710280c41f8e454e41183872b2475d1": 1, - "108b5ea1abc16a6931c62791ff63d28479332324a28b194ef063b26821ed33c1": 1 + "0579b8a3ceb3401991284810b465c683c710280c41f8e454e41183872b2475d1": 1 }, "packages/client/ui-tool/tests/tool-fixtures.client.ts": { "9a6d99786c6afdc9d28a2984f465f14ddf09c84795c6c02abfad39c3013cec69": 1, @@ -1351,7 +1298,6 @@ }, "packages/extensions/tool-cordis/src/providers.ts": { "1b3ab83a5e026a9147a8e49324b677b78af1f2983acde975fe791b29ff755f40": 1, - "2efdc6a912b71e4b7c561b1e7ba6975dab2927589c8b1f1af65806410d065a1e": 1, "3706ba0e7ecb37ef9aa4d5e6bb732001b78443acf77f015f97b1b11d3a2cf941": 1, "67c8811e65c7d4c40d6de95cb9b9576873c717164d609124dcac39e82e95abca": 1 }, @@ -1484,9 +1430,6 @@ "34a631902f39d3a40e1baabacdccb986c3a2205cf2a6c8c1880061b87f39609d": 1, "98bef9260e144cee077af05112a07d3e9626caa43f06c66ad08f9c024f5469b1": 1 }, - "packages/llm/llm-pi-ai/tests/loader-composition.spec.ts": { - "59c63f734e8bba862325f776e3fcbebd78a84cfb4161f8df41c216950d9bf351": 1 - }, "packages/llm/llm-pi-ai/tests/login.spec.ts": { "430df7f7adc302969806296226e1cfda66bccf28e759a7b67a78e386a87db68f": 1 }, @@ -1887,18 +1830,6 @@ "276133cf0e0a7a3f0adfedec0eeee1581a7af3cc1d3bafe7932db7ec5b8506fb": 1, "ad526f8b346ca3f2e278bd8f43478c60f94078ec0958fa929978ce1763be7702": 1 }, - "packages/settings/settings-file/tests/loader-composition.spec.ts": { - "59c63f734e8bba862325f776e3fcbebd78a84cfb4161f8df41c216950d9bf351": 1 - }, - "packages/settings/settings-file/tests/lock-race.spec.ts": { - "f73d407dd2fca4f93a7048b093eeb304136332365828ba790e9911cb873ed1fe": 1 - }, - "packages/settings/settings-file/tests/watcher.spec.ts": { - "6dfe702b6cb8848eeb1d56c5af6ff723d84841aaa1d96790295db4ed70616c65": 1 - }, - "packages/settings/settings/tests/settings.spec.ts": { - "2d7f707771049cc7d16595e3e16fdaafdbca2d271008f5b55a88c407cef7173c": 4 - }, "packages/shell/bash-sandbox/tests/partial-landlock.spec.ts": { "50ac027a8a646160d7f7f539375421b31a9aa3da906d9a6be03222aaa8cb029a": 2 }, diff --git a/scripts/release/bump.ts b/scripts/release/bump.ts index 146580105a..d41afe7fd1 100644 --- a/scripts/release/bump.ts +++ b/scripts/release/bump.ts @@ -19,7 +19,7 @@ import { globSync, readFileSync, writeFileSync } from 'node:fs' import { join, matchesGlob } from 'node:path' import { parseArgs } from 'node:util' import { releaseFamily, type ReleaseFamily, type ReleaseMember } from './families.ts' -import { capture, isEntry } from './process.ts' +import { capture, isEntry, pnpmCommand } from './process.ts' /** Files npm publishes whether or not `files` lists them. */ const ALWAYS_PUBLISHED = ['package.json', 'README*', 'LICENSE*', 'LICENCE*'] as const @@ -392,7 +392,8 @@ function main(): void { const dryRun = values['dry-run'] if (!dryRun) { for (const entry of planned) writeVersion(root, entry.manifestPath, entry.from, entry.to) - capture('pnpm', ['install', '--lockfile-only']) + const [pnpm, ...pnpmArgs] = pnpmCommand() + capture(pnpm, [...pnpmArgs, 'install', '--lockfile-only']) } const summary = sharedVersion diff --git a/scripts/release/process.spec.ts b/scripts/release/process.spec.ts new file mode 100644 index 0000000000..f06b1a782e --- /dev/null +++ b/scripts/release/process.spec.ts @@ -0,0 +1,51 @@ +import { execFileSync } from 'node:child_process' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { capture, pnpmCommand } from './process.ts' +import { packedIdentity, tarballFiles } from './tarball.ts' + +const directories: string[] = [] + +afterEach(async () => { + for (const directory of directories.splice(0)) await rm(directory, { recursive: true, force: true }) +}) + +/** + * Pack one manifest into a tarball shaped like `npm pack` output. + * @returns Absolute path of the archive. + */ +async function packedTarball(name: string, version: string): Promise { + const directory = await mkdtemp(join(tmpdir(), 'dsh-tarball-')) + directories.push(directory) + const contents = join(directory, 'package') + await import('node:fs/promises').then(async fs => fs.mkdir(contents)) + await writeFile(join(contents, 'package.json'), JSON.stringify({ name, version })) + await writeFile(join(contents, 'index.js'), '') + const archive = join(directory, `${name.replaceAll('/', '-').replace('@', '')}-${version}.tgz`) + execFileSync('tar', ['-czf', basename(archive), 'package'], { cwd: dirname(archive) }) + return archive +} + +describe('release process helpers', () => { + it('runs pnpm through a JavaScript entry, which spawnSync can start on Windows', () => { + const [command, ...args] = pnpmCommand() + expect(command === 'pnpm' || /node(?:\.exe)?$/iu.test(command)).toBe(true) + if (command !== 'pnpm') expect(args[0]).toMatch(/pnpm/u) + expect(capture(command, [...args, '--version'])).toMatch(/^\d+\.\d+\.\d+/u) + }) +}) + +describe('release tarball readers', () => { + it('lists members of an archive under an absolute path', async () => { + // GNU tar reads the colon in a Windows drive path as a remote host, so the readers run beside the archive. + const archive = await packedTarball('@deepseek-ai/dsh-probe', '1.2.3') + expect(tarballFiles(archive)).toEqual(expect.arrayContaining(['package/package.json', 'package/index.js'])) + }) + + it('reads what an archive declares about itself', async () => { + const archive = await packedTarball('@deepseek-ai/dsh-probe', '1.2.3') + expect(packedIdentity(archive)).toEqual({ name: '@deepseek-ai/dsh-probe', version: '1.2.3' }) + }) +}) diff --git a/scripts/release/process.ts b/scripts/release/process.ts index c85ca2c719..b25fa8c116 100644 --- a/scripts/release/process.ts +++ b/scripts/release/process.ts @@ -4,7 +4,8 @@ */ import { spawn, spawnSync } from 'node:child_process' -import { realpathSync } from 'node:fs' +import { existsSync, realpathSync } from 'node:fs' +import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' /** Where and with what environment a release step runs a command. */ @@ -100,3 +101,29 @@ export function isEntry(moduleUrl: string): boolean { if (invoked === undefined) return false return realpathSync(invoked) === realpathSync(fileURLToPath(moduleUrl)) } + +/** + * Resolve pnpm as a command prefix that spawns without a shell. On Windows the + * `pnpm` shim is a `.cmd` batch file, which `spawnSync` refuses to run unless a + * shell interprets it, so the prefix runs pnpm's JavaScript entry with the + * current Node instead: the entry that launched this script when a run-script + * did, or the workspace's own pnpm dependency otherwise. pnpm's manifest does + * not export its bin entry, so the dependency is located on disk rather than + * through module resolution. + * @returns Command and leading arguments to prepend before pnpm's arguments. + */ +export function pnpmCommand(): readonly [command: string, ...args: string[]] { + const execpath = process.env.npm_execpath + // `npm run` and `yarn run` set this too, and handing pnpm's arguments to either would write a different lockfile. + if (execpath !== undefined && /[\\/]pnpm[\\/]/u.test(execpath) && /\.[cm]?js$/u.test(execpath)) return [process.execPath, execpath] + for (let directory = dirname(fileURLToPath(import.meta.url)); ; directory = dirname(directory)) { + const entry = join(directory, 'node_modules', 'pnpm', 'bin', 'pnpm.cjs') + if (existsSync(entry)) return [process.execPath, entry] + if (dirname(directory) === directory) break + } + // Windows resolves a bare `pnpm` to a batch shim that spawnSync cannot start, so say that rather than fail later. + if (process.platform === 'win32') { + throw new Error('release: cannot locate pnpm\'s JavaScript entry; run this through a pnpm script or install workspace dependencies') + } + return ['pnpm'] +} diff --git a/scripts/release/tarball.ts b/scripts/release/tarball.ts index b8a005e748..fc2b6dc973 100644 --- a/scripts/release/tarball.ts +++ b/scripts/release/tarball.ts @@ -7,7 +7,7 @@ */ import { readFileSync } from 'node:fs' -import { join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { capture } from './process.ts' /** Name of the file recording the order in which a packed family uploads. */ @@ -27,7 +27,8 @@ export interface PackedIdentity { * @returns Every path inside the archive. */ export function tarballFiles(tarball: string): string[] { - return capture('tar', ['-tzf', tarball]).split(/\r?\n/u).filter(line => line !== '') + // GNU tar reads the colon in a Windows drive path as a remote-host separator, so tar runs beside the tarball. + return capture('tar', ['-tzf', basename(tarball)], { cwd: dirname(tarball) }).split(/\r?\n/u).filter(line => line !== '') } /** @@ -36,7 +37,7 @@ export function tarballFiles(tarball: string): string[] { * @returns The name and version the tarball declares. */ export function packedIdentity(tarball: string): PackedIdentity { - const manifest: unknown = JSON.parse(capture('tar', ['-xOzf', tarball, 'package/package.json'])) + const manifest: unknown = JSON.parse(capture('tar', ['-xOzf', basename(tarball), 'package/package.json'], { cwd: dirname(tarball) })) if (manifest === null || typeof manifest !== 'object') throw new Error(`${tarball} has no manifest`) const { name, version } = manifest as Record if (typeof name !== 'string' || typeof version !== 'string') throw new Error(`${tarball} manifest lacks name/version`) diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index 5d66a71f74..dc12992278 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -80,6 +80,9 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [ { file: 'vendor/schemastery/src/index.ts', upstream: ['schemastery'] }, // Narrows a Standard Schema by the same upstream `vendor:` identifier. { file: 'vendor/loader/src/config/diff.ts', upstream: ['schemastery'] }, + // Native schema detection and its fixture share the vendored runtime's Symbol.for identifier. + { file: 'packages/boot/app-boot/src/config-schema/native.ts', upstream: ['schemastery'] }, + { file: 'packages/boot/app-boot/tests/config-schema.spec.ts', upstream: ['schemastery'] }, // Asserts the vendored-manifest table, which gains an upstream-name column. { file: 'scripts/gen-third-party-notices.spec.ts', upstream: RENAMES.map(rename => rename.upstream) }, // `cordis` is also an agent-preset id, so in these files the bare name is diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 69f4474289..d0c4773c66 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -797,6 +797,7 @@ function docSyncLeafGates(options: { pnpmScript('export-jsdoc', 'verify-export-jsdoc', { label: 'export jsdoc' }), pnpmScript('tool-catalog', 'verify-tool-catalog', { label: 'tool catalog' }), pnpmScript('config-catalog', 'verify-config-catalog', { label: 'config catalog' }), + pnpmScript('plugin-packages', 'verify-plugin-packages', { label: 'skill plugin package list' }), pnpmScript('dependency-catalog', 'verify-dependency-catalog', { label: 'npm dependency catalog', quick: true }), pnpmScript('persistence-catalog', 'verify-persistence-catalog', { label: 'persistence catalog' }), pnpmScript('persistence-changes', 'verify-persistence-changes', { label: 'persistence type history' }), diff --git a/scripts/run-web-snapshots.ts b/scripts/run-web-snapshots.ts index 0a02632ad0..cb05c2283c 100644 --- a/scripts/run-web-snapshots.ts +++ b/scripts/run-web-snapshots.ts @@ -4,6 +4,7 @@ import { pnpmInvocation } from './pnpm-invocation.ts' const serialFiles = [ 'apps/web/tests/hmr-live.e2e.ts', + 'apps/web/tests/client-plugin-live.e2e.ts', ] const workerRaw = process.env.DSH_WEB_SNAPSHOT_WORKERS const workers = Number.parseInt(workerRaw ?? '', 10) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index e36b61fbf7..13b995afc4 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1726,36 +1726,6 @@ "symbol": "SubprocessCollectedOutputs", "source": "packages/subprocess/subprocess/src/types.ts" }, - { - "doc": "docs/subsystems/settings.md", - "symbol": "SettingsNamespace", - "source": "packages/settings/settings/src/types.ts" - }, - { - "doc": "docs/subsystems/settings.md", - "symbol": "SettingsRegisterOptions", - "source": "packages/settings/settings/src/index.ts" - }, - { - "doc": "docs/subsystems/settings.md", - "symbol": "SettingsApplies", - "source": "packages/settings/settings/src/index.ts" - }, - { - "doc": "docs/subsystems/settings.md", - "symbol": "SettingsScope", - "source": "packages/settings/settings/src/index.ts" - }, - { - "doc": "docs/subsystems/settings.md", - "symbol": "SettingsDescriptor", - "source": "packages/settings/settings/src/index.ts" - }, - { - "doc": "docs/subsystems/settings.md", - "symbol": "SettingsUpdateSource", - "source": "packages/settings/settings/src/types.ts" - }, { "doc": "docs/subsystems/credentials.md", "symbol": "ResolvedCredential", @@ -1766,21 +1736,11 @@ "symbol": "CredentialInfo", "source": "packages/credentials/credentials/src/types.ts" }, - { - "doc": "docs/subsystems/settings.md", - "symbol": "SettingsDescribeOptions", - "source": "packages/settings/settings/src/index.ts" - }, { "doc": "docs/subsystems/llm-streaming.md", "symbol": "LlmConfigurableProvider", "source": "packages/llm/llm/src/types.ts" }, - { - "doc": "docs/subsystems/settings.md", - "symbol": "SettingsPathOp", - "source": "packages/settings/settings/src/index.ts" - }, { "doc": "docs/subsystems/permission-presets.md", "symbol": "Config", @@ -1866,6 +1826,16 @@ "symbol": "Workspace", "source": "packages/workspace/workspace/src/types.ts" }, + { + "doc": "docs/subsystems/workspace.md", + "symbol": "SessionActivityKindMap", + "source": "packages/workspace/workspace/src/types.ts" + }, + { + "doc": "docs/subsystems/workspace.md", + "symbol": "SessionActivity", + "source": "packages/workspace/workspace/src/types.ts" + }, { "doc": "docs/subsystems/client-modules.md", "symbol": "WebBootEntry", @@ -2256,6 +2226,16 @@ "doc": "docs/subsystems/deliverables.md", "symbol": "WorkspaceChanges", "source": "packages/deliverables/workspace-changes/src/types.ts" + }, + { + "doc": "docs/subsystems/product-telemetry.md", + "symbol": "ProductTelemetryScalar", + "source": "packages/host/product-telemetry-otel/src/index.ts" + }, + { + "doc": "docs/subsystems/product-telemetry.md", + "symbol": "ProductTelemetryRecord", + "source": "packages/host/product-telemetry-otel/src/index.ts" } ] } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 55efc40035..667b4878e6 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -49,6 +49,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/experimental/speech-to-text-sensevoice': { kind: 'none', reason: 'Local recognition returns transient text without modifying model context.' }, 'packages/experimental/client-ui-voice-input': { kind: 'none', reason: 'Inserts reviewable text into the unsent draft without submitting to the Agent.' }, 'packages/experimental/voice-input-bundle': { kind: 'none', reason: 'Composes dictation and preparation plugins without adding any model-facing contribution.' }, + 'packages/host/product-telemetry-otel': { kind: 'none', reason: 'The plugin exports explicit analytics records without contributing model context.' }, 'packages/api/terminal-controller': { kind: 'none', reason: 'User-owned terminal processes and screen streams never enter model requests or Session events.' }, 'packages/client/ui-sidebar-terminal': { kind: 'none', reason: 'The browser renders user terminal screens without exposing them to the model.' }, 'packages/ssh/ssh': { kind: 'none', reason: 'The connection owner transports private provider operations; consumers own all model-facing content.' }, @@ -59,6 +60,12 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/experimental/browser-use-runtime': { kind: 'indirect', reason: 'Browser providers call the library to own resources and expose upstream MCP tools.' }, 'packages/computer-use/computer-use': { kind: 'none', reason: 'The registry only reserves provider names; providers own all model-facing tools and guidance.' }, 'packages/document/office-to-pdf': { kind: 'none', reason: 'The provider returns PDF bytes without constructing model input.' }, + + 'packages/credentials/deepseek-account': { kind: 'none', reason: 'Account authorization registers no model context or tools; credentials affect HTTP authentication only.' }, + 'packages/credentials/deepseek-account-platform': { kind: 'none', reason: 'Account authorization registers no model context or tools; credentials affect HTTP authentication only.' }, + 'packages/api/account-controller': { kind: 'none', reason: 'Account authorization registers no model context or tools; credentials affect HTTP authentication only.' }, + 'packages/client/ui-settings-account': { kind: 'none', reason: 'Account authorization registers no model context or tools; credentials affect HTTP authentication only.' }, + 'packages/attachment/attachment': { kind: 'indirect', reason: 'The storage seam delegates model request rendering to provider adapters.' }, 'packages/attachment/attachment-local': { kind: 'indirect', reason: 'The local backend delegates model request rendering to provider adapters.' }, 'packages/client/ui-jobs': { kind: 'none', reason: 'Browser-side presentation of host-observed output; touches no prompt, message, schema, stream, or tool result.' }, @@ -100,6 +107,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/file-upload': { kind: 'none', reason: 'Browser-side request-body transport; registers nothing model-facing.' }, 'packages/api/remotes': { kind: 'none', reason: 'The Remote BFF selects business methods and forwarded events; selected services own any model-visible effect.' }, 'packages/experimental/client-ui-agent-team': { kind: 'none', reason: 'The browser projection and task controls register no model-facing input.' }, + 'packages/client/shortcuts': { kind: 'none', reason: 'Browser keyboard routing; registers nothing model-facing.' }, + 'packages/client/ui-shortcuts': { kind: 'none', reason: 'Read-only browser reference; registers nothing model-facing.' }, 'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-dockkit': { kind: 'none', reason: 'Browser-side docking layout engine and components; registers nothing model-facing.' }, 'packages/client/ui-sidebar-right': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, @@ -120,6 +129,10 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/extensions/ui-cordis': { kind: 'indirect', reason: 'The definition card drives the host dynamic run/stop verbs that the model\'s cordis_run/cordis_stop tools also reach; the runner owns any model-visible effect.' }, 'packages/client/ui-permission-presets': { kind: 'indirect', reason: 'The picker submits the host /permission command; the knob events it appends own the model-visible effect through the sandbox/approval consumers.' }, 'packages/client/ui-settings-plugins': { kind: 'none', reason: 'Browser-side settings surface; registers no model surface.' }, + 'packages/client/ui-settings-shell': { kind: 'none', reason: 'Browser-side settings surface; registers no model surface.' }, + 'packages/client/ui-settings-agent-loop': { kind: 'none', reason: 'Browser-side settings surface; registers no model surface.' }, + 'packages/client/ui-settings-subagent': { kind: 'none', reason: 'Browser-side settings surface; registers no model surface.' }, + 'packages/client/ui-settings-web-search': { kind: 'none', reason: 'Browser-side settings surface; registers no model surface.' }, 'packages/client/ui-plan': { kind: 'indirect', reason: 'The chip dispatches /plan off; dsh-plan-mode owns the model-visible policy, exit tool, and logged state.' }, 'packages/client/ui-user-questions': { kind: 'indirect', reason: 'The package mounts dsh-tool-ask-user; that tool owns the model-visible schema and answer rendering.' }, 'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, @@ -170,8 +183,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/session/session-turn-outline': { kind: 'none', reason: 'The turnOutline unit folds already-logged turn boundaries into a client-facing read model and registers nothing model-facing.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers nothing model-facing.' }, 'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers nothing model-facing.' }, + 'packages/boot/config-editor': { kind: 'indirect', reason: 'Configuration consumers own model-visible behavior; this service persists profile edits.' }, 'packages/settings/settings': { kind: 'indirect', reason: 'The seam stores and resolves user settings; consumer plugins own any model-facing content fed by a value.' }, - 'packages/settings/settings-file': { kind: 'indirect', reason: 'The file provider stores and publishes namespace sections; consumers of ctx.settings own any model-facing behavior.' }, 'packages/credentials/credentials': { kind: 'indirect', reason: 'The seam resolves credential references; the consuming adapter owns every model-facing use a value authorizes.' }, 'packages/credentials/credentials-local': { kind: 'indirect', reason: 'The file/environment provider stores credential values; consumers of ctx.credentials own any model-facing behavior.' }, 'packages/credentials/authorization': { kind: 'none', reason: 'A configuration-time conversation with a human; no flow, notice, or prompt reaches a model request.' }, diff --git a/snapshots/session/advanced-toolchain-runtime/session.1.v4.jsonl b/snapshots/session/advanced-toolchain-runtime/session.1.v4.jsonl new file mode 100644 index 0000000000..f0b2556188 --- /dev/null +++ b/snapshots/session/advanced-toolchain-runtime/session.1.v4.jsonl @@ -0,0 +1,18 @@ +{"type":"session","version":4,"id":"{{session:2}}","createdAt":1783950001000,"cwd":"{{cwd}}","parentSession":"{{session:1}}","isSeeded":false,"origin":"subagent","delegationDepth":1} +{"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}} +{"type":"approval/policy","data":{"policy":"never","source":"delegation"}} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:13}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:14}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:13}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{message:15}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[9],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:16}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788699344566,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788699344566,"index":0,"dt":[],"texts":["DIRECT_CHILD_OK"]},{"type":"chunk","time":1788699344566,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}},{"type":"chunk","time":1788699344566,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788699344566,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/advanced-toolchain-runtime/session.2.v4.jsonl b/snapshots/session/advanced-toolchain-runtime/session.2.v4.jsonl new file mode 100644 index 0000000000..b0b648184c --- /dev/null +++ b/snapshots/session/advanced-toolchain-runtime/session.2.v4.jsonl @@ -0,0 +1,18 @@ +{"type":"session","version":4,"id":"{{session:3}}","createdAt":1783950002000,"cwd":"{{cwd}}","parentSession":"{{session:1}}","isSeeded":false,"origin":"subagent","delegationDepth":1} +{"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}} +{"type":"approval/policy","data":{"policy":"never","source":"delegation"}} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:17}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn"}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:18}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:17}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{message:19}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[9],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:20}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788699344748,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788699344748,"index":0,"dt":[],"texts":["WORKFLOW_CHILD_OK"]},{"type":"chunk","time":1788699344748,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}},{"type":"chunk","time":1788699344748,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788699344748,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/advanced-toolchain-runtime/session.v4.jsonl b/snapshots/session/advanced-toolchain-runtime/session.v4.jsonl new file mode 100644 index 0000000000..23001b53d5 --- /dev/null +++ b/snapshots/session/advanced-toolchain-runtime/session.v4.jsonl @@ -0,0 +1,43 @@ +{"type":"session","version":4,"id":"{{session:1}}","createdAt":1783950000000,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: list runtime inspect providers; discover providers through run_code when available; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; list providers again; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: list runtime inspect providers; discover providers through run_code when available; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; list providers again; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Run this advanced flow exactly","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-inspect-before","name":"cordis_inspect_list","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788880370600,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788880370600,"index":0,"dt":[],"id":"advanced-inspect-before","name":"cordis_inspect_list","args":["{}"]},{"type":"chunk","time":1788880370600,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-inspect-before","name":"cordis_inspect_list","arguments":"{}"}}},{"type":"chunk","time":1788880370600,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788880370600,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"advanced-inspect-before","name":"cordis_inspect_list","arguments":"{}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"advanced-inspect-before"},"toolCallId":"advanced-inspect-before","content":[{"type":"text","text":"{\n \"providers\": [\n {\n \"platform\": \"host\",\n \"id\": \"Service\",\n \"description\": \"Progressive Host Service discovery: compact capability/signature directory, then one exact coding contract.\",\n \"methods\": [\n {\n \"name\": \"listService\",\n \"description\": \"Progressive Host Service discovery: compact capability/signature directory, then one exact coding contract.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"service\": {\n \"type\": \"string\",\n \"description\": \"Exact Service key. Omit it for the compact Service and method-signature directory.\"\n }\n },\n \"additionalProperties\": false\n },\n \"outputSchema\": {\n \"description\": \"Compact Service directory, or one exact Service contract with only its referenced type declarations.\"\n }\n }\n ]\n },\n {\n \"platform\": \"host\",\n \"id\": \"Event\",\n \"description\": \"Progressive Host Event discovery: compact listener directory, then one exact event contract.\",\n \"methods\": [\n {\n \"name\": \"listEvents\",\n \"description\": \"Progressive Host Event discovery: compact listener directory, then one exact event contract.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"event\": {\n \"type\": \"string\",\n \"description\": \"Exact Event name. Omit it for the compact Event and listener-signature directory.\"\n }\n },\n \"additionalProperties\": false\n },\n \"outputSchema\": {\n \"description\": \"Compact Event directory, or one exact Event contract with only its referenced type declarations.\"\n }\n }\n ]\n },\n {\n \"platform\": \"host\",\n \"id\": \"Config\",\n \"description\": \"Progressive live plugin Config discovery: paged entry directory with schema status, then one entry's exact JSON Schema.\",\n \"methods\": [\n {\n \"name\": \"listConfigs\",\n \"description\": \"Progressive live plugin Config discovery: paged entry directory with schema status, then one entry's exact JSON Schema.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"entry\": {\n \"type\": \"string\",\n \"description\": \"Exact Loader entry id from the directory; returns that entry's projected Config schema.\"\n },\n \"name\": {\n \"type\": \"string\",\n \"description\": \"Exact plugin package name; limits the directory to its entries.\"\n },\n \"offset\": {\n \"type\": \"number\",\n \"description\": \"Zero-based directory offset; defaults to 0.\"\n },\n \"limit\": {\n \"type\": \"number\",\n \"description\": \"Directory page size, from 1 to 100; defaults to 25.\"\n }\n },\n \"additionalProperties\": false\n },\n \"outputSchema\": {\n \"description\": \"One directory page of live entries with patch ids, Config status, total, and nextOffset, or one entry's projected JSON Schema with shared definitions, omission acceptance, and projection limitations.\"\n }\n }\n ]\n },\n {\n \"platform\": \"host\",\n \"id\": \"Tool\",\n \"description\": \"Tools visible to the requesting Agent, including scoped and dynamic registrations.\",\n \"methods\": [\n {\n \"name\": \"listTools\",\n \"description\": \"Return every Tool schema currently callable by this Agent.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {},\n \"additionalProperties\": false\n },\n \"outputSchema\": {\n \"description\": \"JSON data owned by this inspect provider.\"\n }\n }\n ]\n }\n ]\n}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788880370619,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788880370619,"index":0,"dt":[],"id":"advanced-direct-child","name":"subagent","args":["{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\",\"run_in_background\":false}"]},{"type":"chunk","time":1788880370619,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\",\"run_in_background\":false}"}}},{"type":"chunk","time":1788880370619,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788880370619,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":2,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\",\"run_in_background\":false}"}} +{"type":"subagent/catalog","data":{"version":0,"childId":"{{session:2}}","childCreatedAt":1788880370624,"mode":"one-shot","label":"Check direct child"}} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"advanced-direct-child"},"toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false,"id":"{{message:7}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"step/start","data":{"turn":1,"step":3}} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788880370690,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788880370690,"index":0,"dt":[],"id":"advanced-workflow","name":"workflow","args":["{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"]},{"type":"chunk","time":1788880370690,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}}},{"type":"chunk","time":1788880370690,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788880370690,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":3,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}} +{"type":"tool-workflow/run-start","data":{"runId":"{{workflow:1}}","name":"advanced-acp-snapshot"}} +{"type":"subagent/catalog","data":{"version":0,"childId":"{{session:3}}","childCreatedAt":1788880370792,"mode":"one-shot"}} +{"type":"tool-workflow/agent-start","data":{"runId":"{{workflow:1}}","seq":1,"label":"workflow-child","phase":"Delegate","childId":"{{session:3}}"}} +{"type":"tool-workflow/agent-end","data":{"runId":"{{workflow:1}}","seq":1,"outcome":"completed"}} +{"type":"tool-workflow/run-end","data":{"runId":"{{workflow:1}}","stopReason":"completed"}} +{"type":"tool/result","data":{"turn":1,"step":3,"message":{"role":"tool","source":{"kind":"tool","callId":"advanced-workflow"},"toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-acp-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false,"id":"{{message:9}}"}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":3}} +{"type":"step/start","data":{"turn":1,"step":4}} +{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-inspect-after","name":"cordis_inspect_list","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:10}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788880370853,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788880370853,"index":0,"dt":[],"id":"advanced-inspect-after","name":"cordis_inspect_list","args":["{}"]},{"type":"chunk","time":1788880370853,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-inspect-after","name":"cordis_inspect_list","arguments":"{}"}}},{"type":"chunk","time":1788880370853,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788880370853,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":4,"callId":"advanced-inspect-after","name":"cordis_inspect_list","arguments":"{}"}} +{"type":"tool/result","data":{"turn":1,"step":4,"message":{"role":"tool","source":{"kind":"tool","callId":"advanced-inspect-after"},"toolCallId":"advanced-inspect-after","content":[{"type":"text","text":"{\n \"providers\": [\n {\n \"platform\": \"host\",\n \"id\": \"Service\",\n \"description\": \"Progressive Host Service discovery: compact capability/signature directory, then one exact coding contract.\",\n \"methods\": [\n {\n \"name\": \"listService\",\n \"description\": \"Progressive Host Service discovery: compact capability/signature directory, then one exact coding contract.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"service\": {\n \"type\": \"string\",\n \"description\": \"Exact Service key. Omit it for the compact Service and method-signature directory.\"\n }\n },\n \"additionalProperties\": false\n },\n \"outputSchema\": {\n \"description\": \"Compact Service directory, or one exact Service contract with only its referenced type declarations.\"\n }\n }\n ]\n },\n {\n \"platform\": \"host\",\n \"id\": \"Event\",\n \"description\": \"Progressive Host Event discovery: compact listener directory, then one exact event contract.\",\n \"methods\": [\n {\n \"name\": \"listEvents\",\n \"description\": \"Progressive Host Event discovery: compact listener directory, then one exact event contract.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"event\": {\n \"type\": \"string\",\n \"description\": \"Exact Event name. Omit it for the compact Event and listener-signature directory.\"\n }\n },\n \"additionalProperties\": false\n },\n \"outputSchema\": {\n \"description\": \"Compact Event directory, or one exact Event contract with only its referenced type declarations.\"\n }\n }\n ]\n },\n {\n \"platform\": \"host\",\n \"id\": \"Config\",\n \"description\": \"Progressive live plugin Config discovery: paged entry directory with schema status, then one entry's exact JSON Schema.\",\n \"methods\": [\n {\n \"name\": \"listConfigs\",\n \"description\": \"Progressive live plugin Config discovery: paged entry directory with schema status, then one entry's exact JSON Schema.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"entry\": {\n \"type\": \"string\",\n \"description\": \"Exact Loader entry id from the directory; returns that entry's projected Config schema.\"\n },\n \"name\": {\n \"type\": \"string\",\n \"description\": \"Exact plugin package name; limits the directory to its entries.\"\n },\n \"offset\": {\n \"type\": \"number\",\n \"description\": \"Zero-based directory offset; defaults to 0.\"\n },\n \"limit\": {\n \"type\": \"number\",\n \"description\": \"Directory page size, from 1 to 100; defaults to 25.\"\n }\n },\n \"additionalProperties\": false\n },\n \"outputSchema\": {\n \"description\": \"One directory page of live entries with patch ids, Config status, total, and nextOffset, or one entry's projected JSON Schema with shared definitions, omission acceptance, and projection limitations.\"\n }\n }\n ]\n },\n {\n \"platform\": \"host\",\n \"id\": \"Tool\",\n \"description\": \"Tools visible to the requesting Agent, including scoped and dynamic registrations.\",\n \"methods\": [\n {\n \"name\": \"listTools\",\n \"description\": \"Return every Tool schema currently callable by this Agent.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {},\n \"additionalProperties\": false\n },\n \"outputSchema\": {\n \"description\": \"JSON data owned by this inspect provider.\"\n }\n }\n ]\n }\n ]\n}"}],"isError":false,"id":"{{message:11}}"}},"sourceEventSeqs":[35],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":4}} +{"type":"step/start","data":{"turn":1,"step":5}} +{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_ACP_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:12}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788880370871,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788880370871,"index":0,"dt":[],"texts":["ADVANCED_ACP_OK"]},{"type":"chunk","time":1788880370871,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_ACP_OK"}}},{"type":"chunk","time":1788880370871,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788880370871,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":5}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/advanced-toolchain/session.1.v4.jsonl b/snapshots/session/advanced-toolchain/session.1.v4.jsonl new file mode 100644 index 0000000000..085d1f5488 --- /dev/null +++ b/snapshots/session/advanced-toolchain/session.1.v4.jsonl @@ -0,0 +1,18 @@ +{"type":"session","version":4,"id":"{{session:2}}","createdAt":1783950001000,"cwd":"{{cwd}}","parentSession":"{{session:1}}","isSeeded":false,"origin":"subagent","delegationDepth":1} +{"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}} +{"type":"approval/policy","data":{"policy":"never","source":"delegation"}} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:15}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:16}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:15}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{message:17}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[9],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:18}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788699342933,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788699342933,"index":0,"dt":[],"texts":["DIRECT_CHILD_OK"]},{"type":"chunk","time":1788699342933,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}},{"type":"chunk","time":1788699342933,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788699342933,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/advanced-toolchain/session.2.v4.jsonl b/snapshots/session/advanced-toolchain/session.2.v4.jsonl new file mode 100644 index 0000000000..159092d1da --- /dev/null +++ b/snapshots/session/advanced-toolchain/session.2.v4.jsonl @@ -0,0 +1,18 @@ +{"type":"session","version":4,"id":"{{session:3}}","createdAt":1783950002000,"cwd":"{{cwd}}","parentSession":"{{session:1}}","isSeeded":false,"origin":"subagent","delegationDepth":1} +{"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}} +{"type":"approval/policy","data":{"policy":"never","source":"delegation"}} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:19}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn"}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:20}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:19}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{message:21}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[9],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:22}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788699343148,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788699343148,"index":0,"dt":[],"texts":["WORKFLOW_CHILD_OK"]},{"type":"chunk","time":1788699343148,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}},{"type":"chunk","time":1788699343148,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788699343148,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/advanced-toolchain/session.v4.jsonl b/snapshots/session/advanced-toolchain/session.v4.jsonl new file mode 100644 index 0000000000..03c14d63b1 --- /dev/null +++ b/snapshots/session/advanced-toolchain/session.v4.jsonl @@ -0,0 +1,50 @@ +{"type":"session","version":4,"id":"{{session:1}}","createdAt":1783950000000,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: list runtime inspect providers; discover providers through run_code when available; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; list providers again; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: list runtime inspect providers; discover providers through run_code when available; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; list providers again; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Run this advanced flow exactly","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-inspect-before","name":"cordis_inspect_list","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1789544138268,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789544138268,"index":0,"dt":[],"id":"advanced-inspect-before","name":"cordis_inspect_list","args":["{}"]},{"type":"chunk","time":1789544138268,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-inspect-before","name":"cordis_inspect_list","arguments":"{}"}}},{"type":"chunk","time":1789544138268,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1789544138268,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"advanced-inspect-before","name":"cordis_inspect_list","arguments":"{}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"advanced-inspect-before"},"toolCallId":"advanced-inspect-before","content":[{"type":"text","text":"{\n \"providers\": [\n {\n \"platform\": \"host\",\n \"id\": \"Service\",\n \"description\": \"Progressive Host Service discovery: compact capability/signature directory, then one exact coding contract.\",\n \"methods\": [\n {\n \"name\": \"listService\",\n \"description\": \"Progressive Host Service discovery: compact capability/signature directory, then one exact coding contract.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"service\": {\n \"type\": \"string\",\n \"description\": \"Exact Service key. Omit it for the compact Service and method-signature directory.\"\n }\n },\n \"additionalProperties\": false\n },\n \"outputSchema\": {\n \"description\": \"Compact Service directory, or one exact Service contract with only its referenced type declarations.\"\n }\n }\n ]\n },\n {\n \"platform\": \"host\",\n \"id\": \"Event\",\n \"description\": \"Progressive Host Event discovery: compact listener directory, then one exact event contract.\",\n \"methods\": [\n {\n \"name\": \"listEvents\",\n \"description\": \"Progressive Host Event discovery: compact listener directory, then one exact event contract.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"event\": {\n \"type\": \"string\",\n \"description\": \"Exact Event name. Omit it for the compact Event and listener-signature directory.\"\n }\n },\n \"additionalProperties\": false\n },\n \"outputSchema\": {\n \"description\": \"Compact Event directory, or one exact Event contract with only its referenced type declarations.\"\n }\n }\n ]\n },\n {\n \"platform\": \"host\",\n \"id\": \"Config\",\n \"description\": \"Progressive live plugin Config discovery: paged entry directory with schema status, then one entry's exact JSON Schema.\",\n \"methods\": [\n {\n \"name\": \"listConfigs\",\n \"description\": \"Progressive live plugin Config discovery: paged entry directory with schema status, then one entry's exact JSON Schema.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"entry\": {\n \"type\": \"string\",\n \"description\": \"Exact Loader entry id from the directory; returns that entry's projected Config schema.\"\n },\n \"name\": {\n \"type\": \"string\",\n \"description\": \"Exact plugin package name; limits the directory to its entries.\"\n },\n \"offset\": {\n \"type\": \"number\",\n \"description\": \"Zero-based directory offset; defaults to 0.\"\n },\n \"limit\": {\n \"type\": \"number\",\n \"description\": \"Directory page size, from 1 to 100; defaults to 25.\"\n }\n },\n \"additionalProperties\": false\n },\n \"outputSchema\": {\n \"description\": \"One directory page of live entries with patch ids, Config status, total, and nextOffset, or one entry's projected JSON Schema with shared definitions, omission acceptance, and projection limitations.\"\n }\n }\n ]\n },\n {\n \"platform\": \"host\",\n \"id\": \"Tool\",\n \"description\": \"Tools visible to the requesting Agent, including scoped and dynamic registrations.\",\n \"methods\": [\n {\n \"name\": \"listTools\",\n \"description\": \"Return every Tool schema currently callable by this Agent.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {},\n \"additionalProperties\": false\n },\n \"outputSchema\": {\n \"description\": \"JSON data owned by this inspect provider.\"\n }\n }\n ]\n }\n ]\n}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"const inspected = await tools.cordis_inspect_list({}); return inspected.providers.map(provider => provider.id);\",\"description\":\"Discover runtime APIs through PTC\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1789544138288,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789544138288,"index":0,"dt":[],"id":"advanced-code","name":"run_code","args":["{\"code\":\"const inspected = await tools.cordis_inspect_list({}); return inspected.providers.map(provider => provider.id);\",\"description\":\"Discover runtime APIs through PTC\"}"]},{"type":"chunk","time":1789544138288,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"const inspected = await tools.cordis_inspect_list({}); return inspected.providers.map(provider => provider.id);\",\"description\":\"Discover runtime APIs through PTC\"}"}}},{"type":"chunk","time":1789544138288,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1789544138288,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\":\"const inspected = await tools.cordis_inspect_list({}); return inspected.providers.map(provider => provider.id);\",\"description\":\"Discover runtime APIs through PTC\"}"}} +{"type":"tool/ptc-dispatch-start","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:ptc:1","name":"cordis_inspect_list","arguments":{}}} +{"type":"tool/ptc-dispatch","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:ptc:1","name":"cordis_inspect_list","arguments":{},"isError":false,"content":[{"type":"text","text":"{\n \"providers\": [\n {\n \"platform\": \"host\",\n \"id\": \"Service\",\n \"description\": \"Progressive Host Service discovery: compact capability/signature directory, then one exact coding contract.\",\n \"methods\": [\n {\n \"name\": \"listService\",\n \"description\": \"Progressive Host Service discovery: compact capability/signature directory, then one exact coding contract.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"service\": {\n \"type\": \"string\",\n \"description\": \"Exact Service key. Omit it for the compact Service and method-signature directory.\"\n }\n },\n \"additionalProperties\": false\n },\n \"outputSchema\": {\n \"description\": \"Compact Service directory, or one exact Service contract with only its referenced type declarations.\"\n }\n }\n ]\n },\n {\n \"platform\": \"host\",\n \"id\": \"Event\",\n \"description\": \"Progressive Host Event discovery: compact listener directory, then one exact event contract.\",\n \"methods\": [\n {\n \"name\": \"listEvents\",\n \"description\": \"Progressive Host Event discovery: compact listener directory, then one exact event contract.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"event\": {\n \"type\": \"string\",\n \"description\": \"Exact Event name. Omit it for the compact Event and listener-signature directory.\"\n }\n },\n \"additionalProperties\": false\n },\n \"outputSchema\": {\n \"description\": \"Compact Event directory, or one exact Event contract with only its referenced type declarations.\"\n }\n }\n ]\n },\n {\n \"platform\": \"host\",\n \"id\": \"Config\",\n \"description\": \"Progressive live plugin Config discovery: paged entry directory with schema status, then one entry's exact JSON Schema.\",\n \"methods\": [\n {\n \"name\": \"listConfigs\",\n \"description\": \"Progressive live plugin Config discovery: paged entry directory with schema status, then one entry's exact JSON Schema.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"entry\": {\n \"type\": \"string\",\n \"description\": \"Exact Loader entry id from the directory; returns that entry's projected Config schema.\"\n },\n \"name\": {\n \"type\": \"string\",\n \"description\": \"Exact plugin package name; limits the directory to its entries.\"\n },\n \"offset\": {\n \"type\": \"number\",\n \"description\": \"Zero-based directory offset; defaults to 0.\"\n },\n \"limit\": {\n \"type\": \"number\",\n \"description\": \"Directory page size, from 1 to 100; defaults to 25.\"\n }\n },\n \"additionalProperties\": false\n },\n \"outputSchema\": {\n \"description\": \"One directory page of live entries with patch ids, Config status, total, and nextOffset, or one entry's projected JSON Schema with shared definitions, omission acceptance, and projection limitations.\"\n }\n }\n ]\n },\n {\n \"platform\": \"host\",\n \"id\": \"Tool\",\n \"description\": \"Tools visible to the requesting Agent, including scoped and dynamic registrations.\",\n \"methods\": [\n {\n \"name\": \"listTools\",\n \"description\": \"Return every Tool schema currently callable by this Agent.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {},\n \"additionalProperties\": false\n },\n \"outputSchema\": {\n \"description\": \"JSON data owned by this inspect provider.\"\n }\n }\n ]\n }\n ]\n}"}]}} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"advanced-code"},"toolCallId":"advanced-code","content":[{"type":"text","text":"[\n \"Service\",\n \"Event\",\n \"Config\",\n \"Tool\"\n]"}],"isError":false,"id":"{{message:7}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"step/start","data":{"turn":1,"step":3}} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1789544138399,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789544138399,"index":0,"dt":[],"id":"advanced-direct-child","name":"subagent","args":["{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\",\"run_in_background\":false}"]},{"type":"chunk","time":1789544138399,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\",\"run_in_background\":false}"}}},{"type":"chunk","time":1789544138399,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1789544138399,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\",\"run_in_background\":false}"}} +{"type":"subagent/catalog","data":{"version":0,"childId":"{{session:2}}","childCreatedAt":1789544138405,"mode":"one-shot","label":"Check direct child"}} +{"type":"tool/result","data":{"turn":1,"step":3,"message":{"role":"tool","source":{"kind":"tool","callId":"advanced-direct-child"},"toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false,"id":"{{message:9}}"}},"sourceEventSeqs":[26],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":3}} +{"type":"step/start","data":{"turn":1,"step":4}} +{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:10}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1789544138465,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789544138465,"index":0,"dt":[],"id":"advanced-workflow","name":"workflow","args":["{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"]},{"type":"chunk","time":1789544138465,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}},{"type":"chunk","time":1789544138465,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1789544138465,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}} +{"type":"tool-workflow/run-start","data":{"runId":"{{workflow:1}}","name":"advanced-headless-snapshot"}} +{"type":"subagent/catalog","data":{"version":0,"childId":"{{session:3}}","childCreatedAt":1789544138536,"mode":"one-shot"}} +{"type":"tool-workflow/agent-start","data":{"runId":"{{workflow:1}}","seq":1,"label":"workflow-child","phase":"Delegate","childId":"{{session:3}}"}} +{"type":"tool-workflow/agent-end","data":{"runId":"{{workflow:1}}","seq":1,"outcome":"completed"}} +{"type":"tool-workflow/run-end","data":{"runId":"{{workflow:1}}","stopReason":"completed"}} +{"type":"tool/result","data":{"turn":1,"step":4,"message":{"role":"tool","source":{"kind":"tool","callId":"advanced-workflow"},"toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false,"id":"{{message:11}}"}},"sourceEventSeqs":[32],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":4}} +{"type":"step/start","data":{"turn":1,"step":5}} +{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-inspect-after","name":"cordis_inspect_list","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:12}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1789544138602,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789544138602,"index":0,"dt":[],"id":"advanced-inspect-after","name":"cordis_inspect_list","args":["{}"]},{"type":"chunk","time":1789544138602,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-inspect-after","name":"cordis_inspect_list","arguments":"{}"}}},{"type":"chunk","time":1789544138602,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1789544138602,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":5,"callId":"advanced-inspect-after","name":"cordis_inspect_list","arguments":"{}"}} +{"type":"tool/result","data":{"turn":1,"step":5,"message":{"role":"tool","source":{"kind":"tool","callId":"advanced-inspect-after"},"toolCallId":"advanced-inspect-after","content":[{"type":"text","text":"{\n \"providers\": [\n {\n \"platform\": \"host\",\n \"id\": \"Service\",\n \"description\": \"Progressive Host Service discovery: compact capability/signature directory, then one exact coding contract.\",\n \"methods\": [\n {\n \"name\": \"listService\",\n \"description\": \"Progressive Host Service discovery: compact capability/signature directory, then one exact coding contract.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"service\": {\n \"type\": \"string\",\n \"description\": \"Exact Service key. Omit it for the compact Service and method-signature directory.\"\n }\n },\n \"additionalProperties\": false\n },\n \"outputSchema\": {\n \"description\": \"Compact Service directory, or one exact Service contract with only its referenced type declarations.\"\n }\n }\n ]\n },\n {\n \"platform\": \"host\",\n \"id\": \"Event\",\n \"description\": \"Progressive Host Event discovery: compact listener directory, then one exact event contract.\",\n \"methods\": [\n {\n \"name\": \"listEvents\",\n \"description\": \"Progressive Host Event discovery: compact listener directory, then one exact event contract.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"event\": {\n \"type\": \"string\",\n \"description\": \"Exact Event name. Omit it for the compact Event and listener-signature directory.\"\n }\n },\n \"additionalProperties\": false\n },\n \"outputSchema\": {\n \"description\": \"Compact Event directory, or one exact Event contract with only its referenced type declarations.\"\n }\n }\n ]\n },\n {\n \"platform\": \"host\",\n \"id\": \"Config\",\n \"description\": \"Progressive live plugin Config discovery: paged entry directory with schema status, then one entry's exact JSON Schema.\",\n \"methods\": [\n {\n \"name\": \"listConfigs\",\n \"description\": \"Progressive live plugin Config discovery: paged entry directory with schema status, then one entry's exact JSON Schema.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"entry\": {\n \"type\": \"string\",\n \"description\": \"Exact Loader entry id from the directory; returns that entry's projected Config schema.\"\n },\n \"name\": {\n \"type\": \"string\",\n \"description\": \"Exact plugin package name; limits the directory to its entries.\"\n },\n \"offset\": {\n \"type\": \"number\",\n \"description\": \"Zero-based directory offset; defaults to 0.\"\n },\n \"limit\": {\n \"type\": \"number\",\n \"description\": \"Directory page size, from 1 to 100; defaults to 25.\"\n }\n },\n \"additionalProperties\": false\n },\n \"outputSchema\": {\n \"description\": \"One directory page of live entries with patch ids, Config status, total, and nextOffset, or one entry's projected JSON Schema with shared definitions, omission acceptance, and projection limitations.\"\n }\n }\n ]\n },\n {\n \"platform\": \"host\",\n \"id\": \"Tool\",\n \"description\": \"Tools visible to the requesting Agent, including scoped and dynamic registrations.\",\n \"methods\": [\n {\n \"name\": \"listTools\",\n \"description\": \"Return every Tool schema currently callable by this Agent.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {},\n \"additionalProperties\": false\n },\n \"outputSchema\": {\n \"description\": \"JSON data owned by this inspect provider.\"\n }\n }\n ]\n }\n ]\n}"}],"isError":false,"id":"{{message:13}}"}},"sourceEventSeqs":[42],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":5}} +{"type":"step/start","data":{"turn":1,"step":6}} +{"type":"assistant/message","data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:14}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1789544138620,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1789544138620,"index":0,"dt":[],"texts":["ADVANCED_HEADLESS_OK"]},{"type":"chunk","time":1789544138620,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}},{"type":"chunk","time":1789544138620,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1789544138620,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":6}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/cordis-inspect-jsdoc/session.v4.jsonl b/snapshots/session/cordis-inspect-jsdoc/session.v4.jsonl index 862f41b96f..16fbae7752 100644 --- a/snapshots/session/cordis-inspect-jsdoc/session.v4.jsonl +++ b/snapshots/session/cordis-inspect-jsdoc/session.v4.jsonl @@ -14,7 +14,7 @@ {"type":"session/title","data":{"title":"Inspect the exact tools service","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788778145981,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788778145981,"index":0,"dt":[],"id":"inspect-tools-api","name":"cordis_inspect_query","args":["{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"]},{"type":"chunk","time":1788778145981,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}}},{"type":"chunk","time":1788778145981,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788778145981,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"inspect-tools-api"},"toolCallId":"inspect-tools-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"tools\",\n \"description\": \"Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"tools\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"tools\"\n ],\n \"expression\": \"ctx.tools\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"presentAs(mode: ToolPresentationMode): () => void\",\n \"description\": \"Present the calling scope's tools in `mode` instead of the deployment default. Nearest scope on the chain wins, so a preset's standing declaration covers every agent joined under it.\\n\\nScoped only, and one declaration per scope: this is how an agent preset composes PTC mode agents beside native ones in the same process, and a process-global override would be the `mode` config field instead.\",\n \"parameters\": [\n {\n \"name\": \"mode\",\n \"description\": \"the presentation the covered agents' models see.\"\n }\n ],\n \"returns\": \"the exact disposer that restores the deployment default.\"\n },\n {\n \"signature\": \"register(definition: ToolDefinition): () => void\",\n \"description\": \"Register globally or in the calling agent scope. Scoped tools shadow globals; duplicates within one layer and the reserved `run_code` name fail.\",\n \"parameters\": [\n {\n \"name\": \"definition\",\n \"description\": \"tool schema, execution, and optional finalization/presentation callbacks.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the tool.\"\n },\n {\n \"signature\": \"restrict(filter: ToolRestriction): () => void\",\n \"description\": \"Restrict global tools for the calling agent scope. Empty filters, unknown names, scope-local names, and reserved transport names fail. Restrictions intersect; scoped registrations remain visible.\",\n \"parameters\": [\n {\n \"name\": \"filter\",\n \"description\": \"global-tool mask: `allow` (keep only) and/or `deny` (remove).\"\n }\n ],\n \"returns\": \"the exact disposer that lifts this restriction.\"\n },\n {\n \"signature\": \"guard(guard: ToolGuard): () => void\",\n \"description\": \"Register a monotonic guard after the extensible `tools/pre-execute` waterfall. A plain-context guard applies globally; one registered through `agent.ctx` applies only to that agent. Any matching guard may deny by returning a reason, while no guard can force-allow a call another guard denied. The exact effect disposer is returned for ordered ownership and HMR cleanup.\",\n \"parameters\": [\n {\n \"name\": \"guard\",\n \"description\": \"synchronous check; a returned string denies the execution.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the guard.\"\n },\n {\n \"signature\": \"get(name: string, scope?: ScopeKey): ToolDefinition | undefined\",\n \"description\": \"Look up a tool as one scope sees it (scoped shadows global; a restricted-away global reads as absent). Presenters pass the calling agent so the rendered card matches the definition that actually executed.\",\n \"parameters\": [\n {\n \"name\": \"name\",\n \"description\": \"the tool name as registered.\"\n },\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"the definition the scope resolves, or undefined when none is visible.\"\n },\n {\n \"signature\": \"schemas(scope?: ScopeKey): ToolSchema[]\",\n \"description\": \"Project visible definitions onto the allowlisted model-facing schema fields, excluding execution and presentation callbacks.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"one deep-cloned schema per visible tool.\"\n },\n {\n \"signature\": \"executionMode(exec: ToolExecutionInput): ToolExecutionMode\",\n \"description\": \"Classify a pending call through the caller's visible tool definition. Only an exact `true` is parallel; unknown, hidden, undeclared, invalid, or throwing classifiers are exclusive.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"call name, parsed arguments, and optional agent scope.\"\n }\n ],\n \"returns\": \"the fail-closed scheduling mode.\"\n },\n {\n \"signature\": \"async execute(exec: ToolExecutionInput): Promise\",\n \"description\": \"Execute through pre-policy, guards, around-dispatch, post-policy, definition-owned content finalization, and final notification. Tool and listener failures resolve as materialized error results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen snapshot final observers receive. Cancellation arriving after entry and before final result materialization skips a not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a successful started outcome with `ABORTED`; already-started work is still drained and may retain a tool-owned structured error.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"the typed same-process call input. The registry assigns its correlation token before policy begins.\"\n }\n ],\n \"returns\": \"the materialized final result.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"Agent\",\n \"declaration\": \"export interface Agent {\\n readonly id: SessionId;\\n}\"\n },\n {\n \"name\": \"Branded\",\n \"declaration\": \"export type Branded = string & {\\n readonly [BRAND]: B;\\n};\"\n },\n {\n \"name\": \"DiffCallView\",\n \"declaration\": \"export interface DiffCallView {\\n card: 'diff';\\n title: string;\\n diffs: FileDiff[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"DiffResultView\",\n \"declaration\": \"export interface DiffResultView {\\n card: 'diff';\\n title?: string;\\n diffs: FileDiff[];\\n}\"\n },\n {\n \"name\": \"FileDiff\",\n \"declaration\": \"export interface FileDiff {\\n path: string;\\n oldText: string | null;\\n newText: string;\\n}\"\n },\n {\n \"name\": \"FileLocation\",\n \"declaration\": \"export interface FileLocation {\\n path: string;\\n line?: number;\\n}\"\n },\n {\n \"name\": \"GenericCallView\",\n \"declaration\": \"export interface GenericCallView {\\n card: 'generic';\\n title: string;\\n kind?: ToolCallKind;\\n rawInput?: unknown;\\n content?: ContentBlock[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"GenericResultView\",\n \"declaration\": \"export interface GenericResultView {\\n card: 'generic';\\n title?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"JsonSchemaNode\",\n \"declaration\": \"export interface JsonSchemaNode {\\n type?: JsonSchemaType;\\n oneOf?: JsonSchemaNode[];\\n properties?: Record;\\n required?: string[];\\n additionalProperties?: boolean;\\n items?: JsonSchemaNode;\\n enum?: JsonSchemaScalar[];\\n const?: JsonSchemaScalar;\\n description?: string;\\n title?: string;\\n default?: JsonValue;\\n examples?: JsonValue;\\n}\"\n },\n {\n \"name\": \"JsonSchemaScalar\",\n \"declaration\": \"export type JsonSchemaScalar = string | number | boolean | null;\"\n },\n {\n \"name\": \"JsonSchemaType\",\n \"declaration\": \"export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\"\n },\n {\n \"name\": \"JsonValue\",\n \"declaration\": \"export type JsonValue = null | boolean | number | string | JsonValue[] | {\\n [key: string]: JsonValue;\\n};\"\n },\n {\n \"name\": \"ReadFileLine\",\n \"declaration\": \"export interface ReadFileLine {\\n number: number;\\n text: string;\\n}\"\n },\n {\n \"name\": \"ReadResultView\",\n \"declaration\": \"export interface ReadResultView {\\n card: 'read';\\n title?: string;\\n path: string;\\n offset: number;\\n lines: ReadFileLine[];\\n totalLines: number;\\n lang?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"ScopeKey\",\n \"declaration\": \"export type ScopeKey = object;\"\n },\n {\n \"name\": \"SearchFileMatches\",\n \"declaration\": \"export interface SearchFileMatches {\\n path: string;\\n matches: SearchLineMatch[];\\n}\"\n },\n {\n \"name\": \"SearchLineMatch\",\n \"declaration\": \"export interface SearchLineMatch {\\n lineNumber: number;\\n line: string;\\n}\"\n },\n {\n \"name\": \"SearchMatchesResultView\",\n \"declaration\": \"export interface SearchMatchesResultView {\\n card: 'search';\\n shape: 'matches';\\n title?: string;\\n files: SearchFileMatches[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchPathsResultView\",\n \"declaration\": \"export interface SearchPathsResultView {\\n card: 'search';\\n shape: 'paths';\\n title?: string;\\n paths: string[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchResultView\",\n \"declaration\": \"export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\"\n },\n {\n \"name\": \"SessionId\",\n \"declaration\": \"export type SessionId = Branded<'SessionId'>;\"\n },\n {\n \"name\": \"TerminalCallView\",\n \"declaration\": \"export interface TerminalCallView {\\n card: 'terminal';\\n title: string;\\n description?: string;\\n cwd?: string;\\n}\"\n },\n {\n \"name\": \"TerminalResultView\",\n \"declaration\": \"export interface TerminalResultView {\\n card: 'terminal';\\n title?: string;\\n output?: string;\\n exitCode?: number;\\n signal?: string;\\n}\"\n },\n {\n \"name\": \"ToolCallKind\",\n \"declaration\": \"export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\"\n },\n {\n \"name\": \"ToolCallView\",\n \"declaration\": \"export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\"\n },\n {\n \"name\": \"ToolDefinition\",\n \"declaration\": \"export interface ToolDefinition extends ToolSchema {\\n readonly output: ToolOutputDefinition;\\n execute(args: unknown, exec: ToolRunContext): Promise;\\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n timeoutMs?: number;\\n isConcurrencySafe?(args: unknown): boolean;\\n presentCall?(args: unknown): ToolCallView | undefined;\\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\\n}\"\n },\n {\n \"name\": \"ToolErrorInfo\",\n \"declaration\": \"export interface ToolErrorInfo {\\n name: string;\\n code: string;\\n reason?: string;\\n}\"\n },\n {\n \"name\": \"ToolExecution\",\n \"declaration\": \"export interface ToolExecution extends ToolExecutionInput {\\n readonly rootCallId: ToolCallId;\\n readonly token: ToolExecutionToken;\\n}\"\n },\n {\n \"name\": \"ToolExecutionFailure\",\n \"declaration\": \"export interface ToolExecutionFailure {\\n readonly isError: true;\\n readonly error: ToolFailure;\\n readonly value?: never;\\n readonly content: ContentBlock[];\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: never;\\n}\"\n },\n {\n \"name\": \"ToolExecutionInput\",\n \"declaration\": \"export interface ToolExecutionInput {\\n readonly callId: ToolCallId;\\n readonly rootCallId?: ToolCallId;\\n readonly name: string;\\n readonly schema?: ToolSchema;\\n readonly arguments: unknown;\\n readonly agent?: Agent;\\n readonly parent?: ToolExecutionToken;\\n readonly signal: AbortSignal;\\n}\"\n },\n {\n \"name\": \"ToolExecutionMode\",\n \"declaration\": \"export type ToolExecutionMode = {\\n kind: 'parallel';\\n} | {\\n kind: 'exclusive';\\n};\"\n },\n {\n \"name\": \"ToolExecutionResult\",\n \"declaration\": \"export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\"\n },\n {\n \"name\": \"ToolExecutionSuccess\",\n \"declaration\": \"export interface ToolExecutionSuccess {\\n readonly isError: false;\\n readonly value: JsonValue;\\n readonly content: ContentBlock[];\\n readonly error?: never;\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: true;\\n}\"\n },\n {\n \"name\": \"ToolExecutionToken\",\n \"declaration\": \"export type ToolExecutionToken = symbol & {\\n readonly [toolExecutionTokenBrand]: true;\\n};\"\n },\n {\n \"name\": \"ToolFailure\",\n \"declaration\": \"export interface ToolFailure {\\n message: string;\\n info?: ToolErrorInfo;\\n}\"\n },\n {\n \"name\": \"ToolGuard\",\n \"declaration\": \"export type ToolGuard = (execution: Readonly) => string | undefined;\"\n },\n {\n \"name\": \"ToolOutputDefinition\",\n \"declaration\": \"export interface ToolOutputDefinition {\\n readonly schema: JsonSchemaNode;\\n render(args: unknown, value: JsonValue): ContentBlock[];\\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolPresentationMode\",\n \"declaration\": \"export type ToolPresentationMode = 'native' | 'ptc' | 'both';\"\n },\n {\n \"name\": \"ToolRestriction\",\n \"declaration\": \"export interface ToolRestriction {\\n readonly allow?: readonly string[];\\n readonly deny?: readonly string[];\\n}\"\n },\n {\n \"name\": \"ToolResult\",\n \"declaration\": \"export interface ToolResult {\\n content: ContentBlock[];\\n isError: boolean;\\n meta?: JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolResultView\",\n \"declaration\": \"export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\"\n },\n {\n \"name\": \"ToolRunContext\",\n \"declaration\": \"export interface ToolRunContext extends ToolExecution {\\n deferContext(context: UserMessage): void;\\n concludeTurn(): void;\\n}\"\n },\n {\n \"name\": \"ToolSchema\",\n \"declaration\": \"export interface ToolSchema {\\n deferLoading?: true;\\n name: string;\\n description: string;\\n parameters: Record;\\n}\"\n },\n {\n \"name\": \"UserMessage\",\n \"declaration\": \"export interface UserMessage extends MessageBase {\\n readonly role: 'user';\\n}\"\n },\n {\n \"name\": \"WebFetchResultView\",\n \"declaration\": \"export interface WebFetchResultView {\\n card: 'web';\\n kind: 'fetch';\\n title?: string;\\n url: string;\\n statusCode: number;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebResultView\",\n \"declaration\": \"export type WebResultView = WebSearchResultView | WebFetchResultView;\"\n },\n {\n \"name\": \"WebSearchResultView\",\n \"declaration\": \"export interface WebSearchResultView {\\n card: 'web';\\n kind: 'search';\\n title?: string;\\n sources: WebSource[];\\n answer?: string;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebSource\",\n \"declaration\": \"export interface WebSource {\\n url: string;\\n title?: string;\\n snippet?: string;\\n publishedAt?: string;\\n}\"\n }\n ]\n }\n}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"inspect-tools-api"},"toolCallId":"inspect-tools-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"tools\",\n \"description\": \"Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"tools\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"tools\"\n ],\n \"expression\": \"ctx.tools\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"presentAs(mode: ToolPresentationMode): () => void\",\n \"description\": \"Present the calling scope's tools in `mode` instead of the deployment default. Nearest scope on the chain wins, so a preset's standing declaration covers every agent joined under it.\\n\\nScoped only, and one declaration per scope: this is how an agent preset composes PTC mode agents beside native ones in the same process, and a process-global override would be the `mode` config field instead.\",\n \"parameters\": [\n {\n \"name\": \"mode\",\n \"description\": \"the presentation the covered agents' models see.\"\n }\n ],\n \"returns\": \"the exact disposer that restores the deployment default.\"\n },\n {\n \"signature\": \"register(definition: ToolDefinition): () => void\",\n \"description\": \"Register globally or in the calling agent scope. Scoped tools shadow globals; duplicates within one layer and the reserved `run_code` name fail.\",\n \"parameters\": [\n {\n \"name\": \"definition\",\n \"description\": \"tool schema, execution, and optional finalization/presentation callbacks.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the tool.\"\n },\n {\n \"signature\": \"restrict(filter: ToolRestriction): () => void\",\n \"description\": \"Restrict global tools for the calling agent scope. Empty filters, unknown names, scope-local names, and reserved transport names fail. Restrictions intersect; scoped registrations remain visible.\",\n \"parameters\": [\n {\n \"name\": \"filter\",\n \"description\": \"global-tool mask: `allow` (keep only) and/or `deny` (remove).\"\n }\n ],\n \"returns\": \"the exact disposer that lifts this restriction.\"\n },\n {\n \"signature\": \"guard(guard: ToolGuard): () => void\",\n \"description\": \"Register a monotonic guard after the extensible `tools/pre-execute` waterfall. A plain-context guard applies globally; one registered through `agent.ctx` applies only to that agent. Any matching guard may deny by returning a reason, while no guard can force-allow a call another guard denied. The exact effect disposer is returned for ordered ownership and HMR cleanup.\",\n \"parameters\": [\n {\n \"name\": \"guard\",\n \"description\": \"synchronous check; a returned string denies the execution.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the guard.\"\n },\n {\n \"signature\": \"get(name: string, scope?: ScopeKey): ToolDefinition | undefined\",\n \"description\": \"Look up a tool as one scope sees it (scoped shadows global; a restricted-away global reads as absent). Presenters pass the calling agent so the rendered card matches the definition that actually executed.\",\n \"parameters\": [\n {\n \"name\": \"name\",\n \"description\": \"the tool name as registered.\"\n },\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"the definition the scope resolves, or undefined when none is visible.\"\n },\n {\n \"signature\": \"schemas(scope?: ScopeKey): ToolSchema[]\",\n \"description\": \"Project visible definitions onto the allowlisted model-facing schema fields, excluding execution and presentation callbacks.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"one deep-cloned schema per visible tool.\"\n },\n {\n \"signature\": \"executionMode(exec: ToolExecutionInput): ToolExecutionMode\",\n \"description\": \"Classify a pending call through the caller's visible tool definition. Only an exact `true` is parallel; unknown, hidden, undeclared, invalid, or throwing classifiers are exclusive.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"call name, parsed arguments, and optional agent scope.\"\n }\n ],\n \"returns\": \"the fail-closed scheduling mode.\"\n },\n {\n \"signature\": \"async execute(exec: ToolExecutionInput): Promise\",\n \"description\": \"Execute through pre-policy, guards, around-dispatch, post-policy, definition-owned content finalization, and final notification. Tool and listener failures resolve as materialized error results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen snapshot final observers receive. Cancellation arriving after entry and before final result materialization skips a not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a successful started outcome with `ABORTED`; already-started work is still drained and may retain a tool-owned structured error.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"the typed same-process call input. The registry assigns its correlation token before policy begins.\"\n }\n ],\n \"returns\": \"the materialized final result.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"Agent\",\n \"declaration\": \"export interface Agent {\\n readonly id: SessionId;\\n}\"\n },\n {\n \"name\": \"Branded\",\n \"declaration\": \"export type Branded = string & {\\n readonly [BRAND]: B;\\n};\"\n },\n {\n \"name\": \"DiffCallView\",\n \"declaration\": \"export interface DiffCallView {\\n card: 'diff';\\n title: string;\\n diffs: FileDiff[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"DiffResultView\",\n \"declaration\": \"export interface DiffResultView {\\n card: 'diff';\\n title?: string;\\n diffs: FileDiff[];\\n}\"\n },\n {\n \"name\": \"FileDiff\",\n \"declaration\": \"export interface FileDiff {\\n path: string;\\n oldText: string | null;\\n newText: string;\\n}\"\n },\n {\n \"name\": \"FileLocation\",\n \"declaration\": \"export interface FileLocation {\\n path: string;\\n line?: number;\\n}\"\n },\n {\n \"name\": \"GenericCallView\",\n \"declaration\": \"export interface GenericCallView {\\n card: 'generic';\\n title: string;\\n kind?: ToolCallKind;\\n rawInput?: unknown;\\n content?: ContentBlock[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"GenericResultView\",\n \"declaration\": \"export interface GenericResultView {\\n card: 'generic';\\n title?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"JsonSchemaNode\",\n \"declaration\": \"export interface JsonSchemaNode {\\n type?: JsonSchemaType;\\n oneOf?: JsonSchemaNode[];\\n properties?: Record;\\n required?: string[];\\n additionalProperties?: boolean;\\n items?: JsonSchemaNode;\\n enum?: JsonSchemaScalar[];\\n const?: JsonSchemaScalar;\\n description?: string;\\n title?: string;\\n default?: JsonValue;\\n examples?: JsonValue;\\n}\"\n },\n {\n \"name\": \"JsonSchemaScalar\",\n \"declaration\": \"export type JsonSchemaScalar = string | number | boolean | null;\"\n },\n {\n \"name\": \"JsonSchemaType\",\n \"declaration\": \"export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\"\n },\n {\n \"name\": \"JsonValue\",\n \"declaration\": \"export type JsonValue = null | boolean | number | string | JsonValue[] | {\\n [key: string]: JsonValue;\\n};\"\n },\n {\n \"name\": \"ReadFileLine\",\n \"declaration\": \"export interface ReadFileLine {\\n number: number;\\n text: string;\\n}\"\n },\n {\n \"name\": \"ReadResultView\",\n \"declaration\": \"export interface ReadResultView {\\n card: 'read';\\n title?: string;\\n path: string;\\n offset: number;\\n lines: ReadFileLine[];\\n totalLines: number;\\n lang?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"ScopeKey\",\n \"declaration\": \"export type ScopeKey = object;\"\n },\n {\n \"name\": \"SearchFileMatches\",\n \"declaration\": \"export interface SearchFileMatches {\\n path: string;\\n matches: SearchLineMatch[];\\n}\"\n },\n {\n \"name\": \"SearchLineMatch\",\n \"declaration\": \"export interface SearchLineMatch {\\n lineNumber: number;\\n line: string;\\n}\"\n },\n {\n \"name\": \"SearchMatchesResultView\",\n \"declaration\": \"export interface SearchMatchesResultView {\\n card: 'search';\\n shape: 'matches';\\n title?: string;\\n files: SearchFileMatches[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchPathsResultView\",\n \"declaration\": \"export interface SearchPathsResultView {\\n card: 'search';\\n shape: 'paths';\\n title?: string;\\n paths: string[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchResultView\",\n \"declaration\": \"export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\"\n },\n {\n \"name\": \"SessionId\",\n \"declaration\": \"export type SessionId = Branded<'SessionId'>;\"\n },\n {\n \"name\": \"TerminalCallView\",\n \"declaration\": \"export interface TerminalCallView {\\n card: 'terminal';\\n title: string;\\n description?: string;\\n cwd?: string;\\n}\"\n },\n {\n \"name\": \"TerminalResultView\",\n \"declaration\": \"export interface TerminalResultView {\\n card: 'terminal';\\n title?: string;\\n output?: string;\\n exitCode?: number;\\n signal?: string;\\n}\"\n },\n {\n \"name\": \"ToolCallId\",\n \"declaration\": \"export type ToolCallId = Branded<'ToolCallId'>;\"\n },\n {\n \"name\": \"ToolCallKind\",\n \"declaration\": \"export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\"\n },\n {\n \"name\": \"ToolCallView\",\n \"declaration\": \"export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\"\n },\n {\n \"name\": \"ToolDefinition\",\n \"declaration\": \"export interface ToolDefinition extends ToolSchema {\\n readonly output: ToolOutputDefinition;\\n execute(args: unknown, exec: ToolRunContext): Promise;\\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n timeoutMs?: number;\\n isConcurrencySafe?(args: unknown): boolean;\\n presentCall?(args: unknown): ToolCallView | undefined;\\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\\n}\"\n },\n {\n \"name\": \"ToolErrorInfo\",\n \"declaration\": \"export interface ToolErrorInfo {\\n name: string;\\n code: string;\\n reason?: string;\\n}\"\n },\n {\n \"name\": \"ToolExecution\",\n \"declaration\": \"export interface ToolExecution extends ToolExecutionInput {\\n readonly rootCallId: ToolCallId;\\n readonly token: ToolExecutionToken;\\n}\"\n },\n {\n \"name\": \"ToolExecutionFailure\",\n \"declaration\": \"export interface ToolExecutionFailure {\\n readonly isError: true;\\n readonly error: ToolFailure;\\n readonly value?: never;\\n readonly content: ContentBlock[];\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: never;\\n}\"\n },\n {\n \"name\": \"ToolExecutionInput\",\n \"declaration\": \"export interface ToolExecutionInput {\\n readonly callId: ToolCallId;\\n readonly rootCallId?: ToolCallId;\\n readonly name: string;\\n readonly schema?: ToolSchema;\\n readonly arguments: unknown;\\n readonly agent?: Agent;\\n readonly parent?: ToolExecutionToken;\\n readonly signal: AbortSignal;\\n}\"\n },\n {\n \"name\": \"ToolExecutionMode\",\n \"declaration\": \"export type ToolExecutionMode = {\\n kind: 'parallel';\\n} | {\\n kind: 'exclusive';\\n};\"\n },\n {\n \"name\": \"ToolExecutionResult\",\n \"declaration\": \"export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\"\n },\n {\n \"name\": \"ToolExecutionSuccess\",\n \"declaration\": \"export interface ToolExecutionSuccess {\\n readonly isError: false;\\n readonly value: JsonValue;\\n readonly content: ContentBlock[];\\n readonly error?: never;\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: true;\\n}\"\n },\n {\n \"name\": \"ToolExecutionToken\",\n \"declaration\": \"export type ToolExecutionToken = symbol & {\\n readonly [toolExecutionTokenBrand]: true;\\n};\"\n },\n {\n \"name\": \"ToolFailure\",\n \"declaration\": \"export interface ToolFailure {\\n message: string;\\n info?: ToolErrorInfo;\\n}\"\n },\n {\n \"name\": \"ToolGuard\",\n \"declaration\": \"export type ToolGuard = (execution: Readonly) => string | undefined;\"\n },\n {\n \"name\": \"ToolOutputDefinition\",\n \"declaration\": \"export interface ToolOutputDefinition {\\n readonly schema: JsonSchemaNode;\\n render(args: unknown, value: JsonValue): ContentBlock[];\\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolPresentationMode\",\n \"declaration\": \"export type ToolPresentationMode = 'native' | 'ptc' | 'both';\"\n },\n {\n \"name\": \"ToolRestriction\",\n \"declaration\": \"export interface ToolRestriction {\\n readonly allow?: readonly string[];\\n readonly deny?: readonly string[];\\n}\"\n },\n {\n \"name\": \"ToolResult\",\n \"declaration\": \"export interface ToolResult {\\n content: ContentBlock[];\\n isError: boolean;\\n meta?: JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolResultView\",\n \"declaration\": \"export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\"\n },\n {\n \"name\": \"ToolRunContext\",\n \"declaration\": \"export interface ToolRunContext extends ToolExecution {\\n deferContext(context: UserMessage): void;\\n concludeTurn(): void;\\n}\"\n },\n {\n \"name\": \"ToolSchema\",\n \"declaration\": \"export interface ToolSchema {\\n deferLoading?: true;\\n name: string;\\n description: string;\\n parameters: Record;\\n}\"\n },\n {\n \"name\": \"UserMessage\",\n \"declaration\": \"export interface UserMessage extends MessageBase {\\n readonly role: 'user';\\n}\"\n },\n {\n \"name\": \"WebFetchResultView\",\n \"declaration\": \"export interface WebFetchResultView {\\n card: 'web';\\n kind: 'fetch';\\n title?: string;\\n url: string;\\n statusCode: number;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebResultView\",\n \"declaration\": \"export type WebResultView = WebSearchResultView | WebFetchResultView;\"\n },\n {\n \"name\": \"WebSearchResultView\",\n \"declaration\": \"export interface WebSearchResultView {\\n card: 'web';\\n kind: 'search';\\n title?: string;\\n sources: WebSource[];\\n answer?: string;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebSource\",\n \"declaration\": \"export interface WebSource {\\n url: string;\\n title?: string;\\n snippet?: string;\\n publishedAt?: string;\\n}\"\n }\n ]\n }\n}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788778146017,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788778146017,"index":0,"dt":[],"id":"inspect-subprocess-api","name":"cordis_inspect_query","args":["{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"]},{"type":"chunk","time":1788778146017,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}}},{"type":"chunk","time":1788778146017,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788778146017,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} diff --git a/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md b/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md index 49f4c87e6c..fb46d6e740 100644 --- a/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md +++ b/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md @@ -67,7 +67,7 @@ interface ToolArgsMap { } & Record; /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before writing or configuring a plugin, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */ cordis_inspect_list: Record; - /** Run a read-only query declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before writing plugin code to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. */ + /** Run a read-only query declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before writing plugin code to read exact Service methods, Event modes, plugin Config schemas, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. */ cordis_inspect_query: { /** Runtime platform that owns the Provider. */ platform: "host" | "client"; diff --git a/snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json b/snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json index e76da1a010..63a7b2aad4 100644 --- a/snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json +++ b/snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json @@ -55,7 +55,7 @@ }, { "name": "cordis_inspect_query", - "description": "Run a read-only query declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before writing plugin code to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime.", + "description": "Run a read-only query declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before writing plugin code to read exact Service methods, Event modes, plugin Config schemas, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/session/skill-load/session.v4.jsonl b/snapshots/session/skill-load/session.v4.jsonl index 9f4ec0a005..248b4fe3b0 100644 --- a/snapshots/session/skill-load/session.v4.jsonl +++ b/snapshots/session/skill-load/session.v4.jsonl @@ -15,7 +15,7 @@ {"type":"session/title","data":{"title":"Load the editing-cordis-compositions ski","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5},"stream":[{"type":"chunk","time":1788699430688,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699430688,"index":0,"dt":[],"texts":["Load the requested skill."]},{"type":"chunk","time":1788699430688,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788699430688,"index":1,"dt":[],"id":"call_skill_load","name":"skill","args":["{\"name\":\"editing-cordis-compositions\"}"]},{"type":"chunk","time":1788699430688,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Load the requested skill."}}},{"type":"chunk","time":1788699430688,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}}},{"type":"chunk","time":1788699430688,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}}},{"type":"chunk","time":1788699430688,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_skill_load"},"toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Editing Cordis compositions\n\nAgent presets are ordinary `@deepseek-ai/dsh-agent-preset` declarations carried by bundle patches. Nothing edits a declaration in place: a preset is created or changed by installing a bundle whose patch declares or overrides it. This file is the complete contract; do not read DSH package sources to infer formats.\n\n## Where declarations live\n\nThe shipped Web presets are `presets/.patch.yml` files of the `@deepseek-ai/dsh-web-app` bundle, ids `standard`, `ptc`, `minimal` and `cordis`. Installed, that bundle sits at `$DSH_HOME/profiles//node_modules/@deepseek-ai/dsh-web-app/`; in a source checkout of DSH it is `packages/bundle/web-app/`. Read one file with `cat` when you need a template; `minimal.patch.yml` is the shortest.\n\nA declaration row has these `config` fields: `id` (required, lowercase letters, digits and hyphens), `plugins` (required Cordis entry list), and optional `name`, `description` and `order` (roster position). The Loader row `id` is `preset-` by convention.\n\n## Create a preset\n\nWrite a bundle directory in the workspace with exactly two files, then install it.\n\n`review-preset/package.json`:\n\n```json\n{\n \"name\": \"@local/dsh-review-preset\",\n \"version\": \"1.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"dsh\": { \"bundle\": { \"patch\": \"./cordis.patch.yml\" } }\n}\n```\n\n`review-preset/cordis.patch.yml`:\n\n```yaml\n- insert:\n - id: preset-review\n name: '@deepseek-ai/dsh-agent-preset'\n config:\n id: review\n name: Review\n description: Reviews changes with the shell only.\n order: 10\n plugins:\n - id: persona\n name: '@deepseek-ai/dsh-persona'\n config:\n prefix: You review software changes.\n - id: tool-bash\n name: '@deepseek-ai/dsh-tool-bash'\n```\n\nInstall with `plugin_manager`, `action: install_bundle`, `target` set to the absolute bundle directory. It runs package installation and bundle selection itself; do not reproduce those steps with shell commands.\n\n## Change a shipped preset\n\nOverride the declaration by its Loader row id instead of inserting. The override replaces the complete `config`, so restate `id`, `plugins` and every other field the shipped file carries:\n\n```yaml\n- id: preset-standard\n name: '@deepseek-ai/dsh-agent-preset'\n config:\n id: standard\n order: 1\n plugins:\n # the shipped list with your changes\n```\n\n## Verify\n\n`plugin_manager` `list_bundles` lists the installed bundle; `list_plugins` shows the `preset-` row with its activation state. A declaration whose activation fails stays on the roster with its diagnostic and cannot compose a session until the bundle is fixed and reinstalled. Existing sessions and their children keep the plugin revision they started with; validate changed behavior in a new session. Installing a bundle executes plugin code in the Host process, so it requires Full access or approval.\n\n## Choose plugin placement\n\nHost plugins supply shared services: tools and prompt registries, the Agent loop, sessions, persistence, settings, sandbox policy, model routes, and subagent backends. Preset plugins contribute scoped tools, persona, prompt sections, and policies to those registries. Preset revisions are eagerly activated once and shared by their selecting Agents.\n\nA preset plugin that supplies a service must isolate the provider and all its consumers in the same realm. A service consumed by Host plugins belongs in the Host configuration. Scope controls contributions and event visibility; `isolate` controls service instances. Use ordinary Cordis groups for nested plugin lists. Keep `!!js` expressions only in plugin configuration or `disabled`; the Loader evaluates them when activating the child plugin. Resolve assets from installed packages rather than a preset directory.\n\n## Extend the Host\n\nFor new plugin code or profile-wide capabilities, load `cordis-plugin-development`, author a workspace bundle, and install it with `plugin_manager`. Inspect available APIs through `cordis_inspect_list` and `cordis_inspect_query`; those tools do not invoke Remote methods. Verify the installed capability before reporting completion.\n\n"}],"isError":false,"id":"{{message:6}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_skill_load"},"toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Editing Cordis compositions\n\nAgent presets are ordinary `@deepseek-ai/dsh-agent-preset` declarations carried by bundle patches. Nothing edits a declaration in place: a preset is created or changed by installing a bundle whose patch declares or overrides it. This file states the declaration format; when it leaves a question open, read the `@deepseek-ai/dsh-agent-preset` README and `lib/types` declarations under the `packageDir` that `cordis_inspect_query` `Config.listConfigs` returns when queried with a `preset-` row's `entry` id, or `packages/preset/agent-preset/src` in a source checkout.\n\n## Where declarations live\n\nThe shipped Web presets are `presets/.patch.yml` files of the `@deepseek-ai/dsh-web-app` bundle, ids `standard`, `ptc`, `minimal` and `cordis`. Installed, the bundle resolves from the dsh installation, not the profile; querying `Config.listConfigs` with the `entry` id of any `preset-` row it declares returns that `packageDir`. In a source checkout of DSH it is `packages/bundle/web-app/`. Read one file with the file-read tool when you need a template; `minimal.patch.yml` is the shortest. In Desktop the bundle sits inside `app.asar`, which shell commands cannot open. Load `cordis-composition-reference` for the patch dialect and the list of plugin packages a preset can mount.\n\nA declaration row has these `config` fields: `id` (required, lowercase letters, digits and hyphens), `plugins` (required Cordis entry list), and optional `name`, `description` and `order` (roster position). The Loader row `id` is `preset-` by convention.\n\n## Create a preset\n\nWrite a bundle directory in the workspace with exactly two files, then install it.\n\n`review-preset/package.json`:\n\n```json\n{\n \"name\": \"@local/dsh-review-preset\",\n \"version\": \"1.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"dsh\": { \"bundle\": { \"patch\": \"./cordis.patch.yml\" } }\n}\n```\n\n`review-preset/cordis.patch.yml`:\n\n```yaml\n- insert:\n - id: preset-review\n name: '@deepseek-ai/dsh-agent-preset'\n config:\n id: review\n name: Review\n description: Reviews changes with the shell only.\n order: 10\n plugins:\n - id: persona\n name: '@deepseek-ai/dsh-persona'\n config:\n prefix: You review software changes.\n - id: tool-bash\n name: '@deepseek-ai/dsh-tool-bash'\n```\n\nInstall with `plugin_manager`, `action: install_bundle`, `target` set to the absolute bundle directory. It runs package installation and bundle selection itself; do not reproduce those steps with shell commands.\n\n## Change a shipped preset\n\nOverride the declaration by its Loader row id instead of inserting. The override replaces the complete `config`, so restate `id`, `plugins` and every other field the shipped file carries:\n\n```yaml\n- id: preset-standard\n name: '@deepseek-ai/dsh-agent-preset'\n config:\n id: standard\n order: 1\n plugins:\n # the shipped list with your changes\n```\n\n## Migrate a legacy preset\n\nBefore declaration rows, a user preset was a directory `$DSH_HOME/.agent-presets//` holding `preset.yml` (display `name`, `description`, `order`) and `agent.cordis.yml` (the plugin entry list). Nothing reads that directory any more. To migrate one, create a bundle as above whose declaration takes `id` from the directory name, `name`, `description`, and `order` from `preset.yml`, and `plugins` from `agent.cordis.yml` verbatim; check each plugin name against `cordis-composition-reference` because packages renamed since the preset was written fail at activation. Install it, verify the row, then delete the legacy directory.\n\n## Verify\n\n`plugin_manager` `list_bundles` lists the installed bundle; `list_plugins` shows the `preset-` row with its activation state. A declaration whose activation fails stays on the roster with its diagnostic and cannot compose a session until the bundle is fixed and reinstalled. Existing sessions and their children keep the plugin revision they started with; validate changed behavior in a new session. Installing a bundle executes plugin code in the Host process, so it requires Full access or approval.\n\n## Choose plugin placement\n\nHost plugins supply shared services: tools and prompt registries, the Agent loop, sessions, persistence, settings, sandbox policy, model routes, and subagent backends. Preset plugins contribute scoped tools, persona, prompt sections, and policies to those registries. Preset revisions are eagerly activated once and shared by their selecting Agents.\n\nA preset plugin that supplies a service must isolate the provider and all its consumers in the same realm. A service consumed by Host plugins belongs in the Host configuration. Scope controls contributions and event visibility; `isolate` controls service instances. Use ordinary Cordis groups for nested plugin lists. Keep `!!js` expressions only in plugin configuration or `disabled`; the Loader evaluates them when activating the child plugin. Resolve assets from installed packages rather than a preset directory.\n\n## Extend the Host\n\nFor new plugin code or profile-wide capabilities, load `cordis-plugin-development`, author a workspace bundle, and install it with `plugin_manager`. Inspect available APIs through `cordis_inspect_list` and `cordis_inspect_query`, including a mounted plugin's Config schema through the Host `Config` provider; those tools do not invoke Remote methods. Verify the installed capability before reporting completion.\n\n"}],"isError":false,"id":"{{message:6}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The skill is loaded."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4},"stream":[{"type":"chunk","time":1788699430709,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699430709,"index":0,"dt":[],"texts":["The skill is loaded."]},{"type":"chunk","time":1788699430709,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788699430709,"index":1,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1788699430709,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The skill is loaded."}}},{"type":"chunk","time":1788699430709,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788699430709,"chunk":{"type":"usage","usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4}}},{"type":"chunk","time":1788699430709,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} diff --git a/snapshots/web/auto-review-denial/ui.expected.md b/snapshots/web/auto-review-denial/ui.expected.md index 172a68cb4a..1ceeeb4f1e 100644 --- a/snapshots/web/auto-review-denial/ui.expected.md +++ b/snapshots/web/auto-review-denial/ui.expected.md @@ -9,6 +9,9 @@ - tab "Trajectory" - text: Inspect the protected operation, but do not run it unless authorized. {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Called tools, ran code, ran commands" [expanded] - text: Failed - button "Tool call Rejected by Auto review" - button "Code Catch an Auto-denied inner call" @@ -34,6 +37,9 @@ - tab "Trajectory" - text: Inspect the protected operation, but do not run it unless authorized. {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Called tools, ran code, ran commands" [expanded] - text: Failed - button "Tool call Rejected by Auto review" [expanded] - text: "OUT Tool was not executed. Reason: raw reason" diff --git a/snapshots/web/bash-abort-row/ui.expected.md b/snapshots/web/bash-abort-row/ui.expected.md index 911bf72973..c74b1b8193 100644 --- a/snapshots/web/bash-abort-row/ui.expected.md +++ b/snapshots/web/bash-abort-row/ui.expected.md @@ -7,7 +7,9 @@ - tab "Trajectory" - text: "Run two shell commands: wait for cancellation, then write skipped.txt. {{clock}}" - button "Copy" -- button "Context injection runtime-context" +- status: Stopped +- button "Stopped" [disabled] [expanded] +- button "Ran commands" [expanded] - 'button "Failed Bash Error: tool call aborted" [expanded]' - text: "IN { \"command\": \"node -e \\\"const fs=require('node:fs'); fs.writeFileSync('started.tmp', 'started'); fs.renameSync('started.tmp', 'started.txt'); setInterval(() => {}, 1000)\\\"\", \"description\": \"Wait until cancellation\" } OUT Error: tool call aborted" - button "Inspect" diff --git a/snapshots/web/changed-files-turn/ui.expected.md b/snapshots/web/changed-files-turn/ui.expected.md index cfefec17db..ed54f74056 100644 --- a/snapshots/web/changed-files-turn/ui.expected.md +++ b/snapshots/web/changed-files-turn/ui.expected.md @@ -1,7 +1,8 @@ - text: 不用先查看目录,直接做四件事:把 intro.md 里的标题「示例项目」改成「项目说明」,新建 src/util.ts 导出一个两数相加的 add 函数,新建 app.local 写一行 mode=demo,最后用 bash 在 notes.txt 末尾追加一行 done。 {{clock}} - button "复制" -- button "7 次工具调用" [expanded] -- button "上下文注入 runtime-context" +- status: 已完成工作 +- button "用时 {{duration}}" [expanded] +- button "修改了文件,执行了命令,已读取文件等" [expanded] - button "思考" - button "读取 intro.md": - text: 读取 diff --git a/snapshots/web/deepseek-messages-chat/ui.expected.md b/snapshots/web/deepseek-messages-chat/ui.expected.md index 9704a35af6..021809ac0c 100644 --- a/snapshots/web/deepseek-messages-chat/ui.expected.md +++ b/snapshots/web/deepseek-messages-chat/ui.expected.md @@ -8,8 +8,8 @@ - tab "轨迹" - text: 只回复 MESSAGES_WEB_READY,不调用工具。 {{clock}} - button "复制" -- button "已思考" -- button "思考" +- status: 已完成工作 +- button "用时 {{duration}}" [disabled] - paragraph: MESSAGES_WEB_READY - button "复制" - button "好的回答" diff --git a/snapshots/web/default-workspace/ui.expected.md b/snapshots/web/default-workspace/ui.expected.md index 7988d45c0c..27cdaa5150 100644 --- a/snapshots/web/default-workspace/ui.expected.md +++ b/snapshots/web/default-workspace/ui.expected.md @@ -8,7 +8,8 @@ - tab "Trajectory" - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- button "1 tool call" +- status: Worked +- button "Took {{duration}}" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/document-preview/document.expected.md b/snapshots/web/document-preview/document.expected.md index 633a072fa7..9e0fd04eff 100644 --- a/snapshots/web/document-preview/document.expected.md +++ b/snapshots/web/document-preview/document.expected.md @@ -39,6 +39,8 @@ - Viewer menu hidden: true - Worker: dsh-pdf - Continuous pages: 2 +- Zoom reveal: hidden -> bottom hover -> delayed hidden +- Zoom modes: fit width -> 100% -> 150% -> fit width - Horizontal overflow: false - Canvas fills: red -> blue -> blue - Same tab: true @@ -57,6 +59,12 @@ - Table selection: forward and backward drags exclude later sections - Line-break highlight: transparent +## Image zoom + +- Small PNG fit width remains at intrinsic size; 200% doubles it +- SVG fit width -> 100% -> fit width toggles horizontal overflow: false -> true -> false +- Image and Blob identities remain stable while zoom changes + ## Code paging - Viewer: Code @@ -77,6 +85,8 @@ - Opens without the Office conversion service - Sheets: 季度预算 | 公式与格式; hidden worksheet omitted - Formula workbooks use a compact warning beside fx; notice rows absent +- Unsupported XLSX content: This preview does not support charts, conditional formatting in this workbook. Open it in a system application for the full experience. +- Drawing parts omitted; styled cells and cached formulas retained; notice cleared on file replacement - Formatted percent copied: 80.0%; date copied: 2026-09-16 - Cached XLOOKUP result copied: 42; typing leaves it unchanged - Formula bar is read-only; PDF body and editing toolbar absent diff --git a/snapshots/web/feedback-command/ack-expanded.expected.md b/snapshots/web/feedback-command/ack-expanded.expected.md index d9c09956cf..f9d619e382 100644 --- a/snapshots/web/feedback-command/ack-expanded.expected.md +++ b/snapshots/web/feedback-command/ack-expanded.expected.md @@ -8,8 +8,9 @@ - tab "Trajectory" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" -- button "Thought for a while" [expanded] -- button "Context injection runtime-context" +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Analysis completed" [expanded] - button "Think" - paragraph: LIGHTHOUSE - button "Copy" diff --git a/snapshots/web/feedback-command/ack.expected.md b/snapshots/web/feedback-command/ack.expected.md index b5060084a4..716f3bd304 100644 --- a/snapshots/web/feedback-command/ack.expected.md +++ b/snapshots/web/feedback-command/ack.expected.md @@ -8,7 +8,8 @@ - tab "Trajectory" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" -- button "Thought for a while" +- status: Worked +- button "Took {{duration}}" - paragraph: LIGHTHOUSE - button "Copy" - button "Good response" diff --git a/snapshots/web/feedback-release/ack-expanded.expected.md b/snapshots/web/feedback-release/ack-expanded.expected.md index d9c09956cf..f9d619e382 100644 --- a/snapshots/web/feedback-release/ack-expanded.expected.md +++ b/snapshots/web/feedback-release/ack-expanded.expected.md @@ -8,8 +8,9 @@ - tab "Trajectory" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" -- button "Thought for a while" [expanded] -- button "Context injection runtime-context" +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Analysis completed" [expanded] - button "Think" - paragraph: LIGHTHOUSE - button "Copy" diff --git a/snapshots/web/feedback-release/ack.expected.md b/snapshots/web/feedback-release/ack.expected.md index b5060084a4..716f3bd304 100644 --- a/snapshots/web/feedback-release/ack.expected.md +++ b/snapshots/web/feedback-release/ack.expected.md @@ -8,7 +8,8 @@ - tab "Trajectory" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" -- button "Thought for a while" +- status: Worked +- button "Took {{duration}}" - paragraph: LIGHTHOUSE - button "Copy" - button "Good response" diff --git a/snapshots/web/file-upload-round/ui.expected.md b/snapshots/web/file-upload-round/ui.expected.md index fc6a0cf362..0c60a10a69 100644 --- a/snapshots/web/file-upload-round/ui.expected.md +++ b/snapshots/web/file-upload-round/ui.expected.md @@ -11,7 +11,8 @@ - img "reference-1.png" - text: Read the attached file with the read tool, reply with exactly the single word it contains, and stop. {{clock}} - button "Copy" -- button "1 tool call" +- status: Worked +- button "Took {{duration}}" - paragraph: UPLOAD_ROUND_OK - button "Copy" - button "Good response" diff --git a/snapshots/web/fresh-round-trip/ui-expanded.expected.md b/snapshots/web/fresh-round-trip/ui-expanded.expected.md index 1bbd83f0d9..0c4d334df3 100644 --- a/snapshots/web/fresh-round-trip/ui-expanded.expected.md +++ b/snapshots/web/fresh-round-trip/ui-expanded.expected.md @@ -8,8 +8,9 @@ - tab "Trajectory" - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- button "1 tool call" [expanded] -- button "Context injection runtime-context" +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Ran commands" [expanded] - button "Think" - button "Bash Echo the test string" - button "Think" diff --git a/snapshots/web/fresh-round-trip/ui.expected.md b/snapshots/web/fresh-round-trip/ui.expected.md index 7988d45c0c..27cdaa5150 100644 --- a/snapshots/web/fresh-round-trip/ui.expected.md +++ b/snapshots/web/fresh-round-trip/ui.expected.md @@ -8,7 +8,8 @@ - tab "Trajectory" - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- button "1 tool call" +- status: Worked +- button "Took {{duration}}" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md index 7c9f533f8c..e2d3dc0857 100644 --- a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md @@ -11,9 +11,12 @@ - button "Jump to turn 2" - group "Command input": /goal 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的 - 'button "goal Goal created Status: active Objective: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的 Rounds: 0/256 Activation: armed Commands: /goal edit , /goal pause, /goal clear"' -- button "6 tool calls" [expanded] -- button "Context injection goal" -- button "Context injection runtime-context" +- button "Continuing goal {{clock}}": + - text: Continuing goal + - time: {{clock}} +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Ran commands and updated the plan" [expanded] - button "Think" - button "Bash List package directories deterministically" - button "View goal 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的" @@ -41,8 +44,12 @@ - button "Bad response" - button "Branch into a new conversation" - text: {{clock}} -- button "5 tool calls · 2 messages" [expanded] -- button "Context injection goal" +- button "Continuing goal {{clock}}": + - text: Continuing goal + - time: {{clock}} +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Ran commands" [expanded] - button "Think" - button "Bash Randomly select another package with Python" - button "Think" @@ -64,10 +71,11 @@ - text: ,turn 2: - code: token-meter - text: )。这一轮到此结束。 +- button "Updated the plan" [expanded] - button "View goal 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的" - paragraph: 两个 turn 均已完成,目标达成,标记 goal 为完成。 +- button "Updated the plan" [expanded] - button "Update goal 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的" -- 'button "Context injection tool-goal complete: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的"' - paragraph: 两个 turn 都完成了 ✅ - paragraph: - strong: 总结: diff --git a/snapshots/web/goal-multi-turn-actions/ui.expected.md b/snapshots/web/goal-multi-turn-actions/ui.expected.md index 2d7d6a5d24..b3061a90ea 100644 --- a/snapshots/web/goal-multi-turn-actions/ui.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui.expected.md @@ -11,7 +11,11 @@ - button "Jump to turn 2" - group "Command input": /goal 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的 - 'button "goal Goal created Status: active Objective: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的 Rounds: 0/256 Activation: armed Commands: /goal edit , /goal pause, /goal clear"' -- button "6 tool calls" +- button "Continuing goal {{clock}}": + - text: Continuing goal + - time: {{clock}} +- status: Worked +- button "Took {{duration}}" - paragraph: - strong: Turn 1 / 2 - text: — 随机选中的包: @@ -27,7 +31,11 @@ - button "Bad response" - button "Branch into a new conversation" - text: {{clock}} -- button "5 tool calls · 2 messages" +- button "Continuing goal {{clock}}": + - text: Continuing goal + - time: {{clock}} +- status: Worked +- button "Took {{duration}}" - paragraph: 两个 turn 都完成了 ✅ - paragraph: - strong: 总结: diff --git a/snapshots/web/lifecycle-chrome/connection-error.expected.md b/snapshots/web/lifecycle-chrome/connection-error.expected.md index 6674078cb3..51ccb442fa 100644 --- a/snapshots/web/lifecycle-chrome/connection-error.expected.md +++ b/snapshots/web/lifecycle-chrome/connection-error.expected.md @@ -1,2 +1,2 @@ -- button "Settings" +- button "Account menu": Not signed in - button "Reconnecting, reconnect now": Reconnecting diff --git a/snapshots/web/lifecycle-chrome/hero.expected.md b/snapshots/web/lifecycle-chrome/hero.expected.md index ce6698be33..4d1f24ef42 100644 --- a/snapshots/web/lifecycle-chrome/hero.expected.md +++ b/snapshots/web/lifecycle-chrome/hero.expected.md @@ -11,7 +11,7 @@ - tree "Sessions": - treeitem "workspace" [expanded] - treeitem "New Session" [selected] -- button "Settings" +- button "Account menu": Not signed in - banner: - button "Open right sidebar" - text: Into the Unknown Preview diff --git a/snapshots/web/lifecycle-chrome/plan-active-zh.expected.md b/snapshots/web/lifecycle-chrome/plan-active-zh.expected.md index c01c19bc4b..67147659df 100644 --- a/snapshots/web/lifecycle-chrome/plan-active-zh.expected.md +++ b/snapshots/web/lifecycle-chrome/plan-active-zh.expected.md @@ -11,7 +11,7 @@ - tree "会话": - treeitem "workspace" [expanded] - treeitem "新会话" [selected] -- button "设置" +- button "账号菜单": 尚未登录 - banner: - button "打开右侧边栏" - text: 探索未至之境 预览版 diff --git a/snapshots/web/lifecycle-chrome/plan-active.expected.md b/snapshots/web/lifecycle-chrome/plan-active.expected.md index dee04dc9e8..9d0ee73cde 100644 --- a/snapshots/web/lifecycle-chrome/plan-active.expected.md +++ b/snapshots/web/lifecycle-chrome/plan-active.expected.md @@ -11,7 +11,7 @@ - tree "Sessions": - treeitem "workspace" [expanded] - treeitem "New Session" [selected] -- button "Settings" +- button "Account menu": Not signed in - banner: - button "Open right sidebar" - text: Into the Unknown Preview diff --git a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md index 8b6f7eae5f..761eb41613 100644 --- a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md @@ -8,8 +8,9 @@ - tab "Trajectory" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" -- button "Thought for a while" [expanded] -- button "Context injection runtime-context" +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Analysis completed" [expanded] - button "Think" - paragraph: LIGHTHOUSE - button "Copy" diff --git a/snapshots/web/lifecycle-chrome/reloaded.expected.md b/snapshots/web/lifecycle-chrome/reloaded.expected.md index a760c82371..f6a61355d8 100644 --- a/snapshots/web/lifecycle-chrome/reloaded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded.expected.md @@ -8,7 +8,8 @@ - tab "Trajectory" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" -- button "Thought for a while" +- status: Worked +- button "Took {{duration}}" - paragraph: LIGHTHOUSE - button "Copy" - button "Good response" diff --git a/snapshots/web/live-interactions/cancel-expanded.expected.md b/snapshots/web/live-interactions/cancel-expanded.expected.md index 3813c500b7..3b9f992e2a 100644 --- a/snapshots/web/live-interactions/cancel-expanded.expected.md +++ b/snapshots/web/live-interactions/cancel-expanded.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Thought for a while" [expanded] -- button "Context injection runtime-context" +- status: Stopped +- button "Stopped" [disabled] - paragraph: partial - text: Stopped - button "Copy" diff --git a/snapshots/web/live-interactions/cancel.expected.md b/snapshots/web/live-interactions/cancel.expected.md index 8b64580291..3b9f992e2a 100644 --- a/snapshots/web/live-interactions/cancel.expected.md +++ b/snapshots/web/live-interactions/cancel.expected.md @@ -8,7 +8,8 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Thought for a while" +- status: Stopped +- button "Stopped" [disabled] - paragraph: partial - text: Stopped - button "Copy" diff --git a/snapshots/web/live-interactions/error-auth.expected.md b/snapshots/web/live-interactions/error-auth.expected.md index 4a5dd961fe..eecdfc4bd5 100644 --- a/snapshots/web/live-interactions/error-auth.expected.md +++ b/snapshots/web/live-interactions/error-auth.expected.md @@ -8,7 +8,8 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Context injection runtime-context" +- status: Failed +- button "Failed" [disabled] - status: - text: This turn failedAPI key is invalid - code: AUTH diff --git a/snapshots/web/live-interactions/loading.expected.md b/snapshots/web/live-interactions/loading.expected.md index 4635768121..7976fcca1c 100644 --- a/snapshots/web/live-interactions/loading.expected.md +++ b/snapshots/web/live-interactions/loading.expected.md @@ -8,9 +8,9 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Context injection runtime-context" -- paragraph: partial - status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] +- paragraph: partial - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/live-interactions/retry-exhausted.expected.md b/snapshots/web/live-interactions/retry-exhausted.expected.md index 2433cb613f..5decef7623 100644 --- a/snapshots/web/live-interactions/retry-exhausted.expected.md +++ b/snapshots/web/live-interactions/retry-exhausted.expected.md @@ -8,7 +8,8 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Context injection runtime-context" +- status: Failed +- button "Failed" [disabled] [expanded] - group: - status: Retried model request (2/2) · {{duration}} - status: diff --git a/snapshots/web/live-interactions/retry-expanded.expected.md b/snapshots/web/live-interactions/retry-expanded.expected.md index 5ea5441f23..ad8e9f8f2b 100644 --- a/snapshots/web/live-interactions/retry-expanded.expected.md +++ b/snapshots/web/live-interactions/retry-expanded.expected.md @@ -8,10 +8,11 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Thought for a while" [expanded] -- button "Context injection runtime-context" +- status: Worked +- button "Took {{duration}}" [expanded] - group: - status: Retried model request (1/5) · {{duration}} +- button "Analysis completed" [expanded] - button "Think" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" diff --git a/snapshots/web/live-interactions/retry.expected.md b/snapshots/web/live-interactions/retry.expected.md index a122990338..33c058c865 100644 --- a/snapshots/web/live-interactions/retry.expected.md +++ b/snapshots/web/live-interactions/retry.expected.md @@ -8,7 +8,8 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Thought for a while" +- status: Worked +- button "Took {{duration}}" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" diff --git a/snapshots/web/live-interactions/running-draft.expected.md b/snapshots/web/live-interactions/running-draft.expected.md index 92ddaed171..1bdf81ba91 100644 --- a/snapshots/web/live-interactions/running-draft.expected.md +++ b/snapshots/web/live-interactions/running-draft.expected.md @@ -8,9 +8,9 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Context injection runtime-context" -- paragraph: partial - status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] +- paragraph: partial - textbox "Message or run a task, / commands, @ files or sessions": - paragraph: Queue this follow-up while the current turn is running. - button "Add files or run commands" diff --git a/snapshots/web/message-actions/ui.expected.md b/snapshots/web/message-actions/ui.expected.md index f4776040f5..ed11835a6c 100644 --- a/snapshots/web/message-actions/ui.expected.md +++ b/snapshots/web/message-actions/ui.expected.md @@ -12,21 +12,19 @@ - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" - tooltip "Copy" -- button "Think" +- status: Stopped +- button "Stopped" [disabled] [expanded] +- button "Analysis completed" - paragraph: I will read both files before answering. +- button "Read files" - button "Copy" - button "Good response" - button "Bad response" - button "Branch into a new conversation" [disabled] -- text: Available only on the last message of a completed turn {{clock}} -- button "Read a.txt": - - text: Read - - button "a.txt" -- button "Read b.txt": - - text: Read - - button "b.txt" -- text: Now give the final answer. {{clock}} +- text: Available only on the last message of a completed turn {{clock}} Now give the final answer. {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: DONE - button "Copy" - button "Good response" @@ -34,6 +32,8 @@ - button "Branch into a new conversation" - text: {{clock}} Keep this later input in the original conversation. {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: ORIGINAL ONLY - button "Copy" - button "Good response" diff --git a/snapshots/web/minimal-preset/ui.expected.md b/snapshots/web/minimal-preset/ui.expected.md index e7e695c43c..e489e916e6 100644 --- a/snapshots/web/minimal-preset/ui.expected.md +++ b/snapshots/web/minimal-preset/ui.expected.md @@ -8,7 +8,9 @@ - tab "Trajectory" - text: "Use the bash tool to run exactly: printf 'MINIMAL_BASH_CARD_OK\\n'. Then reply exactly MINIMAL_PRESET_REQUEST_OK and stop. {{clock}}" - button "Copy" -- button "1 tool call" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Ran commands" [expanded] - button "Bash printf 'MINIMAL_BASH_CARD_OK\\n'" [expanded] - text: "IN { \"command\": \"printf 'MINIMAL_BASH_CARD_OK\\\\n'\" } OUT MINIMAL_BASH_CARD_OK [Command finished with exit code 0]" - button "Inspect" diff --git a/snapshots/web/plan-review/approved-expanded.expected.md b/snapshots/web/plan-review/approved-expanded.expected.md index 602b7120f8..6f978228c7 100644 --- a/snapshots/web/plan-review/approved-expanded.expected.md +++ b/snapshots/web/plan-review/approved-expanded.expected.md @@ -7,13 +7,15 @@ - tab "Trajectory" - text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "Copy" -- button "1 tool call · 1 message" [expanded] -- button "Context injection runtime-context" +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Analysis completed" [expanded] - button "Think" - paragraph: - text: Since the user has explicitly asked me not to read or write any files and to go straight to planning, I'll proceed with - code: exit_plan_mode - text: . +- button "Called tools" [expanded] - 'button "Tool call exit_plan_mode · # Add `--greeting` flag to CLI"' - button "Think" - paragraph: DONE diff --git a/snapshots/web/plan-review/approved.expected.md b/snapshots/web/plan-review/approved.expected.md index fe52ec9268..279843a249 100644 --- a/snapshots/web/plan-review/approved.expected.md +++ b/snapshots/web/plan-review/approved.expected.md @@ -7,7 +7,8 @@ - tab "Trajectory" - text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "Copy" -- button "1 tool call · 1 message" +- status: Worked +- button "Took {{duration}}" - paragraph: DONE - 'button "Open plan: Add `--greeting` flag to CLI"': "Add `--greeting` flag to CLI Plan · Markdown Open" - button "Copy" diff --git a/snapshots/web/present-svg/ui.expected.md b/snapshots/web/present-svg/ui.expected.md index 9920928bed..57c6e63f6c 100644 --- a/snapshots/web/present-svg/ui.expected.md +++ b/snapshots/web/present-svg/ui.expected.md @@ -1,9 +1,11 @@ - text: 简单画一个 SVG 表示冯诺依曼架构, 保存为 von-neumann.svg {{clock}} - button "复制" -- button "2 次工具调用 · 1 条消息" [expanded] -- button "上下文注入 runtime-context" +- status: 已完成工作 +- button "用时 {{duration}}" [expanded] +- button "已完成分析" [expanded] - button "思考" - paragraph: 我来创建一个冯诺依曼架构的 SVG 示意图。 +- button "修改了文件并已调用工具" [expanded] - button "写入 von-neumann.svg +83 -0": - text: 写入 - button "von-neumann.svg" diff --git a/snapshots/web/present/ui.expected.md b/snapshots/web/present/ui.expected.md index 2892aa7897..34a2a77047 100644 --- a/snapshots/web/present/ui.expected.md +++ b/snapshots/web/present/ui.expected.md @@ -8,8 +8,9 @@ - tab "Trajectory" - text: "Use one run_code program to do the following in order. Call present for missing.txt and catch its error without creating that file. Use bash to run exactly `printf \"DELIVERED_REPORT\\n\" > report.txt; printf \"DELIVERED_NOTE\\n\" > 说明.txt`. Call present for report.txt and 说明.txt. After present succeeds, deliberately throw the string \"AFTER_PRESENT\" (not an Error object) from that same run_code program. Do not retry the program or create any other files. Finish by mentioning `report.txt` and `说明.txt` in inline code, and put PRESENT_DONE in a separate paragraph. {{clock}}" - button "Copy" -- button "1 tool call" [expanded] -- button "Context injection runtime-context" +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Called tools, ran code, ran commands" [expanded] - button "Think" - text: Failed - 'button "Code Error: code run failed (exception): AFTER_PRESENT"' diff --git a/snapshots/web/ptc-round/ui.expected.md b/snapshots/web/ptc-round/ui.expected.md index 9e68c8604a..986dcf439c 100644 --- a/snapshots/web/ptc-round/ui.expected.md +++ b/snapshots/web/ptc-round/ui.expected.md @@ -8,8 +8,9 @@ - tab "Trajectory" - text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}" - button "Copy" -- button "1 tool call" [expanded] -- button "Context injection runtime-context" +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Ran code, ran commands, read files" [expanded] - button "Think" - button "Code Run bash echo and catch missing file read" - button "Bash Echo CODE_ROUND_OK" [expanded] diff --git a/snapshots/web/question-composer/answered-expanded.expected.md b/snapshots/web/question-composer/answered-expanded.expected.md index 488e8b8029..346b77ff8d 100644 --- a/snapshots/web/question-composer/answered-expanded.expected.md +++ b/snapshots/web/question-composer/answered-expanded.expected.md @@ -8,8 +8,9 @@ - tab "Trajectory" - text: "Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop. {{clock}}" - button "Copy" -- button "1 tool call" [expanded] -- button "Context injection runtime-context" +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Asked questions" [expanded] - button "Think" - button "Ask question 1/1 answered" [expanded] - term: Which color do you prefer? diff --git a/snapshots/web/question-composer/answered.expected.md b/snapshots/web/question-composer/answered.expected.md index fa8b822467..be7b65aa37 100644 --- a/snapshots/web/question-composer/answered.expected.md +++ b/snapshots/web/question-composer/answered.expected.md @@ -8,7 +8,8 @@ - tab "Trajectory" - text: "Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop. {{clock}}" - button "Copy" -- button "1 tool call" +- status: Worked +- button "Took {{duration}}" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/question-composer/cancelled.expected.md b/snapshots/web/question-composer/cancelled.expected.md index 7238215d90..7d5d89a078 100644 --- a/snapshots/web/question-composer/cancelled.expected.md +++ b/snapshots/web/question-composer/cancelled.expected.md @@ -7,7 +7,9 @@ - tab "Trajectory" - text: "Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop. {{clock}}" - button "Copy" -- button "Context injection runtime-context" +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Asked questions" [expanded] - button "Think" - button "Ask question cancelled" [expanded] - paragraph: This question set was cancelled before answers were submitted. diff --git a/snapshots/web/queue-actions/collapsed.expected.md b/snapshots/web/queue-actions/collapsed.expected.md index bcb7ecf753..64738a0101 100644 --- a/snapshots/web/queue-actions/collapsed.expected.md +++ b/snapshots/web/queue-actions/collapsed.expected.md @@ -8,9 +8,9 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Context injection runtime-context" -- paragraph: partial - status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] +- paragraph: partial - button "2 queued messages" - textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Add files or run commands" diff --git a/snapshots/web/queue-actions/editing.expected.md b/snapshots/web/queue-actions/editing.expected.md index 13925614bf..0e02090d39 100644 --- a/snapshots/web/queue-actions/editing.expected.md +++ b/snapshots/web/queue-actions/editing.expected.md @@ -8,9 +8,9 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Context injection runtime-context" -- paragraph: partial - status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] +- paragraph: partial - button "2 queued messages" [disabled] [expanded] - list: - listitem: diff --git a/snapshots/web/queue-actions/layout.expected.md b/snapshots/web/queue-actions/layout.expected.md index 4ca4469eed..2455bfb385 100644 --- a/snapshots/web/queue-actions/layout.expected.md +++ b/snapshots/web/queue-actions/layout.expected.md @@ -8,10 +8,12 @@ - tab "Trajectory" - group "Command input": /goal Keep the composer context panels aligned - 'button "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit , /goal pause, /goal clear"' -- button "Context injection goal" -- button "Context injection runtime-context" -- paragraph: partial +- button "Continuing goal {{clock}}": + - text: Continuing goal + - time: {{clock}} - status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] +- paragraph: partial - region "To-dos": - button "To-dos 1 completed · 1 in progress" - text: Ongoing Goal Keep the composer context panels aligned diff --git a/snapshots/web/queue-actions/preserved-expanded.expected.md b/snapshots/web/queue-actions/preserved-expanded.expected.md index e95a3f6fb3..3ea9a43df2 100644 --- a/snapshots/web/queue-actions/preserved-expanded.expected.md +++ b/snapshots/web/queue-actions/preserved-expanded.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Thought for a while" [expanded] -- button "Context injection runtime-context" +- status: Stopped +- button "Stopped" [disabled] - paragraph: partial - text: Stopped - button "Copy" diff --git a/snapshots/web/queue-actions/preserved.expected.md b/snapshots/web/queue-actions/preserved.expected.md index 3f186da354..3ea9a43df2 100644 --- a/snapshots/web/queue-actions/preserved.expected.md +++ b/snapshots/web/queue-actions/preserved.expected.md @@ -8,7 +8,8 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Thought for a while" +- status: Stopped +- button "Stopped" [disabled] - paragraph: partial - text: Stopped - button "Copy" diff --git a/snapshots/web/queue-actions/sending.expected.md b/snapshots/web/queue-actions/sending.expected.md index 02e60e5f77..594d0053e8 100644 --- a/snapshots/web/queue-actions/sending.expected.md +++ b/snapshots/web/queue-actions/sending.expected.md @@ -8,9 +8,9 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Context injection runtime-context" -- paragraph: partial - status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] +- paragraph: partial - list: - listitem: - text: Queue item to remove diff --git a/snapshots/web/queue-actions/ui.expected.md b/snapshots/web/queue-actions/ui.expected.md index fcc1477d43..6107949766 100644 --- a/snapshots/web/queue-actions/ui.expected.md +++ b/snapshots/web/queue-actions/ui.expected.md @@ -8,9 +8,9 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Context injection runtime-context" -- paragraph: partial - status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] +- paragraph: partial - list: - listitem: - text: Edited queue item diff --git a/snapshots/web/queued-image/delivered.expected.md b/snapshots/web/queued-image/delivered.expected.md index a77a5d3547..d014db4b09 100644 --- a/snapshots/web/queued-image/delivered.expected.md +++ b/snapshots/web/queued-image/delivered.expected.md @@ -12,7 +12,8 @@ - button "Jump to turn 3" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Thought for a while" +- status: Stopped +- button "Stopped" [disabled] - paragraph: partial - text: Stopped - button "Copy" @@ -24,7 +25,8 @@ - img "queued.png" - text: Compare with this screenshot {{clock}} - button "Copy" -- button "Thought for a while" +- status: Worked +- button "Took {{duration}}" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" @@ -32,7 +34,8 @@ - button "Branch into a new conversation" - text: {{clock}} Continue with the queued comparison {{clock}} - button "Copy" -- button "Thought for a while" +- status: Worked +- button "Took {{duration}}" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" diff --git a/snapshots/web/queued-image/queued.expected.md b/snapshots/web/queued-image/queued.expected.md index 95b98d26cd..6496bb83f5 100644 --- a/snapshots/web/queued-image/queued.expected.md +++ b/snapshots/web/queued-image/queued.expected.md @@ -8,9 +8,9 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Context injection runtime-context" -- paragraph: partial - status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] +- paragraph: partial - list: - listitem: - img "Queued message image" diff --git a/snapshots/web/seeded-history/command-row.expected.md b/snapshots/web/seeded-history/command-row.expected.md index bcebaccbe0..86f3b1a9cc 100644 --- a/snapshots/web/seeded-history/command-row.expected.md +++ b/snapshots/web/seeded-history/command-row.expected.md @@ -10,7 +10,9 @@ - button "Jump to turn 2" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- button "2 tool calls" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Read files" [expanded] - button "Think" - button "Read a.txt": - text: Read @@ -26,7 +28,8 @@ - button "Branch into a new conversation" - text: {{clock}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" -- button "Context injection AGENTS.md" +- status: Worked +- button "Took {{duration}}" [disabled] - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Read Only"': Read Only diff --git a/snapshots/web/seeded-history/feedback-row.expected.md b/snapshots/web/seeded-history/feedback-row.expected.md index cb0bde06e7..769c31023f 100644 --- a/snapshots/web/seeded-history/feedback-row.expected.md +++ b/snapshots/web/seeded-history/feedback-row.expected.md @@ -10,7 +10,9 @@ - button "Jump to turn 2" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- button "2 tool calls" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Read files" [expanded] - button "Think" - button "Read a.txt": - text: Read @@ -26,7 +28,8 @@ - button "Branch into a new conversation" - text: {{clock}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" -- button "Context injection AGENTS.md" +- status: Worked +- button "Took {{duration}}" [disabled] - 'button "feedback Feedback recorded for session {{seededId}} Anonymous user: {{uuid}}." [expanded]' - text: "Feedback recorded for session {{seededId}} Anonymous user: {{uuid}}." - textbox "Message or run a task, / commands, @ files or sessions" diff --git a/snapshots/web/seeded-history/ui-expanded.expected.md b/snapshots/web/seeded-history/ui-expanded.expected.md index 4f6ac15000..6cbe989134 100644 --- a/snapshots/web/seeded-history/ui-expanded.expected.md +++ b/snapshots/web/seeded-history/ui-expanded.expected.md @@ -10,7 +10,9 @@ - button "Jump to turn 2" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- button "2 tool calls" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Read files" [expanded] - button "Think" - button "Read a.txt": - text: Read @@ -26,7 +28,8 @@ - button "Branch into a new conversation" - text: {{clock}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" -- button "Context injection AGENTS.md" +- status: Worked +- button "Took {{duration}}" [disabled] - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/seeded-history/ui.expected.md b/snapshots/web/seeded-history/ui.expected.md index 4f703e11d9..b1842edc41 100644 --- a/snapshots/web/seeded-history/ui.expected.md +++ b/snapshots/web/seeded-history/ui.expected.md @@ -10,7 +10,8 @@ - button "Jump to turn 2" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- button "2 tool calls" +- status: Worked +- button "Took {{duration}}" - paragraph: DONE - button "Copy" - button "Good response" @@ -18,7 +19,8 @@ - button "Branch into a new conversation" - text: {{clock}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" -- button "Context injection AGENTS.md" +- status: Worked +- button "Took {{duration}}" [disabled] - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/skill-tool-row/ui.expected.md b/snapshots/web/skill-tool-row/ui.expected.md index f57d8ffb88..6e08ccbd47 100644 --- a/snapshots/web/skill-tool-row/ui.expected.md +++ b/snapshots/web/skill-tool-row/ui.expected.md @@ -7,12 +7,12 @@ - tab "Trajectory" - text: Load the editing-cordis-compositions skill with the skill tool, then reply DONE. {{date}} {{clock}} - button "Copy" -- button "1 tool call" [expanded] -- button "Context injection runtime-context" -- button "Context injection skill-catalog" +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Called tools" [expanded] - button "Think" - button "Skill editing-cordis-compositions" [expanded] -- region "Instructions": "Instructions Base directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. # Editing Cordis compositions Agent presets are ordinary `@deepseek-ai/dsh-agent-preset` declarations carried by bundle patches. Nothing edits a declaration in place: a preset is created or changed by installing a bundle whose patch declares or overrides it. This file is the complete contract; do not read DSH package sources to infer formats. ## Where declarations live The shipped Web presets are `presets/.patch.yml` files of the `@deepseek-ai/dsh-web-app` bundle, ids `standard`, `ptc`, `minimal` and `cordis`. Installed, that bundle sits at `$DSH_HOME/profiles//node_modules/@deepseek-ai/dsh-web-app/`; in a source checkout of DSH it is `packages/bundle/web-app/`. Read one file with `cat` when you need a template; `minimal.patch.yml` is the shortest. A declaration row has these `config` fields: `id` (required, lowercase letters, digits and hyphens), `plugins` (required Cordis entry list), and optional `name`, `description` and `order` (roster position). The Loader row `id` is `preset-` by convention. ## Create a preset Write a bundle directory in the workspace with exactly two files, then install it. `review-preset/package.json`: ```json { \"name\": \"@local/dsh-review-preset\", \"version\": \"1.0.0\", \"private\": true, \"type\": \"module\", \"dsh\": { \"bundle\": { \"patch\": \"./cordis.patch.yml\" } } } ``` `review-preset/cordis.patch.yml`: ```yaml - insert: - id: preset-review name: '@deepseek-ai/dsh-agent-preset' config: id: review name: Review description: Reviews changes with the shell only. order: 10 plugins: - id: persona name: '@deepseek-ai/dsh-persona' config: prefix: You review software changes. - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' ``` Install with `plugin_manager`, `action: install_bundle`, `target` set to the absolute bundle directory. It runs package installation and bundle selection itself; do not reproduce those steps with shell commands. ## Change a shipped preset Override the declaration by its Loader row id instead of inserting. The override replaces the complete `config`, so restate `id`, `plugins` and every other field the shipped file carries: ```yaml - id: preset-standard name: '@deepseek-ai/dsh-agent-preset' config: id: standard order: 1 plugins: # the shipped list with your changes ``` ## Verify `plugin_manager` `list_bundles` lists the installed bundle; `list_plugins` shows the `preset-` row with its activation state. A declaration whose activation fails stays on the roster with its diagnostic and cannot compose a session until the bundle is fixed and reinstalled. Existing sessions and their children keep the plugin revision they started with; validate changed behavior in a new session. Installing a bundle executes plugin code in the Host process, so it requires Full access or approval. ## Choose plugin placement Host plugins supply shared services: tools and prompt registries, the Agent loop, sessions, persistence, settings, sandbox policy, model routes, and subagent backends. Preset plugins contribute scoped tools, persona, prompt sections, and policies to those registries. Preset revisions are eagerly activated once and shared by their selecting Agents. A preset plugin that supplies a service must isolate the provider and all its consumers in the same realm. A service consumed by Host plugins belongs in the Host configuration. Scope controls contributions and event visibility; `isolate` controls service instances. Use ordinary Cordis groups for nested plugin lists. Keep `!!js` expressions only in plugin configuration or `disabled`; the Loader evaluates them when activating the child plugin. Resolve assets from installed packages rather than a preset directory. ## Extend the Host For new plugin code or profile-wide capabilities, load `cordis-plugin-development`, author a workspace bundle, and install it with `plugin_manager`. Inspect available APIs through `cordis_inspect_list` and `cordis_inspect_query`; those tools do not invoke Remote methods. Verify the installed capability before reporting completion. " +- region "Instructions": "Instructions Base directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. # Editing Cordis compositions Agent presets are ordinary `@deepseek-ai/dsh-agent-preset` declarations carried by bundle patches. Nothing edits a declaration in place: a preset is created or changed by installing a bundle whose patch declares or overrides it. This file states the declaration format; when it leaves a question open, read the `@deepseek-ai/dsh-agent-preset` README and `lib/types` declarations under the `packageDir` that `cordis_inspect_query` `Config.listConfigs` returns when queried with a `preset-` row's `entry` id, or `packages/preset/agent-preset/src` in a source checkout. ## Where declarations live The shipped Web presets are `presets/.patch.yml` files of the `@deepseek-ai/dsh-web-app` bundle, ids `standard`, `ptc`, `minimal` and `cordis`. Installed, the bundle resolves from the dsh installation, not the profile; querying `Config.listConfigs` with the `entry` id of any `preset-` row it declares returns that `packageDir`. In a source checkout of DSH it is `packages/bundle/web-app/`. Read one file with the file-read tool when you need a template; `minimal.patch.yml` is the shortest. In Desktop the bundle sits inside `app.asar`, which shell commands cannot open. Load `cordis-composition-reference` for the patch dialect and the list of plugin packages a preset can mount. A declaration row has these `config` fields: `id` (required, lowercase letters, digits and hyphens), `plugins` (required Cordis entry list), and optional `name`, `description` and `order` (roster position). The Loader row `id` is `preset-` by convention. ## Create a preset Write a bundle directory in the workspace with exactly two files, then install it. `review-preset/package.json`: ```json { \"name\": \"@local/dsh-review-preset\", \"version\": \"1.0.0\", \"private\": true, \"type\": \"module\", \"dsh\": { \"bundle\": { \"patch\": \"./cordis.patch.yml\" } } } ``` `review-preset/cordis.patch.yml`: ```yaml - insert: - id: preset-review name: '@deepseek-ai/dsh-agent-preset' config: id: review name: Review description: Reviews changes with the shell only. order: 10 plugins: - id: persona name: '@deepseek-ai/dsh-persona' config: prefix: You review software changes. - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' ``` Install with `plugin_manager`, `action: install_bundle`, `target` set to the absolute bundle directory. It runs package installation and bundle selection itself; do not reproduce those steps with shell commands. ## Change a shipped preset Override the declaration by its Loader row id instead of inserting. The override replaces the complete `config`, so restate `id`, `plugins` and every other field the shipped file carries: ```yaml - id: preset-standard name: '@deepseek-ai/dsh-agent-preset' config: id: standard order: 1 plugins: # the shipped list with your changes ``` ## Migrate a legacy preset Before declaration rows, a user preset was a directory `$DSH_HOME/.agent-presets//` holding `preset.yml` (display `name`, `description`, `order`) and `agent.cordis.yml` (the plugin entry list). Nothing reads that directory any more. To migrate one, create a bundle as above whose declaration takes `id` from the directory name, `name`, `description`, and `order` from `preset.yml`, and `plugins` from `agent.cordis.yml` verbatim; check each plugin name against `cordis-composition-reference` because packages renamed since the preset was written fail at activation. Install it, verify the row, then delete the legacy directory. ## Verify `plugin_manager` `list_bundles` lists the installed bundle; `list_plugins` shows the `preset-` row with its activation state. A declaration whose activation fails stays on the roster with its diagnostic and cannot compose a session until the bundle is fixed and reinstalled. Existing sessions and their children keep the plugin revision they started with; validate changed behavior in a new session. Installing a bundle executes plugin code in the Host process, so it requires Full access or approval. ## Choose plugin placement Host plugins supply shared services: tools and prompt registries, the Agent loop, sessions, persistence, settings, sandbox policy, model routes, and subagent backends. Preset plugins contribute scoped tools, persona, prompt sections, and policies to those registries. Preset revisions are eagerly activated once and shared by their selecting Agents. A preset plugin that supplies a service must isolate the provider and all its consumers in the same realm. A service consumed by Host plugins belongs in the Host configuration. Scope controls contributions and event visibility; `isolate` controls service instances. Use ordinary Cordis groups for nested plugin lists. Keep `!!js` expressions only in plugin configuration or `disabled`; the Loader evaluates them when activating the child plugin. Resolve assets from installed packages rather than a preset directory. ## Extend the Host For new plugin code or profile-wide capabilities, load `cordis-plugin-development`, author a workspace bundle, and install it with `plugin_manager`. Inspect available APIs through `cordis_inspect_list` and `cordis_inspect_query`, including a mounted plugin's Config schema through the Host `Config` provider; those tools do not invoke Remote methods. Verify the installed capability before reporting completion. " - button "Inspect" - button "Think" - paragraph: DONE diff --git a/snapshots/web/steering/mid-steer.expected.md b/snapshots/web/steering/mid-steer.expected.md index f8b2fc5561..0a731c2f3e 100644 --- a/snapshots/web/steering/mid-steer.expected.md +++ b/snapshots/web/steering/mid-steer.expected.md @@ -8,10 +8,9 @@ - tab "Trajectory" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" -- button "Context injection runtime-context" -- text: Running -- button "Ask question waiting" - status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] +- button "Waiting for your action" - text: "Interjection: include the word BANANA in your final reply." - button "Copy" - region "Ready to continue?": diff --git a/snapshots/web/steering/session.v4.jsonl b/snapshots/web/steering/session.v4.jsonl new file mode 100644 index 0000000000..73fea86556 --- /dev/null +++ b/snapshots/web/steering/session.v4.jsonl @@ -0,0 +1,27 @@ +{"type":"session","version":4,"id":"{{session:1}}","createdAt":1788240428489,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0,"agentPreset":"standard"} +{"type":"permission/preset","data":{"preset":"workspace-write"}} +{"type":"sandbox/mode","data":{"mode":"workspace-write"}} +{"type":"approval/policy","data":{"policy":"ask"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"checkpoint\", question \"Ready to continue?\", header \"Checkpoint\", and options labeled \"Yes\" and \"No\". After I answer, reply with one short sentence acknowledging my answer and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"checkpoint\", question \"Ready to continue?\", header \"Checkpoint\", and options labeled \"Yes\" and \"No\". After I answer, reply with one short sentence acknowledging my answer and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} +{"type":"session/title","data":{"title":"Use the ask_user_question tool to","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Interjection: include the word BANANA in your final reply."}],"source":{"kind":"user","rpcId":"{{rpc:2}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:4}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[],"outcome":"canceled"}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Interjection: include the word BANANA in your final reply."}],"source":{"kind":"user","rpcId":"{{rpc:2}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:4}}"}]}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"steering-question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"checkpoint\",\"question\":\"Ready to continue?\",\"header\":\"Checkpoint\",\"options\":[{\"label\":\"Yes\"},{\"label\":\"No\"}]}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1788699399190,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788699399690,"index":0,"dt":[],"id":"steering-question","name":"ask_user_question","args":["{\"questions\":[{\"id\":\"checkpoint\",\"question\":\"Ready to continue?\",\"header\":\"Checkpoint\",\"options\":[{\"label\":\"Yes\"},{\"label\":\"No\"}]}]}"]},{"type":"chunk","time":1788699400192,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"steering-question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"checkpoint\",\"question\":\"Ready to continue?\",\"header\":\"Checkpoint\",\"options\":[{\"label\":\"Yes\"},{\"label\":\"No\"}]}]}"}}},{"type":"chunk","time":1788699400693,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1788699401194,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"steering-question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"checkpoint\",\"question\":\"Ready to continue?\",\"header\":\"Checkpoint\",\"options\":[{\"label\":\"Yes\"},{\"label\":\"No\"}]}]}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"steering-question"},"toolCallId":"steering-question","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"checkpoint\",\"selected\":[\"Yes\"]}]}"}],"isError":false,"id":"{{message:6}}"}},"sourceEventSeqs":[17],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Interjection: include the word BANANA in your final reply."}],"source":{"kind":"user","rpcId":"{{rpc:2}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"Great, let's move forward. BANANA!"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1788699402139,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788699402640,"index":0,"dt":[],"texts":["Great, let's move forward. BANANA!"]},{"type":"chunk","time":1788699403142,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Great, let's move forward. BANANA!"}}},{"type":"chunk","time":1788699403642,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1788699404143,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/web/steering/settled-expanded.expected.md b/snapshots/web/steering/settled-expanded.expected.md index 037a6f71da..b04beec4bb 100644 --- a/snapshots/web/steering/settled-expanded.expected.md +++ b/snapshots/web/steering/settled-expanded.expected.md @@ -8,8 +8,9 @@ - tab "Trajectory" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" -- button "1 tool call" [expanded] -- button "Context injection runtime-context" +- status: Worked +- button "Took {{duration}}" [disabled] +- button "Asked questions" [expanded] - button "Ask question 1/1 answered" - text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy" diff --git a/snapshots/web/steering/settled.expected.md b/snapshots/web/steering/settled.expected.md index b1cb14742e..030841b7e4 100644 --- a/snapshots/web/steering/settled.expected.md +++ b/snapshots/web/steering/settled.expected.md @@ -8,7 +8,9 @@ - tab "Trajectory" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" -- button "1 tool call" +- status: Worked +- button "Took {{duration}}" [disabled] +- button "Asked questions" - text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy" - paragraph: Great, let's move forward. BANANA! diff --git a/snapshots/web/subagent-conversation/nested.expected.md b/snapshots/web/subagent-conversation/nested.expected.md index 86ebfc5c77..c04320b0b6 100644 --- a/snapshots/web/subagent-conversation/nested.expected.md +++ b/snapshots/web/subagent-conversation/nested.expected.md @@ -12,6 +12,8 @@ - tab "Trajectory" - text: Give one concrete event sourcing example. {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - status: - strong: This subagent is read-only for now - text: The parent session is offline; reopen it to continue sending messages. diff --git a/snapshots/web/subagent-conversation/sidebar-chat.expected.md b/snapshots/web/subagent-conversation/sidebar-chat.expected.md index 2563dc0d92..607e142b62 100644 --- a/snapshots/web/subagent-conversation/sidebar-chat.expected.md +++ b/snapshots/web/subagent-conversation/sidebar-chat.expected.md @@ -1,6 +1,7 @@ - text: "Explain event sourcing in one sentence.Your parent agent id is \"session-{{uuid}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"session-{{uuid}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn. {{clock}}" - button "Copy" -- button "Thought for a while" +- status: Worked +- button "Took {{duration}}" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" diff --git a/snapshots/web/subagent-conversation/ui-expanded.expected.md b/snapshots/web/subagent-conversation/ui-expanded.expected.md index c4a77f1285..625180666b 100644 --- a/snapshots/web/subagent-conversation/ui-expanded.expected.md +++ b/snapshots/web/subagent-conversation/ui-expanded.expected.md @@ -15,8 +15,9 @@ - button "Jump to turn 2" - text: "Explain event sourcing in one sentence.Your parent agent id is \"session-{{uuid}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"session-{{uuid}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn. {{clock}}" - button "Copy" -- button "Thought for a while" [expanded] -- button "Context injection runtime-context" +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Analysis completed" [expanded] - button "Think" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" @@ -25,7 +26,9 @@ - button "Branch into a new conversation" - text: {{clock}} Now give the same explanation to a human reader. {{clock}} - button "Copy" -- button "Thought for a while" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Analysis completed" [expanded] - button "Think" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" diff --git a/snapshots/web/subagent-conversation/ui.expected.md b/snapshots/web/subagent-conversation/ui.expected.md index 5e1ccfc3fa..6e67c79557 100644 --- a/snapshots/web/subagent-conversation/ui.expected.md +++ b/snapshots/web/subagent-conversation/ui.expected.md @@ -15,7 +15,8 @@ - button "Jump to turn 2" - text: "Explain event sourcing in one sentence.Your parent agent id is \"session-{{uuid}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"session-{{uuid}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn. {{clock}}" - button "Copy" -- button "Thought for a while" +- status: Worked +- button "Took {{duration}}" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" @@ -23,7 +24,8 @@ - button "Branch into a new conversation" - text: {{clock}} Now give the same explanation to a human reader. {{clock}} - button "Copy" -- button "Thought for a while" +- status: Worked +- button "Took {{duration}}" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" diff --git a/snapshots/web/subagent-interrupt/offline-composer.expected.md b/snapshots/web/subagent-interrupt/offline-composer.expected.md index 7c67669374..c6e866c5e2 100644 --- a/snapshots/web/subagent-interrupt/offline-composer.expected.md +++ b/snapshots/web/subagent-interrupt/offline-composer.expected.md @@ -11,9 +11,9 @@ - tab "Trajectory" - text: "Explain event sourcing in one sentence.Your parent agent id is \"session-{{uuid}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"session-{{uuid}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn. {{clock}}" - button "Copy" -- button "Context injection runtime-context" -- paragraph: partial - status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] +- paragraph: partial - list: - listitem: - text: Keep working until I stop you again. diff --git a/snapshots/web/turn-tail-actions/completed.expected.md b/snapshots/web/turn-tail-actions/completed.expected.md index 52872ef7db..75ee221050 100644 --- a/snapshots/web/turn-tail-actions/completed.expected.md +++ b/snapshots/web/turn-tail-actions/completed.expected.md @@ -8,7 +8,8 @@ - tab "Trajectory" - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" -- button "1 tool call · 1 message" +- status: Worked +- button "Took {{duration}}" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/turn-tail-actions/focused.expected.md b/snapshots/web/turn-tail-actions/focused.expected.md index cd8c64c32e..a31f0d0c79 100644 --- a/snapshots/web/turn-tail-actions/focused.expected.md +++ b/snapshots/web/turn-tail-actions/focused.expected.md @@ -8,10 +8,11 @@ - tab "Trajectory" - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" -- button "1 tool call · 1 message" [expanded] -- button "Context injection runtime-context" -- button "Think" +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Analysis completed" - paragraph: Reading the workspace now. +- button "Ran commands" [expanded] - button "Bash Print alpha to stdout" - paragraph: DONE - button "Copy" diff --git a/snapshots/web/turn-tail-actions/running.expected.md b/snapshots/web/turn-tail-actions/running.expected.md index ce077f8d64..8a9f36aee9 100644 --- a/snapshots/web/turn-tail-actions/running.expected.md +++ b/snapshots/web/turn-tail-actions/running.expected.md @@ -9,12 +9,12 @@ - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" - tooltip "Copy" -- button "Context injection runtime-context" -- button "Think" -- paragraph: Reading the workspace now. -- button "Bash Print alpha to stdout" -- paragraph: partial - status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] +- button "Analysis completed" +- paragraph: Reading the workspace now. +- button "Ran commands" +- paragraph: partial - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/turn-tail-actions/settled.expected.md b/snapshots/web/turn-tail-actions/settled.expected.md index df291fa049..cbe5273748 100644 --- a/snapshots/web/turn-tail-actions/settled.expected.md +++ b/snapshots/web/turn-tail-actions/settled.expected.md @@ -8,7 +8,11 @@ - tab "Trajectory" - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" -- button "1 tool call · 1 message" +- status: Stopped +- button "Stopped" [disabled] [expanded] +- button "Analysis completed" +- paragraph: Reading the workspace now. +- button "Ran commands" - paragraph: partial - text: Stopped - button "Copy" diff --git a/snapshots/web/turn-tail-actions/usage-expanded.expected.md b/snapshots/web/turn-tail-actions/usage-expanded.expected.md index 19a3319879..8ced72edd1 100644 --- a/snapshots/web/turn-tail-actions/usage-expanded.expected.md +++ b/snapshots/web/turn-tail-actions/usage-expanded.expected.md @@ -8,7 +8,8 @@ - tab "Trajectory" - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" -- button "1 tool call · 1 message" +- status: Worked +- button "Took {{duration}}" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/web-search-round/ui.expected.md b/snapshots/web/web-search-round/ui.expected.md index b769848c9f..f229309b8c 100644 --- a/snapshots/web/web-search-round/ui.expected.md +++ b/snapshots/web/web-search-round/ui.expected.md @@ -8,8 +8,9 @@ - tab "Trajectory" - text: Use web_search once with queries ["DeepSeek Harness snapshot search","DeepSeek Harness multi-query search"]. Then reply exactly SEARCH_DONE and stop. {{clock}} - button "Copy" -- button "1 tool call" [expanded] -- button "Context injection runtime-context" +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Searched the web" [expanded] - button "Search DeepSeek Harness snapshot search, DeepSeek Harness multi-query search" - paragraph: SEARCH_DONE - button "Copy" diff --git a/snapshots/web/workflow-run/ui.expected.md b/snapshots/web/workflow-run/ui.expected.md index cd0ee5f743..12ffccb420 100644 --- a/snapshots/web/workflow-run/ui.expected.md +++ b/snapshots/web/workflow-run/ui.expected.md @@ -1,7 +1,8 @@ - text: "Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): phase('Run') const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') return { reply } After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool. {{clock}}" - button "Copy" -- button "1 tool call" [expanded] -- button "Context injection runtime-context" +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Called tools" [expanded] - button "Think" - button "Run workflow snapshot-flow" - button "snapshot-flow 1 member Completed" diff --git a/tsconfig.base.json b/tsconfig.base.json index 7f3b436ca2..36608fff46 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -28,6 +28,15 @@ // not declaration path aliases, keep each package/vendor source compiled // under its own tsconfig boundary. "paths": { + "@deepseek-ai/dsh-client-ui-settings-account": ["./packages/client/ui-settings-account/src/index.ts"], + "@deepseek-ai/dsh-client-ui-settings-account/client": ["./packages/client/ui-settings-account/src/client/index.ts"], + "@deepseek-ai/dsh-api-gateway/stream-protocol": ["./packages/api/gateway/src/stream-protocol.ts"], + "@deepseek-ai/dsh-api-account-controller": ["./packages/api/account-controller/src/index.ts"], + "@deepseek-ai/dsh-api-account-controller/types": ["./packages/api/account-controller/src/types.ts"], + "@deepseek-ai/dsh-deepseek-account-platform": ["./packages/credentials/deepseek-account-platform/src/index.ts"], + "@deepseek-ai/dsh-deepseek-account-platform/types": ["./packages/credentials/deepseek-account-platform/src/types.ts"], + "@deepseek-ai/dsh-deepseek-account": ["./packages/credentials/deepseek-account/src/index.ts"], + "@deepseek-ai/dsh-deepseek-account/types": ["./packages/credentials/deepseek-account/src/types.ts"], "@deepseek-ai/dsh-experimental-speech-to-text": ["./packages/experimental/speech-to-text/src/index.ts"], "@deepseek-ai/dsh-experimental-speech-to-text-sensevoice": ["./packages/experimental/speech-to-text-sensevoice/src/index.ts"], "@deepseek-ai/dsh-experimental-api-speech-to-text": ["./packages/experimental/api-speech-to-text/src/index.ts"], @@ -187,6 +196,7 @@ "@deepseek-ai/dsh-host-directory-picker-native/*": ["./packages/host/directory-picker-native/src/*"], "@deepseek-ai/dsh-host-directory-picker-auto": ["./packages/host/directory-picker-auto/src"], "@deepseek-ai/dsh-host-directory-picker-auto/*": ["./packages/host/directory-picker-auto/src/*"], + "@deepseek-ai/dsh-host-product-telemetry-otel": ["./packages/host/product-telemetry-otel/src"], "@deepseek-ai/dsh-host-open-in-app": ["./packages/host/open-in-app/src"], "@deepseek-ai/dsh-host-open-in-app/shared": ["./packages/host/open-in-app/src/shared.ts"], "@deepseek-ai/dsh-host-webserver": ["./packages/host/webserver/src"], @@ -235,6 +245,9 @@ "@deepseek-ai/dsh-client-ui-cordis": ["./packages/extensions/ui-cordis/src"], "@deepseek-ai/dsh-client-ui-cordis/client": ["./packages/extensions/ui-cordis/src/client"], "@deepseek-ai/dsh-client-test-runtime": ["./packages/test-support/client-runtime/src"], + "@deepseek-ai/dsh-client-shortcuts/protocol": ["./packages/client/shortcuts/src/protocol.ts"], + "@deepseek-ai/dsh-client-shortcuts": ["./packages/client/shortcuts/src"], + "@deepseek-ai/dsh-client-ui-shortcuts": ["./packages/client/ui-shortcuts/src"], "@deepseek-ai/dsh-client-ui-layout": ["./packages/client/ui-layout/src"], "@deepseek-ai/dsh-client-ui-sidebar": ["./packages/client/ui-sidebar/src"], "@deepseek-ai/dsh-client-ui-brand-official": ["./packages/client/ui-brand-official/src"], @@ -261,6 +274,10 @@ "@deepseek-ai/dsh-client-ui-subagent": ["./packages/client/ui-subagent/src"], "@deepseek-ai/dsh-experimental-client-ui-agent-team": ["./packages/experimental/client-ui-agent-team/src"], "@deepseek-ai/dsh-client-ui-settings-plugins": ["./packages/client/ui-settings-plugins/src"], + "@deepseek-ai/dsh-client-ui-settings-shell": ["./packages/client/ui-settings-shell/src"], + "@deepseek-ai/dsh-client-ui-settings-agent-loop": ["./packages/client/ui-settings-agent-loop/src"], + "@deepseek-ai/dsh-client-ui-settings-subagent": ["./packages/client/ui-settings-subagent/src"], + "@deepseek-ai/dsh-client-ui-settings-web-search": ["./packages/client/ui-settings-web-search/src"], "@deepseek-ai/dsh-client-ui-jobs": ["./packages/client/ui-jobs/src"], "@deepseek-ai/dsh-client-ui-jobs/client": ["./packages/client/ui-jobs/src/client/index.ts"], "@deepseek-ai/dsh-client-ui-jobs/invariant": ["./packages/client/ui-jobs/src/invariant.ts"], @@ -345,6 +362,7 @@ "@deepseek-ai/dsh-compaction-basic": ["./packages/compaction/compaction-basic/src"], "@deepseek-ai/dsh-compaction-image-offload": ["./packages/compaction/compaction-image-offload/src"], "@deepseek-ai/dsh-compaction-tool-result-pruner": ["./packages/compaction/compaction-tool-result-pruner/src"], + "@deepseek-ai/dsh-config-editor": ["./packages/boot/config-editor/src"], "@deepseek-ai/dsh-credentials": ["./packages/credentials/credentials/src"], "@deepseek-ai/dsh-credentials/invariant": ["./packages/credentials/credentials/src/invariant.ts"], "@deepseek-ai/dsh-credentials-local": ["./packages/credentials/credentials-local/src"], @@ -443,8 +461,6 @@ "@deepseek-ai/dsh-session-title-llm": ["./packages/session/session-title-llm/src"], "@deepseek-ai/dsh-session-turn-outline": ["./packages/session/session-turn-outline/src"], "@deepseek-ai/dsh-settings": ["./packages/settings/settings/src"], - "@deepseek-ai/dsh-settings/invariant": ["./packages/settings/settings/src/invariant.ts"], - "@deepseek-ai/dsh-settings-file": ["./packages/settings/settings-file/src"], "@deepseek-ai/dsh-shell": ["./packages/shell/shell/src"], "@deepseek-ai/dsh-skill": ["./packages/skill/skill/src"], "@deepseek-ai/dsh-skill-badge": ["./packages/skill/skill-badge/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 2ef05010e8..ab28d1c14f 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -14,6 +14,7 @@ "types": ["node"] }, "include": [ + "apps/desktop/tests/**/*.client.spec.tsx", // Source-subpath test imports can arrive through workspace links whose // realpath semantics vary by host. Load package CSS declarations directly. "packages/client/*/src/css-modules.d.ts", @@ -42,12 +43,14 @@ "packages/client/*/tests/**/*.host.spec.ts" ], "references": [ + { "path": "./apps/desktop/tsconfig.client.json" }, { "path": "./packages/experimental/client-ui-voice-input" }, { "path": "./packages/client/ui-sidebar-terminal" }, { "path": "./packages/api/terminal-controller/tsconfig.client.json" }, // Producer-to-UI spill tests use the ptc-runtime contract with the real tool registry. { "path": "./packages/ptc-runtime/ptc-runtime" }, + { "path": "./packages/client/ui-settings-account" }, // Shared leaf: web e2e boots the real host webserver (fixture + real-host // smoke policy). webserver has zero workspace deps and no cordis merge, // so it cannot drag host-side Context augmentation into this program. @@ -74,12 +77,14 @@ { "path": "./packages/extensions/ui-cordis" }, { "path": "./packages/test-support/client-runtime" }, { "path": "./packages/test-support/remote-mock" }, + { "path": "./packages/client/shortcuts/tsconfig.client.json" }, + { "path": "./packages/client/ui-shortcuts" }, { "path": "./packages/client/ui-layout" }, { "path": "./packages/client/ui-sidebar" }, { "path": "./packages/client/ui-brand-official" }, { "path": "./packages/client/ui-approval" }, - { "path": "./packages/client/ui-chat" }, - { "path": "./packages/client/ui-conversation" }, + { "path": "./packages/client/ui-chat/tsconfig.client.json" }, + { "path": "./packages/client/ui-conversation/tsconfig.client.json" }, { "path": "./packages/client/ui-tool" }, { "path": "./packages/client/ui-deliverables/tsconfig.client.json" }, { "path": "./packages/client/ui-workflow-run" }, @@ -106,17 +111,21 @@ { "path": "./packages/client/ui-permission-presets" }, { "path": "./packages/client/ui-plan" }, { "path": "./packages/client/ui-settings-plugins" }, + { "path": "./packages/client/ui-settings-shell" }, + { "path": "./packages/client/ui-settings-agent-loop" }, + { "path": "./packages/client/ui-settings-subagent" }, + { "path": "./packages/client/ui-settings-web-search" }, { "path": "./packages/client/ui-user-questions" }, { "path": "./packages/client/ui-trajectory" }, { "path": "./packages/session-query/session-log-export/tsconfig.client.json" }, { "path": "./packages/client/ui-open-in-app" }, - { "path": "./packages/client/ui-theme" }, - { "path": "./packages/client/ui-settings" }, + { "path": "./packages/client/ui-theme/tsconfig.client.json" }, + { "path": "./packages/client/ui-settings/tsconfig.client.json" }, { "path": "./packages/client/ui-settings-general/tsconfig.client.json" }, { "path": "./packages/client/ui-settings-models" }, { "path": "./packages/client/ui-plugin-manager" }, { "path": "./packages/client/ui-settings-plugin-inventory" }, - { "path": "./packages/client/locale" }, + { "path": "./packages/client/locale/tsconfig.client.json" }, { "path": "./packages/client/ui-renderer" }, { "path": "./packages/client/ui-session" }, { "path": "./packages/client/resources" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 50c576de65..38cd79d6af 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -7,9 +7,22 @@ "noEmit": true, "rewriteRelativeImportExtensions": false }, - "files": [], + "files": [ + // Native keyboard tests exercise the Cordis-free renderer adapters in the same event stream. + "packages/client/shortcuts/src/client/registry.ts", + "packages/client/shortcuts/src/client/dom.ts", + "packages/client/shortcuts/src/client/native.ts", + "packages/client/shortcuts/src/client/types.ts" + ], "include": [ "apps/web/product-isolation.ts", + "apps/web/tests/connection-recovery.e2e.ts", + "apps/web/tests/server-restart.e2e.ts", + "apps/web/tests/shortcuts.e2e.ts", + "apps/web/tests/shortcuts-desktop.e2e.ts", + "apps/web/tests/shortcuts-workspace.e2e.ts", + "apps/web/tests/shortcuts-panels.e2e.ts", + "apps/web/tests/shortcuts-fixed.e2e.ts", "apps/web/tests/default-product-isolation.e2e.ts", "apps/web/tests/diff-context.e2e.ts", "apps/web/tests/scaffold.ts", @@ -55,11 +68,15 @@ "apps/web/tests/plugin-install-registry.e2e.ts", "apps/web/tests/client-plugin-live.e2e.ts", "apps/web/tests/settings-chrome.e2e.ts", + "apps/web/tests/desktop-locale.e2e.ts", + "apps/web/tests/window-controls.e2e.ts", + "apps/web/tests/settings-import.e2e.ts", "apps/web/tests/models-settings.e2e.ts", "apps/web/tests/deepseek-messages-settings.e2e.ts", "apps/web/tests/deepseek-messages-chat.e2e.ts", "apps/web/tests/models-settings-recovery.e2e.ts", "apps/web/tests/onboarding-deepseek-config.e2e.ts", + "apps/web/tests/onboarding-native.e2e.ts", "apps/web/tests/onboarding-usable-provider.e2e.ts", "apps/web/tests/remote-welcome.e2e.ts", "apps/web/tests/public-mount.e2e.ts", @@ -69,6 +86,7 @@ "apps/web/tests/new-session-refused.e2e.ts", "apps/web/tests/workspace-recency.e2e.ts", "apps/web/tests/session-unarchive.e2e.ts", + "apps/web/tests/session-archive-active.e2e.ts", "apps/web/tests/replay-round-trip.e2e.ts", "apps/web/tests/hmr-live.e2e.ts", "apps/web/tests/preview-boot.e2e.ts", @@ -139,6 +157,7 @@ "apps/web/tests/chat-long-interactions.e2e.ts", "apps/web/tests/chat-continuous-conversation.e2e.ts", "apps/web/tests/composer-tab-geometry.e2e.ts", + "apps/web/tests/composer-shortcuts.e2e.ts", "apps/web/tests/complex-history.perf.ts", "apps/web/tests/pwsh-terminal.e2e.ts", "apps/web/tests/workflow-run.e2e.ts", @@ -176,7 +195,15 @@ { "path": "./packages/experimental/speech-to-text-sensevoice" }, { "path": "./packages/experimental/api-speech-to-text" }, { "path": "./packages/experimental/voice-input-bundle" }, + { "path": "./packages/client/locale/tsconfig.host.json" }, + { "path": "./packages/client/ui-theme/tsconfig.host.json" }, + { "path": "./packages/client/ui-chat/tsconfig.host.json" }, + { "path": "./packages/client/ui-conversation/tsconfig.host.json" }, + { "path": "./packages/client/ui-settings/tsconfig.host.json" }, + { "path": "./packages/client/ui-settings-general/tsconfig.host.json" }, + { "path": "./packages/host/product-telemetry-otel" }, + { "path": "./packages/boot/config-editor" }, { "path": "./packages/client/connection/tsconfig.host.json" }, { "path": "./packages/skill/skill-office" }, { "path": "./packages/skill/tool-workspace-dependencies" }, @@ -193,6 +220,10 @@ { "path": "./packages/client/ui-sidebar-browser/tsconfig.host.json" }, { "path": "./packages/client/ui-settings-general/tsconfig.host.json" }, { "path": "./packages/document/office-to-pdf" }, + + { "path": "./packages/api/account-controller" }, + { "path": "./packages/credentials/deepseek-account-platform" }, + { "path": "./packages/credentials/deepseek-account" }, { "path": "./packages/client/ui-deliverables/tsconfig.host.json" }, { "path": "./vendor/cosmokit" }, { "path": "./vendor/schemastery" }, @@ -255,7 +286,6 @@ { "path": "./packages/session-query/session-query-sqlite" }, { "path": "./packages/session-query/session-log-export/tsconfig.host.json" }, { "path": "./packages/settings/settings" }, - { "path": "./packages/settings/settings-file" }, { "path": "./packages/credentials/credentials" }, { "path": "./packages/credentials/credentials-local" }, { "path": "./packages/credentials/authorization" }, @@ -428,6 +458,6 @@ { "path": "./packages/lsp/tool-lsp" }, { "path": "./apps/cli" }, { "path": "./apps/desktop-host" }, - { "path": "./apps/desktop" } + { "path": "./apps/desktop/tsconfig.host.json" } ] } diff --git a/vitest.config.ts b/vitest.config.ts index 022e229488..5de17ca3c6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -122,7 +122,7 @@ const pwshCoverageExclusions = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProf const testIncludes = [ 'packages/*/*/tests/**/*.spec.{ts,tsx}', - 'apps/*/tests/**/*.spec.ts', + 'apps/*/tests/**/*.spec.{ts,tsx}', 'scripts/**/*.spec.ts', 'website/tests/**/*.spec.ts', ] diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index de04f77bed..882357bb69 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -40,9 +40,9 @@ export default defineConfig({ test: { execArgv: vitestExecArgv, setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-invariants.ts'], - // apps/cli only, not apps/*: apps/web/tests/*.e2e.ts needs the built + // Explicit Node app owners only: apps/web/tests/*.e2e.ts needs the built // frontend dist and runs under vitest.web.config.ts (the test:web job). - include: ['packages/*/*/tests/**/*.e2e.ts', 'apps/cli/tests/**/*.e2e.ts'], + include: ['packages/*/*/tests/**/*.e2e.ts', 'apps/cli/tests/**/*.e2e.ts', 'apps/desktop/tests/**/*.e2e.ts'], exclude: [ '**/*.expected.e2e.ts', 'packages/experimental/inspector/tests/client-browser.e2e.ts', diff --git a/website/docs.ts b/website/docs.ts index e366926c9c..5279241051 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -440,7 +440,7 @@ const reference = [ ['adding-a-package.md', '新增 Package', 'Adding a package'], ['adding-a-tool.md', '新增 Tool', 'Adding a tool'], ['adding-an-llm-adapter.md', '新增 LLM Adapter', 'Adding an LLM adapter'], - ['adding-a-settings-card.md', '新增设置卡片', 'Adding a settings card'], + ['adding-a-settings-card.md', '新增设置页', 'Adding a settings page'], ['extension-cookbook.md', '扩展模式', 'Extension patterns'], ] as const).map(([file, rootLabel, enLabel], order): PairedPage => ({ source: `docs/cookbook/${file}`,