From bf1235541c755f8890ad58d2d027e1dc9c315e94 Mon Sep 17 00:00:00 2001 From: Shigma Date: Wed, 14 Oct 2026 18:19:16 +0800 Subject: [PATCH 01/32] chore(cordis): complete Loader rc.8 and share upstream Include patches --- .../host/directory-picker-auto/src/index.ts | 2 +- .../preset/agent-preset-registry/src/mount.ts | 4 +- .../agent-preset-registry/tests/mount.spec.ts | 2 +- scripts/cordis-entry-commit.spec.ts | 117 ++++++++ vendor/README.md | 8 +- vendor/include/README.md | 6 + vendor/include/src/index.ts | 113 +------- vendor/include/src/journal.ts | 242 ++++++++++++++++ vendor/include/src/patch.ts | 261 ++++++++++++++++++ vendor/loader/README.md | 4 + vendor/loader/src/config/entry.ts | 4 +- vendor/loader/src/config/group.ts | 5 +- vendor/loader/src/config/tree.ts | 45 ++- vendor/loader/src/index.ts | 37 ++- 14 files changed, 716 insertions(+), 134 deletions(-) create mode 100644 scripts/cordis-entry-commit.spec.ts create mode 100644 vendor/include/src/journal.ts create mode 100644 vendor/include/src/patch.ts diff --git a/packages/host/directory-picker-auto/src/index.ts b/packages/host/directory-picker-auto/src/index.ts index 4d2a6a3e2c..5f324158b4 100644 --- a/packages/host/directory-picker-auto/src/index.ts +++ b/packages/host/directory-picker-auto/src/index.ts @@ -76,7 +76,7 @@ export async function apply(ctx: Context): Promise { linuxChooser: hasLinuxChooserBinary(process.env.PATH, canExecute), }) await ctx.effect(async () => { - // Root-tree create: the Loader root is in-memory (write() is a no-op), so + // Root-tree create: the Loader root is in-memory (commit() is a no-op), so // the mounted rows can never be persisted back into a config file. The // backend lands first: the surface's browser half drives the capability // the backend registers. diff --git a/packages/preset/agent-preset-registry/src/mount.ts b/packages/preset/agent-preset-registry/src/mount.ts index 23eec09cdf..32c36a5e39 100644 --- a/packages/preset/agent-preset-registry/src/mount.ts +++ b/packages/preset/agent-preset-registry/src/mount.ts @@ -1,6 +1,6 @@ /** Runtime plugin trees shared by Agents selecting one preset revision. */ import { Context, type Fiber } from '@deepseek-ai/cordis' -import { EntryTree, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { EntryTree, type EntryChange, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { prepareProfileEntries } from '@deepseek-ai/dsh-app-boot' import type { PresetDefinition } from './definition.ts' import { scopeOf, type ScopeKey } from '@deepseek-ai/dsh-scope' @@ -20,7 +20,7 @@ class PresetTree extends EntryTree { } } - override write(): void {} + override commit(_change: EntryChange): void {} } /** One live revision shared by Agents and scoped readers. */ diff --git a/packages/preset/agent-preset-registry/tests/mount.spec.ts b/packages/preset/agent-preset-registry/tests/mount.spec.ts index 51960106a4..3706af4962 100644 --- a/packages/preset/agent-preset-registry/tests/mount.spec.ts +++ b/packages/preset/agent-preset-registry/tests/mount.spec.ts @@ -73,7 +73,7 @@ it('does not replace the declaring Loader entry subtree or persist runtime tree expect(mountedCompositionRows(mount.tree)[0]!.entryId).toBe('child') expect(entry.subtree).toBe(previousTree) expect(entry.subgroup).toBe(previousGroup) - mount.tree.write() + mount.tree.commit({ id: 'missing', group: mount.tree.root }) entry.subtree = mount.tree entry.subgroup = mount.tree.root const other = createScope(owner, {}) diff --git a/scripts/cordis-entry-commit.spec.ts b/scripts/cordis-entry-commit.spec.ts new file mode 100644 index 0000000000..912335fcec --- /dev/null +++ b/scripts/cordis-entry-commit.spec.ts @@ -0,0 +1,117 @@ +/** Tree mutation reports and detached patch inputs across the Loader/Include upgrade. */ +import { Context, FiberState } from '@deepseek-ai/cordis' +import Loader, { Group, type EntryChange, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { applyEntryPatches } from '@deepseek-ai/cordis-plugin-include' +import { describe, expect, it, onTestFinished, vi } from 'vitest' + +async function fixture() { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(Loader) + ctx.loader.builtins.group = Group + ctx.loader.builtins.example = () => {} + const changes: EntryChange[] = [] + vi.spyOn(ctx.loader, 'commit').mockImplementation((change) => { changes.push(change) }) + return { ctx, loader: ctx.loader, changes } +} + +describe('Loader tree mutation reports', () => { + it('reports assigned ids and applied options for create, update, move and remove', async () => { + const { loader, changes } = await fixture() + const id = await loader.create({ name: 'cordis:example', config: { value: 1 } }) + await loader.await() + expect(changes).toHaveLength(1) + expect(changes[0]).toEqual({ id, group: loader.root, options: loader.resolve(id).options }) + expect(id).not.toBe('') + const groupId = await loader.create({ name: 'cordis:group', group: true, config: [] }) + await loader.await() + changes.length = 0 + await loader.update(id, { config: { value: 2 } }, groupId) + expect(changes).toHaveLength(1) + expect(changes[0]).toMatchObject({ id, from: loader.root, group: loader.resolveGroup(groupId), + legacy: { config: { value: 1 } }, options: { config: { value: 2 } } }) + expect(changes[0]!.options).toBe(loader.resolve(id).options) + changes.length = 0 + await loader.update(id, { config: { value: 3 } }) + expect(changes).toHaveLength(1) + expect(changes[0]!.from).toBeUndefined() + changes.length = 0 + const legacy = loader.resolve(id).options + loader.remove(id) + await loader.await() + expect(changes).toEqual([{ id, group: loader.resolveGroup(groupId), legacy }]) + expect(legacy.disabled).toBeUndefined() + }) + + it('reports explicit Fiber saves and self-disposal but ignores child disposal', async () => { + const { loader, changes } = await fixture() + const id = await loader.create({ name: 'cordis:example', config: { value: 1 } }) + await loader.await() + const fiber = loader.resolve(id).fiber! + changes.length = 0 + await fiber.update({ value: 2 }) + expect(changes).toHaveLength(1) + expect(changes[0]).toMatchObject({ legacy: { config: { value: 1 } }, options: { config: { value: 2 } } }) + changes.length = 0 + const child = fiber.ctx.plugin(() => {}) + await child + await child.dispose() + expect(changes).toEqual([]) + await fiber.dispose() + expect(changes).toHaveLength(1) + expect(changes[0]!.options!.disabled).toBe(true) + expect(changes[0]!.legacy!.disabled).toBeUndefined() + }) + + it('keeps adopted entries and does not report file-driven removal or owner teardown', async () => { + const { ctx, loader, changes } = await fixture() + await loader.root.update([ + { id: 'g', name: 'cordis:group', group: true, config: [] }, + { id: 'x', name: 'cordis:example' }, + ]) + await loader.await() + const target = loader.resolveGroup('g') + const options = loader.resolve('x').options + target.data.push(options) + await target.create(options) + loader.root.remove('x') + expect(loader.resolve('x').parent).toBe(target) + expect(loader.resolve('x').fiber!.state).toBe(FiberState.ACTIVE) + await target.update([]) + await loader.await() + expect(loader.store.x).toBeUndefined() + expect(changes).toEqual([]) + await ctx.fiber.dispose() + expect(changes).toEqual([]) + }) + + it('rejects separator-containing ids before changing a tree', async () => { + const { loader, changes } = await fixture() + const options: EntryOptions = { id: 'a:b', name: 'cordis:example' } + await expect(loader.create(options)).rejects.toThrow('entry id must not contain') + expect(loader.root.data).toEqual([]) + expect(changes).toEqual([]) + }) +}) + +describe('Include patch data ownership', () => { + it('detaches file entries even with no patches', () => { + const data: EntryOptions[] = [{ id: 'x', name: 'example', config: { value: 1 } }] + const result = applyEntryPatches(data, undefined, vi.fn()) + result[0]!.disabled = true + ;(result[0]!.config as { value: number }).value = 2 + expect(data).toEqual([{ id: 'x', name: 'example', config: { value: 1 } }]) + }) + + it('clones inserted groups and lets later patches address their descendants', () => { + const insert: EntryOptions[] = [{ id: 'g', name: 'group', group: true, + config: [{ id: 'x', name: 'example', config: { value: 1 } }] }] + const warn = vi.fn() + const result = applyEntryPatches([], [{ insert }, { id: 'x', disabled: true }], warn) + const children = result[0]!.config as Array<{ disabled?: boolean; config: { value: number } }> + expect(children[0]!.disabled).toBe(true) + children[0]!.config.value = 2 + expect(insert[0]!.config).toEqual([{ id: 'x', name: 'example', config: { value: 1 } }]) + expect(warn).not.toHaveBeenCalled() + }) +}) diff --git a/vendor/README.md b/vendor/README.md index 9e38bee93f..064860f3cf 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -17,7 +17,7 @@ Upstream workspace: `cordis-workspace` (local checkout: `~/repos/cordis-workspac | `cosmokit/` | `@deepseek-ai/cosmokit` | `cosmokit` | 1.8.1 | https://github.com/deepseek-harness/cosmokit | `16f6fc058ade66e8ac5da0033d35a8d0f279f544` | | `schemastery/` | `@deepseek-ai/schemastery` | `schemastery` | 3.18.0 | https://github.com/deepseek-harness/schemastery (`packages/core`) | `e67cee00ad725bd1534aee930a979ea3eec6f698` | | `cordis/` | `@deepseek-ai/cordis` | `cordis` | 4.0.0-rc.11 | https://github.com/cordiverse/cordis (`packages/core`) | `0b96d9fd517b998e44e2209560c9e5f0671ce933` | -| `loader/` | `@deepseek-ai/cordis-plugin-loader` | `@cordisjs/plugin-loader` | 1.0.0-rc.5 | https://github.com/cordiverse/cordis (`packages/loader`) | `56b3d4f725681cf4556c1a8695a709cc3b6eed74` | +| `loader/` | `@deepseek-ai/cordis-plugin-loader` | `@cordisjs/plugin-loader` | 1.0.0-rc.8 | https://github.com/cordiverse/cordis (`packages/loader`) | `0b96d9fd517b998e44e2209560c9e5f0671ce933` | | `include/` | `@deepseek-ai/cordis-plugin-include` | `@cordisjs/plugin-include` | 1.0.4 | https://github.com/deepseek-harness/cordis (`packages/include`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | | `group/` | `@deepseek-ai/cordis-plugin-group` | `@cordisjs/plugin-group` | 1.0.0 | https://github.com/deepseek-harness/cordis (`packages/group`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | | `timer/` | `@deepseek-ai/cordis-plugin-timer` | `@cordisjs/plugin-timer` | 1.1.2 | https://github.com/deepseek-harness/cordis (`packages/timer`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | @@ -42,14 +42,14 @@ Keep this log exhaustive — every divergence from upstream must be listed. 8. **`include/src/index.ts` resilient file refresh and patch reapplication**: [#514](https://github.com/deepseek-harness/deepseek-harness/pull/514) validates a top-level entry array before caching parsed content, logs refresh failures, reapplies patches after file or Include-config edits, updates the Include config when it vetoes a restart, and uses `initial` only after `ENOENT`. These parse protections retain the running tree after an invalid file; plugin activation failures can leave a partially applied tree. Loader entry/group/tree mutations follow the pinned eager, non-transactional implementation and do not restore previous plugins or options. Covered by `packages/boot/app-boot/tests/{config-reload,user-patches}.spec.ts`. 9. **`hmr/src/index.ts` module-watcher readiness and native paths**: realpath the existing watch base, classify framework modules and attach listeners before reporting readiness, and compare config paths using both canonical and configured spellings. This preserves Node module-cache identity across Windows short-name paths and filesystem aliases. The main watcher uses `ignoreInitial: true` so startup reads do not trigger another refresh; exact profile patch watching is owned by `packages/boot/hmr/src/watch-config.ts`. Covered by `packages/boot/hmr/tests/watch-config.spec.ts`. 10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`. Conditional exports select `.mjs` for import and `.cjs` for require; without them, concurrent imports under module-hook hosts reach the CJS entry and can race its require of ESM Cosmokit (`ERR_REQUIRE_ESM_RACE_CONDITION`). -11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. The entry list is deep-cloned when patches are present; without patches, the returned array retains the entry objects. Public patch and Include fields carry JSDoc for config tooling. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`. +11. **Include patch and YAML exports**: `applyEntryPatches` aliases rc.11’s pure `patch.ts` implementation, and `entryListSchema` exposes the `!!js` YAML dialect for config dumps. Upstream now clones file and inserted entries and indexes inserted rows for later patches, retiring the duplicate algorithm and insert-index fix. Include imports `patch.ts` and its `journal.ts` helpers from the rc.11 source while its runtime still uses the old writer; runtime journal adoption belongs to plan step 4f. 12. **Loader activation observer**: the rc.11 `Entry.init()` observer handles both activation outcomes, retiring the local observer workaround. Fiber errors remain available to explicit consumer audits. 13. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change. 14. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained writes before and after child removal during Include teardown. The first drain preserves an existing terminal write failure even if child removal schedules a later write. Missing-file initialization awaits the write and forces a fresh parse before mounting the initial entries. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures. 15. **Configuration access across Core, Loader, Include and HMR**: rc.11 Core owns raw `Fiber.config`, `internal/config(fiber, next)`, `internal/commit` and awaited `internal/update(config, next)`. Root-fiber candidate resolution remains available without a plugin schema. Local `rawConfig`, `resolveConfig(candidate)` and `effectiveConfig` accessors retain the DSH consumer API. Fiber caches the object passed to activation or a successful update that keeps the instance; candidate resolution is pure, failed or superseded updates cannot replace the cache, and volatile updates retain references. Loader resolves only entry roots and excludes Group/Include tree carriers, so nested `!!js` expressions wait for their own injections. Include retains its tree-carrier marker, writer and patches, and HMR transfers raw config through the accessors. Covered by `scripts/cordis-config-access.spec.ts`, `scripts/cordis-core-lifecycle.spec.ts` and the boot/HMR suites. 16. **`cordis/package.json` publishes `src`**: added `src` to the `files` list, joining the other eight vendored packages. Cordis declares `"./src/*": "./src/*"` in its exports, so a tarball without `src` publishes an export map pointing at absent files; the release change judgement also reads `files` to decide whether a diff reaches the payload, and a package whose only published paths are build output has no tracked path to match. 17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md). -18. **Entry `disabled` interpolation in `loader/src/config/entry.ts`**: a `disabled: !!js` expression evaluates against the loader context at every mount decision; the raw node stays in the options, so write-back keeps the `!!js` form. `disabled` is the only interpolated metadata field. Covered by `packages/boot/app-boot/tests/user-patches.spec.ts` and `apps/cli/tests/windows-shell.spec.ts`. +18. **Entry `disabled` interpolation**: the rc.11 Loader implementation evaluates `!!js` expressions and preserves their raw nodes, retiring the local interpolation patch. `disabled` remains the only interpolated metadata field. 19. **Loader resolver slice from Cordis v4.0.0-rc.11**: `loader/src/resolve.ts` and the no-internals branch of `EntryTree.import()` use upstream project-relative ESM resolution, preserving import conditions, package imports and self-references. A helper under the nearest package scope supplies `import.meta.resolve`; absent scopes and helper-write failures retain the native import fallback. `loader/src/internal.ts` matches upstream runtime API detection, retiring the local Node-major workaround. Node builtin imports use default namespaces so the Web shell can bundle Loader with its existing browser externalization; the shell supplies its own module loader. The local resolver normalizes trailing directory separators before caching a scope, so concurrent requests from a root config and a nested config share one helper write. The Loader manifest remains at 1.0.0-rc.5 until the remaining package changes are reconciled. Covered by `scripts/cordis-loader-resolve.spec.ts` and the Node compatibility matrix. 20. **Loader fiber identity**: rc.11 Core canonicalizes `update()` and `restart()` through `ctx.fiber`, retiring the local unwrapping workaround. `Entry.fiber` retains the registry wrapper; volatile commits explicitly use the canonical fiber. HMR retains its existing replacement algorithm. @@ -62,7 +62,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 24. **`timer/locale/{en,zh}.json` display metadata**: local English and Chinese titles and descriptions for the Settings plugin inventory. `timer/package.json` exports and publishes these locale resources. -25. **Loader lifecycle slice from Cordis v4.0.0-rc.11**: entry updates await context patches, Fiber updates and disabled-entry disposal; isolation uses `Impl.ctx` and maintains `Impl.isolate` during transfers. Group and Include return their child-update work. The existing writer receives `internal/commit` with schema-bound simplification. A local dependency refresh before a combined context/config update prevents activation against stale isolation bindings. Loader, Include and HMR retain their manifest baselines until the remaining package changes are reconciled. Failed fibers require an explicit update after correcting their input or dependencies. Core retains the effect hardening in item 6; rc.11 owns dependency teardown ordering and superseded-activation handling. +25. **Loader/Include lifecycle adaptations**: Loader now uses the complete rc.11 baseline with `EntryChange` reports through `commit(change)`. The retained local dependency refresh before a combined context/config update prevents activation against stale isolation bindings; disabled-entry updates await disposal. Include keeps its existing baseline and forwards commits to the old writer until plan step 4f adopts the journal runtime. In-memory trees use no-op commits. Failed fibers require an explicit update after correcting their input or dependencies. Core retains item 6’s effect hardening; rc.11 owns dependency teardown ordering and superseded-activation handling. ## Sync procedure diff --git a/vendor/include/README.md b/vendor/include/README.md index de0ecdc51f..d669bf6736 100644 --- a/vendor/include/README.md +++ b/vendor/include/README.md @@ -41,3 +41,9 @@ Example `cordis.yml`: | `enableLogs` | Enables loader apply, reload, and unload logs. | Patches can insert entries or override fields on entries with a matching `id`. + +## Patch composition and persistence + +`applyEntryPatches(data, patches, warn)` uses the same upstream algorithm as mounting. It clones file entries even without patches and clones inserted entry lists before indexing them, so later patches can address inserted rows without mutating those input lists. `entryListSchema` exposes Include’s YAML dialect to configuration tools. Nested entry expressions remain literal until their owning Loader entry evaluates them. + +Tree mutations arrive through `commit(change)` and schedule the existing debounced writer. The imported journal helpers support patch ownership; runtime file reconciliation still uses `refresh()` and the existing write queue. diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 075252dfff..dd6a86fa21 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -1,10 +1,11 @@ -import { EntryGroup, EntryTree, isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { EntryGroup, EntryTree, isJsExpr, type EntryChange, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { Context, Service } from '@deepseek-ai/cordis' import { extname } from 'node:path' import { access, constants, readFile, rename, writeFile } from 'node:fs/promises' import { setTimeout as delay } from 'node:timers/promises' import { fileURLToPath, pathToFileURL } from 'node:url' import * as yaml from 'js-yaml' +import { applyPatches, type PatchOptions } from './patch.ts' const JsExpr = new yaml.Type('tag:yaml.org,2002:js', { kind: 'scalar', @@ -40,105 +41,10 @@ function retryableWriteError(error: unknown): boolean { return code === 'EACCES' || code === 'EBUSY' || code === 'EPERM' } -/** - * Apply patch lists to an entry list — THE patch semantics of this include, - * shared by mounting (`applyPatches`) and offline config tooling - * (`dsh --dump-config`) so a dump can never drift from what boots. The input - * is never mutated: patching shared entry objects would bake earlier patch - * values into the cached parse, so repeated application (config hot-reloads) - * could never revert a removed or changed patch. Inserted entries are indexed - * as they are added, so a later patch in the same list can target a row an - * earlier patch inserted. A patch that matches nothing warns and is skipped. - * @param data - the parsed entry list (JSON-safe plain data). - * @param patches - the patch list to apply, in order. - * @param warn - sink for skipped-patch diagnostics (printf-style, `%C` = code). - * @returns a detached entry list with every applicable patch applied. - */ -export function applyEntryPatches( - data: EntryOptions[], - patches: PatchOptions[] | undefined, - warn: (message: string, ...args: any[]) => void, -): EntryOptions[] { - if (!patches?.length) return [...data] - data = structuredClone(data) - - const entryMap = new Map() - const buildMap = (entries: EntryOptions[]) => { - for (const entry of entries) { - if (entry.id) entryMap.set(entry.id, entry) - if (entry.group && Array.isArray(entry.config)) { - buildMap(entry.config) - } - } - } - buildMap(data) - - for (const patch of patches) { - const { id, insert, name, ...overrides } = patch - - if (insert) { - if (id) { - const target = entryMap.get(id) - if (!target) { - warn('patch insert: entry %C not found', id) - continue - } - if (!target.group) { - warn('patch insert: entry %C is not a group', id) - continue - } - if (!Array.isArray(target.config)) target.config = [] - target.config.push(...insert) - } else { - data.push(...insert) - } - // Index what this patch added so a LATER patch in the same list can - // target it. Patch lists compose one layer per source (each bundle - // layer, then the user's, then `--patch` overlays), and a layer must be - // able to configure or disable a row an earlier layer inserted; without - // this, inserted rows were silently unpatchable. - buildMap(insert) - continue - } - - if (!id) { - warn('patch: id is required for non-insert patches') - continue - } - - const target = entryMap.get(id) - if (!target) { - warn('patch: entry %C not found', id) - continue - } - - if (name && name !== target.name) { - warn('patch: name mismatch for %C (expected %C, got %C), skipping', id, target.name, name) - continue - } - - for (const [key, value] of Object.entries(overrides)) { - if (key === 'id') continue - target[key] = value - } - } - - return data -} - -/** Runtime patch applied to entries loaded from an included config file. */ -export interface PatchOptions { - id?: string - insert?: EntryOptions[] - name?: string - config?: any - group?: boolean | null - disabled?: boolean | null - inject?: any - intercept?: any - isolate?: any - [key: string]: any -} +/** Apply the upstream patch algorithm to detached entry data for mounting or config dumps. */ +export { applyPatches as applyEntryPatches } from './patch.ts' +/** Serialized entry patch options. */ +export type { PatchOptions } from './patch.ts' /** Config namespace for the file-backed include loader. */ export namespace Include { @@ -233,7 +139,7 @@ export class Include extends EntryTree { } private applyPatches(data: EntryOptions[], patches = this.config.patches): EntryOptions[] { - return applyEntryPatches(data, patches, (message, ...args) => { + return applyPatches(data, patches, (message, ...args) => { this.ctx.root.logger?.('loader').warn(message, ...args) }) } @@ -329,6 +235,11 @@ export class Include extends EntryTree { return run } + /** Forward tree mutations to the debounced writer until journal adoption. */ + commit(_change: EntryChange) { + this.write() + } + /** Schedule a write of the current root entry data. */ write() { this.context.emit('loader/config-update') diff --git a/vendor/include/src/journal.ts b/vendor/include/src/journal.ts new file mode 100644 index 0000000000..7b52685ce9 --- /dev/null +++ b/vendor/include/src/journal.ts @@ -0,0 +1,242 @@ +import type { EntryChange, EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { deepEqual, type Dict, omit } from '@deepseek-ai/cosmokit' + +/** One tree-side mutation that has not reached the file yet, keyed by entry id. */ +export type JournalRecord = JournalRecord.Remove | JournalRecord.Upsert + +export namespace JournalRecord { + export interface Remove { + kind: 'remove' + } + + export interface Upsert { + kind: 'upsert' + /** The entry did not exist when the tree first reported it. */ + created: boolean + /** + * The group the tree placed the entry in (`null` for the root), or + * `undefined` when the tree did not relocate it. + */ + parent: string | null | undefined + /** Index within `parent`; only meaningful alongside a `parent`. */ + position: number + /** + * Per-key delta relative to the options the entry had when the change + * was reported; `undefined` deletes a key. Collected at report time: the + * live tree is reconciled asynchronously and may lag behind the file. + */ + changes: Dict + } +} + +export type Journal = Map + +export function diffOptions(legacy: EntryOptions, options: EntryOptions) { + const changes: Dict = {} + for (const key of new Set([...Object.keys(legacy), ...Object.keys(options)])) { + if (key === 'id') continue + if (deepEqual(legacy[key], options[key])) continue + changes[key] = options[key] + } + return changes +} + +/** Fold a later record into an earlier one for the same entry. */ +export function mergeRecords(older: JournalRecord | undefined, newer: JournalRecord): JournalRecord | undefined { + if (!older) return newer + if (newer.kind === 'remove') { + // an entry created and removed before ever being written leaves no trace + return older.kind === 'upsert' && older.created ? undefined : newer + } + if (older.kind === 'remove') { + return { ...newer, created: false } + } + const relocated = newer.parent !== undefined + return { + kind: 'upsert', + created: older.created, + parent: relocated ? newer.parent : older.parent, + position: relocated ? newer.position : older.position, + changes: { ...older.changes, ...newer.changes }, + } +} + +export function merge(journal: Journal, id: string, record: JournalRecord) { + const merged = mergeRecords(journal.get(id), record) + if (merged) { + journal.set(id, merged) + } else { + journal.delete(id) + } +} + +export function record(journal: Journal, change: EntryChange, parent: string | null) { + const { id, options, legacy } = change + if (!options) { + return merge(journal, id, { kind: 'remove' }) + } + const position = change.group.data.indexOf(options) + if (!legacy) { + return merge(journal, id, { kind: 'upsert', created: true, parent, position, changes: omit(options, ['id']) }) + } + merge(journal, id, { + kind: 'upsert', + created: false, + parent: change.from ? parent : undefined, + position, + changes: diffOptions(legacy, options), + }) +} + +export interface FlatEntry { + parent: string | null + position: number + options: EntryOptions + list: EntryOptions[] +} + +/** Index every entry (including nested group members) by id. */ +export function flatten(data: EntryOptions[], parent: string | null = null, result = new Map()) { + data.forEach((options, position) => { + if (options.id) result.set(options.id, { parent, position, options, list: data }) + if (options.group && Array.isArray(options.config)) { + flatten(options.config, options.id ?? null, result) + } + }) + return result +} + +export function applyChanges(options: EntryOptions, changes: Dict, filter?: (key: string) => boolean) { + for (const [key, value] of Object.entries(changes)) { + if (filter && !filter(key)) continue + if (value === undefined) { + delete options[key] + } else { + options[key] = value + } + } +} + +export function detach(data: EntryOptions[], id: string) { + const flat = flatten(data).get(id) + if (!flat) return + flat.list.splice(flat.list.indexOf(flat.options), 1) + return flat.options +} + +/** + * Insert `options` under `parent` (a group id, or `null` for the root) at + * `position`, clamped to the group's length. Returns false when the parent + * group does not exist. + */ +export function place(data: EntryOptions[], options: EntryOptions, parent: string | null, position: number) { + let list = data + if (parent !== null) { + const group = flatten(data).get(parent) + if (!group?.options.group) return false + list = group.options.config ??= [] + } + list.splice(Math.min(position, list.length), 0, options) + return true +} + +/** + * Overlay a journal onto `data` in place. `filter(id, key)` restricts which + * changes apply; `key` is `null` when asking about the entry as a whole + * (creation, removal, relocation). + */ +export function applyJournal( + data: EntryOptions[], + journal: Journal, + filter: (id: string, key: string | null) => boolean = () => true, + warn: (format: string, ...args: any[]) => void = () => {}, +) { + for (const [id, record] of journal) { + if (!filter(id, null)) continue + if (record.kind === 'remove') { + detach(data, id) + continue + } + const flat = flatten(data).get(id) + if (!flat) { + const options = { id } as EntryOptions + applyChanges(options, record.changes, key => filter(id, key)) + const parent = record.parent ?? null + if (!place(data, options, parent, record.position)) { + warn('cannot place entry %C: group %C not found', id, parent) + } + continue + } + applyChanges(flat.options, record.changes, key => filter(id, key)) + if (record.parent !== undefined && flat.parent !== record.parent) { + detach(data, id) + if (!place(data, flat.options, record.parent, record.position)) { + warn('cannot move entry %C: group %C not found', id, record.parent) + place(data, flat.options, flat.parent, flat.position) + } + } + } + return data +} + +export interface Conflict { + id: string + reason: string +} + +/** + * Three-way reconciliation of a journal against a new file state. The file + * wins every conflict: conflicting keys are dropped from the journal so that + * the tree follows the file, and each drop is reported. + * + * `fileOwned(id, key)` scopes the check to what the file can actually express + * (patch-owned keys and patch-inserted entries live elsewhere). + */ +export function reconcile( + journal: Journal, + base: Map, + theirs: Map, + fileOwned: (id: string, key: string | null) => boolean, +) { + const conflicts: Conflict[] = [] + for (const [id, record] of journal) { + if (!fileOwned(id, null)) continue + const b = base.get(id) + const t = theirs.get(id) + if (record.kind === 'remove') { + if (!t) { + journal.delete(id) + } else if (!b || !deepEqual(t.options, b.options)) { + conflicts.push({ id, reason: b ? 'modified in file, removed at runtime' : 'added in file, removed at runtime' }) + journal.delete(id) + } + continue + } + if (!t) { + if (b) { + conflicts.push({ id, reason: 'removed in file, modified at runtime' }) + journal.delete(id) + } + // else: created at runtime, nothing in the file to conflict with + continue + } + if (record.created) record.created = false + for (const key of Object.keys(record.changes)) { + if (!fileOwned(id, key)) continue + const bv = b?.options[key] + const tv = t.options[key] + if (deepEqual(bv, tv) || deepEqual(tv, record.changes[key])) continue + conflicts.push({ id, reason: `key "${key}" modified both in file and at runtime` }) + delete record.changes[key] + } + if (record.parent !== undefined && b && t.parent !== b.parent && t.parent !== record.parent) { + conflicts.push({ id, reason: 'moved both in file and at runtime' }) + record.parent = undefined + } + const moved = record.parent !== undefined && record.parent !== t.parent + if (!Object.keys(record.changes).length && !moved) { + journal.delete(id) + } + } + return conflicts +} diff --git a/vendor/include/src/patch.ts b/vendor/include/src/patch.ts new file mode 100644 index 0000000000..45a4700bfe --- /dev/null +++ b/vendor/include/src/patch.ts @@ -0,0 +1,261 @@ +import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { type Dict, omit } from '@deepseek-ai/cosmokit' +import { applyChanges, detach, flatten, type Journal, place } from './journal.ts' + +export interface PatchOptions { + id?: string + insert?: EntryOptions[] + name?: string + config?: any + group?: boolean | null + disabled?: boolean | null + inject?: any + intercept?: any + isolate?: any + [key: string]: any +} + +export type Warn = (format: string, ...args: any[]) => void + +function randomId(used: (id: string) => boolean) { + let id: string + do { + id = Math.random().toString(16).slice(2, 10) + } while (used(id)) + return id +} + +/** Give every entry (nested groups included) an id, in place. */ +export function ensureIds(entries: EntryOptions[], used: (id: string) => boolean) { + for (const entry of entries) { + if (!entry.id) entry.id = randomId(used) + if (entry.group && Array.isArray(entry.config)) ensureIds(entry.config, used) + } +} + +/** Runtime changes are routed back to an inserted entry by its id, so every insert needs one. */ +export function ensureInsertIds(patches: PatchOptions[] | undefined, used: (id: string) => boolean) { + for (const patch of patches ?? []) { + if (patch.insert) ensureIds(patch.insert, used) + } +} + +/** + * Overlay `patches` onto a fresh clone of `data`. Inserted entries are cloned + * too, so the tree owns every object it mounts. + */ +export function applyPatches(data: EntryOptions[], patches: PatchOptions[] | undefined, warn: Warn): EntryOptions[] { + data = structuredClone(data) + if (!patches?.length) return data + + const entryMap = new Map() + const buildMap = (entries: EntryOptions[]) => { + for (const entry of entries) { + if (entry.id) entryMap.set(entry.id, entry) + if (entry.group && Array.isArray(entry.config)) { + buildMap(entry.config) + } + } + } + buildMap(data) + + for (const patch of patches) { + const { id, insert, name, ...overrides } = patch + + if (insert) { + const clone = structuredClone(insert) + if (id) { + const target = entryMap.get(id) + if (!target) { + warn('patch insert: entry %C not found', id) + continue + } + if (!target.group) { + warn('patch insert: entry %C is not a group', id) + continue + } + if (!Array.isArray(target.config)) target.config = [] + target.config.push(...clone) + } else { + data.push(...clone) + } + buildMap(clone) + continue + } + + if (!id) { + warn('patch: id is required for non-insert patches') + continue + } + + const target = entryMap.get(id) + if (!target) { + warn('patch: entry %C not found', id) + continue + } + + if (name && name !== target.name) { + warn('patch: name mismatch for %C (expected %C, got %C), skipping', id, target.name, name) + continue + } + + for (const [key, value] of Object.entries(overrides)) { + if (key === 'id') continue + target[key] = value + } + } + + return data +} + +/** + * Who owns an entry, or one key of it, in the patched tree: the file, a patch + * that overrides that key (the last one wins), or the patch that inserted the + * whole entry. + */ +export type EntryOwner = EntryOwner.File | EntryOwner.Patch | EntryOwner.Insert + +export namespace EntryOwner { + export interface File { + type: 'file' + } + + export interface Patch { + type: 'patch' + /** Index into the patch list. */ + index: number + } + + export interface Insert { + type: 'insert' + /** Index into the patch list. */ + index: number + /** The insert list (or nested group config) holding the entry. */ + list: EntryOptions[] + options: EntryOptions + /** The group the entry is inserted into (`null` for the root). */ + parent: string | null + } +} + +export class PatchIndex { + private keys = new Map>() + private inserts = new Map() + + constructor(public patches: PatchOptions[]) { + patches.forEach((patch, index) => { + const { id, insert } = patch + if (insert) { + const walk = (list: EntryOptions[], parent: string | null) => { + for (const options of list) { + this.inserts.set(options.id, { type: 'insert', index, list, options, parent }) + if (options.group && Array.isArray(options.config)) walk(options.config, options.id) + } + } + walk(insert, id ?? null) + return + } + if (!id) return + const keys = this.keys.get(id) ?? new Map() + for (const key of Object.keys(omit(patch, ['id', 'insert', 'name']))) { + keys.set(key, index) + } + this.keys.set(id, keys) + }) + } + + /** Owner of the entry as a whole. */ + entry(id: string | null): EntryOwner { + if (id === null) return { type: 'file' } + return this.inserts.get(id) ?? { type: 'file' } + } + + /** Owner of one key of an entry. */ + key(id: string, key: string): EntryOwner { + const insert = this.inserts.get(id) + if (insert) return insert + const index = this.keys.get(id)?.get(key) + return index === undefined ? { type: 'file' } : { type: 'patch', index } + } + + fileOwned(id: string, key: string | null) { + return (key === null ? this.entry(id) : this.key(id, key)).type === 'file' + } +} + +/** + * Route a journal to its owners: file-owned changes are applied to `data`, + * patch-owned ones to `patches`. Both are mutated in place, so pass clones. + * Returns whether any patch changed. + */ +export function routeJournal(journal: Journal, data: EntryOptions[], patches: PatchOptions[], warn: Warn) { + const index = new PatchIndex(patches) + let patched = false + + const insertList = (owner: EntryOwner.Insert) => owner.options.config ??= [] + + const placeAt = (options: EntryOptions, parent: string | null, position: number) => { + const owner = index.entry(parent) + if (owner.type === 'insert') { + const list = insertList(owner) + list.splice(Math.min(position, list.length), 0, options) + patched = true + } else if (!place(data, options, parent, position)) { + warn('cannot place entry %C: group %C not found', options.id, parent) + } + } + + for (const [id, record] of journal) { + const owner = index.entry(id) + if (record.kind === 'remove') { + if (owner.type === 'insert') { + owner.list.splice(owner.list.indexOf(owner.options), 1) + patched = true + } else { + detach(data, id) + } + continue + } + + const current = owner.type === 'insert' ? owner : flatten(data).get(id) + if (!current) { + const options = { id } as EntryOptions + applyChanges(options, record.changes) + placeAt(options, record.parent ?? null, record.position) + continue + } + + if (record.parent !== undefined && current.parent !== record.parent) { + // moving across owners: carry the full options over + if (owner.type === 'insert') { + owner.list.splice(owner.list.indexOf(owner.options), 1) + patched = true + } else { + detach(data, id) + } + applyChanges(current.options, record.changes) + placeAt(current.options, record.parent, record.position) + continue + } + + if (owner.type === 'insert') { + applyChanges(current.options, record.changes) + patched = true + continue + } + + const patchChanges: Dict = {} + applyChanges(current.options, record.changes, (key) => { + const keyOwner = index.key(id, key) + if (keyOwner.type !== 'patch') return true + ;(patchChanges[keyOwner.index] ??= {})[key] = record.changes[key] + return false + }) + for (const [i, changes] of Object.entries(patchChanges)) { + applyChanges(patches[i] as any, changes) + patched = true + } + } + + return patched +} diff --git a/vendor/loader/README.md b/vendor/loader/README.md index a5e795e372..d67385bb81 100644 --- a/vendor/loader/README.md +++ b/vendor/loader/README.md @@ -59,3 +59,7 @@ Without Node module internals, Loader resolves bare specifiers, package imports ## Update completion Entry updates await `loader/patch-context`, the Fiber update waterfall and disabled-entry disposal. Combined context/config changes refresh required bindings before restarting. Group and Include updates expose completion of their child updates; file persistence still follows Include’s debounced writer. + +## Tree mutation reports + +`EntryTree.commit(change)` receives each programmatic mutation synchronously after options and group membership change. A missing `legacy` means creation; missing `options` means removal. Updates carry both, with `from` naming the previous group after a move. Explicit Fiber saves report their simplified configuration. File-driven reconciliation, parent teardown and Loader removal do not produce spurious self-disable reports. In-memory trees implement a no-op; the existing Include writer persists the current tree. Entry ids cannot contain `:`, which separates nested tree paths. diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index b014ed6497..b73c116d3f 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -78,7 +78,7 @@ export class Entry { if (this.options.group) return false let entry: Entry | undefined = this do { - if (this.disabledOf(entry.options)) return true + if (this._resolveDisabled(entry.options)) return true entry = entry.parent.ctx.fiber.entry } while (entry) return false @@ -88,7 +88,7 @@ export class Entry { * Effective disabled state: a `!!js` expression evaluates against the loader * context. The raw node stays in the options, so write-back keeps the form. */ - private disabledOf(options: EntryOptions): boolean { + private _resolveDisabled(options: EntryOptions): boolean { return isJsExpr(options.disabled) ? Boolean(this.evaluate(options.disabled.__jsExpr)) : Boolean(options.disabled) diff --git a/vendor/loader/src/config/group.ts b/vendor/loader/src/config/group.ts index ba797a44ee..448f3a3588 100644 --- a/vendor/loader/src/config/group.ts +++ b/vendor/loader/src/config/group.ts @@ -36,12 +36,13 @@ export class EntryGroup { remove(id: string, isDispose = false) { const entry = this.tree.store[id] - if (!entry) return + if (!entry || entry.parent !== this) return + // Unregister before disposal so Loader can distinguish removal from self-disposal. + delete this.tree.store[id] entry.fiber?.dispose() if (!isDispose) { this.unlink(entry.options) } - delete this.tree.store[id] this.context.emit('loader/partial-dispose', entry, entry.options, false) } diff --git a/vendor/loader/src/config/tree.ts b/vendor/loader/src/config/tree.ts index 0ac369af96..34a2b1c731 100644 --- a/vendor/loader/src/config/tree.ts +++ b/vendor/loader/src/config/tree.ts @@ -4,6 +4,23 @@ import { Entry, type EntryOptions } from './entry.ts' import { EntryGroup } from './group.ts' import { createResolve } from '../resolve.ts' +/** + * One tree-side mutation, reported to `EntryTree.commit()` synchronously after + * it has been applied to `entry.options` / `group.data`. + * + * - `options` absent: the entry was removed from `group`. + * - `legacy` absent: the entry was created in `group`. + * - both present: the entry was updated and now lives in `group`; `from` is + * the group it left if the update moved it. + */ +export interface EntryChange { + id: string + group: EntryGroup + from?: EntryGroup + options?: EntryOptions + legacy?: EntryOptions +} + /** Mutable tree of loader entries. Persistence is supplied by subclasses. */ export abstract class EntryTree { static readonly sep = ':' @@ -54,6 +71,8 @@ export abstract class EntryTree { do { options.id = Math.random().toString(16).slice(2, 10) } while (this.store[options.id]) + } else if (options.id.includes(EntryTree.sep)) { + throw new Error(`entry id must not contain "${EntryTree.sep}": ${options.id}`) } return options.id! } @@ -82,31 +101,41 @@ export abstract class EntryTree { /** Create an entry in the root group or a nested group. */ async create(options: Omit, parent: string | null = null, position = Infinity) { const group = this.resolveGroup(parent) + const id = group.tree.ensureId(options) group.data.splice(position, 0, options as EntryOptions) - group.tree.write() + group.tree.commit({ id, group, options: options as EntryOptions }) return group.create(options) } /** Stop and remove an entry from its parent group. */ remove(id: string) { const entry = this.resolve(id) - entry.parent.remove(id) - entry.parent.tree.write() + const group = entry.parent + const legacy = entry.options + group.remove(legacy.id) + group.tree.commit({ id: legacy.id, group, legacy }) } /** Update an entry and optionally move it to another group. */ async update(id: string, options: Omit, parent?: string | null, position?: number) { const entry = this.resolve(id) const source = entry.parent + const legacy = { ...entry.options } if (parent !== undefined) { const target = this.resolveGroup(parent) source.unlink(entry.options) target.data.splice(position ?? Infinity, 0, entry.options) - target.tree.write() entry.parent = target } - source.tree.write() - return entry.update(options, false, true) + // `Entry.update` assigns the new options before its first `await`, so the + // change is fully visible to `commit()` once the call returns. + const task = entry.update(options, false, true) + if (entry.parent.tree !== source.tree) { + source.tree.commit({ id: legacy.id, group: source, legacy }) + } + const from = entry.parent === source ? undefined : source + entry.parent.tree.commit({ id: legacy.id, group: entry.parent, from, options: entry.options, legacy }) + return task } /** Import a plugin module from a specifier or `cordis:` builtin. */ @@ -131,6 +160,6 @@ export abstract class EntryTree { }, getOuterStack) } - /** Persist current tree state. In-memory trees may implement this as a no-op. */ - abstract write(): void + /** Report an applied mutation synchronously. In-memory trees may use a no-op. */ + abstract commit(change: EntryChange): void } diff --git a/vendor/loader/src/index.ts b/vendor/loader/src/index.ts index f9d9baff0a..ac57527455 100644 --- a/vendor/loader/src/index.ts +++ b/vendor/loader/src/index.ts @@ -4,7 +4,7 @@ import { ModuleLoader } from './internal.ts' import { Entry, type EntryOptions } from './config/entry.ts' import { EntryGroup } from './config/group.ts' import isolate from './config/isolate.ts' -import { EntryTree } from './config/tree.ts' +import { EntryTree, type EntryChange } from './config/tree.ts' import { interpolate } from './config/utils.ts' /** Re-export entry node APIs. */ @@ -72,7 +72,7 @@ export namespace Loader { /** * Service that owns a loader entry tree and imports configured plugins. * - * Subclasses provide persistence by implementing `write()` on `EntryTree`. + * Subclasses provide persistence by implementing `commit(change)` on `EntryTree`. */ export class Loader extends EntryTree { declare [Service.config]: Loader.Intercept @@ -113,8 +113,10 @@ export class Loader extends EntryTree { if (!fiber.entry || fiber.parent.fiber?.entry === fiber.entry) return const schema = fiber.runtime?.Config const unparse = schema?.['simplify'] - fiber.entry.options.config = unparse ? unparse.call(schema, config) : config - fiber.entry.parent.tree.write() + const { entry } = fiber + const legacy = { ...entry.options } + entry.options.config = unparse ? unparse.call(schema, config) : config + entry.parent.tree.commit({ id: entry.options.id, group: entry.parent, options: entry.options, legacy }) }) ctx.on('internal/update', function (config, next) { @@ -149,24 +151,33 @@ export class Loader extends EntryTree { if (!ctx.registry.has(fiber.runtime!.callback)) return // case 5: the entry's tree is being disposed - const treeOwner = fiber.entry.parent.tree.ctx.fiber - if (!treeOwner.uid || treeOwner.state === FiberState.UNLOADING) return + if (!fiber.entry.parent.tree.ctx.fiber.uid) return - this.showLog(fiber.entry, 'unload') + const { entry } = fiber + this.showLog(entry, 'unload') - // case 6: fiber is disposed by loader behavior + // case 6: the entry's parent is being unloaded, which takes the entry with it + // (the parent may still be registered, e.g. when it has lost a dependency) + if (fiber.parent.fiber.state === FiberState.UNLOADING) return + + // case 7: the entry is being removed by the loader (`EntryGroup.remove` + // unregisters it before disposing the fiber) + if (entry.parent.tree.store[entry.options.id] !== entry) return + + // case 8: fiber is disposed by loader behavior // such as inject checker, config file update, ancestor group disable - if (fiber.entry.disabled) return + if (entry.disabled) return - fiber.entry.options.disabled = true - fiber.entry.parent.tree.write() + const legacy = { ...entry.options } + entry.options.disabled = true + entry.parent.tree.commit({ id: entry.options.id, group: entry.parent, options: entry.options, legacy }) }) ctx.plugin(isolate) } - write() { - // Loader's root tree is in-memory; writes are no-ops. + commit(_change: EntryChange) { + // Loader's root tree is in-memory; commits are no-ops. } [Service.check]() { From 4fbd33baefa23c10f59d2bf1643c2c09b6a9dd13 Mon Sep 17 00:00:00 2001 From: Shigma Date: Wed, 14 Oct 2026 18:33:24 +0800 Subject: [PATCH 02/32] docs(cordis): align resolver note with completed Loader baseline --- vendor/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/README.md b/vendor/README.md index 064860f3cf..e9435b4675 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -50,7 +50,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 16. **`cordis/package.json` publishes `src`**: added `src` to the `files` list, joining the other eight vendored packages. Cordis declares `"./src/*": "./src/*"` in its exports, so a tarball without `src` publishes an export map pointing at absent files; the release change judgement also reads `files` to decide whether a diff reaches the payload, and a package whose only published paths are build output has no tracked path to match. 17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md). 18. **Entry `disabled` interpolation**: the rc.11 Loader implementation evaluates `!!js` expressions and preserves their raw nodes, retiring the local interpolation patch. `disabled` remains the only interpolated metadata field. -19. **Loader resolver slice from Cordis v4.0.0-rc.11**: `loader/src/resolve.ts` and the no-internals branch of `EntryTree.import()` use upstream project-relative ESM resolution, preserving import conditions, package imports and self-references. A helper under the nearest package scope supplies `import.meta.resolve`; absent scopes and helper-write failures retain the native import fallback. `loader/src/internal.ts` matches upstream runtime API detection, retiring the local Node-major workaround. Node builtin imports use default namespaces so the Web shell can bundle Loader with its existing browser externalization; the shell supplies its own module loader. The local resolver normalizes trailing directory separators before caching a scope, so concurrent requests from a root config and a nested config share one helper write. The Loader manifest remains at 1.0.0-rc.5 until the remaining package changes are reconciled. Covered by `scripts/cordis-loader-resolve.spec.ts` and the Node compatibility matrix. +19. **Loader resolver slice from Cordis v4.0.0-rc.11**: `loader/src/resolve.ts` and the no-internals branch of `EntryTree.import()` use upstream project-relative ESM resolution, preserving import conditions, package imports and self-references. A helper under the nearest package scope supplies `import.meta.resolve`; absent scopes and helper-write failures retain the native import fallback. `loader/src/internal.ts` matches upstream runtime API detection, retiring the local Node-major workaround. Node builtin imports use default namespaces so the Web shell can bundle Loader with its existing browser externalization; the shell supplies its own module loader. The local resolver normalizes trailing directory separators before caching a scope, so concurrent requests from a root config and a nested config share one helper write. Covered by `scripts/cordis-loader-resolve.spec.ts` and the Node compatibility matrix. 20. **Loader fiber identity**: rc.11 Core canonicalizes `update()` and `restart()` through `ctx.fiber`, retiring the local unwrapping workaround. `Entry.fiber` retains the registry wrapper; volatile commits explicitly use the canonical fiber. HMR retains its existing replacement algorithm. From d6fc02b1739b4b8ba563b6b2dbcc5638fcce2996 Mon Sep 17 00:00:00 2001 From: Shigma Date: Wed, 28 Oct 2026 17:56:32 +0800 Subject: [PATCH 03/32] docs(cordis): name EntryTree.commit in tree consumers --- packages/host/directory-picker-auto/README.i18n.yaml | 4 ++-- packages/host/directory-picker-auto/README.md | 2 +- packages/host/directory-picker-auto/README.zh.md | 2 +- .../tests/fixtures/plugins/self-dispose.js | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/host/directory-picker-auto/README.i18n.yaml b/packages/host/directory-picker-auto/README.i18n.yaml index 8d47ba212a..5a69888f50 100644 --- a/packages/host/directory-picker-auto/README.i18n.yaml +++ b/packages/host/directory-picker-auto/README.i18n.yaml @@ -33,8 +33,8 @@ en: 42b15ac6321706aa zh: 4ed0861911abaf5d /deepseek-ai-dsh-host-directory-picker-auto/understand-the-implementation/design-concept: - en: abc8d3962e4580c3 - zh: 4442dde69faf680f + en: 232e9be2165fae9e + zh: 6305799128399971 /deepseek-ai-dsh-host-directory-picker-auto/understand-the-implementation/the-resolution-table: en: 0edb097d5df327e8 zh: e9b59fc2070dfc42 diff --git a/packages/host/directory-picker-auto/README.md b/packages/host/directory-picker-auto/README.md index 53058f761e..518916028a 100644 --- a/packages/host/directory-picker-auto/README.md +++ b/packages/host/directory-picker-auto/README.md @@ -53,7 +53,7 @@ A wrong `native` choice degrades to the backend's existing retryable failure dia ### Design concept -The chooser is a pure decision plus a mount: `resolveDirectoryPickerBackend` samples host facts once at boot and returns a backend kind, and `apply` mounts the matching backend and surface packages as real Loader entries in the in-memory root tree — never persisted to a config file, because the root tree's `write()` is a no-op. The effect's disposer removes both entries and joins their fibers' teardown, so unloading returns only after both faces of the mounted interaction quiesced. +The chooser is a pure decision plus a mount: `resolveDirectoryPickerBackend` samples host facts once at boot and returns a backend kind, and `apply` mounts the matching backend and surface packages as real Loader entries in the in-memory root tree — never persisted to a config file, because the root tree's `commit(change)` is a no-op. The effect's disposer removes both entries and joins their fibers' teardown, so unloading returns only after both faces of the mounted interaction quiesced. ### The resolution table diff --git a/packages/host/directory-picker-auto/README.zh.md b/packages/host/directory-picker-auto/README.zh.md index b4dc0b4dce..7fba47e345 100644 --- a/packages/host/directory-picker-auto/README.zh.md +++ b/packages/host/directory-picker-auto/README.zh.md @@ -53,7 +53,7 @@ kind: "package-reference" ### 设计理念 -选择器是一次纯决策加一次挂载:`resolveDirectoryPickerBackend` 在启动时采样宿主事实并返回一个后端类型,`apply` 把匹配的后端与界面包作为真实 Loader 条目挂进内存根树——绝不持久化到配置文件,因为根树的 `write()` 是 no-op。该 effect 的 disposer 会移除两个条目并汇合其 fiber 的拆除,因此卸载只在所挂载交互的两面完全停稳后返回。 +选择器是一次纯决策加一次挂载:`resolveDirectoryPickerBackend` 在启动时采样宿主事实并返回一个后端类型,`apply` 把匹配的后端与界面包作为真实 Loader 条目挂进内存根树——绝不持久化到配置文件,因为根树的 `commit(change)` 是 no-op。该 effect 的 disposer 会移除两个条目并汇合其 fiber 的拆除,因此卸载只在所挂载交互的两面完全停稳后返回。 ### 判定表 diff --git a/packages/preset/agent-preset-registry/tests/fixtures/plugins/self-dispose.js b/packages/preset/agent-preset-registry/tests/fixtures/plugins/self-dispose.js index 97c01f95a4..d9c209deea 100644 --- a/packages/preset/agent-preset-registry/tests/fixtures/plugins/self-dispose.js +++ b/packages/preset/agent-preset-registry/tests/fixtures/plugins/self-dispose.js @@ -1,5 +1,5 @@ // Disposes itself once active. The Loader treats a self-disposing entry as a -// config change and writes the tree back through `EntryTree.write()`, which is +// config change and reports it through `EntryTree.commit(change)`, which is // the exact path that once truncated a preset file to `[]`. export const name = 'self-dispose' export function apply(ctx) { From c6f8fb1d37e44934286b04b66200c923b32ca676 Mon Sep 17 00:00:00 2001 From: Shigma Date: Wed, 28 Oct 2026 18:30:55 +0800 Subject: [PATCH 04/32] docs(vendor): sync rescope Loader version with rc.8 pin --- docs/rescope.i18n.yaml | 4 ++-- docs/rescope.md | 2 +- docs/rescope.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/rescope.i18n.yaml b/docs/rescope.i18n.yaml index f418069ce3..1d8ab0d003 100644 --- a/docs/rescope.i18n.yaml +++ b/docs/rescope.i18n.yaml @@ -6,8 +6,8 @@ en: c8aa4d3384ea1306 zh: 5aa030587d5b634d /vendored-package-rescope/name-mapping: - en: 239fa7993825ac62 - zh: df35fa9f5ef42a05 + en: c74ffbc1036a93bf + zh: 9538352d8d72ebca /vendored-package-rescope/what-the-rename-does-not-touch: en: 9bb7d09f8cccf695 zh: 2a8f3f60ae5116f3 diff --git a/docs/rescope.md b/docs/rescope.md index 85917bbfce..62b4dbfbac 100644 --- a/docs/rescope.md +++ b/docs/rescope.md @@ -11,7 +11,7 @@ The Cordis framework and its foundation libraries are vendored under [`vendor/`] | `vendor/cordis/` | `cordis` | `@deepseek-ai/cordis` | 4.0.0-rc.11 | Framework core: `Context`, `Service`, `Fiber`, events | | `vendor/cosmokit/` | `cosmokit` | `@deepseek-ai/cosmokit` | 1.8.1 | Shared utilities the framework and Schemastery build on | | `vendor/schemastery/` | `schemastery` | `@deepseek-ai/schemastery` | 3.18.0 | Config schemas (`Schema`) behind every plugin's `Config` | -| `vendor/loader/` | `@cordisjs/plugin-loader` | `@deepseek-ai/cordis-plugin-loader` | 1.0.0-rc.5 | `cordis.yml` loading, plugin resolution, repository cache | +| `vendor/loader/` | `@cordisjs/plugin-loader` | `@deepseek-ai/cordis-plugin-loader` | 1.0.0-rc.8 | `cordis.yml` loading, plugin resolution, repository cache | | `vendor/include/` | `@cordisjs/plugin-include` | `@deepseek-ai/cordis-plugin-include` | 1.0.4 | Config includes and patch overlays | | `vendor/group/` | `@cordisjs/plugin-group` | `@deepseek-ai/cordis-plugin-group` | 1.0.0 | Nested plugin groups | | `vendor/timer/` | `@cordisjs/plugin-timer` | `@deepseek-ai/cordis-plugin-timer` | 1.1.4 | Disposal-aware timers on `ctx` | diff --git a/docs/rescope.zh.md b/docs/rescope.zh.md index 8d13ed539b..5e6a75de68 100644 --- a/docs/rescope.zh.md +++ b/docs/rescope.zh.md @@ -11,7 +11,7 @@ Cordis 框架及其基础库以源码形式 vendored 在 [`vendor/`](../vendor/R | `vendor/cordis/` | `cordis` | `@deepseek-ai/cordis` | 4.0.0-rc.11 | 框架核心:`Context`、`Service`、`Fiber`、事件 | | `vendor/cosmokit/` | `cosmokit` | `@deepseek-ai/cosmokit` | 1.8.1 | 框架与 Schemastery 共用的基础工具 | | `vendor/schemastery/` | `schemastery` | `@deepseek-ai/schemastery` | 3.18.0 | 配置 schema(`Schema`),每个插件的 `Config` 都基于它 | -| `vendor/loader/` | `@cordisjs/plugin-loader` | `@deepseek-ai/cordis-plugin-loader` | 1.0.0-rc.5 | `cordis.yml` 装载、插件解析、repository 缓存 | +| `vendor/loader/` | `@cordisjs/plugin-loader` | `@deepseek-ai/cordis-plugin-loader` | 1.0.0-rc.8 | `cordis.yml` 装载、插件解析、repository 缓存 | | `vendor/include/` | `@cordisjs/plugin-include` | `@deepseek-ai/cordis-plugin-include` | 1.0.4 | 配置包含与 patch 叠加 | | `vendor/group/` | `@cordisjs/plugin-group` | `@deepseek-ai/cordis-plugin-group` | 1.0.0 | 嵌套插件分组 | | `vendor/timer/` | `@cordisjs/plugin-timer` | `@deepseek-ai/cordis-plugin-timer` | 1.1.4 | `ctx` 上随 disposal 回收的定时器 | From db1a3b3754b104e6498965024c00ec8a2a771d4c Mon Sep 17 00:00:00 2001 From: Shigma Date: Wed, 28 Oct 2026 19:40:54 +0800 Subject: [PATCH 05/32] chore(cordis): adopt Include 1.1.1 journal alongside Loader rc.8 --- packages/boot/app-boot/src/index.ts | 11 +- .../tests/loader-composition.spec.ts | 18 +- .../agent-preset-registry/tests/mount.spec.ts | 3 +- .../tests/teardown-loader.e2e.ts | 5 +- scripts/cordis-include-journal.spec.ts | 160 ++++++ scripts/loader-volatile-update.spec.ts | 1 + vendor/README.md | 17 +- vendor/cordis-rc11-upgrade-plan.md | 14 +- vendor/include/README.md | 4 +- vendor/include/src/index.ts | 527 ++++++++++++------ vendor/loader/README.md | 4 +- vendor/loader/src/config/tree.ts | 17 +- 12 files changed, 589 insertions(+), 192 deletions(-) create mode 100644 scripts/cordis-include-journal.spec.ts diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index b89eb3632a..32636ea8e8 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -656,10 +656,9 @@ export function renderConfigDump( const base = parsed as Parameters[0] // snapshot_k = ONE application of layers 1..k flattened, using the exact // arguments boot passes for that prefix. snapshot_N is the mounted composition. - // The patches are cloned per call: applyEntryPatches detaches the entry - // list but pushes `insert` rows by reference from the patch list, so - // sharing patch objects across snapshot calls would leak a later - // snapshot's mutations into an earlier one's result. + // The patches are cloned per call: applyEntryPatches clones entries and + // inserted rows but assigns override values such as `config` by reference, + // so each snapshot keeps its own copies of those values. const snapshot = (count: number, warnings: string[]): ReturnType => { const flattened = structuredClone(layers.slice(0, count).flatMap(layer => layer.patches)) return applyEntryPatches(base, flattened, (message: string, ...args: unknown[]) => { @@ -804,8 +803,8 @@ export interface FailLoudProcess { exit(code: number): void } -// Loader rc.5 derives and drops a rejected promise after a fiber fails. Keep -// exact reasons already folded into the boot diagnostic visible through the +// An activation rejection already folded into the boot diagnostic can also +// reach the process rejection handler. Keep exact reasons visible through the // next process rejection checkpoint so the process guard can coalesce them. const assembledActivationRejections = new Map() diff --git a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts index 2989150118..fabd34d2c7 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -239,11 +239,10 @@ describe('real Loader composition', () => { expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE) expect(ctx.get('directoryPicker')).toBeUndefined() // Self-disposing an include-tree entry persists `disabled: true` (loader - // behavior, not the chooser's); await that debounced write so it cannot - // race the temp-dir removal, and pin that the persisted row is the - // chooser itself — the resolved backend still never reaches the file. - // stop() drains the Include write queue, so this assertion does not depend - // on the debounce timer racing Windows coverage load. + // behavior, not the chooser's); await that write so it cannot race the + // temp-dir removal, and pin that the persisted row is the chooser itself — + // the resolved backend still never reaches the file. stop() drains the + // Include journal, so this assertion does not depend on write timing. await includeTree(ctx).stop() expect(await readFile(configPath, 'utf8')).toContain('disabled: true') expect(await readFile(configPath, 'utf8')).not.toContain(NATIVE) @@ -299,7 +298,7 @@ describe('real Loader composition', () => { await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow() expect(entryNames(ctx)).not.toContain(NATIVE) expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE) - // Same self-dispose persistence as above: drain the Include write queue + // Same self-dispose persistence as above: drain the Include journal // deterministically before asserting the persisted row. await includeTree(ctx).stop() expect(await readFile(configPath, 'utf8')).toContain('disabled: true') @@ -308,17 +307,20 @@ describe('real Loader composition', () => { expect(renameControl.attempts).toBeGreaterThanOrEqual(2) }) - it('reports a terminal debounced-write failure again to the teardown owner', { timeout: 60_000 }, async () => { + it('reports unsaved changes at shutdown and clears the error after a successful retry', { timeout: 60_000 }, async () => { stubAttendedHost() const { ctx } = await loadComposition('127.0.0.1') const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)! const include = includeTree(ctx) renameControl.failureCode = 'EIO' - renameControl.remainingFailures = 1 + renameControl.remainingFailures = Infinity await autoEntry.fiber!.dispose() await expect.poll(() => renameControl.injectedFailures).toBe(1) await expect(include.stop()).rejects.toMatchObject({ code: 'EIO' }) + renameControl.remainingFailures = 0 + await expect(include.flush()).resolves.toBeUndefined() + await expect(include.flush()).resolves.toBeUndefined() await expect(ctx.fiber.dispose()).resolves.not.toThrow() context = undefined }) diff --git a/packages/preset/agent-preset-registry/tests/mount.spec.ts b/packages/preset/agent-preset-registry/tests/mount.spec.ts index 3706af4962..f669058d92 100644 --- a/packages/preset/agent-preset-registry/tests/mount.spec.ts +++ b/packages/preset/agent-preset-registry/tests/mount.spec.ts @@ -73,7 +73,8 @@ it('does not replace the declaring Loader entry subtree or persist runtime tree expect(mountedCompositionRows(mount.tree)[0]!.entryId).toBe('child') expect(entry.subtree).toBe(previousTree) expect(entry.subgroup).toBe(previousGroup) - mount.tree.commit({ id: 'missing', group: mount.tree.root }) + const row = mount.tree.root.data[0]! + mount.tree.commit({ id: 'child', group: mount.tree.root, legacy: { ...row }, options: row }) entry.subtree = mount.tree entry.subgroup = mount.tree.root const other = createScope(owner, {}) diff --git a/packages/session/session-persistence-jsonl/tests/teardown-loader.e2e.ts b/packages/session/session-persistence-jsonl/tests/teardown-loader.e2e.ts index e8f301a851..b06980f014 100644 --- a/packages/session/session-persistence-jsonl/tests/teardown-loader.e2e.ts +++ b/packages/session/session-persistence-jsonl/tests/teardown-loader.e2e.ts @@ -114,8 +114,11 @@ it('waits for injected consumer teardown, persists its final event, and releases expect(persisted.at(-1)).toMatchObject({ type: 'turn/end', seq: 1, data: { turn: 1, reason: { kind: 'completed' } } }) await consumingEntry.fiber!.dispose() - await provider.init() + // Self-disposal records disabled: true; re-enable through Loader so the journal agrees with the runtime. + await ctx.loader.update(provider.id, { disabled: false }) + await (provider.parent.tree as Include).flush() await provider.fiber!.await() + expect(provider.disabled).toBe(false) const reopened = await ctx.sessionPersistence.open(live.id, 'write') try { expect((await reopened.read()).events).toEqual(live.snapshotEvents()) diff --git a/scripts/cordis-include-journal.spec.ts b/scripts/cordis-include-journal.spec.ts new file mode 100644 index 0000000000..e5d0100d89 --- /dev/null +++ b/scripts/cordis-include-journal.spec.ts @@ -0,0 +1,160 @@ +/** File reconciliation and persistence results through real Loader entries. */ +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context, FiberState } from '@deepseek-ai/cordis' +import Loader, { Group, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import { expect, it, onTestFinished, vi } from 'vitest' + +const row = (id: string, value: number): EntryOptions => ({ id, name: 'cordis:example', config: { value } }) + +// File rows may omit ids; Include assigns them. +async function fixture(data: Array & { id?: string }>) { + const dir = await mkdtemp(join(tmpdir(), 'dsh-include-journal-')) + const ctx = new Context() + onTestFinished(async () => { + await ctx.fiber.dispose() + await rm(dir, { recursive: true, force: true, maxRetries: 3 }) + }) + ctx.baseUrl = pathToFileURL(dir).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + ctx.loader.builtins.group = Group + ctx.loader.builtins.example = () => {} + const filename = join(dir, 'root.json') + await writeFile(filename, JSON.stringify(data)) + const id = await ctx.loader.create({ name: 'cordis:include', config: { path: './root.json' } }) + await ctx.loader.await() + const include = ctx.loader.resolve(id).subtree as Include + const read = async () => JSON.parse(await readFile(filename, 'utf8')) as EntryOptions[] + const edit = (entries: EntryOptions[]) => writeFile(filename, JSON.stringify(entries)) + const update = (id: string, value: number) => include.update(id, { config: { value } }) + const value = (id: string) => (include.store[id]!.fiber!.effectiveConfig as { value: number }).value + return { ctx, dir, filename, id, include, read, edit, update, value } +} + +it.each(['refresh-first', 'update-first'] as const)('merges independent file and runtime edits with %s ordering', async (order) => { + const app = await fixture([row('a', 1), row('b', 1)]) + await app.edit([row('a', 1), row('b', 3)]) + if (order === 'refresh-first') await Promise.all([app.include.refresh(), app.update('a', 2)]) + else await Promise.all([app.update('a', 2), app.include.refresh()]) + await app.include.flush() + expect(app.value('a')).toBe(2) + expect(app.value('b')).toBe(3) + expect(await app.read()).toEqual([row('a', 2), row('b', 3)]) +}) + +it('lets the file win a conflicting config edit and reports the conflict', async () => { + const app = await fixture([row('a', 1)]) + const error = vi.spyOn(app.include.ctx.logger, 'error') + await app.edit([row('a', 3)]) + await app.update('a', 2) + await app.include.flush() + expect(app.value('a')).toBe(3) + expect(await app.read()).toEqual([row('a', 3)]) + expect(error.mock.calls.some(([message]) => String(message).includes('config conflict'))).toBe(true) +}) + +it('retains unsaved edits across a missing or invalid file and clears failure after recovery', async () => { + const app = await fixture([row('a', 1)]) + await rm(app.filename) + await app.update('a', 2) + await expect(app.include.flush()).rejects.toThrow('unsaved change') + await expect(readFile(app.filename)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(app.value('a')).toBe(2) + await writeFile(app.filename, '{') + await expect(app.include.refresh()).rejects.toThrow('failed to parse') + expect(await readFile(app.filename, 'utf8')).toBe('{') + await app.edit([row('a', 1), row('b', 1)]) + await app.include.refresh() + await expect(app.include.flush()).resolves.toBeUndefined() + expect(await app.read()).toEqual([row('a', 2), row('b', 1)]) +}) + +it('routes patch-owned fields and inserted rows into the parent file', async () => { + const app = await fixture([]) + const inner = join(app.dir, 'inner.json') + const original = JSON.stringify([row('a', 1)]) + await writeFile(inner, original) + const id = await app.include.create({ name: 'cordis:include', config: { + path: './inner.json', patches: [{ id: 'a', disabled: true }, { insert: [row('b', 1)] }], + } }) + await app.ctx.loader.await() + const child = app.ctx.loader.resolve(id).subtree as Include + await child.update('a', { disabled: false }) + await child.update('b', { config: { value: 2 } }) + await child.flush() + await app.include.flush() + expect(await readFile(inner, 'utf8')).toBe(original) + expect((await app.read())[0]!.config).toEqual({ path: './inner.json', patches: [ + { id: 'a', disabled: false }, { insert: [row('b', 2)] }, + ] }) + expect(child.store.a!.fiber!.effectiveConfig).toEqual({ value: 1 }) + expect(child.store.b!.fiber!.effectiveConfig).toEqual({ value: 2 }) +}) + +// Windows does not implement POSIX write permission bits. +it.skipIf(process.platform === 'win32')('retains read-only file changes until a writable retry succeeds', async () => { + const app = await fixture([row('a', 1)]) + try { + await chmod(app.filename, 0o444) + await app.update('a', 2) + await expect(app.include.flush()).rejects.toThrow('unsaved change') + expect(await app.read()).toEqual([row('a', 1)]) + expect(app.value('a')).toBe(2) + } finally { + await chmod(app.filename, 0o644) + } + await app.include.flush() + expect(await app.read()).toEqual([row('a', 2)]) +}) + +it('preserves file text on file-driven removal and drains runtime edits before shutdown', async () => { + const app = await fixture([row('a', 1), row('b', 1)]) + const edited = JSON.stringify([row('a', 1)], null, 4) + '\n' + await writeFile(app.filename, edited) + await app.include.refresh() + expect(app.include.store.b).toBeUndefined() + expect(await readFile(app.filename, 'utf8')).toBe(edited) + await app.update('a', 2) + await app.include.stop() + expect(await app.read()).toEqual([row('a', 2)]) +}) + +it('keeps persisted runtime edits when the Include config changes', async () => { + const app = await fixture([row('a', 1)]) + await app.include.update('a', { disabled: true }) + await app.include.flush() + await app.ctx.loader.update(app.id, { config: { path: './root.json', enableLogs: true } }) + await app.include.flush() + expect(app.include.store.a!.fiber?.state).not.toBe(FiberState.ACTIVE) + expect(await app.read()).toEqual([{ ...row('a', 1), disabled: true }]) +}) + +it('keeps generated ids and fibers for anonymous file and inserted rows across updates', async () => { + const app = await fixture([{ name: 'cordis:example', config: { value: 1 } }]) + const config = { path: './root.json', patches: [{ insert: [{ name: 'cordis:example', config: { value: 2 } }] }] } + await app.ctx.loader.update(app.id, { config }) + await app.include.flush() + const fibers = () => Object.values(app.include.store).map(entry => entry.fiber) + const before = fibers() + expect(before).toHaveLength(2) + await app.ctx.loader.update(app.id, { config: { ...config, enableLogs: true } }) + await app.include.flush() + await app.include.refresh() + expect(fibers()).toEqual(before) + expect(fibers().every((fiber, index) => fiber === before[index])).toBe(true) +}) + +it('persists a row moved into the Include from another tree as a complete creation', async () => { + const app = await fixture([{ id: 'g', name: 'cordis:group', group: true, config: [] }]) + const id = await app.ctx.loader.create({ name: 'cordis:example', config: { value: 1 } }) + await app.ctx.loader.await() + await app.ctx.loader.update(id, { config: { value: 2 } }, `${app.id}:g`) + await app.include.flush() + expect(await app.read()).toEqual([{ id: 'g', name: 'cordis:group', group: true, config: [ + { id, name: 'cordis:example', config: { value: 2 } }, + ] }]) +}) diff --git a/scripts/loader-volatile-update.spec.ts b/scripts/loader-volatile-update.spec.ts index bdcb8917d3..71192ba28e 100644 --- a/scripts/loader-volatile-update.spec.ts +++ b/scripts/loader-volatile-update.spec.ts @@ -304,6 +304,7 @@ it('updates volatile children through Group and Include custom updates and saves expect(initial.title.get()).toBe('group-update') expect(group.options.config).toEqual([child('group-update')]) await includeEntry.fiber!.update({ path: includePath, patches: [{ id: 'consumer', config: { title: 'include-update' } }] }) + await include.flush() await ctx.loader.await() expect(initial.title.get()).toBe('include-update') expect(includeEntry.fiber).toBe(originalInclude) diff --git a/vendor/README.md b/vendor/README.md index 667631cd3f..d39dfae8d8 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -18,7 +18,7 @@ Upstream workspace: `cordis-workspace` (local checkout: `~/repos/cordis-workspac | `schemastery/` | `@deepseek-ai/schemastery` | `schemastery` | 3.18.0 | https://github.com/deepseek-harness/schemastery (`packages/core`) | `e67cee00ad725bd1534aee930a979ea3eec6f698` | | `cordis/` | `@deepseek-ai/cordis` | `cordis` | 4.0.0-rc.11 | https://github.com/cordiverse/cordis (`packages/core`) | `0b96d9fd517b998e44e2209560c9e5f0671ce933` | | `loader/` | `@deepseek-ai/cordis-plugin-loader` | `@cordisjs/plugin-loader` | 1.0.0-rc.8 | https://github.com/cordiverse/cordis (`packages/loader`) | `0b96d9fd517b998e44e2209560c9e5f0671ce933` | -| `include/` | `@deepseek-ai/cordis-plugin-include` | `@cordisjs/plugin-include` | 1.0.4 | https://github.com/deepseek-harness/cordis (`packages/include`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | +| `include/` | `@deepseek-ai/cordis-plugin-include` | `@cordisjs/plugin-include` | 1.1.1 | https://github.com/cordiverse/cordis (`packages/include`) | `0b96d9fd517b998e44e2209560c9e5f0671ce933` | | `group/` | `@deepseek-ai/cordis-plugin-group` | `@cordisjs/plugin-group` | 1.0.0 | https://github.com/deepseek-harness/cordis (`packages/group`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | | `timer/` | `@deepseek-ai/cordis-plugin-timer` | `@cordisjs/plugin-timer` | 1.1.4 | https://github.com/cordiverse/cordis (`packages/timer`) | `0b96d9fd517b998e44e2209560c9e5f0671ce933` | | `hmr/` | `@deepseek-ai/cordis-plugin-hmr` | `@cordisjs/plugin-hmr` | 1.0.15 | https://github.com/deepseek-harness/cordis (`packages/hmr`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | @@ -30,27 +30,26 @@ Intentionally **not** vendored (verified unused by this set): `reggol`, `@cordis ## Local modifications -Keep this log exhaustive — every divergence from upstream must be listed. Each entry has a stable ID: remove an entry when its divergence no longer exists, and never renumber or reuse IDs. The next new entry is V26. +Keep this log exhaustive — every divergence from upstream must be listed. Each entry has a stable ID: remove an entry when its divergence no longer exists, and never renumber or reuse IDs. The next new entry is V27. - **V1 · `hmr/src/index.ts`**: removed the `./locales/en-US.yml` / `./locales/zh-CN.yml` imports, the `.i18n({...})` call on the `Config` schema, and the `src/locales/` directory. Rationale: those imports require a runtime YAML loader hook (`@cordisjs/unyaml`) that we do not vendor; the i18n texts only localize config descriptions. - **V2 · All `package.json` files**: regenerated for Harness releases with scoped names, release versions, publication metadata, precise bundled-runtime and `lib/types/**/*.d.ts` / `.d.ts.map` file entries, source exports where applicable, and declaration metadata pointing at `lib/types`. References within the vendored set use `workspace:~`. HMR declares `esbuild` as a direct dev dependency for its imported `BuildFailure` type, and Loader requires `node-addon-require-builtin@^0.1.4` to match published app runtimes. - **V3 · All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references. - **V4 · Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. Type-only dependencies use `import type` or inline `type` modifiers so ESM output does not retain erased interfaces. - **V5 · `schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. - **V6 · `cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. -- **V7 · Cordis, Loader and Timer JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), module re-exports, shared utility helpers, `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Loader documentation covers `loader/src/{index,internal,config/entry,config/group,config/isolate,config/tree,config/utils}.ts`, including entry ownership, tree mutation methods, and the `!!js` discriminator. The upstream `EventsService.dispatch()` deprecation annotation is omitted: DSH enumerates callbacks to contain each observer failure independently, which the typed dispatch methods do not provide. Comment-only; no code changes. Timer retains its local class and helper summaries; its README is local documentation because the upstream package has none. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. -- **V8 · `include/src/index.ts` resilient file refresh and patch reapplication**: [#514](https://github.com/deepseek-harness/deepseek-harness/pull/514) validates a top-level entry array before caching parsed content, logs refresh failures, reapplies patches after file or Include-config edits, updates the Include config when it vetoes a restart, and uses `initial` only after `ENOENT`. These parse protections retain the running tree after an invalid file; plugin activation failures can leave a partially applied tree. Loader entry/group/tree mutations follow the pinned eager, non-transactional implementation and do not restore previous plugins or options. Covered by `packages/boot/app-boot/tests/{config-reload,user-patches}.spec.ts`. +- **V7 · Cordis, Loader and Timer JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), module re-exports, shared utility helpers, `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Loader documentation covers `loader/src/{index,internal,config/entry,config/group,config/isolate,config/tree,config/utils}.ts`, including entry ownership, tree mutation methods, and the `!!js` discriminator. The upstream `EventsService.dispatch()` deprecation annotation is omitted: DSH enumerates callbacks to contain each observer failure independently, which the typed dispatch methods do not provide. Timer retains its local class and helper summaries; its README is local documentation because the upstream package has none. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. - **V9 · `hmr/src/index.ts` module-watcher readiness and native paths**: realpath the existing watch base, classify framework modules and attach listeners before reporting readiness, and compare config paths using both canonical and configured spellings. This preserves Node module-cache identity across Windows short-name paths and filesystem aliases. The main watcher uses `ignoreInitial: true` so startup reads do not trigger another refresh; exact profile patch watching is owned by `packages/boot/hmr/src/watch-config.ts`. Covered by `packages/boot/hmr/tests/watch-config.spec.ts`. - **V10 · Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`. Conditional exports select `.mjs` for import and `.cjs` for require; without them, concurrent imports under module-hook hosts reach the CJS entry and can race its require of ESM Cosmokit (`ERR_REQUIRE_ESM_RACE_CONDITION`). -- **V11 · Include patch and YAML exports**: `applyEntryPatches` aliases the pure `patch.ts` implementation from Cordis `v4.0.0-rc.11`, and `entryListSchema` exposes the `!!js` YAML dialect for config dumps. Include imports `patch.ts` and its `journal.ts` helpers from the rc.11 source on top of its 1.0.4 baseline; its runtime keeps the existing writer (V14). -- **V13 · `include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change. -- **V14 · `include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained writes before and after child removal during Include teardown. The first drain preserves an existing terminal write failure even if child removal schedules a later write. Missing-file initialization awaits the write and forces a fresh parse before mounting the initial entries. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures. -- **V15 · Configuration access across Core, Loader, Include and HMR**: rc.11 Core owns raw `Fiber.config`, `internal/config(fiber, next)`, `internal/commit` and awaited `internal/update(config, next)`. Root-fiber candidate resolution remains available without a plugin schema. Local `rawConfig`, `resolveConfig(candidate)` and `effectiveConfig` accessors retain the DSH consumer API. Fiber caches the object passed to activation or a successful update that keeps the instance; candidate resolution is pure, failed or superseded updates cannot replace the cache, and volatile updates retain references. Loader resolves only entry roots and excludes Group/Include tree carriers, so nested `!!js` expressions wait for their own injections. Include retains its tree-carrier marker, writer and patches, and HMR transfers raw config through the accessors. Covered by `scripts/cordis-config-access.spec.ts`, `scripts/cordis-core-lifecycle.spec.ts` and the boot/HMR suites. +- **V11 · Include patch and YAML exports**: `include/src/index.ts` exports upstream `applyPatches` as `applyEntryPatches` and the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes entries with the same algorithm that mounting uses. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`. +- **V14 · Include persistence results**: `include/src/index.ts` keeps an error for unsaved journal changes. `flush()` retries pending writes and rejects while changes remain unsaved; `stop()` returns the same result and always stops the child tree; a later successful flush clears previous errors. A failed `refresh()` can also reject while runtime changes remain unsaved. Patch-owned changes route to the parent Include, so callers that need them persisted must flush that parent too. Ordinary Fiber teardown keeps its error containment. Covered by `scripts/cordis-include-journal.spec.ts` and `packages/host/directory-picker-auto/tests/loader-composition.spec.ts`. +- **V15 · Configuration access across Core, Loader, Include and HMR**: rc.11 Core owns raw `Fiber.config`, `internal/config(fiber, next)`, `internal/commit` and awaited `internal/update(config, next)`. Root-fiber candidate resolution remains available without a plugin schema. Local `rawConfig`, `resolveConfig(candidate)` and `effectiveConfig` accessors retain the DSH consumer API. Fiber caches the object passed to activation or a successful update that keeps the instance; candidate resolution is pure, failed or superseded updates cannot replace the cache, and volatile updates retain references. Loader resolves only entry roots and excludes Group/Include tree carriers, so nested `!!js` expressions wait for their own injections. Include retains its tree-carrier marker and public patch exports, and HMR transfers raw config through the accessors. Covered by `scripts/cordis-config-access.spec.ts`, `scripts/cordis-core-lifecycle.spec.ts` and the boot/HMR suites. - **V16 · `cordis/package.json` publishes `src`**: added `src` to the `files` list, joining the other eight vendored packages. Cordis declares `"./src/*": "./src/*"` in its exports, so a tarball without `src` publishes an export map pointing at absent files; the release change judgement also reads `files` to decide whether a diff reaches the payload, and a package whose only published paths are build output has no tracked path to match. - **V17 · `@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md). - **V22 · Volatile config across `cosmokit`, `schemastery`, `cordis`, and `loader`**: Cosmokit supplies immutable references and framework-owned commits; `deepEqual` treats two references as equal, propagates strict comparison into arrays, compares every array slot with holes treated as undefined, handles cycles conservatively, compares URLs by normalized href, and compares other opaque objects by identity in strict mode. Schemastery adds `.volatile()`, optional/default inference, placement validation without eager lazy-schema expansion (a lazy inner is validated once when built), metadata-free serialized node compatibility, serialized metadata, reference-aware simplification, unsupported volatile values reported as `ValidationError` with their path, and `.default()` accepting the raw input type `Partial` because a volatile field makes the output type carry references. Loader's save path calls `simplify` with its schema receiver (`unparse.call`), an independent fix for the unbound-`this` call. Cordis exports consumer types without changing Fiber lifecycle. Loader compares raw options strictly in `Entry.update`; for a config-only change on an active fiber it calls the pure `equalExceptVolatile` function in `config/diff.ts`, which narrows the Standard Schema to Schemastery's `Schema` type (a new type-only `@deepseek-ai/schemastery` dependency of `loader`) and recursively skips fixed volatile paths using schema metadata without executing config hooks or validation. Missing or null object nodes compare using their declared defaults, including nodes without volatile descendants; schema backedges retain raw comparison. Entry passes its actual schema and preserves ordinary context and custom-update lifecycles. For a volatile-only change, `Entry._commitVolatile` takes the canonical fiber `entry.fiber.ctx.fiber` (`Entry.fiber` holds the registry wrapper), re-parses the raw config through `Fiber.resolveConfig()`, compares ordinary effective values, commits references and emits the instance-local `loader/volatile-update` declared in `loader/src/index.ts`; a changed ordinary effective value falls back to the ordinary remount, and an invalid candidate is logged while the raw config stays retained for the next activation. Cosmokit provides reference enumeration and commits, and `simplify` is called with its schema receiver. The package READMEs document these additions; schema tests live in `scripts/volatile-config.spec.ts`, Loader runtime and real-file tests in `scripts/loader-volatile-update.spec.ts`, and code replacement tests in the HMR module suite. - **V23 · `loader/src/config/entry.ts` module identity**: retains each entry's raw import result as `moduleNamespace`, before plugin export normalization. HMR matches that object to Node's cached module namespace and updates it after a successful reload. `EntryTree.import()` keeps its existing arguments and return value; `loader/README.md` documents the additive field. - **V24 · `timer/locale/{en,zh}.json` display metadata**: local English and Chinese titles and descriptions for the Settings plugin inventory. `timer/package.json` exports and publishes these locale resources. -- **V25 · Loader and Include lifecycle adaptations**: before a combined context/config update, Loader refreshes dependencies locally, which prevents activation against stale isolation bindings; disabled-entry updates await disposal. Include forwards `commit(change)` reports to its existing writer, and in-memory trees use no-op commits. Failed fibers require an explicit update after correcting their input or dependencies. Core retains the effect hardening in V6; rc.11 owns dependency teardown ordering and superseded-activation handling. +- **V25 · Loader lifecycle adaptations**: before a combined context/config update, Loader refreshes dependencies locally, which prevents activation against stale isolation bindings; disabled-entry updates await disposal. Failed fibers require an explicit update after correcting their input or dependencies. Core retains the effect hardening in V6; rc.11 owns dependency teardown ordering and superseded-activation handling. +- **V26 · `loader/src/config/tree.ts` cross-tree move reports**: `EntryTree.update()` reports a move into another tree as a removal to the source tree and a creation, without `legacy` or `from`, to the destination tree. Upstream reports the destination as an update with `legacy`, which the Include journal records as a partial row for an id that its file does not contain. Covered by `scripts/cordis-entry-commit.spec.ts`. ### Resolver adaptations diff --git a/vendor/cordis-rc11-upgrade-plan.md b/vendor/cordis-rc11-upgrade-plan.md index eb1b601b91..7833cf3514 100644 --- a/vendor/cordis-rc11-upgrade-plan.md +++ b/vendor/cordis-rc11-upgrade-plan.md @@ -41,13 +41,13 @@ The identifiers below are plan labels, not GitHub issue numbers. Preconditions a | 3 | Upgrade Timer to 1.1.4, retaining DSH locale resources and build metadata. | 0 | Retain V24 (display metadata); no logged runtime patch retired. | Concurrent interval reads, throttle cancellation and void return types; check callers using the old Core. | | 4a | Upgrade Core to rc.11 with the minimum Loader, Include and HMR compatibility slice described below. | 1 | Retire V12 (activation observer), V21 (logger disposer) and, after canonical-fiber audits, V20 (Entry identity workaround). Partial V6 (lifecycle) and V15 (config model). Retain/adapt V22 and V23 (module namespace). | Config resolution, volatile identity, service isolation, dependent-before-provider teardown, retained effect regressions and application startup. | | 4b | Import Loader's project-relative bare-specifier resolver, runtime API detection and fallback when Node internals are absent. | 0 | Retire V19: upstream now detects the internal loader by its available APIs. | Resolve in the config project's package scope, retain subpaths, handle read-only helper creation and test Node 24's older v1 API. | -| 4c | Complete Loader's `EntryChange` / `commit(change)` reporting and remaining changes together with upstream Include's pure patch implementation and journal helper module. Adapt the old writer and in-memory trees; keep `applyEntryPatches` and `entryListSchema` exports. | 4a, 4b | Retire V18 (`disabled` expressions). Partial V11: retire the duplicate patch algorithm and insert-index fix; retain public exports. Retain/adapt V22, including the schema receiver fix, and V23. | Create, move, remove and self-update reports; no false self-disposal writes; later patches address inserted rows; cloning and config dumps agree with mounting. The old Include writer remains active. | +| 4c | Complete Loader's `EntryChange` / `commit(change)` reporting and remaining changes, and upgrade Include to its 1.1.1 journal implementation in the same PR. Both HMR providers keep finding Include through their existing scans; keep `applyEntryPatches` and `entryListSchema` exports and in-memory no-op commits. | 4a, 4b | Retire V18 (`disabled` expressions), V8 (old refresh implementation) and V13 (`writeTask` type). Partial V11: retire the duplicate patch algorithm and insert-index fix; retain public exports. Partial V14: replace the write queue; retain rejection while the journal contains unsaved changes. Retain/adapt V22, including the schema receiver fix, V23 and the V15 child-expression marker. | Create, move, remove and self-update reports; no false self-disposal writes; later patches address inserted rows; stable ids for anonymous entries; file/runtime conflict handling, patch-owned writes, shutdown results and nested expressions; cloning and config dumps agree with mounting. | | 4e | Complete vendor HMR's upgrade to 1.1.1, preserving required local path, readiness and explicit-watch behavior. | 2, 3, 4a | Partial V9: adopt upstream ordering and path handling where equivalent; retain explicit readiness and realpath gaps. Retain V1 (no YAML locale imports). | Three-stage module replacement, nested entries, disabled/removed entries, explicit watches and the existing local path cases. | -| 4f | Upgrade Include to its journal implementation, retaining the explicit watch registration already adopted in PR 2. | 2, 4c | Retire V8 (old refresh implementation) and V13 (`writeTask` type). Partial V14: replace the write queue; decide unsaved-error reporting. Retain the remaining V11 exports and V15 child-expression marker. | File/runtime conflict handling, patch-owned writes, shutdown results, nested expressions and one coordinated refresh under either HMR provider. | +| 4f | Register the journal Include through the explicit watch adopted in PR 2 and remove the remaining Include scans. | 2, 4c | Adapt V9's config-path matching to the journal Include. | One coordinated refresh per change under either HMR provider; profile watching unchanged. | Shared integration differences remain in every affected sync: V2 (manifests), V3 (TypeScript projects), V4/V10 (imports and Node-compatible modules), V5 (build overrides), V7 (API documentation), V16 (published Core source) and V17 (scope). Untouched packages retain them without edits. V6 only loses the stale-epoch guard already supplied upstream; its remaining ownership and cleanup guarantees stay. V15 replaces the old raw/effective-config and event implementation, but the Include child-expression behavior remains local. V22 is not upstreamed by this release, including its schema-bound `simplify` call. -After PR 0, PRs 1, 2, 3 and 4b have no implementation dependency on one another. Core depends on configuration preparation; it does not depend on Timer. Loader completion and vendor HMR can proceed independently after Core. Include journal adoption depends on Loader and the complete watch migration, not on the new vendor HMR replacement algorithm; PRs 4e and 4f can merge in either order. No single GitHub stack should impose a dependency between unrelated branches. Shared files such as the vendor manifest may still require merge-conflict resolution. +After PR 0, PRs 1, 2, 3 and 4b have no implementation dependency on one another. Core depends on configuration preparation; it does not depend on Timer. Loader completion and vendor HMR can proceed independently after Core. Include journal adoption is part of Loader completion; PR 4f only connects that Include to PR 2's explicit watches and does not depend on the new vendor HMR replacement algorithm, so PRs 4e and 4f can merge in either order. No single GitHub stack should impose a dependency between unrelated branches. Shared files such as the vendor manifest may still require merge-conflict resolution. ### Minimum Core compatibility slice @@ -62,9 +62,9 @@ Module resolution, structured tree-change reporting, journal reconciliation and ### Temporary compatibility code -PR 4c changes tree mutation producers to `commit(change)` while the old Include remains installed. That Include can implement `commit(change)` by invoking its existing `write()` operation; it deliberately ignores the detailed change report until the journal replaces the writer. In-memory trees, including `PresetTree`, implement a no-op commit. This avoids maintaining two mutation-reporting protocols in Loader. PR 4f deletes the Include writer adapter and old queue. +PR 4c changes tree mutation producers to `commit(change)` and adopts the journal Include in the same PR: upstream `patch.ts` clones file and inserted entries, while the old Include relied on sharing entry objects with the running tree for its cached data and generated ids. In-memory trees, including `PresetTree`, implement a no-op commit. Until PR 4f, both HMR providers find the journal Include through their existing scans and call its `refresh()`. -PR 4c imports `patch.ts` together with its `journal.ts` helpers and their Loader `EntryChange` dependency, even though Include does not start runtime journaling yet. The public patch export may remain an alias; it must not retain a second algorithm. Every temporary divergence has an owning introduction PR and a named removal PR. No runtime version detection or second Core event protocol is planned. +The public patch export may remain an alias; it must not retain a second algorithm. Every temporary divergence has an owning introduction PR and a named removal PR. No runtime version detection or second Core event protocol is planned. PR 2 moves Include refresh ownership in one step: both HMR providers expose `watch()`, the existing Include registers its callback, and both providers stop their equivalent Include scans. The upstream HMR source no longer performs that scan, so importing it before migrating the Include consumer would lose config refresh. Moving the complete watch concern first avoids a temporary scan adapter and makes the later module-replacement and persistence decisions independent. @@ -73,8 +73,8 @@ PR 2 moves Include refresh ownership in one step: both HMR providers expose `wat | Decision | Owning PR | Proposal and unresolved choice | |---|---|---| | Active effective config | 1, 4a | Store or expose the object actually supplied to the current instance. Resolving a candidate through `internal/config` must not overwrite it. Decide the publication point and invalidation rules before switching Core; a WeakMap alone does not establish those rules. | -| Persistence outcome | 4f | Prefer the journal's retry and reconciliation mechanism. Decide how explicit callers observe modifications still unsaved at shutdown. Do not require a historical failure to remain fatal after a successful retry merely because the old test expects it. | -| Nested config interpolation | 4a, 4f | Preserve child-owned expression evaluation. Keep the existing Include marker initially; any refinement that permits interpolation of Include's own fields while excluding child lists is a separate behavior change. | +| Persistence outcome | 4c | Use the journal's retry and reconciliation mechanism. Explicit `flush()` and `stop()` reject while changes remain unsaved; later successful retries clear previous errors. Keep this local adaptation minimal. | +| Nested config interpolation | 4a, 4c | Preserve child-owned expression evaluation. Keep the existing Include marker initially; any refinement that permits interpolation of Include's own fields while excluding child lists is a separate behavior change. | | HMR replacement failure | 4e | Adopt upstream behavior for vendor HMR. Preserve DSH HMR's current source-reload rollback policy in this series; replacing that policy requires its own proposal and tests. | | Effect cleanup guarantees | 4a | Retain guarantees not supplied by rc.11. Further upstreaming or retirement is separate work, not a prerequisite for reaching the release baseline. | diff --git a/vendor/include/README.md b/vendor/include/README.md index d669bf6736..2d9db4965f 100644 --- a/vendor/include/README.md +++ b/vendor/include/README.md @@ -46,4 +46,6 @@ Patches can insert entries or override fields on entries with a matching `id`. `applyEntryPatches(data, patches, warn)` uses the same upstream algorithm as mounting. It clones file entries even without patches and clones inserted entry lists before indexing them, so later patches can address inserted rows without mutating those input lists. `entryListSchema` exposes Include’s YAML dialect to configuration tools. Nested entry expressions remain literal until their owning Loader entry evaluates them. -Tree mutations arrive through `commit(change)` and schedule the existing debounced writer. The imported journal helpers support patch ownership; runtime file reconciliation still uses `refresh()` and the existing write queue. +Tree mutations arrive through `commit(change)` and enter a journal. Include merges them with the latest file before writing; file edits win conflicts on the same fields and produce a diagnostic. Patch-owned fields and inserted entries update the parent Include’s patches instead of the included file. Invalid, missing or read-only files leave unsaved changes in memory. + +`flush()` waits for this Include’s pending file operations and tree reconciliation, retries outstanding writes, and rejects if changes remain unsaved. Successful persistence clears previous errors. `refresh()` rereads the file and exposes the same completion result. To persist patch-owned changes, flush the child and then its parent. `stop()` makes a final attempt and always stops child entries, even when it rejects; ordinary Fiber disposal still contains cleanup errors. File writes use an atomic rename with bounded retries for transient destination locks. diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index dd6a86fa21..5dd86d50ec 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -1,11 +1,17 @@ -import { EntryGroup, EntryTree, isJsExpr, type EntryChange, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { type EntryChange, type EntryOptions, EntryTree, EntryGroup, isJsExpr } from '@deepseek-ai/cordis-plugin-loader' import { Context, Service } from '@deepseek-ai/cordis' -import { extname } from 'node:path' -import { access, constants, readFile, rename, writeFile } from 'node:fs/promises' -import { setTimeout as delay } from 'node:timers/promises' +import { deepEqual } from '@deepseek-ai/cosmokit' +import { basename, dirname, extname, join } from 'node:path' +import { access, constants, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { setTimeout as sleep } from 'node:timers/promises' import { fileURLToPath, pathToFileURL } from 'node:url' import * as yaml from 'js-yaml' -import { applyPatches, type PatchOptions } from './patch.ts' +import { applyJournal, flatten, type Journal, merge, reconcile, record } from './journal.ts' +import { applyPatches, ensureIds, ensureInsertIds, PatchIndex, type PatchOptions, routeJournal } from './patch.ts' +/** Shared patch semantics used by config dumps and mounting. */ +export { applyPatches as applyEntryPatches } from './patch.ts' +/** Serialized entry patch options. */ +export type { PatchOptions } from './patch.ts' const JsExpr = new yaml.Type('tag:yaml.org,2002:js', { kind: 'scalar', @@ -15,235 +21,444 @@ const JsExpr = new yaml.Type('tag:yaml.org,2002:js', { represent: (data) => data['__jsExpr'], }) -/** - * The entry-list YAML dialect: `!!js` scalars round-trip as expression nodes - * the Loader evaluates at entry activation. Exported so config tooling - * (`dsh --dump-config`) parses and prints exactly the dialect this include - * mounts. - */ +/** YAML dialect preserving child-owned JavaScript expression nodes. */ export const entryListSchema = yaml.JSON_SCHEMA.extend(JsExpr) - const schema = entryListSchema -const writable: Record = { +const types: Record = { '.json': 'application/json', '.yaml': 'application/yaml', '.yml': 'application/yaml', } -const supported = new Set(Object.keys(writable)) +type ConfigStage = 'read' | 'parse' | 'validate' -const WRITE_RETRY_LIMIT = 10 -const WRITE_RETRY_DELAY_MS = 50 - -function retryableWriteError(error: unknown): boolean { - const code = (error as NodeJS.ErrnoException | null)?.code - return code === 'EACCES' || code === 'EBUSY' || code === 'EPERM' +interface Snapshot { + content: string + data: EntryOptions[] } -/** Apply the upstream patch algorithm to detached entry data for mounting or config dumps. */ -export { applyPatches as applyEntryPatches } from './patch.ts' -/** Serialized entry patch options. */ -export type { PatchOptions } from './patch.ts' +export class ConfigFileError extends Error { + name = 'ConfigFileError' + + constructor(public stage: ConfigStage, path: string, cause: unknown) { + super(`failed to ${stage} config file ${path}`, { cause }) + } +} + +/** The file changed under us between the pre-check and the rename. */ +class StaleWriteError extends Error { + name = 'StaleWriteError' +} + +interface Anonymous { + parent: string | null + options: EntryOptions +} + +function isENOENT(error: unknown) { + return (error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT' +} + +const RENAME_RETRIES = 10 +const RENAME_BACKOFF = 20 + +/** + * On Windows a rename over a file that another process holds open (typically + * an antivirus scanning what we just wrote) fails with one of these; the lock + * is normally gone within milliseconds. A bounded retry is cheap enough to + * apply on every platform. + */ +function isTransientRenameError(error: unknown) { + const code = (error as NodeJS.ErrnoException | undefined)?.code + return code === 'EACCES' || code === 'EPERM' || code === 'EBUSY' +} -/** Config namespace for the file-backed include loader. */ export namespace Include { - /** Config for a file-backed loader subtree. */ export interface Config { - /** YAML or JSON path resolved from `ctx.baseUrl`. */ path: string - /** Entry list written when the file does not already exist. */ initial?: any[] - /** Runtime patches applied after reading the file. */ patches?: PatchOptions[] - /** Enables loader apply/reload/unload logs for this subtree. */ enableLogs?: boolean } } -/** Loader entry tree backed by a YAML or JSON file. */ export class Include extends EntryTree { static inject = ['loader'] - // Tree-carrier marker (the Group plugin declares the same): this config is - // entry and patch lists, so the Loader's `internal/config` interpolation - // keeps it literal — a `!!js` expression inside a nested row's config - // belongs to that row's fiber, resolving lazily in the row's own context. - // Include's own fields (`path`, `enableLogs`) therefore stay literal too. + // Child entries evaluate their expressions against their own injections. static readonly [EntryGroup.key] = true public filename: string - private type?: string - private readonly: boolean - private content?: string - private data?: EntryOptions[] - private writeTask?: NodeJS.Timeout | undefined - private pendingWrite?: EntryOptions[] - private writeQueue: Promise = Promise.resolve() + private type: string + + /** The last file state we read or wrote. */ + private cache?: Snapshot + /** Tree-side changes that have not reached the file yet. */ + private journal: Journal = new Map() + /** Ids we assigned to entries the file leaves anonymous. */ + private anonymous = new Map() + + private dirtyRead = false + private dirtyWrite = false + private _draining?: Promise + + /** Only the latest intended tree is ever applied; older ones are skipped. */ + private _pendingTree?: EntryOptions[] + private _applyTask?: Promise + + private _disposed = false + private _seq = 0 + private persistenceError: unknown constructor(ctx: Context, public config: Include.Config) { super(ctx) this.enableLogs = config.enableLogs ?? ctx.fiber.entry?.parent.tree.enableLogs ?? false this.filename = fileURLToPath(new URL(this.config.path, this.ctx.baseUrl)) const ext = extname(this.filename) - if (!supported.has(ext)) { + if (!types[ext]) { throw new Error(`extension "${ext}" not supported`) } - this.type = writable[ext] - this.readonly = !this.type + this.type = types[ext] this.ctx.baseUrl = new URL('.', pathToFileURL(this.filename)).href + ensureInsertIds(config.patches, id => !!this.store[id]) ctx.on('internal/update', (config, next) => { if (config.path !== this.config.path) return next() this.config = config - return this.root.update(this.applyPatches(this.data!, config.patches)).catch((error) => { - this.ctx.logger.warn('config update at %C failed', this.filename) - this.ctx.logger.warn(error) - }) + ensureInsertIds(config.patches, id => !!this.store[id]) + this._scheduleApply() + return this._applyTask }) } - private async checkAccess() { - if (!this.type) return - try { - await access(this.filename, constants.W_OK) - } catch { - this.readonly = true - } - } - - private async read(forced = false) { - const content = await readFile(this.filename, 'utf8') - if (!forced && this.content === content) return false - let data: any - if (this.type === 'application/yaml') { - data = yaml.load(content, { schema: entryListSchema }) - } else if (this.type === 'application/json') { - data = JSON.parse(content) - } else { - const module = await import(/* @vite-ignore */ this.filename) - data = module.default || module - } - // An empty or truncated file (common mid-edit: editors and `sed -i` write - // through temp states) parses to `undefined`, not an error; reject every - // non-array shape here so callers see one "invalid file" signal. Content - // and data commit only on success, so an edit that is later reverted to - // the exact last good content correctly reads as "unchanged". - if (!Array.isArray(data)) { - throw new TypeError(`config file must be a top-level array of entries: ${this.filename}`) - } - this.content = content - this.data = data - await this.checkAccess() - return true - } - - private applyPatches(data: EntryOptions[], patches = this.config.patches): EntryOptions[] { - return applyPatches(data, patches, (message, ...args) => { - this.ctx.root.logger?.('loader').warn(message, ...args) - }) + private warn = (format: string, ...args: any[]) => { + this.ctx.root.logger?.('loader').warn(format, ...args) } async* [Service.init]() { try { - await this.read() + await this._read() } catch (error) { - // Only a missing file falls back to `initial` (or the not-found error): - // an existing-but-invalid file must fail loud with its real parse error, - // never be mislabelled as absent or silently overwritten. - if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') throw error - if (this.config.initial) { - await this._writeFile(this.config.initial as any) - await this.read(true) - } else { + // only a missing file falls back to `initial` + const cause = error instanceof ConfigFileError && error.stage === 'read' ? error.cause : undefined + if (!isENOENT(cause)) throw error + if (!this.config.initial) { throw new Error(`config file not found: ${this.filename}`) } + const initial = structuredClone(this.config.initial) as EntryOptions[] + ensureIds(initial, id => !!this.store[id]) + await this._writeText(this.dump(initial)) + await this._read() } yield () => this.stop() - await this.root.update(this.applyPatches(this.data!)) + await this._applyTask } async stop() { + this._disposed = true + this._pendingTree = undefined + // Flush what the user last did before the tree goes away; this is also the + // last attempt for changes a failed write left in the journal. + if (this.journal.size) { + this.dirtyWrite = true + this._drain() + } try { - await this.flushWrite() + await this.flush() } finally { this.root.stop() - await this.flushWrite() } } /** - * Re-read the file and refresh child entries when content changed. An - * unreadable or unparsable file logs a warning and keeps the last good - * tree: a hot-reload of a live app must never take the process down. + * Resolves once every pending read and write has been processed and the + * tree reflects the outcome. Retries pending writes and rejects while changes remain unsaved. */ - async refresh() { - try { - if (!await this.read()) return - await this.root.update(this.applyPatches(this.data!)) - } catch (error) { - this.ctx.logger.warn('config reload at %C failed; keeping the running tree', this.filename) - this.ctx.logger.warn(error) + async flush() { + if (this.journal.size && !this._draining) { + this.dirtyWrite = true + this._drain() } + while (this._draining || this._applyTask) { + await this._draining + await this._applyTask + } + if (this.journal.size) { + throw this.persistenceError ?? new Error(`config file ${this.filename} has ${this.journal.size} unsaved change(s)`) + } + this.persistenceError = undefined } - private async _writeFile(config: EntryOptions[]) { - if (this.readonly) { - throw new Error(`cannot overwrite readonly config`) - } - if (this.type === 'application/yaml') { - this.content = yaml.dump(config, { schema }) - } else if (this.type === 'application/json') { - this.content = JSON.stringify(config, null, 2) - } - await writeFile(this.filename + '.tmp', this.content!) - for (let retry = 0; ; retry++) { - try { - await rename(this.filename + '.tmp', this.filename) - return - } catch (error) { - if (!retryableWriteError(error) || retry >= WRITE_RETRY_LIMIT) throw error - await delay((retry + 1) * WRITE_RETRY_DELAY_MS) + /** Re-read the file and reconcile the tree with it. */ + refresh() { + this.dirtyRead = true + this._drain() + return this.flush() + } + + /** Called synchronously by the loader once a tree-side change is in place. */ + commit(change: EntryChange) { + this.context.emit('loader/config-update') + const parent = change.group === this.root ? null : change.group.ctx.fiber.entry!.options.id + record(this.journal, change, parent) + this.dirtyWrite = true + this._drain() + } + + private _drain() { + if (this._draining) return + this._draining = this._loop().finally(() => { + this._draining = undefined + }) + } + + private async _loop() { + while (this.dirtyRead || this.dirtyWrite) { + if (this.dirtyRead) { + this.dirtyRead = false + try { + await this._read() + } catch (error) { + // Half-written files are routine while the user is editing, hence + // only a warning. With the file in an unknown state there is nothing + // safe to write: unless another read is due, stop here and leave + // `dirtyWrite` set for the next trigger. + this.persistenceError = error + this.ctx.logger.warn(error) + if (this.dirtyRead) continue + return + } + } + if (this.dirtyWrite) { + this.dirtyWrite = false + try { + await this._write() + } catch (error) { + this.persistenceError = error + this.ctx.logger.error(error) + } } } } - private writeFile(config: EntryOptions[]) { - clearTimeout(this.writeTask) - this.pendingWrite = config - this.writeTask = setTimeout(() => { - void this.flushWrite() - }, 0) + private async _read() { + let content: string + try { + content = await readFile(this.filename, 'utf8') + } catch (error) { + throw new ConfigFileError('read', this.filename, error) + } + if (content === this.cache?.content) return + + let data: any + try { + data = this.type === 'application/yaml' + ? yaml.load(content, { schema }) + : JSON.parse(content) + } catch (error) { + throw new ConfigFileError('parse', this.filename, error) + } + // An empty or truncated file (routine mid-edit) parses to `undefined`, so + // every non-array shape is rejected here as one "invalid file" signal. + if (!Array.isArray(data)) { + throw new ConfigFileError('validate', this.filename, new TypeError('config file must be a top-level array')) + } + + this._assignIds(data) + + if (this.cache) { + const index = new PatchIndex(this.config.patches ?? []) + const conflicts = reconcile(this.journal, flatten(this.cache.data), flatten(data), (id, key) => index.fileOwned(id, key)) + for (const { id, reason } of conflicts) { + this.ctx.logger.error('config conflict in %C: entry %C %s; file wins', this.filename, id, reason) + } + } + + this.cache = { content, data } + this._scheduleApply() + // the file changed while tree-side changes were pending: write the merge + if (this.journal.size) this.dirtyWrite = true } - private flushWrite(): Promise { - clearTimeout(this.writeTask) - this.writeTask = undefined - const config = this.pendingWrite - this.pendingWrite = undefined - if (config === undefined) return this.writeQueue - const run = this.writeQueue.then( - () => this._writeFile(config), - () => this._writeFile(config), - ) - this.writeQueue = run - void run.catch((error) => { - this.ctx.root.logger?.('loader').warn('failed to write config file %C', this.filename) - this.ctx.root.logger?.('loader').warn(error) + private async _write() { + if (!this.cache || !this.journal.size) return + const current = await readFile(this.filename, 'utf8').catch((error) => { + if (isENOENT(error)) return undefined + throw error }) - return run + if (current === undefined) { + this.ctx.logger.warn('config file %C is missing, %d pending change(s) kept in memory', this.filename, this.journal.size) + return + } + if (current !== this.cache.content) { + // someone else wrote the file: merge first, then come back + this.dirtyRead = true + this.dirtyWrite = true + return + } + + // Snapshot and swap: changes reported while we are writing belong to the + // next round. + const batch = this.journal + this.journal = new Map() + const restore = () => { + for (const [id, record] of this.journal) merge(batch, id, record) + this.journal = batch + } + + const data = structuredClone(this.cache.data) + const patches = structuredClone(this.config.patches ?? []) + if (routeJournal(batch, data, patches, this.warn)) { + // Take the new patches now so that the tree scheduled below already + // reflects them; the round trip through the parent only persists them. + this.config = { ...this.config, patches } + if (this.ctx.fiber.entry) { + // Not awaited: the returned promise is the tree reconciliation, and + // the drain loop must not block on fibers. + try { + Promise.resolve(this.ctx.fiber.update(this.config)).catch((error) => { + this.ctx.logger.error(error) + }) + } catch (error) { + this.ctx.logger.error(error) + } + } else { + this.ctx.logger.warn('patches of %C changed at runtime but cannot be persisted', this.filename) + } + } + + // nothing for the file itself (patch-only changes, or ids we assigned to + // anonymous entries that are already part of the cache) + if (deepEqual(data, this.cache.data)) { + this._scheduleApply() + return + } + const text = this.dump(data) + + try { + await access(this.filename, constants.W_OK) + } catch { + restore() + this.ctx.logger.warn('config file %C is read-only, %d pending change(s) kept in memory', this.filename, this.journal.size) + return + } + + try { + await this._writeText(text, this.cache.content) + } catch (error) { + restore() + if (error instanceof StaleWriteError) { + this.dirtyRead = true + this.dirtyWrite = true + return + } + this.ctx.logger.warn('failed to write config file %C, %d pending change(s) kept in memory', this.filename, this.journal.size) + throw error + } + this.cache = { content: text, data } + this._scheduleApply() } - /** Forward tree mutations to the debounced writer until journal adoption. */ - commit(_change: EntryChange) { - this.write() + /** + * Write through a temp file and a rename, so that no reader ever sees a + * truncated file. When `expected` is given, the rename (and every retry of + * it) only goes ahead while the file still holds that content. + */ + private async _writeText(text: string, expected?: string) { + const tmp = join(dirname(this.filename), `.${basename(this.filename)}.${process.pid}.${this._seq++}.tmp`) + try { + await writeFile(tmp, text) + for (let attempt = 0; ; attempt++) { + if (expected !== undefined) { + const current = await readFile(this.filename, 'utf8').catch((error) => { + if (isENOENT(error)) return undefined + throw error + }) + if (current !== expected) throw new StaleWriteError() + } + try { + await rename(tmp, this.filename) + return + } catch (error) { + if (attempt >= RENAME_RETRIES || !isTransientRenameError(error)) throw error + } + await sleep(RENAME_BACKOFF * (attempt + 1)) + } + } catch (error) { + await rm(tmp, { force: true }).catch(() => {}) + throw error + } } - /** Schedule a write of the current root entry data. */ - write() { - this.context.emit('loader/config-update') - return this.writeFile(this.root.data) + private dump(data: EntryOptions[]) { + return this.type === 'application/yaml' + ? yaml.dump(data, { schema }) + : JSON.stringify(data, null, 2) + } + + /** + * Entries the file leaves anonymous get an id here, before the loader sees + * them. One that looks exactly like an entry we named on the previous read + * keeps that id, so editing a neighbor does not restart it; anything else + * gets a fresh id. Assigned ids stay in memory until the next write. + */ + private _assignIds(data: EntryOptions[]) { + const used = new Set() + const pending: { options: EntryOptions; parent: string | null }[] = [] + const walk = (entries: EntryOptions[], parent: string | null) => { + for (const options of entries) { + if (options.id) { + used.add(options.id) + } else { + pending.push({ options, parent }) + } + if (options.group && Array.isArray(options.config)) { + walk(options.config, options.id ?? null) + } + } + } + walk(data, null) + + const previous = this.anonymous + this.anonymous = new Map() + for (const { options, parent } of pending) { + let id: string | undefined + for (const [candidate, info] of previous) { + if (info.parent !== parent || !deepEqual(info.options, options)) continue + id = candidate + previous.delete(candidate) + break + } + if (!id) { + do { + id = Math.random().toString(16).slice(2, 10) + } while (used.has(id) || this.store[id]) + } + used.add(id) + this.anonymous.set(id, { parent, options: structuredClone(options) }) + const rest = { ...options } + for (const key of Object.keys(options)) delete options[key] + Object.assign(options, { id }, rest) + } + } + + private _scheduleApply() { + if (this._disposed || !this.cache) return + const tree = applyPatches(this.cache.data, this.config.patches, this.warn) + this._pendingTree = applyJournal(tree, this.journal, undefined, this.warn) + this._applyTask ??= (async () => { + try { + while (this._pendingTree) { + const tree = this._pendingTree + this._pendingTree = undefined + await this.root.update(tree) + } + } finally { + this._applyTask = undefined + } + })() } } diff --git a/vendor/loader/README.md b/vendor/loader/README.md index 038eaa844b..ae7e82a6f2 100644 --- a/vendor/loader/README.md +++ b/vendor/loader/README.md @@ -64,8 +64,8 @@ Node module internals are classified by their available APIs, including the v1 A ## Update completion -Entry updates await `loader/patch-context`, the Fiber update waterfall and disabled-entry disposal. Combined context/config changes refresh required bindings before restarting. Group and Include updates expose completion of their child updates; file persistence still follows Include’s debounced writer. +Entry updates await `loader/patch-context`, the Fiber update waterfall and disabled-entry disposal. Combined context/config changes refresh required bindings before restarting. Group and Include updates expose completion of their child updates; file persistence completes through Include’s `flush()`. ## Tree mutation reports -`EntryTree.commit(change)` receives each programmatic mutation synchronously after options and group membership change. A missing `legacy` means creation; missing `options` means removal. Updates carry both, with `from` naming the previous group after a move. Explicit Fiber saves report their simplified configuration. File-driven reconciliation, parent teardown and Loader removal do not produce spurious self-disable reports. In-memory trees implement a no-op; the existing Include writer persists the current tree. Entry ids cannot contain `:`, which separates nested tree paths. +`EntryTree.commit(change)` receives each mutation made through `EntryTree` methods synchronously after options and group membership change; file-driven `EntryGroup` reconciliation does not report. A missing `legacy` means creation; missing `options` means removal. Updates carry both, with `from` naming the previous group after a move within the tree; a move into another tree reports a removal to the source tree and a creation to the destination tree. Explicit Fiber saves report their simplified configuration. File-driven reconciliation, parent teardown and Loader removal do not produce spurious self-disable reports. In-memory trees implement a no-op; Include journals the reports and reconciles them with file edits. Entry ids cannot contain `:`, which separates nested tree paths. diff --git a/vendor/loader/src/config/tree.ts b/vendor/loader/src/config/tree.ts index 34a2b1c731..2d99fb68d8 100644 --- a/vendor/loader/src/config/tree.ts +++ b/vendor/loader/src/config/tree.ts @@ -11,7 +11,13 @@ import { createResolve } from '../resolve.ts' * - `options` absent: the entry was removed from `group`. * - `legacy` absent: the entry was created in `group`. * - both present: the entry was updated and now lives in `group`; `from` is - * the group it left if the update moved it. + * the group it left if the update moved it within the same tree. A move to + * another tree reports a removal to the source tree and a creation to the + * destination tree. + * + * `id` is local to the receiving tree; `Entry.id` prefixes it with the parent + * tree's path. Only `EntryTree` mutation methods and explicit Fiber saves + * report changes; file-driven `EntryGroup` reconciliation does not. */ export interface EntryChange { id: string @@ -66,6 +72,12 @@ export abstract class EntryTree { } } + /** + * Assign a random tree-local id when `options.id` is absent. + * @param options - Entry options; a generated id is written to `options.id`. + * @returns The existing or generated id. + * @throws When an explicit id contains `EntryTree.sep`, which separates nested tree paths. + */ ensureId(options: Partial) { if (!options.id) { do { @@ -132,6 +144,9 @@ export abstract class EntryTree { const task = entry.update(options, false, true) if (entry.parent.tree !== source.tree) { source.tree.commit({ id: legacy.id, group: source, legacy }) + // The destination tree has no previous row, so it receives a creation. + entry.parent.tree.commit({ id: legacy.id, group: entry.parent, options: entry.options }) + return task } const from = entry.parent === source ? undefined : source entry.parent.tree.commit({ id: legacy.id, group: entry.parent, from, options: entry.options, legacy }) From eef0b3e688c8edfd9da2ce60c7280637f0cbe5c7 Mon Sep 17 00:00:00 2001 From: Shigma Date: Wed, 28 Oct 2026 19:40:56 +0800 Subject: [PATCH 06/32] docs(upgrade-guide): cover Loader entry tree reports and Include journal --- .../cordis-loader-entry-trees/guide.i18n.yaml | 16 ++++++++++++ .../cordis-loader-entry-trees/guide.md | 25 +++++++++++++++++++ .../cordis-loader-entry-trees/guide.zh.md | 25 +++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.md create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.zh.md diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.i18n.yaml new file mode 100644 index 0000000000..763bc61176 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.md +/: + en: 4b668dd74d0315b9 + zh: a1885d2bbb4ef33e +/loader-entry-tree-reports-and-include-persistence: + en: dc27815908c90cd9 + zh: e9cc077d4d3fdbec +/loader-entry-tree-reports-and-include-persistence/change: + en: 2436912b9554b6dd + zh: ff4582ebc4672662 +/loader-entry-tree-reports-and-include-persistence/migration: + en: cd7db13e17bfd339 + zh: be43c51c78e55439 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.md new file mode 100644 index 0000000000..764cc06772 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.md @@ -0,0 +1,25 @@ +--- +kind: upgrade-guide +description: "Loader entry trees report mutations through commit(change), reject ids containing a colon, and Include persists them through a journal." +--- + +# Loader entry tree reports and Include persistence + +English | [中文](guide.zh.md) + +## Change + +`@deepseek-ai/cordis-plugin-loader` follows Loader 1.0.0-rc.8, and `@deepseek-ai/cordis-plugin-include` follows Include 1.1.1. This affects `cordis.yml` and patch files, code that subclasses `EntryTree`, and code that calls `applyEntryPatches` or relies on Include writes. + +- `EntryTree.write()` is removed. Subclasses implement `commit(change: EntryChange)`, which receives each create, update, move, removal and explicit Fiber save synchronously. In-memory trees implement a no-op. +- An entry `id` containing `:` is rejected, because `:` separates nested tree paths. A configured row such as `id: foo:bar` previously mounted; it is now logged as an error and skipped, and a programmatic `create()` throws. +- `applyEntryPatches(data, patches, warn)` always returns cloned entries, including without patches, and clones inserted rows. Callers can no longer mutate the input through the result. +- Include persists runtime changes through a journal reconciled with the latest file. File edits win conflicting fields and are logged as conflicts. Changes to patch-owned fields and inserted rows update the parent Include's patches. `flush()` waits for pending writes and rejects while changes remain unsaved; `refresh()` and `stop()` return the same result. + +## Migration + +1. Rename entry ids that contain `:` in `cordis.yml`, profile patches and `--patch` overlays, for example `foo:bar` to `foo-bar`, and update patches that address them. +2. In `EntryTree` subclasses, replace `write()` with `commit(change)`. Persist from `change.options` and `change.legacy`: a missing `legacy` is a creation and a missing `options` is a removal. +3. Treat the `applyEntryPatches` result as the only copy to modify. +4. Where code needs a runtime change on disk, await `include.flush()`; for patch-owned changes, flush the child Include and then its parent. +5. Start the application and confirm that no `entry id must not contain ":"` errors are logged and that runtime edits appear in the configuration file after `flush()`. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.zh.md new file mode 100644 index 0000000000..74cd3f6ec4 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.zh.md @@ -0,0 +1,25 @@ +--- +kind: upgrade-guide +description: "Loader 条目树通过 commit(change) 报告变更并拒绝含冒号的 id,Include 通过 journal 持久化这些变更。" +--- + +# Loader 条目树变更报告与 Include 持久化 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/cordis-plugin-loader` 跟随 Loader 1.0.0-rc.8,`@deepseek-ai/cordis-plugin-include` 跟随 Include 1.1.1。受影响的是 `cordis.yml` 与 patch 文件、继承 `EntryTree` 的代码,以及调用 `applyEntryPatches` 或依赖 Include 写盘的代码。 + +- 移除了 `EntryTree.write()`。子类改为实现 `commit(change: EntryChange)`,它会同步收到每一次创建、更新、移动、删除和显式 Fiber 保存。内存树实现为空操作。 +- 含 `:` 的条目 `id` 会被拒绝,因为 `:` 用于分隔嵌套树路径。此前 `id: foo:bar` 这样的配置行可以挂载;现在会记录一条错误并跳过该行,程序化调用 `create()` 会抛出异常。 +- `applyEntryPatches(data, patches, warn)` 总是返回克隆后的条目,没有 patch 时也一样,插入行同样会被克隆。调用方不能再通过结果修改输入。 +- Include 通过与最新文件协调的 journal 持久化运行时变更。文件编辑在字段冲突时优先,并记录为冲突。对 patch 所拥有字段和插入行的修改会写入父 Include 的 patch。`flush()` 等待待处理的写入,并在仍有未保存变更时拒绝;`refresh()` 和 `stop()` 返回相同的结果。 + +## 迁移 + +1. 在 `cordis.yml`、profile patch 和 `--patch` 覆盖层中重命名含 `:` 的条目 id,例如把 `foo:bar` 改为 `foo-bar`,并同步更新引用它们的 patch。 +2. 在 `EntryTree` 子类中把 `write()` 换成 `commit(change)`。根据 `change.options` 和 `change.legacy` 持久化:缺少 `legacy` 表示创建,缺少 `options` 表示删除。 +3. 只修改 `applyEntryPatches` 返回的结果。 +4. 需要运行时变更落盘时,await `include.flush()`;对于 patch 所拥有的变更,先 flush 子 Include,再 flush 其父 Include。 +5. 启动应用,确认没有记录 `entry id must not contain ":"` 错误,并且 `flush()` 之后运行时编辑已出现在配置文件中。 From 9a26b453c383762c2901ca778ff1853bfb74da36 Mon Sep 17 00:00:00 2001 From: Shigma Date: Thu, 29 Oct 2026 01:50:09 +0800 Subject: [PATCH 07/32] fix(cordis): accept colon-containing entry ids used by generated bundles --- .../cordis-loader-entry-trees/guide.i18n.yaml | 12 ++++++------ .../cordis-loader-entry-trees/guide.md | 12 +++++------- .../cordis-loader-entry-trees/guide.zh.md | 12 +++++------- scripts/cordis-entry-commit.spec.ts | 10 ++++++---- vendor/README.md | 3 ++- vendor/loader/README.md | 2 +- vendor/loader/src/config/tree.ts | 7 +++---- 7 files changed, 28 insertions(+), 30 deletions(-) diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.i18n.yaml index 763bc61176..81fc6c8fac 100644 --- a/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.i18n.yaml @@ -3,14 +3,14 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.md /: - en: 4b668dd74d0315b9 - zh: a1885d2bbb4ef33e + en: 02dfa517303f3206 + zh: abf9a22603299cc7 /loader-entry-tree-reports-and-include-persistence: en: dc27815908c90cd9 zh: e9cc077d4d3fdbec /loader-entry-tree-reports-and-include-persistence/change: - en: 2436912b9554b6dd - zh: ff4582ebc4672662 + en: 6ada19e725d5c04e + zh: efbc6183cfd094e1 /loader-entry-tree-reports-and-include-persistence/migration: - en: cd7db13e17bfd339 - zh: be43c51c78e55439 + en: 574f30ad53da1e66 + zh: 1b9914c4ead412f7 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.md index 764cc06772..566d87df9b 100644 --- a/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.md +++ b/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.md @@ -1,6 +1,6 @@ --- kind: upgrade-guide -description: "Loader entry trees report mutations through commit(change), reject ids containing a colon, and Include persists them through a journal." +description: "Loader entry trees report mutations through commit(change), and Include persists them through a journal." --- # Loader entry tree reports and Include persistence @@ -12,14 +12,12 @@ English | [中文](guide.zh.md) `@deepseek-ai/cordis-plugin-loader` follows Loader 1.0.0-rc.8, and `@deepseek-ai/cordis-plugin-include` follows Include 1.1.1. This affects `cordis.yml` and patch files, code that subclasses `EntryTree`, and code that calls `applyEntryPatches` or relies on Include writes. - `EntryTree.write()` is removed. Subclasses implement `commit(change: EntryChange)`, which receives each create, update, move, removal and explicit Fiber save synchronously. In-memory trees implement a no-op. -- An entry `id` containing `:` is rejected, because `:` separates nested tree paths. A configured row such as `id: foo:bar` previously mounted; it is now logged as an error and skipped, and a programmatic `create()` throws. - `applyEntryPatches(data, patches, warn)` always returns cloned entries, including without patches, and clones inserted rows. Callers can no longer mutate the input through the result. - Include persists runtime changes through a journal reconciled with the latest file. File edits win conflicting fields and are logged as conflicts. Changes to patch-owned fields and inserted rows update the parent Include's patches. `flush()` waits for pending writes and rejects while changes remain unsaved; `refresh()` and `stop()` return the same result. ## Migration -1. Rename entry ids that contain `:` in `cordis.yml`, profile patches and `--patch` overlays, for example `foo:bar` to `foo-bar`, and update patches that address them. -2. In `EntryTree` subclasses, replace `write()` with `commit(change)`. Persist from `change.options` and `change.legacy`: a missing `legacy` is a creation and a missing `options` is a removal. -3. Treat the `applyEntryPatches` result as the only copy to modify. -4. Where code needs a runtime change on disk, await `include.flush()`; for patch-owned changes, flush the child Include and then its parent. -5. Start the application and confirm that no `entry id must not contain ":"` errors are logged and that runtime edits appear in the configuration file after `flush()`. +1. In `EntryTree` subclasses, replace `write()` with `commit(change)`. Persist from `change.options` and `change.legacy`: a missing `legacy` is a creation and a missing `options` is a removal. +2. Treat the `applyEntryPatches` result as the only copy to modify. +3. Where code needs a runtime change on disk, await `include.flush()`; for patch-owned changes, flush the child Include and then its parent. +4. Start the application and confirm that runtime edits appear in the configuration file after `flush()`. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.zh.md index 74cd3f6ec4..fc38566b63 100644 --- a/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.zh.md +++ b/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.zh.md @@ -1,6 +1,6 @@ --- kind: upgrade-guide -description: "Loader 条目树通过 commit(change) 报告变更并拒绝含冒号的 id,Include 通过 journal 持久化这些变更。" +description: "Loader 条目树通过 commit(change) 报告变更,Include 通过 journal 持久化这些变更。" --- # Loader 条目树变更报告与 Include 持久化 @@ -12,14 +12,12 @@ description: "Loader 条目树通过 commit(change) 报告变更并拒绝含冒 `@deepseek-ai/cordis-plugin-loader` 跟随 Loader 1.0.0-rc.8,`@deepseek-ai/cordis-plugin-include` 跟随 Include 1.1.1。受影响的是 `cordis.yml` 与 patch 文件、继承 `EntryTree` 的代码,以及调用 `applyEntryPatches` 或依赖 Include 写盘的代码。 - 移除了 `EntryTree.write()`。子类改为实现 `commit(change: EntryChange)`,它会同步收到每一次创建、更新、移动、删除和显式 Fiber 保存。内存树实现为空操作。 -- 含 `:` 的条目 `id` 会被拒绝,因为 `:` 用于分隔嵌套树路径。此前 `id: foo:bar` 这样的配置行可以挂载;现在会记录一条错误并跳过该行,程序化调用 `create()` 会抛出异常。 - `applyEntryPatches(data, patches, warn)` 总是返回克隆后的条目,没有 patch 时也一样,插入行同样会被克隆。调用方不能再通过结果修改输入。 - Include 通过与最新文件协调的 journal 持久化运行时变更。文件编辑在字段冲突时优先,并记录为冲突。对 patch 所拥有字段和插入行的修改会写入父 Include 的 patch。`flush()` 等待待处理的写入,并在仍有未保存变更时拒绝;`refresh()` 和 `stop()` 返回相同的结果。 ## 迁移 -1. 在 `cordis.yml`、profile patch 和 `--patch` 覆盖层中重命名含 `:` 的条目 id,例如把 `foo:bar` 改为 `foo-bar`,并同步更新引用它们的 patch。 -2. 在 `EntryTree` 子类中把 `write()` 换成 `commit(change)`。根据 `change.options` 和 `change.legacy` 持久化:缺少 `legacy` 表示创建,缺少 `options` 表示删除。 -3. 只修改 `applyEntryPatches` 返回的结果。 -4. 需要运行时变更落盘时,await `include.flush()`;对于 patch 所拥有的变更,先 flush 子 Include,再 flush 其父 Include。 -5. 启动应用,确认没有记录 `entry id must not contain ":"` 错误,并且 `flush()` 之后运行时编辑已出现在配置文件中。 +1. 在 `EntryTree` 子类中把 `write()` 换成 `commit(change)`。根据 `change.options` 和 `change.legacy` 持久化:缺少 `legacy` 表示创建,缺少 `options` 表示删除。 +2. 只修改 `applyEntryPatches` 返回的结果。 +3. 需要运行时变更落盘时,await `include.flush()`;对于 patch 所拥有的变更,先 flush 子 Include,再 flush 其父 Include。 +4. 启动应用,确认 `flush()` 之后运行时编辑已出现在配置文件中。 diff --git a/scripts/cordis-entry-commit.spec.ts b/scripts/cordis-entry-commit.spec.ts index 912335fcec..e915af7b29 100644 --- a/scripts/cordis-entry-commit.spec.ts +++ b/scripts/cordis-entry-commit.spec.ts @@ -85,12 +85,14 @@ describe('Loader tree mutation reports', () => { expect(changes).toEqual([]) }) - it('rejects separator-containing ids before changing a tree', async () => { + it('mounts explicit ids containing the separator without nested-path resolution', async () => { const { loader, changes } = await fixture() const options: EntryOptions = { id: 'a:b', name: 'cordis:example' } - await expect(loader.create(options)).rejects.toThrow('entry id must not contain') - expect(loader.root.data).toEqual([]) - expect(changes).toEqual([]) + await expect(loader.create(options)).resolves.toBe('a:b') + await loader.await() + expect(loader.store['a:b']!.fiber!.state).toBe(FiberState.ACTIVE) + expect(changes).toEqual([{ id: 'a:b', group: loader.root, options }]) + expect(() => loader.resolve('a:b')).toThrow('cannot resolve entry a:b') }) }) diff --git a/vendor/README.md b/vendor/README.md index d39dfae8d8..2c6e377a2a 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -30,7 +30,7 @@ Intentionally **not** vendored (verified unused by this set): `reggol`, `@cordis ## Local modifications -Keep this log exhaustive — every divergence from upstream must be listed. Each entry has a stable ID: remove an entry when its divergence no longer exists, and never renumber or reuse IDs. The next new entry is V27. +Keep this log exhaustive — every divergence from upstream must be listed. Each entry has a stable ID: remove an entry when its divergence no longer exists, and never renumber or reuse IDs. The next new entry is V28. - **V1 · `hmr/src/index.ts`**: removed the `./locales/en-US.yml` / `./locales/zh-CN.yml` imports, the `.i18n({...})` call on the `Config` schema, and the `src/locales/` directory. Rationale: those imports require a runtime YAML loader hook (`@cordisjs/unyaml`) that we do not vendor; the i18n texts only localize config descriptions. - **V2 · All `package.json` files**: regenerated for Harness releases with scoped names, release versions, publication metadata, precise bundled-runtime and `lib/types/**/*.d.ts` / `.d.ts.map` file entries, source exports where applicable, and declaration metadata pointing at `lib/types`. References within the vendored set use `workspace:~`. HMR declares `esbuild` as a direct dev dependency for its imported `BuildFailure` type, and Loader requires `node-addon-require-builtin@^0.1.4` to match published app runtimes. - **V3 · All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references. @@ -50,6 +50,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. Each - **V24 · `timer/locale/{en,zh}.json` display metadata**: local English and Chinese titles and descriptions for the Settings plugin inventory. `timer/package.json` exports and publishes these locale resources. - **V25 · Loader lifecycle adaptations**: before a combined context/config update, Loader refreshes dependencies locally, which prevents activation against stale isolation bindings; disabled-entry updates await disposal. Failed fibers require an explicit update after correcting their input or dependencies. Core retains the effect hardening in V6; rc.11 owns dependency teardown ordering and superseded-activation handling. - **V26 · `loader/src/config/tree.ts` cross-tree move reports**: `EntryTree.update()` reports a move into another tree as a removal to the source tree and a creation, without `legacy` or `from`, to the destination tree. Upstream reports the destination as an update with `legacy`, which the Include journal records as a partial row for an id that its file does not contain. Covered by `scripts/cordis-entry-commit.spec.ts`. +- **V27 · `loader/src/config/tree.ts` explicit ids with `:`**: `ensureId()` accepts explicit ids containing `EntryTree.sep`; upstream rejects them. Generated bundles from `@deepseek-ai/dsh-creation-recommendations` prefix their row ids with `:`, and installed profiles already contain such rows. `resolve()` still splits ids on `:`, so these entries cannot be addressed by nested path. Covered by `scripts/cordis-entry-commit.spec.ts` and `packages/experimental/creation-recommendations/tests/loader-composition.spec.ts`. ### Resolver adaptations diff --git a/vendor/loader/README.md b/vendor/loader/README.md index ae7e82a6f2..0b17513be8 100644 --- a/vendor/loader/README.md +++ b/vendor/loader/README.md @@ -68,4 +68,4 @@ Entry updates await `loader/patch-context`, the Fiber update waterfall and disab ## Tree mutation reports -`EntryTree.commit(change)` receives each mutation made through `EntryTree` methods synchronously after options and group membership change; file-driven `EntryGroup` reconciliation does not report. A missing `legacy` means creation; missing `options` means removal. Updates carry both, with `from` naming the previous group after a move within the tree; a move into another tree reports a removal to the source tree and a creation to the destination tree. Explicit Fiber saves report their simplified configuration. File-driven reconciliation, parent teardown and Loader removal do not produce spurious self-disable reports. In-memory trees implement a no-op; Include journals the reports and reconciles them with file edits. Entry ids cannot contain `:`, which separates nested tree paths. +`EntryTree.commit(change)` receives each mutation made through `EntryTree` methods synchronously after options and group membership change; file-driven `EntryGroup` reconciliation does not report. A missing `legacy` means creation; missing `options` means removal. Updates carry both, with `from` naming the previous group after a move within the tree; a move into another tree reports a removal to the source tree and a creation to the destination tree. Explicit Fiber saves report their simplified configuration. File-driven reconciliation, parent teardown and Loader removal do not produce spurious self-disable reports. In-memory trees implement a no-op; Include journals the reports and reconciles them with file edits. Explicit entry ids may contain `:`; `resolve()` cannot address such entries by nested path, because `:` separates nested tree paths. diff --git a/vendor/loader/src/config/tree.ts b/vendor/loader/src/config/tree.ts index 2d99fb68d8..f8c05dfefe 100644 --- a/vendor/loader/src/config/tree.ts +++ b/vendor/loader/src/config/tree.ts @@ -73,18 +73,17 @@ export abstract class EntryTree { } /** - * Assign a random tree-local id when `options.id` is absent. + * Assign a random tree-local id when `options.id` is absent. Explicit ids + * may contain `EntryTree.sep`; `resolve()` and `resolveGroup()` cannot + * address such entries by nested path. * @param options - Entry options; a generated id is written to `options.id`. * @returns The existing or generated id. - * @throws When an explicit id contains `EntryTree.sep`, which separates nested tree paths. */ ensureId(options: Partial) { if (!options.id) { do { options.id = Math.random().toString(16).slice(2, 10) } while (this.store[options.id]) - } else if (options.id.includes(EntryTree.sep)) { - throw new Error(`entry id must not contain "${EntryTree.sep}": ${options.id}`) } return options.id! } From f45c48dce426c419cf5409e59ee8dd1749010fe9 Mon Sep 17 00:00:00 2001 From: Shigma Date: Thu, 29 Oct 2026 18:54:29 +0800 Subject: [PATCH 08/32] fix(cordis): transfer cross-tree moves and contain Include refresh rejections --- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +- ...26-08-08-native-windows-pull-request-ci.md | 2 +- ...08-08-native-windows-pull-request-ci.zh.md | 2 +- ...26-09-09-nontransactional-loader.i18n.yaml | 8 ++-- .../2026-09-09-nontransactional-loader.md | 8 ++-- .../2026-09-09-nontransactional-loader.zh.md | 8 ++-- docs/rescope.i18n.yaml | 4 +- docs/rescope.md | 2 +- docs/rescope.zh.md | 2 +- .../cordis-loader-entry-trees/guide.i18n.yaml | 4 +- .../cordis-loader-entry-trees/guide.md | 1 + .../cordis-loader-entry-trees/guide.zh.md | 1 + packages/boot/app-boot/src/index.ts | 2 +- packages/boot/hmr/src/index.ts | 5 ++- packages/boot/hmr/tests/coordination.spec.ts | 25 ++++++++++++ scripts/cordis-include-journal.spec.ts | 23 ++++++++++- scripts/cordis-vendor-hmr-include.spec.ts | 40 +++++++++++++++++++ vendor/README.md | 6 +-- vendor/hmr/src/index.ts | 3 +- vendor/include/README.md | 2 + vendor/loader/README.md | 2 +- vendor/loader/src/config/tree.ts | 8 ++++ 22 files changed, 131 insertions(+), 31 deletions(-) create mode 100644 scripts/cordis-vendor-hmr-include.spec.ts diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 8c2c549e30..73329cbb21 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -9,8 +9,8 @@ en: 3e229ecf646af866 zh: d358507b1e1d381f /agent-note-wine-and-native-windows-ci/decision: - en: 1125b44a170f70fb - zh: 37838b9da2003651 + en: 9038773113cf3553 + zh: 67875d393093c105 /agent-note-wine-and-native-windows-ci/alternatives-considered: en: 0e9a322cfc38d313 zh: f7bdeb86ba4fea1a diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index cb14645f13..c7c8be3f26 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -30,7 +30,7 @@ Native watchers use `canonicalizeWatchPath()` to realpath the deepest existing a Windows durable JSONL paths keep drive roots in native spelling and apply the extended-length namespace only to descendants and staging paths. The ACP teardown ladder uses real Node children, proves graceful and forced tiers with host-appropriate outcomes, and avoids claiming POSIX signal delivery on Windows. Executable fixtures provide `.cmd` shims and `PATHEXT` where the product accepts a bare command. Repository-cache helpers live inside the selected Git subpath so their declared `file:` dependencies expose command shims identically on Windows. The bundled installer exports pnpm's own workspace-ignore configuration, retains `PNPM_HOME` for pnpm data while removing that directory from lifecycle `PATH`, and prioritizes `.CMD` in `PATHEXT`; nested Git-package installation therefore cannot rejoin the enclosing workspace or let an inherited Windows pnpm executable outrank the transaction-owned wrapper. -Post-boot profile watcher setup proceeds only while the root fiber and Loader are both live. A concurrent setup error is contained only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. The [process-shutdown controller](../../../../apps/cli/src/process-shutdown.ts) lets a successful one-shot completion drain Node's remaining handles after root disposal, while teardown failure, deadline, and signal escalation retain forced exit. The vendored Include serializes debounced writes, retries only transient access or busy failures with bounded backoff, and observes every timer rejection. A terminal persistence failure remains on the queue and is rethrown to the teardown owner, while successful teardown drains the latest write. +Post-boot profile watcher setup proceeds only while the root fiber and Loader are both live. A concurrent setup error is contained only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. The [process-shutdown controller](../../../../apps/cli/src/process-shutdown.ts) lets a successful one-shot completion drain Node's remaining handles after root disposal, while teardown failure, deadline, and signal escalation retain forced exit. The vendored Include journals runtime changes and writes them through an atomic rename, retrying only transient access or busy failures with bounded backoff. `flush()` and `stop()` reject while changes remain unsaved, and a later successful flush clears the previous error. Shiki disables lazy TextMate-regex compilation and warms each boot grammar before user content enters the unchanged per-line tokenization budget, so scheduler contention cannot publish a partial highlighted stream. The Codex real-product fixture is pinned to stable 0.153.4 schemas and selects an actually advertised command tool and argument shape, preserving the provider-owned protocol while proving unattended rejection and whole-tree exit on each host. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index b999ac8ef8..12ffd61a2c 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -30,7 +30,7 @@ Wine 在 Linux 内核与区分大小写的 ext4 之上采用 hoisted 依赖布 Windows 的持久 JSONL 路径会保留驱动器根目录的原生写法,并仅对后代路径与暂存路径应用扩展长度命名空间。ACP(Agent Client Protocol)拆卸阶梯使用真实 Node 子进程,以符合宿主语义的结果证明优雅终止与强制终止两个层级,并避免声称 Windows 会交付 POSIX 信号。产品接受裸命令时,可执行 fixture 会提供 `.cmd` 包装脚本与 `PATHEXT`。repository-cache 辅助包位于所选 Git 子路径内,因此它们声明的 `file:` 依赖会在 Windows 上以相同方式暴露命令包装脚本。随附的安装器会导出 pnpm 自有的 workspace-ignore 配置,保留 `PNPM_HOME` 作为 pnpm 数据配置,同时从生命周期 `PATH` 中移除该目录,并在 `PATHEXT` 中优先选择 `.CMD`;因此,嵌套 Git 包安装既不会重新加入外层 workspace,也不会让继承的 Windows pnpm 可执行文件抢在事务持有的 wrapper 之前。 -启动后,只有根 fiber 与 Loader 均处于活跃状态时,系统才会继续设置 profile watcher。只有当同一次调用所记录的信号已取得关闭流程所有权时,系统才会隔离并发设置错误;无关 HMR 故障仍会响亮失败。[进程关闭控制器](../../../../apps/cli/src/process-shutdown.ts)会在根级 dispose 成功后让单次任务的正常完成流程排空 Node 剩余句柄,同时让拆卸失败、截止时间到期和信号升级继续强制退出。vendored Include 会串行化防抖写入,只对瞬时访问或忙碌故障执行有界退避重试,并确保每个由计时器触发的拒绝都得到观察。持久化最终失败后,该故障会保留在队列中,并重新抛给拆卸责任方;成功拆卸则会排空最新写入。 +启动后,只有根 fiber 与 Loader 均处于活跃状态时,系统才会继续设置 profile watcher。只有当同一次调用所记录的信号已取得关闭流程所有权时,系统才会隔离并发设置错误;无关 HMR 故障仍会响亮失败。[进程关闭控制器](../../../../apps/cli/src/process-shutdown.ts)会在根级 dispose 成功后让单次任务的正常完成流程排空 Node 剩余句柄,同时让拆卸失败、截止时间到期和信号升级继续强制退出。vendored Include 通过 journal 记录运行时变更,并以原子 rename 写入,只对瞬时访问或忙碌故障执行有界退避重试。仍有未保存变更时,`flush()` 和 `stop()` 会拒绝;之后一次成功的 flush 会清除先前的错误。 Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持不变的逐行 tokenization(词元化)预算前预热每种启动语法,从而避免调度器争用发布不完整的高亮流。Codex 真实产品 fixture 固定使用稳定版 0.153.4 schema,并选择实际提供的命令工具与对应参数形态;这样既保留由提供方负责的协议,也能在每种宿主上证明无人值守拒绝和整棵进程树退出。 diff --git a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.i18n.yaml index f00222dc57..fc8bf04b3d 100644 --- a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.i18n.yaml @@ -9,11 +9,11 @@ en: d4ab65a39cf4fd7c zh: 62144be9b4fd7956 /agent-note-keep-loader-mutations-non-transactional/decision: - en: 9b661159de63d421 - zh: 15697f468a90edb9 + en: 83268169e9436127 + zh: ce740df03e9fe24a /agent-note-keep-loader-mutations-non-transactional/alternatives-considered: - en: 72b871064c7ae315 - zh: a09e5168f8124c4c + en: 645db43a48b9cfc1 + zh: d03fabd1e82dadbd /agent-note-keep-loader-mutations-non-transactional/consequences: en: 100f687b9d498308 zh: 1c4bc2d989e43ec7 diff --git a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md index d37539cb3e..58ba37bd3c 100644 --- a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md +++ b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md @@ -12,19 +12,19 @@ Transactional config reload preserves an old plugin generation after a failed ed Revert the five commits in #932, resolving package moves and retaining independent later behavior. The reported merge commit belongs to the larger #936 dependency chain; reverting its first-parent diff would remove unrelated repository-plugin support. The [vendor ledger](../../../../vendor/README.md#local-modifications) records every retained source change against the unchanged pins. -Loader changes entry options eagerly. EntryGroup starts siblings concurrently and logs application failures; EntryTree waits for outstanding work without rejecting failed fibers. Neither restores a previous plugin or configuration. Include retains parse validation and patch reapplication, but plugin failures can leave a partially applied tree. +Loader changes entry options eagerly. EntryGroup starts siblings concurrently and logs application failures; EntryTree waits for outstanding work without rejecting failed fibers. Neither restores a previous plugin or configuration. Include keeps the running tree after an invalid file, but plugin failures can leave a partially applied tree. Application consumers own their completion checks. The CLI waits for its fallback HMR service before installing live patch watchers. The directory chooser checks the entries it mounts. The chooser and browser package runner capture the first fiber-disposal result before removing the entry, then await it before reporting teardown complete. Preset mounting waits for its subtree and reports import, activation, and missing-service failures. [App boot](../../../../packages/boot/app-boot/README.md) owns exact patch-file watching, activation audits, and partial-context cleanup. Web startup audits activation before printing a URL or opening the browser. These adaptations preserve existing consumer behavior after the reverse patch. -Fiber, Entry, and isolate keep their upstream update return behavior. App boot observes discarded restart promises through the existing `internal/update` waterfall and waits for fibers before auditing a patch reload. The detached import-completion observer handles both fiber outcomes; the fiber still retains its failure for an explicit audit. Durable Include writes drain before and after child removal so a later teardown write cannot erase an earlier terminal write failure. +Fiber, Entry, and isolate keep their upstream update return behavior. App boot observes discarded restart promises through the existing `internal/update` waterfall and waits for fibers before auditing a patch reload. The detached import-completion observer handles both fiber outcomes; the fiber still retains its failure for an explicit audit. Include persistence follows the journal adaptation in the vendor ledger: `flush()` and `stop()` reject while changes remain unsaved. -Two #932-specific vendor changes remain: awaited initial-file creation and forced rereading in Include, and Schemastery conditional exports. Restoring the pre-#932 debounced write/read sequence reproduces `ENOENT` in the missing-file initialization test. Keeping these two lines preserves the existing `initial` option without an application-side file writer or a second YAML serializer. Removing Schemastery exports reproduces `ERR_REQUIRE_ESM_RACE_CONDITION` while the Web preset suite boots: Node falls back to the CJS entry during concurrent ESM imports. The HMR injection decorators, conditional patch cloning, and update return values use the pre-#932 behavior. Explicit `workspace:` dependencies make the #932 workspace-link switch and dedicated lockfile check unnecessary. +One #932-specific vendor change remains: Schemastery conditional exports. Include 1.1.1 writes the `initial` file before reading it, so missing-file initialization no longer needs a local change. Removing Schemastery exports reproduces `ERR_REQUIRE_ESM_RACE_CONDITION` while the Web preset suite boots: Node falls back to the CJS entry during concurrent ESM imports. The HMR injection decorators and update return values use the pre-#932 behavior; patch cloning follows upstream Include. Explicit `workspace:` dependencies make the #932 workspace-link switch and dedicated lockfile check unnecessary. ## Alternatives considered **Keep transactional Loader updates.** They provide automatic recovery from a rejected plugin candidate, but retain the vendored lifecycle machinery being removed. Parse failures can be contained without plugin rollback. -**Restore every vendored file verbatim.** This would also remove lazy injected config evaluation, conditional disabled entries, lifecycle disposal fixes, durable writes, and module-loader compatibility. Those changes have independent consumers and remain recorded in the vendor ledger. +**Restore every vendored file verbatim.** This would also remove lazy injected config evaluation, conditional disabled entries, lifecycle disposal fixes, and module-loader compatibility. Those changes have independent consumers and remain recorded in the vendor ledger. **Move generic rollback into app boot.** This would retain the same candidate-generation and restoration obligations under another owner. Applications instead report failures and allow a later valid edit to recover. diff --git a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md index 57ad3bb687..9ecadde422 100644 --- a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md +++ b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md @@ -12,19 +12,19 @@ Status: implemented 撤销 #932 中的五个提交,解决包移动冲突并保留后续独立行为。记录的合并提交属于更大的 #936 依赖链;撤销其第一父提交差异还会删除无关的仓库插件支持。[Vendor 修改记录](../../../../vendor/README.md#local-modifications) 按不变的固定来源记录每项保留的源码更改。 -Loader 立即更改条目选项。EntryGroup 并发启动同级条目并记录应用失败;EntryTree 等待未完成的工作,但不因失败的 fiber 而拒绝。两者均不恢复旧插件或配置。Include 保留解析校验和 patch 重应用,但插件失败可能留下部分应用的配置树。 +Loader 立即更改条目选项。EntryGroup 并发启动同级条目并记录应用失败;EntryTree 等待未完成的工作,但不因失败的 fiber 而拒绝。两者均不恢复旧插件或配置。Include 在文件无效时保留正在运行的配置树,但插件失败可能留下部分应用的配置树。 应用消费者负责完成检查。CLI 在安装实时 patch 监视器前等待其回退 HMR 服务。目录选择器检查其挂载的条目。选择器和浏览器包运行器在移除条目前取得首次 fiber 释放的结果,并等待该结果后才报告拆卸完成。预设挂载等待其子树,并报告导入、激活和缺少服务的失败。[应用启动](../../../../packages/boot/app-boot/README.zh.md) 负责精确 patch 文件监视、激活检查和部分上下文清理。Web 启动在打印 URL 或打开浏览器前检查激活状态。这些适配维持应用反向补丁后已有的消费者行为。 -Fiber、Entry 和 isolate 保持上游的更新返回行为。App boot 通过现有的 `internal/update` waterfall 观察被丢弃的重启 promise,并在检查 patch 重载前等待 fiber。游离的导入完成观察器处理 fiber 的两种结果;fiber 仍保留失败信息供显式检查。Include 的持久写入在删除子条目前后均排空,防止后续拆卸写入掩盖更早的终止性写入失败。 +Fiber、Entry 和 isolate 保持上游的更新返回行为。App boot 通过现有的 `internal/update` waterfall 观察被丢弃的重启 promise,并在检查 patch 重载前等待 fiber。游离的导入完成观察器处理 fiber 的两种结果;fiber 仍保留失败信息供显式检查。Include 的持久化遵循 vendor 修改记录中的 journal 适配:仍有未保存变更时,`flush()` 和 `stop()` 会拒绝。 -保留两项 #932 专属 vendor 改动:Include 中等待初始文件创建并强制重新读取,以及 Schemastery 条件导出。恢复 #932 前的防抖写入和读取顺序,会在缺失文件初始化测试中复现 `ENOENT`。保留这两行可以维持已有 `initial` 选项,而无需在应用侧增加文件写入器或另一份 YAML 序列化逻辑。移除 Schemastery exports 后,Web preset 测试在启动时复现 `ERR_REQUIRE_ESM_RACE_CONDITION`:并发 ESM 导入使 Node 回退到 CJS 入口。HMR 注入装饰器、条件 patch 克隆及更新返回值采用 #932 前的行为。显式 `workspace:` 依赖使 #932 的 workspace 链接开关与专用锁文件检查不再必要。 +保留一项 #932 专属 vendor 改动:Schemastery 条件导出。Include 1.1.1 会先写入 `initial` 文件再读取,因此缺失文件的初始化不再需要本地改动。移除 Schemastery exports 后,Web preset 测试在启动时复现 `ERR_REQUIRE_ESM_RACE_CONDITION`:并发 ESM 导入使 Node 回退到 CJS 入口。HMR 注入装饰器及更新返回值采用 #932 前的行为;patch 克隆遵循上游 Include。显式 `workspace:` 依赖使 #932 的 workspace 链接开关与专用锁文件检查不再必要。 ## 考虑过的替代方案 **保留事务化 Loader 更新。** 它们能从被拒绝的插件候选自动恢复,但会保留本次删除的 vendor 生命周期机制。解析失败可以独立于插件回滚进行处理。 -**逐字恢复所有 vendor 文件。** 这还会删除延迟注入配置求值、条件禁用条目、生命周期释放修复、持久写入和模块加载器兼容性。这些更改具有独立消费者,并继续记录在 vendor 修改记录中。 +**逐字恢复所有 vendor 文件。** 这还会删除延迟注入配置求值、条件禁用条目、生命周期释放修复和模块加载器兼容性。这些更改具有独立消费者,并继续记录在 vendor 修改记录中。 **将通用回滚移入应用启动。** 这会在另一归属下保留相同的候选代次与恢复义务。应用改为报告失败,并允许后续有效编辑恢复。 diff --git a/docs/rescope.i18n.yaml b/docs/rescope.i18n.yaml index 1d8ab0d003..3eb63034c4 100644 --- a/docs/rescope.i18n.yaml +++ b/docs/rescope.i18n.yaml @@ -6,8 +6,8 @@ en: c8aa4d3384ea1306 zh: 5aa030587d5b634d /vendored-package-rescope/name-mapping: - en: c74ffbc1036a93bf - zh: 9538352d8d72ebca + en: 50a68b9b3724eff8 + zh: 9facad930deeb014 /vendored-package-rescope/what-the-rename-does-not-touch: en: 9bb7d09f8cccf695 zh: 2a8f3f60ae5116f3 diff --git a/docs/rescope.md b/docs/rescope.md index 62b4dbfbac..c3ec1d0682 100644 --- a/docs/rescope.md +++ b/docs/rescope.md @@ -12,7 +12,7 @@ The Cordis framework and its foundation libraries are vendored under [`vendor/`] | `vendor/cosmokit/` | `cosmokit` | `@deepseek-ai/cosmokit` | 1.8.1 | Shared utilities the framework and Schemastery build on | | `vendor/schemastery/` | `schemastery` | `@deepseek-ai/schemastery` | 3.18.0 | Config schemas (`Schema`) behind every plugin's `Config` | | `vendor/loader/` | `@cordisjs/plugin-loader` | `@deepseek-ai/cordis-plugin-loader` | 1.0.0-rc.8 | `cordis.yml` loading, plugin resolution, repository cache | -| `vendor/include/` | `@cordisjs/plugin-include` | `@deepseek-ai/cordis-plugin-include` | 1.0.4 | Config includes and patch overlays | +| `vendor/include/` | `@cordisjs/plugin-include` | `@deepseek-ai/cordis-plugin-include` | 1.1.1 | Config includes and patch overlays | | `vendor/group/` | `@cordisjs/plugin-group` | `@deepseek-ai/cordis-plugin-group` | 1.0.0 | Nested plugin groups | | `vendor/timer/` | `@cordisjs/plugin-timer` | `@deepseek-ai/cordis-plugin-timer` | 1.1.4 | Disposal-aware timers on `ctx` | | `vendor/hmr/` | `@cordisjs/plugin-hmr` | `@deepseek-ai/cordis-plugin-hmr` | 1.0.15 | Hot module replacement for plugins and config | diff --git a/docs/rescope.zh.md b/docs/rescope.zh.md index 5e6a75de68..a02d152204 100644 --- a/docs/rescope.zh.md +++ b/docs/rescope.zh.md @@ -12,7 +12,7 @@ Cordis 框架及其基础库以源码形式 vendored 在 [`vendor/`](../vendor/R | `vendor/cosmokit/` | `cosmokit` | `@deepseek-ai/cosmokit` | 1.8.1 | 框架与 Schemastery 共用的基础工具 | | `vendor/schemastery/` | `schemastery` | `@deepseek-ai/schemastery` | 3.18.0 | 配置 schema(`Schema`),每个插件的 `Config` 都基于它 | | `vendor/loader/` | `@cordisjs/plugin-loader` | `@deepseek-ai/cordis-plugin-loader` | 1.0.0-rc.8 | `cordis.yml` 装载、插件解析、repository 缓存 | -| `vendor/include/` | `@cordisjs/plugin-include` | `@deepseek-ai/cordis-plugin-include` | 1.0.4 | 配置包含与 patch 叠加 | +| `vendor/include/` | `@cordisjs/plugin-include` | `@deepseek-ai/cordis-plugin-include` | 1.1.1 | 配置包含与 patch 叠加 | | `vendor/group/` | `@cordisjs/plugin-group` | `@deepseek-ai/cordis-plugin-group` | 1.0.0 | 嵌套插件分组 | | `vendor/timer/` | `@cordisjs/plugin-timer` | `@deepseek-ai/cordis-plugin-timer` | 1.1.4 | `ctx` 上随 disposal 回收的定时器 | | `vendor/hmr/` | `@cordisjs/plugin-hmr` | `@deepseek-ai/cordis-plugin-hmr` | 1.0.15 | 插件与配置的热替换 | diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.i18n.yaml index 81fc6c8fac..9aa7a50527 100644 --- a/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.i18n.yaml @@ -9,8 +9,8 @@ en: dc27815908c90cd9 zh: e9cc077d4d3fdbec /loader-entry-tree-reports-and-include-persistence/change: - en: 6ada19e725d5c04e - zh: efbc6183cfd094e1 + en: 6bbe6a3a14e182d1 + zh: 4256bd997293f8f2 /loader-entry-tree-reports-and-include-persistence/migration: en: 574f30ad53da1e66 zh: 1b9914c4ead412f7 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.md index 566d87df9b..0f25effb5d 100644 --- a/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.md +++ b/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.md @@ -14,6 +14,7 @@ English | [中文](guide.zh.md) - `EntryTree.write()` is removed. Subclasses implement `commit(change: EntryChange)`, which receives each create, update, move, removal and explicit Fiber save synchronously. In-memory trees implement a no-op. - `applyEntryPatches(data, patches, warn)` always returns cloned entries, including without patches, and clones inserted rows. Callers can no longer mutate the input through the result. - Include persists runtime changes through a journal reconciled with the latest file. File edits win conflicting fields and are logged as conflicts. Changes to patch-owned fields and inserted rows update the parent Include's patches. `flush()` waits for pending writes and rejects while changes remain unsaved; `refresh()` and `stop()` return the same result. +- Include writes generated ids back into file rows that had none. Runtime changes to rows inserted by patches, including bundle rows, update the parent's patches instead of the included file. The parent of the `dsh` profile's root Include is the in-memory Loader, so such changes, for example a bundle row's self-disposal `disabled: true`, no longer survive a restart; previously the whole patched tree was written into `cordis.yml`. ## Migration diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.zh.md index fc38566b63..f91c2042a6 100644 --- a/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.zh.md +++ b/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.zh.md @@ -14,6 +14,7 @@ description: "Loader 条目树通过 commit(change) 报告变更,Include 通 - 移除了 `EntryTree.write()`。子类改为实现 `commit(change: EntryChange)`,它会同步收到每一次创建、更新、移动、删除和显式 Fiber 保存。内存树实现为空操作。 - `applyEntryPatches(data, patches, warn)` 总是返回克隆后的条目,没有 patch 时也一样,插入行同样会被克隆。调用方不能再通过结果修改输入。 - Include 通过与最新文件协调的 journal 持久化运行时变更。文件编辑在字段冲突时优先,并记录为冲突。对 patch 所拥有字段和插入行的修改会写入父 Include 的 patch。`flush()` 等待待处理的写入,并在仍有未保存变更时拒绝;`refresh()` 和 `stop()` 返回相同的结果。 +- Include 会把生成的 id 写回原本没有 id 的文件行。对 patch 插入行(包括 bundle 行)的运行时修改会写入父级的 patch,而不是被 include 的文件。`dsh` profile 根 Include 的父级是内存中的 Loader,因此这类修改(例如 bundle 行自行释放时写入的 `disabled: true`)重启后不再保留;此前整棵已应用 patch 的树会被写入 `cordis.yml`。 ## 迁移 diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 32636ea8e8..daf1406a43 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -413,7 +413,7 @@ async function recompose( // Ascending original positions put each row back where it was. for (const { group, options, index } of stopped.toSorted((left, right) => left.index - right.index)) { // `create` starts the entry but leaves the group's option list as it is; that list is what the next update - // compares against to remove the row and what the Include writes back. + // compares against to remove the row. group.data.splice(index, 0, options) await group.create(options) } diff --git a/packages/boot/hmr/src/index.ts b/packages/boot/hmr/src/index.ts index 36c16ba6ac..8c5c97070a 100644 --- a/packages/boot/hmr/src/index.ts +++ b/packages/boot/hmr/src/index.ts @@ -469,7 +469,10 @@ class Hmr extends Service { loader.exit() return } - for (const include of includes) await include.refresh() + for (const include of includes) { + // A rejected refresh reports unsaved runtime changes; Include keeps its running tree. + try { await include.refresh() } catch (error) { this.ctx.logger.warn(error) } + } if (this.stashed.size > 0) { try { await this.partialReload() } finally { this.stashed.clear() } } diff --git a/packages/boot/hmr/tests/coordination.spec.ts b/packages/boot/hmr/tests/coordination.spec.ts index e0dceccb99..d19e92729e 100644 --- a/packages/boot/hmr/tests/coordination.spec.ts +++ b/packages/boot/hmr/tests/coordination.spec.ts @@ -135,6 +135,31 @@ it('refreshes an Include through the queue and skips registered exact paths', as expect(refresh).toHaveBeenCalledOnce() }) +it('continues the batch when an Include refresh reports unsaved changes', async () => { + const { ctx, dir } = await fixture() + const moduleWatcher = watchers.at(-1)! + const imported = vi.spyOn(ctx.loader, 'import').mockResolvedValue(Include) + onTestFinished(() => { imported.mockRestore() }) + const includes = [] + for (const name of ['first.yml', 'second.yml']) { + const file = join(dir, name) + writeFileSync(file, '[]\n') + const id = await ctx.loader.create({ name: 'include', config: { path: pathToFileURL(file).href } }) + includes.push({ file, id }) + } + await ctx.loader.await() + const [first, second] = includes.map(({ id }) => ctx.loader.resolve(id).subtree as Include) + const failure = new Error('config file has 1 unsaved change(s)') + const rejected = vi.spyOn(first!, 'refresh').mockRejectedValue(failure) + const refreshed = vi.spyOn(second!, 'refresh') + const warn = vi.spyOn(ctx.logger, 'warn') + moduleWatcher.emit('change', includes[0]!.file) + moduleWatcher.emit('change', includes[1]!.file) + await vi.waitFor(() => { expect(refreshed).toHaveBeenCalledOnce() }) + expect(rejected).toHaveBeenCalledOnce() + expect(warn).toHaveBeenCalledWith(failure) +}) + it('queues cached module replacements behind configuration mutations and recovers after failure', async () => { const { ctx, dir, hmr } = await fixture() const file = join(dir, 'source.mjs') diff --git a/scripts/cordis-include-journal.spec.ts b/scripts/cordis-include-journal.spec.ts index e5d0100d89..f39ae5c1b4 100644 --- a/scripts/cordis-include-journal.spec.ts +++ b/scripts/cordis-include-journal.spec.ts @@ -150,11 +150,30 @@ it('keeps generated ids and fibers for anonymous file and inserted rows across u it('persists a row moved into the Include from another tree as a complete creation', async () => { const app = await fixture([{ id: 'g', name: 'cordis:group', group: true, config: [] }]) - const id = await app.ctx.loader.create({ name: 'cordis:example', config: { value: 1 } }) + const apply = vi.fn() + app.ctx.loader.builtins.counted = apply + const id = await app.ctx.loader.create({ name: 'cordis:counted', config: { value: 1 } }) await app.ctx.loader.await() + const entry = app.ctx.loader.store[id]! await app.ctx.loader.update(id, { config: { value: 2 } }, `${app.id}:g`) await app.include.flush() + await app.ctx.loader.await() + expect(app.include.store[id] === entry).toBe(true) + expect(id in app.ctx.loader.store).toBe(false) + expect(apply).toHaveBeenCalledTimes(2) + expect(entry.fiber!.state).toBe(FiberState.ACTIVE) expect(await app.read()).toEqual([{ id: 'g', name: 'cordis:group', group: true, config: [ - { id, name: 'cordis:example', config: { value: 2 } }, + { id, name: 'cordis:counted', config: { value: 2 } }, ] }]) }) + +it('rejects a cross-tree move onto an id the destination already holds', async () => { + const app = await fixture([{ id: 'g', name: 'cordis:group', group: true, config: [row('x', 1)] }]) + await app.ctx.loader.create({ id: 'x', name: 'cordis:example', config: { value: 2 } } as EntryOptions) + await app.ctx.loader.await() + await expect(app.ctx.loader.update('x', {}, `${app.id}:g`)).rejects.toThrow('already exists in the target tree') + expect(app.ctx.loader.root.data.map(options => options.id)).toContain('x') + expect(app.value('x')).toBe(1) + await app.include.flush() + expect(await app.read()).toEqual([{ id: 'g', name: 'cordis:group', group: true, config: [row('x', 1)] }]) +}) diff --git a/scripts/cordis-vendor-hmr-include.spec.ts b/scripts/cordis-vendor-hmr-include.spec.ts new file mode 100644 index 0000000000..8f960bc0a1 --- /dev/null +++ b/scripts/cordis-vendor-hmr-include.spec.ts @@ -0,0 +1,40 @@ +/** Vendor HMR containment of Include refresh rejections. */ +import { mkdtempSync, realpathSync, 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 Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import Timer from '@deepseek-ai/cordis-plugin-timer' +import Hmr from '@deepseek-ai/cordis-plugin-hmr' +import { expect, it, onTestFinished, vi } from 'vitest' + +it('logs a rejected Include refresh instead of leaving it unhandled', async () => { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-vendor-hmr-include-'))) + const ctx = new Context() + ctx.baseUrl = pathToFileURL(dir).href + '/' + onTestFinished(async () => { await ctx.fiber.dispose(); rmSync(dir, { recursive: true, force: true }) }) + await ctx.plugin(Loader) + await ctx.plugin(Timer) + ctx.loader.builtins.include = Include + const file = join(dir, 'nested.yml') + writeFileSync(file, '[]\n') + const id = await ctx.loader.create({ name: 'cordis:include', config: { path: './nested.yml' } }) + await ctx.loader.await() + await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) + // The service keeps its chokidar watcher private; the test drives its change listener directly. + const watcher = Reflect.get(ctx.get('hmr')!, 'watcher') as { emit(event: 'change', path: string): boolean } + const include = ctx.loader.resolve(id).subtree as Include + const failure = new Error('config file has 1 unsaved change(s)') + const refresh = vi.spyOn(include, 'refresh').mockRejectedValue(failure) + const warn = vi.spyOn(ctx.logger, 'warn') + const unhandled = vi.fn() + process.on('unhandledRejection', unhandled) + onTestFinished(() => { process.off('unhandledRejection', unhandled) }) + watcher.emit('change', file) + await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith(failure) }) + await new Promise(resolve => setImmediate(resolve)) + expect(refresh).toHaveBeenCalledOnce() + expect(unhandled).not.toHaveBeenCalled() +}) diff --git a/vendor/README.md b/vendor/README.md index 2c6e377a2a..bb1888ba49 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -38,9 +38,9 @@ Keep this log exhaustive — every divergence from upstream must be listed. Each - **V5 · `schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. - **V6 · `cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. - **V7 · Cordis, Loader and Timer JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), module re-exports, shared utility helpers, `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Loader documentation covers `loader/src/{index,internal,config/entry,config/group,config/isolate,config/tree,config/utils}.ts`, including entry ownership, tree mutation methods, and the `!!js` discriminator. The upstream `EventsService.dispatch()` deprecation annotation is omitted: DSH enumerates callbacks to contain each observer failure independently, which the typed dispatch methods do not provide. Timer retains its local class and helper summaries; its README is local documentation because the upstream package has none. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. -- **V9 · `hmr/src/index.ts` module-watcher readiness and native paths**: realpath the existing watch base, classify framework modules and attach listeners before reporting readiness, and compare config paths using both canonical and configured spellings. This preserves Node module-cache identity across Windows short-name paths and filesystem aliases. The main watcher uses `ignoreInitial: true` so startup reads do not trigger another refresh; exact profile patch watching is owned by `packages/boot/hmr/src/watch-config.ts`. Covered by `packages/boot/hmr/tests/watch-config.spec.ts`. +- **V9 · `hmr/src/index.ts` module-watcher readiness and native paths**: realpath the existing watch base, classify framework modules and attach listeners before reporting readiness, and compare config paths using both canonical and configured spellings. This preserves Node module-cache identity across Windows short-name paths and filesystem aliases. The main watcher uses `ignoreInitial: true` so startup reads do not trigger another refresh; exact profile patch watching is owned by `packages/boot/hmr/src/watch-config.ts`. The Include change listener logs a rejected `Include.refresh()` instead of leaving the rejection unhandled; the rejection reports unsaved runtime changes while Include keeps its running tree. Covered by `packages/boot/hmr/tests/watch-config.spec.ts` and `scripts/cordis-vendor-hmr-include.spec.ts`. - **V10 · Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`. Conditional exports select `.mjs` for import and `.cjs` for require; without them, concurrent imports under module-hook hosts reach the CJS entry and can race its require of ESM Cosmokit (`ERR_REQUIRE_ESM_RACE_CONDITION`). -- **V11 · Include patch and YAML exports**: `include/src/index.ts` exports upstream `applyPatches` as `applyEntryPatches` and the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes entries with the same algorithm that mounting uses. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`. +- **V11 · Include patch and YAML exports**: `include/src/index.ts` exports upstream `applyPatches` as `applyEntryPatches` and the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes entries with the same algorithm that mounting uses. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `scripts/cordis-entry-commit.spec.ts`. - **V14 · Include persistence results**: `include/src/index.ts` keeps an error for unsaved journal changes. `flush()` retries pending writes and rejects while changes remain unsaved; `stop()` returns the same result and always stops the child tree; a later successful flush clears previous errors. A failed `refresh()` can also reject while runtime changes remain unsaved. Patch-owned changes route to the parent Include, so callers that need them persisted must flush that parent too. Ordinary Fiber teardown keeps its error containment. Covered by `scripts/cordis-include-journal.spec.ts` and `packages/host/directory-picker-auto/tests/loader-composition.spec.ts`. - **V15 · Configuration access across Core, Loader, Include and HMR**: rc.11 Core owns raw `Fiber.config`, `internal/config(fiber, next)`, `internal/commit` and awaited `internal/update(config, next)`. Root-fiber candidate resolution remains available without a plugin schema. Local `rawConfig`, `resolveConfig(candidate)` and `effectiveConfig` accessors retain the DSH consumer API. Fiber caches the object passed to activation or a successful update that keeps the instance; candidate resolution is pure, failed or superseded updates cannot replace the cache, and volatile updates retain references. Loader resolves only entry roots and excludes Group/Include tree carriers, so nested `!!js` expressions wait for their own injections. Include retains its tree-carrier marker and public patch exports, and HMR transfers raw config through the accessors. Covered by `scripts/cordis-config-access.spec.ts`, `scripts/cordis-core-lifecycle.spec.ts` and the boot/HMR suites. - **V16 · `cordis/package.json` publishes `src`**: added `src` to the `files` list, joining the other eight vendored packages. Cordis declares `"./src/*": "./src/*"` in its exports, so a tarball without `src` publishes an export map pointing at absent files; the release change judgement also reads `files` to decide whether a diff reaches the payload, and a package whose only published paths are build output has no tracked path to match. @@ -49,7 +49,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. Each - **V23 · `loader/src/config/entry.ts` module identity**: retains each entry's raw import result as `moduleNamespace`, before plugin export normalization. HMR matches that object to Node's cached module namespace and updates it after a successful reload. `EntryTree.import()` keeps its existing arguments and return value; `loader/README.md` documents the additive field. - **V24 · `timer/locale/{en,zh}.json` display metadata**: local English and Chinese titles and descriptions for the Settings plugin inventory. `timer/package.json` exports and publishes these locale resources. - **V25 · Loader lifecycle adaptations**: before a combined context/config update, Loader refreshes dependencies locally, which prevents activation against stale isolation bindings; disabled-entry updates await disposal. Failed fibers require an explicit update after correcting their input or dependencies. Core retains the effect hardening in V6; rc.11 owns dependency teardown ordering and superseded-activation handling. -- **V26 · `loader/src/config/tree.ts` cross-tree move reports**: `EntryTree.update()` reports a move into another tree as a removal to the source tree and a creation, without `legacy` or `from`, to the destination tree. Upstream reports the destination as an update with `legacy`, which the Include journal records as a partial row for an id that its file does not contain. Covered by `scripts/cordis-entry-commit.spec.ts`. +- **V26 · `loader/src/config/tree.ts` cross-tree moves**: `EntryTree.update()` moves an entry's store registration with it when the destination group belongs to another tree, and rejects the move before changing either tree when the destination tree already holds the id. It reports a removal to the source tree and a creation, without `legacy` or `from`, to the destination tree. Upstream keeps the entry registered in the source tree and reports the destination as an update with `legacy`; the Include journal then writes a partial row, and re-applying the destination file creates a second Entry for the same id. Covered by `scripts/cordis-include-journal.spec.ts`. - **V27 · `loader/src/config/tree.ts` explicit ids with `:`**: `ensureId()` accepts explicit ids containing `EntryTree.sep`; upstream rejects them. Generated bundles from `@deepseek-ai/dsh-creation-recommendations` prefix their row ids with `:`, and installed profiles already contain such rows. `resolve()` still splits ids on `:`, so these entries cannot be addressed by nested path. Covered by `scripts/cordis-entry-commit.spec.ts` and `packages/experimental/creation-recommendations/tests/loader-composition.spec.ts`. ### Resolver adaptations diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index 39b3461d9b..f7a49cbffe 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -146,7 +146,8 @@ class Hmr extends Service { for (const entry of loader.entries()) { const include = entry.subtree as Include | undefined if (include?.filename !== filename && include?.filename !== configuredFilename) continue - await include.refresh() + // A rejected refresh reports unsaved runtime changes; Include keeps its running tree. + try { await include.refresh() } catch (error) { this.ctx.logger.warn(error) } return } diff --git a/vendor/include/README.md b/vendor/include/README.md index 2d9db4965f..ae01993dc8 100644 --- a/vendor/include/README.md +++ b/vendor/include/README.md @@ -49,3 +49,5 @@ Patches can insert entries or override fields on entries with a matching `id`. Tree mutations arrive through `commit(change)` and enter a journal. Include merges them with the latest file before writing; file edits win conflicts on the same fields and produce a diagnostic. Patch-owned fields and inserted entries update the parent Include’s patches instead of the included file. Invalid, missing or read-only files leave unsaved changes in memory. `flush()` waits for this Include’s pending file operations and tree reconciliation, retries outstanding writes, and rejects if changes remain unsaved. Successful persistence clears previous errors. `refresh()` rereads the file and exposes the same completion result. To persist patch-owned changes, flush the child and then its parent. `stop()` makes a final attempt and always stops child entries, even when it rejects; ordinary Fiber disposal still contains cleanup errors. File writes use an atomic rename with bounded retries for transient destination locks. + +Ids that Include generates for anonymous inserted rows are written into the patch objects of its own configuration. When the parent tree re-creates that configuration from a file, for example after the parent Include writes, those ids are generated again and the inserted rows restart; give inserted rows explicit ids where their instance state matters. diff --git a/vendor/loader/README.md b/vendor/loader/README.md index 0b17513be8..ddfe7f091e 100644 --- a/vendor/loader/README.md +++ b/vendor/loader/README.md @@ -68,4 +68,4 @@ Entry updates await `loader/patch-context`, the Fiber update waterfall and disab ## Tree mutation reports -`EntryTree.commit(change)` receives each mutation made through `EntryTree` methods synchronously after options and group membership change; file-driven `EntryGroup` reconciliation does not report. A missing `legacy` means creation; missing `options` means removal. Updates carry both, with `from` naming the previous group after a move within the tree; a move into another tree reports a removal to the source tree and a creation to the destination tree. Explicit Fiber saves report their simplified configuration. File-driven reconciliation, parent teardown and Loader removal do not produce spurious self-disable reports. In-memory trees implement a no-op; Include journals the reports and reconciles them with file edits. Explicit entry ids may contain `:`; `resolve()` cannot address such entries by nested path, because `:` separates nested tree paths. +`EntryTree.commit(change)` receives each mutation made through `EntryTree` methods synchronously after options and group membership change; file-driven `EntryGroup` reconciliation does not report. A missing `legacy` means creation; missing `options` means removal. Updates carry both, with `from` naming the previous group after a move within the tree; a move into another tree transfers the entry's registration, reports a removal to the source tree and a creation to the destination tree, and is rejected when the destination tree already holds the id. Explicit Fiber saves report their simplified configuration. File-driven reconciliation, parent teardown and Loader removal do not produce spurious self-disable reports. In-memory trees implement a no-op; Include journals the reports and reconciles them with file edits. Explicit entry ids may contain `:`; `resolve()` cannot address such entries by nested path, because `:` separates nested tree paths. diff --git a/vendor/loader/src/config/tree.ts b/vendor/loader/src/config/tree.ts index f8c05dfefe..33482b6c6a 100644 --- a/vendor/loader/src/config/tree.ts +++ b/vendor/loader/src/config/tree.ts @@ -134,9 +134,17 @@ export abstract class EntryTree { const legacy = { ...entry.options } if (parent !== undefined) { const target = this.resolveGroup(parent) + if (target.tree !== source.tree && target.tree.store[legacy.id]) { + throw new Error(`entry ${legacy.id} already exists in the target tree`) + } source.unlink(entry.options) target.data.splice(position ?? Infinity, 0, entry.options) entry.parent = target + // Each tree indexes its own entries, so a cross-tree move transfers the registration. + if (target.tree !== source.tree) { + delete source.tree.store[legacy.id] + target.tree.store[legacy.id] = entry + } } // `Entry.update` assigns the new options before its first `await`, so the // change is fully visible to `commit()` once the call returns. From 7a5df74f31e5d3b8eb0e3245bd5def1a9a439fe1 Mon Sep 17 00:00:00 2001 From: Shigma Date: Thu, 29 Oct 2026 20:42:07 +0800 Subject: [PATCH 09/32] fix(cordis): backport cross-tree group member transfer from cordis#271 --- scripts/cordis-include-journal.spec.ts | 25 +++++++++++++++++ vendor/README.md | 2 +- vendor/loader/README.md | 2 +- vendor/loader/src/config/tree.ts | 38 +++++++++++++++++++------- 4 files changed, 55 insertions(+), 12 deletions(-) diff --git a/scripts/cordis-include-journal.spec.ts b/scripts/cordis-include-journal.spec.ts index f39ae5c1b4..a98050dc84 100644 --- a/scripts/cordis-include-journal.spec.ts +++ b/scripts/cordis-include-journal.spec.ts @@ -177,3 +177,28 @@ it('rejects a cross-tree move onto an id the destination already holds', async ( await app.include.flush() expect(await app.read()).toEqual([{ id: 'g', name: 'cordis:group', group: true, config: [row('x', 1)] }]) }) + +it('transfers the members of a group moved into the Include', async () => { + const app = await fixture([{ id: 'g', name: 'cordis:group', group: true, config: [] }]) + const apply = vi.fn() + app.ctx.loader.builtins.counted = apply + const outer = await app.ctx.loader.create({ name: 'cordis:group', group: true, config: [] }) + await app.ctx.loader.await() + const inner = await app.ctx.loader.create({ name: 'cordis:counted', config: { value: 1 } }, outer) + await app.ctx.loader.await() + const [group, member] = [app.ctx.loader.store[outer]!, app.ctx.loader.store[inner]!] + await app.ctx.loader.update(outer, {}, `${app.id}:g`) + await app.include.flush() + await app.ctx.loader.await() + expect(app.include.store[outer] === group && app.include.store[inner] === member).toBe(true) + expect(outer in app.ctx.loader.store || inner in app.ctx.loader.store).toBe(false) + expect(group.subgroup!.tree === app.include).toBe(true) + expect(apply).toHaveBeenCalledOnce() + expect(await app.read()).toEqual([{ id: 'g', name: 'cordis:group', group: true, config: [ + { id: outer, name: 'cordis:group', group: true, config: [{ id: inner, name: 'cordis:counted', config: { value: 1 } }] }, + ] }]) + app.ctx.loader.remove(`${app.id}:${outer}`) + await app.include.flush() + await app.ctx.loader.await() + expect(member.fiber?.state).not.toBe(FiberState.ACTIVE) +}) diff --git a/vendor/README.md b/vendor/README.md index bb1888ba49..084761deea 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -49,7 +49,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. Each - **V23 · `loader/src/config/entry.ts` module identity**: retains each entry's raw import result as `moduleNamespace`, before plugin export normalization. HMR matches that object to Node's cached module namespace and updates it after a successful reload. `EntryTree.import()` keeps its existing arguments and return value; `loader/README.md` documents the additive field. - **V24 · `timer/locale/{en,zh}.json` display metadata**: local English and Chinese titles and descriptions for the Settings plugin inventory. `timer/package.json` exports and publishes these locale resources. - **V25 · Loader lifecycle adaptations**: before a combined context/config update, Loader refreshes dependencies locally, which prevents activation against stale isolation bindings; disabled-entry updates await disposal. Failed fibers require an explicit update after correcting their input or dependencies. Core retains the effect hardening in V6; rc.11 owns dependency teardown ordering and superseded-activation handling. -- **V26 · `loader/src/config/tree.ts` cross-tree moves**: `EntryTree.update()` moves an entry's store registration with it when the destination group belongs to another tree, and rejects the move before changing either tree when the destination tree already holds the id. It reports a removal to the source tree and a creation, without `legacy` or `from`, to the destination tree. Upstream keeps the entry registered in the source tree and reports the destination as an update with `legacy`; the Include journal then writes a partial row, and re-applying the destination file creates a second Entry for the same id. Covered by `scripts/cordis-include-journal.spec.ts`. +- **V26 · `loader/src/config/tree.ts` cross-tree moves**: backports [cordiverse/cordis#271](https://github.com/cordiverse/cordis/pull/271), merged after `v4.0.0-rc.11`. When `EntryTree.update()` moves an entry into a group of another tree, the entry and the same-tree members of its group move their store registrations to the target tree, and the move is rejected before either tree changes when the target tree already holds one of their ids. The source tree receives a removal and the target tree a creation, without `legacy` or `from`. In rc.11 the entry stays registered in the source tree and the target receives an update with `legacy`, so the Include journal writes a partial row and reconciling the target file creates a second Entry. Retire this entry when the vendored Loader includes the upstream fix. Covered by `scripts/cordis-include-journal.spec.ts`. - **V27 · `loader/src/config/tree.ts` explicit ids with `:`**: `ensureId()` accepts explicit ids containing `EntryTree.sep`; upstream rejects them. Generated bundles from `@deepseek-ai/dsh-creation-recommendations` prefix their row ids with `:`, and installed profiles already contain such rows. `resolve()` still splits ids on `:`, so these entries cannot be addressed by nested path. Covered by `scripts/cordis-entry-commit.spec.ts` and `packages/experimental/creation-recommendations/tests/loader-composition.spec.ts`. ### Resolver adaptations diff --git a/vendor/loader/README.md b/vendor/loader/README.md index ddfe7f091e..2b71deba01 100644 --- a/vendor/loader/README.md +++ b/vendor/loader/README.md @@ -68,4 +68,4 @@ Entry updates await `loader/patch-context`, the Fiber update waterfall and disab ## Tree mutation reports -`EntryTree.commit(change)` receives each mutation made through `EntryTree` methods synchronously after options and group membership change; file-driven `EntryGroup` reconciliation does not report. A missing `legacy` means creation; missing `options` means removal. Updates carry both, with `from` naming the previous group after a move within the tree; a move into another tree transfers the entry's registration, reports a removal to the source tree and a creation to the destination tree, and is rejected when the destination tree already holds the id. Explicit Fiber saves report their simplified configuration. File-driven reconciliation, parent teardown and Loader removal do not produce spurious self-disable reports. In-memory trees implement a no-op; Include journals the reports and reconciles them with file edits. Explicit entry ids may contain `:`; `resolve()` cannot address such entries by nested path, because `:` separates nested tree paths. +`EntryTree.commit(change)` receives each mutation made through `EntryTree` methods synchronously after options and group membership change; file-driven `EntryGroup` reconciliation does not report. A missing `legacy` means creation; missing `options` means removal. Updates carry both, with `from` naming the previous group after a move within the tree; a move into another tree transfers the registrations of the entry and its group members, reports a removal to the source tree and a creation to the destination tree, and is rejected when the destination tree already holds the id. Explicit Fiber saves report their simplified configuration. File-driven reconciliation, parent teardown and Loader removal do not produce spurious self-disable reports. In-memory trees implement a no-op; Include journals the reports and reconciles them with file edits. Explicit entry ids may contain `:`; `resolve()` cannot address such entries by nested path, because `:` separates nested tree paths. diff --git a/vendor/loader/src/config/tree.ts b/vendor/loader/src/config/tree.ts index 33482b6c6a..5b5648f576 100644 --- a/vendor/loader/src/config/tree.ts +++ b/vendor/loader/src/config/tree.ts @@ -27,6 +27,19 @@ export interface EntryChange { legacy?: EntryOptions } +/** The entry and the same-tree members of its group, recursively. */ +function members(entry: Entry): Entry[] { + const { tree } = entry.parent + const result = [entry] + const group = entry.subgroup + if (group?.tree !== tree) return result + for (const options of group.data) { + const member = tree.store[options.id] + if (member?.parent === group) result.push(...members(member)) + } + return result +} + /** Mutable tree of loader entries. Persistence is supplied by subclasses. */ export abstract class EntryTree { static readonly sep = ':' @@ -134,29 +147,34 @@ export abstract class EntryTree { const legacy = { ...entry.options } if (parent !== undefined) { const target = this.resolveGroup(parent) - if (target.tree !== source.tree && target.tree.store[legacy.id]) { - throw new Error(`entry ${legacy.id} already exists in the target tree`) + const moved = target.tree === source.tree ? [] : members(entry) + for (const member of moved) { + const { id } = member.options + if (target.tree.store[id]) throw new Error(`entry ${id} already exists in the target tree`) } source.unlink(entry.options) target.data.splice(position ?? Infinity, 0, entry.options) entry.parent = target - // Each tree indexes its own entries, so a cross-tree move transfers the registration. - if (target.tree !== source.tree) { - delete source.tree.store[legacy.id] - target.tree.store[legacy.id] = entry + // The target tree owns the moved entries from now on, so that it adopts + // them instead of creating new ones when it reconciles its source. + for (const member of moved) { + delete source.tree.store[member.options.id] + target.tree.store[member.options.id] = member + if (member.subgroup?.tree === source.tree) member.subgroup.tree = target.tree } } // `Entry.update` assigns the new options before its first `await`, so the // change is fully visible to `commit()` once the call returns. const task = entry.update(options, false, true) if (entry.parent.tree !== source.tree) { + // a move across trees removes the entry from the source tree and + // creates it in the target tree source.tree.commit({ id: legacy.id, group: source, legacy }) - // The destination tree has no previous row, so it receives a creation. entry.parent.tree.commit({ id: legacy.id, group: entry.parent, options: entry.options }) - return task + } else { + const from = entry.parent === source ? undefined : source + entry.parent.tree.commit({ id: legacy.id, group: entry.parent, from, options: entry.options, legacy }) } - const from = entry.parent === source ? undefined : source - entry.parent.tree.commit({ id: legacy.id, group: entry.parent, from, options: entry.options, legacy }) return task } From 86b59d4a177ca942e60ad21b4208732c42084c1f Mon Sep 17 00:00:00 2001 From: Shigma Date: Fri, 30 Oct 2026 14:00:01 +0800 Subject: [PATCH 10/32] test(cordis): load vendor HMR without its type declarations --- scripts/cordis-vendor-hmr-include.spec.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/cordis-vendor-hmr-include.spec.ts b/scripts/cordis-vendor-hmr-include.spec.ts index 8f960bc0a1..64f7447f69 100644 --- a/scripts/cordis-vendor-hmr-include.spec.ts +++ b/scripts/cordis-vendor-hmr-include.spec.ts @@ -3,13 +3,16 @@ import { mkdtempSync, realpathSync, 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 { Context, type Plugin } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import Timer from '@deepseek-ai/cordis-plugin-timer' -import Hmr from '@deepseek-ai/cordis-plugin-hmr' import { expect, it, onTestFinished, vi } from 'vitest' +// A literal import would add vendor HMR's `Context.hmr` declaration to the +// repository type program, where DSH HMR declares the same property. +const vendorHmr: string = '@deepseek-ai/cordis-plugin-hmr' + it('logs a rejected Include refresh instead of leaving it unhandled', async () => { const dir = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-vendor-hmr-include-'))) const ctx = new Context() @@ -22,9 +25,10 @@ it('logs a rejected Include refresh instead of leaving it unhandled', async () = writeFileSync(file, '[]\n') const id = await ctx.loader.create({ name: 'cordis:include', config: { path: './nested.yml' } }) await ctx.loader.await() + const { default: Hmr } = await import(vendorHmr) as { default: Plugin } await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) // The service keeps its chokidar watcher private; the test drives its change listener directly. - const watcher = Reflect.get(ctx.get('hmr')!, 'watcher') as { emit(event: 'change', path: string): boolean } + const watcher = Reflect.get(ctx.get('hmr') as object, 'watcher') as { emit(event: 'change', path: string): boolean } const include = ctx.loader.resolve(id).subtree as Include const failure = new Error('config file has 1 unsaved change(s)') const refresh = vi.spyOn(include, 'refresh').mockRejectedValue(failure) From aae41629d18d6b946b39cf6eb7f44be63e185f19 Mon Sep 17 00:00:00 2001 From: Shigma Date: Fri, 30 Oct 2026 18:31:13 +0800 Subject: [PATCH 11/32] docs(vendor): record omitted Include HMR watch registration --- vendor/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/README.md b/vendor/README.md index 084761deea..9020f5dcb6 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -41,7 +41,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. Each - **V9 · `hmr/src/index.ts` module-watcher readiness and native paths**: realpath the existing watch base, classify framework modules and attach listeners before reporting readiness, and compare config paths using both canonical and configured spellings. This preserves Node module-cache identity across Windows short-name paths and filesystem aliases. The main watcher uses `ignoreInitial: true` so startup reads do not trigger another refresh; exact profile patch watching is owned by `packages/boot/hmr/src/watch-config.ts`. The Include change listener logs a rejected `Include.refresh()` instead of leaving the rejection unhandled; the rejection reports unsaved runtime changes while Include keeps its running tree. Covered by `packages/boot/hmr/tests/watch-config.spec.ts` and `scripts/cordis-vendor-hmr-include.spec.ts`. - **V10 · Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`. Conditional exports select `.mjs` for import and `.cjs` for require; without them, concurrent imports under module-hook hosts reach the CJS entry and can race its require of ESM Cosmokit (`ERR_REQUIRE_ESM_RACE_CONDITION`). - **V11 · Include patch and YAML exports**: `include/src/index.ts` exports upstream `applyPatches` as `applyEntryPatches` and the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes entries with the same algorithm that mounting uses. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `scripts/cordis-entry-commit.spec.ts`. -- **V14 · Include persistence results**: `include/src/index.ts` keeps an error for unsaved journal changes. `flush()` retries pending writes and rejects while changes remain unsaved; `stop()` returns the same result and always stops the child tree; a later successful flush clears previous errors. A failed `refresh()` can also reject while runtime changes remain unsaved. Patch-owned changes route to the parent Include, so callers that need them persisted must flush that parent too. Ordinary Fiber teardown keeps its error containment. Covered by `scripts/cordis-include-journal.spec.ts` and `packages/host/directory-picker-auto/tests/loader-composition.spec.ts`. +- **V14 · Include persistence results**: `include/src/index.ts` keeps an error for unsaved journal changes. `flush()` retries pending writes and rejects while changes remain unsaved; `stop()` returns the same result and always stops the child tree; a later successful flush clears previous errors. A failed `refresh()` can also reject while runtime changes remain unsaved. Patch-owned changes route to the parent Include, so callers that need them persisted must flush that parent too. The constructor omits upstream's `ctx.hmr.watch(this.filename, () => this.refresh())` registration because neither HMR provider exposes `watch()`; both find each Include by scanning loader entries for its `filename` and call `refresh()`. Ordinary Fiber teardown keeps its error containment. Covered by `scripts/cordis-include-journal.spec.ts` and `packages/host/directory-picker-auto/tests/loader-composition.spec.ts`. - **V15 · Configuration access across Core, Loader, Include and HMR**: rc.11 Core owns raw `Fiber.config`, `internal/config(fiber, next)`, `internal/commit` and awaited `internal/update(config, next)`. Root-fiber candidate resolution remains available without a plugin schema. Local `rawConfig`, `resolveConfig(candidate)` and `effectiveConfig` accessors retain the DSH consumer API. Fiber caches the object passed to activation or a successful update that keeps the instance; candidate resolution is pure, failed or superseded updates cannot replace the cache, and volatile updates retain references. Loader resolves only entry roots and excludes Group/Include tree carriers, so nested `!!js` expressions wait for their own injections. Include retains its tree-carrier marker and public patch exports, and HMR transfers raw config through the accessors. Covered by `scripts/cordis-config-access.spec.ts`, `scripts/cordis-core-lifecycle.spec.ts` and the boot/HMR suites. - **V16 · `cordis/package.json` publishes `src`**: added `src` to the `files` list, joining the other eight vendored packages. Cordis declares `"./src/*": "./src/*"` in its exports, so a tarball without `src` publishes an export map pointing at absent files; the release change judgement also reads `files` to decide whether a diff reaches the payload, and a package whose only published paths are build output has no tracked path to match. - **V17 · `@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md). From 125fabd8d0987ca5dfe4aa0fe9574ed456f39e93 Mon Sep 17 00:00:00 2001 From: fz Date: Sun, 1 Nov 2026 20:31:59 +0800 Subject: [PATCH 12/32] fix(python): deploy the runtime from the workspace lockfile (cherry picked from commit c2e5f4828ecf698ba17c6b2eab67ff7b3a914d49) --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +-- ...ile-executable-sdk-runtime-distribution.md | 2 +- ...-executable-sdk-runtime-distribution.zh.md | 2 +- python/sdk-runtime/README.i18n.yaml | 4 +-- python/sdk-runtime/README.md | 2 +- python/sdk-runtime/README.zh.md | 2 +- .../build-exe-for-python-sdk-staging.spec.ts | 7 ++-- scripts/build-exe-for-python-sdk.ts | 32 +++-------------- scripts/python-runtime-deploy.pnpmfile.d.mts | 15 ++++++++ scripts/python-runtime-deploy.pnpmfile.mjs | 22 ++++++++++++ scripts/python-runtime-deploy.spec.ts | 35 +++++++++++++++++++ 11 files changed, 89 insertions(+), 38 deletions(-) create mode 100644 scripts/python-runtime-deploy.pnpmfile.d.mts create mode 100644 scripts/python-runtime-deploy.pnpmfile.mjs create mode 100644 scripts/python-runtime-deploy.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 061ddeed50..ffb1190f7e 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -21,8 +21,8 @@ en: 985e0eb63f3d1fd6 zh: 337cc44fadc6227e /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/build-pipeline-and-artifacts: - en: 9a4ba22142a7be24 - zh: 8553b02a923641a3 + en: 13d4c28466577e97 + zh: 4fa5cf739d5727fe /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/python-sdk-distribution-two-carriers-exe-for-production-node-for-development: en: b2a740a1ab7a5002 zh: a48be6ca6fc65912 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 34d346c96a..2f9c52b317 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -48,7 +48,7 @@ The [Office kit](2026-09-14-independent-libreoffice-kit.md) and its installed de ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → verify the deployed bootstrap and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea --compress Brotli` once per target, where the vendored pkg patch pins Brotli's quality to 6: pkg passes no options, so its default quality 11 measured 132.8 s against 3.2 s at quality 6 for an archive 12% larger → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it with its lightweight resource manifest and Office skill assets into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment reference](../../../../packages/subprocess/subprocess-local/README.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. The deploy flags preserve the measured runtime layout: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted supplies package directories that staging makes symlink-free; staging removes nested workspace copies only when pnpm records the same dependency identity at the payload root, preserving singleton services; disabling workspace hoisting excludes unrelated workspace source packages; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. Production deployment permits unused workspace patches because development-only tools can be absent from its closure; included-package patch failures still fail deployment. Workspace installs retain strict unused-patch validation. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.pnpmfile=scripts/python-runtime-deploy.pnpmfile.mjs --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → verify the deployed bootstrap and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea --compress Brotli` once per target, where the vendored pkg patch pins Brotli's quality to 6: pkg passes no options, so its default quality 11 measured 132.8 s against 3.2 s at quality 6 for an archive 12% larger → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it with its lightweight resource manifest and Office skill assets into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment reference](../../../../packages/subprocess/subprocess-local/README.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container and copies the resulting addon into the deployed tree. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. The deploy flags preserve the measured runtime layout: the dedicated deploy lockfile retains the source lockfile’s versions and peer settings; the deploy-only pnpm hook translates reviewed local build approvals to exact file URLs without changing approval decisions; hoisted supplies package directories that staging makes symlink-free; staging removes nested workspace copies only when pnpm records the same dependency identity at the payload root, preserving singleton services; disabling workspace hoisting excludes unrelated workspace source packages; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so deploy never resolves those unpublished names from a registry. Production deployment permits unused workspace patches because development-only tools can be absent from its closure; included-package patch failures still fail deployment. Workspace installs retain strict unused-patch validation. CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) runs [installed-wheel validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) on Linux/Windows x64 for pull requests and Linux ARM64 plus both macOS architectures for master pushes. The [public publication workflow](../../archived/process/2026-08-11-python-publication-workflow.md) calls it for all five targets; `workflow_dispatch` can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64 / macos-x64 (`macos-15-intel`) / win-x64 (`windows-2025`), with `~/.pkg-cache` cached where applicable, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, verifies their package and executable origin, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests and master pushes additionally run a real DeepSeek two-turn tool smoke on their selected targets; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS checks the runtime, ripgrep, and PTY helper architectures and verifies that all three deployment targets fit the wheel tag. A full five-target run retains six artifacts, each containing one release file: the platform-independent SDK wheel and five native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and five native runtime wheels, then a single serialized job checks and publishes all six to the project PyPI registry. The [`python/sdk-runtime` README](../../../../python/sdk-runtime/README.md) owns the Windows target and the explicit exclusion of Windows arm64. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 6e1e2c51b6..ca1976c9bd 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -48,7 +48,7 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 ### 构建流水线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea --compress Brotli`,其中 vendor 的 pkg 补丁把 Brotli 质量固定为 6:pkg 不传参数时默认质量 11,同一载荷实测 132.8 s,质量 6 为 3.2 s,产物大 12% → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/`,并将它与轻量资源清单及 Office skill 资源一同拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 参考](../../../../packages/subprocess/subprocess-local/README.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 提供包目录,再由暂存步骤消除符号链接;只有 pnpm 在载荷根目录记录了相同依赖标识时,暂存步骤才删除对应的嵌套工作区副本,以保留服务单例;关闭工作区提升可排除无关的工作区源码包;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 生产部署允许未使用的工作区补丁,因为开发工具可能不属于运行时闭包;闭包内包的补丁应用失败仍会中止部署。工作区安装继续严格校验未使用的补丁。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.pnpmfile=scripts/python-runtime-deploy.pnpmfile.mjs --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea --compress Brotli`,其中 vendor 的 pkg 补丁把 Brotli 质量固定为 6:pkg 不传参数时默认质量 11,同一载荷实测 132.8 s,质量 6 为 3.2 s,产物大 12% → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/`,并将它与轻量资源清单及 Office skill 资源一同拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 参考](../../../../packages/subprocess/subprocess-local/README.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,并将生成的 addon 复制到部署树中。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。部署标志都有实测依据:独立部署锁文件保留源锁文件的版本及对等依赖设置;仅用于部署的 pnpm hook 将已审阅的本地构建审批转换为精确文件 URL,不改变审批决定;`hoisted` 提供包目录,再由暂存步骤消除符号链接;只有 pnpm 在载荷根目录记录了相同依赖标识时,暂存步骤才删除对应的嵌套工作区副本,以保留服务单例;关闭工作区提升可排除无关的工作区源码包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 deploy 不会从注册表解析这些未发布名称。 生产部署允许未使用的工作区补丁,因为开发工具可能不属于运行时闭包;闭包内包的补丁应用失败仍会中止部署。工作区安装继续严格校验未使用的补丁。 CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel 包验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)在拉取请求上运行 Linux/Windows x64,在 master 推送上运行 Linux ARM64 与两种 macOS 架构。[公开发布工作流](../../archived/process/2026-08-11-python-publication-workflow.md)调用它构建全部五个目标;`workflow_dispatch` 仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)、macos-arm64、macos-x64(`macos-15-intel`)与 win-x64(`windows-2025`)分别进行原生构建,并在适用平台缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求与 master 推送还会在各自选定的目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则检查 runtime、ripgrep 与 PTY helper 的架构,并验证三个载荷的部署目标都符合 wheel 包标签。完整构建五个目标时保留 6 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 5 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 5 个原生运行时 wheel 包,再由单个串行任务校验并将这 6 个文件发布到项目的 PyPI 注册表。[`python/sdk-runtime` README](../../../../python/sdk-runtime/README.zh.md)负责 Windows 目标及对 Windows arm64 的明确排除。 diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index b7f6458756..9cc5d6efa9 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -15,5 +15,5 @@ en: 0f476fb87a1a3349 zh: 359dbd9b24b18b46 /deepseek-harness-runtime-bin/build-and-distribution: - en: a41e8eb7e99b9532 - zh: 2789d8f43b1b54be + en: 2512cd7c3624ef6e + zh: 455f6a528e5f9d61 diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 346813cb1c..21a5ec546d 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -62,7 +62,7 @@ External profile management uses `dsh plugin --profile ...`. After explic ## Build and distribution -Production deployment permits unused workspace patches for packages outside the runtime closure; patches for included packages must still apply successfully. This exception is confined to the deploy command; repository installation still rejects unused patches. +Production deployment installs the workspace lockfile’s pinned dependency graph without re-resolving versions. It permits unused workspace patches for packages outside the runtime closure; patches for included packages must still apply successfully. This exception is confined to the deploy command; repository installation still rejects unused patches. From the repository root, `pnpm exec tsx scripts/build-exe-for-python-sdk.ts` verifies the closure, builds packages, deploys a symlink-free tree, packages the selected target, and syncs the executable and sidecars into this module. The executable stores embedded files with Brotli compression. Only the target platform’s PTY prebuilds are packaged. Package README, CHANGELOG, HISTORY Markdown, source maps, and TypeScript declarations outside runtime assets are omitted. Deployment disables workspace hoisting, and packaging excludes checkout dependencies and workspace source trees outside the deployment so dependency scans cannot add development packages to the payload. Staging shares root workspace packages with nested consumers of the same pnpm dependency identity, preserving singleton services while retaining different peer resolutions. `scripts/build-python-release.py` stages release-shaped wheels at the root repository version and pins `deepseek-harness-sdk` to the exact runtime version. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 0339c1020b..e187ceb37d 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -62,7 +62,7 @@ Python 与打包的 bootstrap 共享资源布局:`//office/` ## 构建与分发 -生产部署允许工作区中不属于运行时闭包的补丁保持未使用;闭包内包的补丁仍必须成功应用。此例外仅用于部署命令,仓库安装仍拒绝未使用的补丁。 +生产部署按工作区锁文件安装固定的依赖图,不重新解析版本。部署允许工作区中不属于运行时闭包的补丁保持未使用;闭包内包的补丁仍必须成功应用。此例外仅用于部署命令,仓库安装仍拒绝未使用的补丁。 在仓库根目录运行 `pnpm exec tsx scripts/build-exe-for-python-sdk.ts`,会校验闭包、构建包、部署无符号链接的文件树、打包所选目标,并把可执行程序及伴随文件同步到本模块。可执行文件使用 Brotli 压缩存储内嵌文件。打包时只保留目标平台的 PTY 预构建文件,并排除运行时资源目录之外的包内 README、CHANGELOG、HISTORY Markdown 文档、source map 和 TypeScript 声明。部署会关闭工作区提升,打包时还会排除部署目录之外的检出依赖和工作区源码树,防止依赖扫描把开发包加入载荷。暂存步骤让使用相同 pnpm 依赖标识的嵌套消费者共享根目录的工作区包,保留服务单例,同时保留不同的对等依赖解析。`scripts/build-python-release.py` 按仓库根版本暂存发布形态的 wheel 包,并将 `deepseek-harness-sdk` 固定到完全相同的运行时版本。 diff --git a/scripts/build-exe-for-python-sdk-staging.spec.ts b/scripts/build-exe-for-python-sdk-staging.spec.ts index cc5d526463..2429086b8c 100644 --- a/scripts/build-exe-for-python-sdk-staging.spec.ts +++ b/scripts/build-exe-for-python-sdk-staging.spec.ts @@ -66,7 +66,7 @@ it('copies workspace package links as files and removes command shims', async () expect(await listSymlinks(staging)).toEqual([]) }) -it('shares an identical workspace package while retaining different peers and third-party copies', async () => { +it.each(['source', 'deployment'])('shares identical workspace packages with %s-relative metadata', async (base) => { const workspace = await fixture('dsh-python-staging-dedupe-') const staging = join(workspace, 'payload') const modules = join(staging, 'node_modules') @@ -79,14 +79,15 @@ it('shares an identical workspace package while retaining different peers and th await mkdir(directory, { recursive: true }) await writeFile(join(directory, 'index.js'), 'exports.scheduler = Symbol("scheduler")\n') } - const locations = (paths: string[]) => paths.map(path => relative(workspace, path)) + const metadataRoot = base === 'source' ? workspace : staging + const locations = (paths: string[]) => paths.map(path => relative(metadataRoot, path)) await writeFile(join(modules, '.modules.yaml'), JSON.stringify({ hoistedLocations: { '@fixture/tools@file:packages/tools(peer@1)': locations([root, duplicate]), '@fixture/tools@file:packages/tools(peer@2)': locations([differentPeers]), 'external@1.0.0(@fixture/tools@file:packages/tools)': locations([external, externalCopy]), } })) - await deduplicateStagedWorkspacePackages(staging, workspace) + await deduplicateStagedWorkspacePackages(staging, metadataRoot) expect(existsSync(duplicate)).toBe(false) for (const directory of [root, differentPeers, external, externalCopy]) { diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index 447c98ff14..627c13f0e2 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -9,7 +9,7 @@ import { createHash } from 'node:crypto' import { build as bundle } from 'tsdown' import { spawn } from 'node:child_process' -import { pnpmInvocation, restoreLegacyHoists } from './executable-packaging.ts' +import { pnpmInvocation } from './executable-packaging.ts' import { existsSync, statSync } from 'node:fs' import { chmod, copyFile, cp, glob, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { basename, dirname, join, resolve, sep } from 'node:path' @@ -37,8 +37,6 @@ const OUT_DIR = 'dist-exe' const PYTHON_RUNTIME_DIR = 'python/sdk-runtime/src/deepseek_harness_runtime/runtime' /** The deployed closure doubles as the node-mode carrier. */ const PYTHON_NODE_SUBDIR = 'node' -/** Legacy deploy may hoist peer-specialized workspace packages back here. */ -const DEPLOY_SOURCE_NODE_MODULES = 'python/sdk-runtime/node_modules' /** Documentation excluded from the generated runtime directory. */ const DEPLOY_ONLY_DOCS = ['README.md', 'README.zh.md', 'README.i18n.yaml'] @@ -304,24 +302,19 @@ class SingleExeBuild { } if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${this.staging}`) else await rm(this.staging, { recursive: true, force: true }) - try { await this.runPnpm('deploy', [ + await this.runPnpm('deploy', [ '--filter', DEPLOY_ROOT_PACKAGE, 'deploy', - '--legacy', '--prod', // Production deployment omits workspace tooling such as Electron's patched signer. '--config.allow-unused-patches=true', '--config.node-linker=hoisted', - '--config.auto-install-peers=false', + `--config.pnpmfile=${join(root, 'scripts/python-runtime-deploy.pnpmfile.mjs')}`, '--config.link-workspace-packages=true', '--config.hoist-workspace-packages=false', this.staging, - ]) } finally { - // Legacy deploy records production-only workspace state; restore the development installation before exec. - await this.runPnpm('restore development dependencies', ['install', '--offline', '--frozen-lockfile', '--prod=false', '--ignore-scripts']) - } - await this.restoreLegacyHoists() + ]) await this.materializeStagedLinks() if (this.cli.dryRun) { for (const name of DEPLOY_ONLY_DOCS) console.log(`build-exe-for-python-sdk: [dry-run] rm -f ${join(this.staging, name)}`) @@ -333,21 +326,6 @@ class SingleExeBuild { } } - /** - * Restore direct packages that pnpm's legacy hoister places beside the deploy - * source instead of in the target. The runtime manifest supplies every peer, - * so package-local node_modules trees are omitted to preserve one flat Cordis - * instance and a symlink-free packaged payload. - */ - private async restoreLegacyHoists(): Promise { - if (this.cli.dryRun) { - console.log('build-exe-for-python-sdk: [dry-run] restore direct dependencies omitted by legacy deploy') - return - } - const restored = await restoreLegacyHoists(this.staging, resolve(root, DEPLOY_SOURCE_NODE_MODULES)) - if (restored.length > 0) console.log(`build-exe-for-python-sdk: restored legacy deploy hoists: ${restored.join(', ')}`) - } - /** Replace deploy-time package links with files and reject any remaining link. */ private async materializeStagedLinks(): Promise { if (this.cli.dryRun) { @@ -355,7 +333,7 @@ class SingleExeBuild { return } await materializeStagedLinks(this.staging) - await deduplicateStagedWorkspacePackages(this.staging, root) + await deduplicateStagedWorkspacePackages(this.staging, this.staging) } /** Add the executable entry and pkg assets to the staged manifest. */ diff --git a/scripts/python-runtime-deploy.pnpmfile.d.mts b/scripts/python-runtime-deploy.pnpmfile.d.mts new file mode 100644 index 0000000000..d9808daf64 --- /dev/null +++ b/scripts/python-runtime-deploy.pnpmfile.d.mts @@ -0,0 +1,15 @@ +/** Workspace settings relevant to exact local build approvals. */ +export interface RuntimeDeployConfig { + readonly allowBuilds?: Readonly> + readonly [setting: string]: unknown +} + +/** Pnpm configuration hooks scoped to Python runtime deployment. */ +export const hooks: { + /** + * Translate local build identities without changing their approval status. + * @param config - Source workspace configuration. + * @returns Deployment configuration with exact file URL approvals. + */ + updateConfig(config: RuntimeDeployConfig): RuntimeDeployConfig +} diff --git a/scripts/python-runtime-deploy.pnpmfile.mjs b/scripts/python-runtime-deploy.pnpmfile.mjs new file mode 100644 index 0000000000..d3cd00ff71 --- /dev/null +++ b/scripts/python-runtime-deploy.pnpmfile.mjs @@ -0,0 +1,22 @@ +/** Preserve workspace build approvals when pnpm deploy converts local dependencies to file URLs. */ +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +/** + * Translate local build identities without changing their approved or denied status. + * @param {Record & { allowBuilds?: Record }} config - Source workspace configuration. + * @returns {Record} Deployment configuration with exact file URL approvals. + */ +function updateConfig(config) { + if (config.allowBuilds === undefined) return config + const allowBuilds = Object.fromEntries(Object.entries(config.allowBuilds).map(([key, value]) => { + const marker = key.indexOf('@file:') + if (marker < 0 || key.slice(marker + 6).startsWith('//')) return [key, value] + const source = resolve(import.meta.dirname, '..', key.slice(marker + 6)) + return [`${key.slice(0, marker + 1)}${pathToFileURL(source).href}`, value] + })) + return { ...config, allowBuilds } +} + +/** Pnpm configuration hooks scoped to Python runtime deployment. */ +export const hooks = { updateConfig } diff --git a/scripts/python-runtime-deploy.spec.ts b/scripts/python-runtime-deploy.spec.ts new file mode 100644 index 0000000000..a06b7c1b5b --- /dev/null +++ b/scripts/python-runtime-deploy.spec.ts @@ -0,0 +1,35 @@ +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { expect, it } from 'vitest' +import { hooks } from './python-runtime-deploy.pnpmfile.mjs' + +it('preserves exact local build approvals, denials, and unrelated configuration', () => { + const config = { + strictDepBuilds: true, + allowBuilds: { + 'allowed@file:packages/allowed': true, + 'denied@file:packages/denied': false, + 'pending@file:packages/pending': 'set this to true or false', + 'absolute@file:///already/reviewed': true, + 'registry-package': false, + }, + } + const identity = (name: string) => `${name}@${pathToFileURL(resolve(import.meta.dirname, '..', 'packages', name)).href}` + + expect(hooks.updateConfig(config)).toEqual({ + strictDepBuilds: true, + allowBuilds: { + [identity('allowed')]: true, + [identity('denied')]: false, + [identity('pending')]: 'set this to true or false', + 'absolute@file:///already/reviewed': true, + 'registry-package': false, + }, + }) + expect(config.allowBuilds['allowed@file:packages/allowed']).toBe(true) +}) + +it('leaves configuration without local build rules unchanged', () => { + const config = { strictDepBuilds: true } + expect(hooks.updateConfig(config)).toBe(config) +}) From 67e2d1e50624ca3dc3271ec5e69394dc80efb8d3 Mon Sep 17 00:00:00 2001 From: fz Date: Sun, 1 Nov 2026 21:24:41 +0800 Subject: [PATCH 13/32] fix(python): preserve deployed dependency validation (cherry picked from commit e18abca88589347975a5866925c590a16f1b2aed) --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +-- ...ile-executable-sdk-runtime-distribution.md | 2 +- ...-executable-sdk-runtime-distribution.zh.md | 2 +- ...-01-locked-python-runtime-deploy.i18n.yaml | 22 +++++++++++++ ...2026-11-01-locked-python-runtime-deploy.md | 33 +++++++++++++++++++ ...6-11-01-locked-python-runtime-deploy.zh.md | 33 +++++++++++++++++++ scripts/build-exe-for-python-sdk-staging.ts | 8 ++--- scripts/build-exe-for-python-sdk.spec.ts | 3 ++ scripts/build-exe-for-python-sdk.ts | 3 +- scripts/executable-packaging.spec.ts | 19 ++++++++++- scripts/executable-packaging.ts | 24 ++++++++++---- scripts/python-runtime-deploy.pnpmfile.mjs | 9 +++-- scripts/verify-runtime-closure.ts | 4 +-- 13 files changed, 145 insertions(+), 21 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-11-01-locked-python-runtime-deploy.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-11-01-locked-python-runtime-deploy.md create mode 100644 .agents/notes/implemented/bug-fix/2026-11-01-locked-python-runtime-deploy.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index ffb1190f7e..95484d284b 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -21,8 +21,8 @@ en: 985e0eb63f3d1fd6 zh: 337cc44fadc6227e /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/build-pipeline-and-artifacts: - en: 13d4c28466577e97 - zh: 4fa5cf739d5727fe + en: 4a55602284b462e8 + zh: 762c4f8b719f179c /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/python-sdk-distribution-two-carriers-exe-for-production-node-for-development: en: b2a740a1ab7a5002 zh: a48be6ca6fc65912 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 2f9c52b317..a70ab115be 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -48,7 +48,7 @@ The [Office kit](2026-09-14-independent-libreoffice-kit.md) and its installed de ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.pnpmfile=scripts/python-runtime-deploy.pnpmfile.mjs --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → verify the deployed bootstrap and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea --compress Brotli` once per target, where the vendored pkg patch pins Brotli's quality to 6: pkg passes no options, so its default quality 11 measured 132.8 s against 3.2 s at quality 6 for an archive 12% larger → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it with its lightweight resource manifest and Office skill assets into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment reference](../../../../packages/subprocess/subprocess-local/README.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container and copies the resulting addon into the deployed tree. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. The deploy flags preserve the measured runtime layout: the dedicated deploy lockfile retains the source lockfile’s versions and peer settings; the deploy-only pnpm hook translates reviewed local build approvals to exact file URLs without changing approval decisions; hoisted supplies package directories that staging makes symlink-free; staging removes nested workspace copies only when pnpm records the same dependency identity at the payload root, preserving singleton services; disabling workspace hoisting excludes unrelated workspace source packages; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so deploy never resolves those unpublished names from a registry. Production deployment permits unused workspace patches because development-only tools can be absent from its closure; included-package patch failures still fail deployment. Workspace installs retain strict unused-patch validation. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.pnpmfile=scripts/python-runtime-deploy.pnpmfile.mjs --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → reject missing direct dependency directories, verify the deployed bootstrap, and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea --compress Brotli` once per target, where the vendored pkg patch pins Brotli's quality to 6: pkg passes no options, so its default quality 11 measured 132.8 s against 3.2 s at quality 6 for an archive 12% larger → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it with its lightweight resource manifest and Office skill assets into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment reference](../../../../packages/subprocess/subprocess-local/README.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container and copies the resulting addon into the deployed tree. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. The deploy flags preserve the measured runtime layout: the dedicated deploy lockfile retains the source lockfile’s versions and peer settings; [`scripts/python-runtime-deploy.pnpmfile.mjs`](../../../../scripts/python-runtime-deploy.pnpmfile.mjs) preserves exact local build approvals; the [locked deployment decision](../bug-fix/2026-11-01-locked-python-runtime-deploy.md) supersedes the legacy deployment requirement and disabled-peer rationale; hoisted supplies package directories that staging makes symlink-free; staging removes nested workspace copies only when pnpm records the same dependency identity at the payload root, preserving singleton services; disabling workspace hoisting excludes unrelated workspace source packages; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so deploy never resolves those unpublished names from a registry. Production deployment permits unused workspace patches because development-only tools can be absent from its closure; included-package patch failures still fail deployment. Workspace installs retain strict unused-patch validation. CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) runs [installed-wheel validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) on Linux/Windows x64 for pull requests and Linux ARM64 plus both macOS architectures for master pushes. The [public publication workflow](../../archived/process/2026-08-11-python-publication-workflow.md) calls it for all five targets; `workflow_dispatch` can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64 / macos-x64 (`macos-15-intel`) / win-x64 (`windows-2025`), with `~/.pkg-cache` cached where applicable, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, verifies their package and executable origin, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests and master pushes additionally run a real DeepSeek two-turn tool smoke on their selected targets; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS checks the runtime, ripgrep, and PTY helper architectures and verifies that all three deployment targets fit the wheel tag. A full five-target run retains six artifacts, each containing one release file: the platform-independent SDK wheel and five native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and five native runtime wheels, then a single serialized job checks and publishes all six to the project PyPI registry. The [`python/sdk-runtime` README](../../../../python/sdk-runtime/README.md) owns the Windows target and the explicit exclusion of Windows arm64. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index ca1976c9bd..e1ff276504 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -48,7 +48,7 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 ### 构建流水线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.pnpmfile=scripts/python-runtime-deploy.pnpmfile.mjs --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea --compress Brotli`,其中 vendor 的 pkg 补丁把 Brotli 质量固定为 6:pkg 不传参数时默认质量 11,同一载荷实测 132.8 s,质量 6 为 3.2 s,产物大 12% → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/`,并将它与轻量资源清单及 Office skill 资源一同拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 参考](../../../../packages/subprocess/subprocess-local/README.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,并将生成的 addon 复制到部署树中。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。部署标志都有实测依据:独立部署锁文件保留源锁文件的版本及对等依赖设置;仅用于部署的 pnpm hook 将已审阅的本地构建审批转换为精确文件 URL,不改变审批决定;`hoisted` 提供包目录,再由暂存步骤消除符号链接;只有 pnpm 在载荷根目录记录了相同依赖标识时,暂存步骤才删除对应的嵌套工作区副本,以保留服务单例;关闭工作区提升可排除无关的工作区源码包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 deploy 不会从注册表解析这些未发布名称。 生产部署允许未使用的工作区补丁,因为开发工具可能不属于运行时闭包;闭包内包的补丁应用失败仍会中止部署。工作区安装继续严格校验未使用的补丁。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.pnpmfile=scripts/python-runtime-deploy.pnpmfile.mjs --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 拒绝缺失的直接依赖目录,验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea --compress Brotli`,其中 vendor 的 pkg 补丁把 Brotli 质量固定为 6:pkg 不传参数时默认质量 11,同一载荷实测 132.8 s,质量 6 为 3.2 s,产物大 12% → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/`,并将它与轻量资源清单及 Office skill 资源一同拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 参考](../../../../packages/subprocess/subprocess-local/README.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,并将生成的 addon 复制到部署树中。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。部署标志都有实测依据:独立部署锁文件保留源锁文件的版本及对等依赖设置;[`scripts/python-runtime-deploy.pnpmfile.mjs`](../../../../scripts/python-runtime-deploy.pnpmfile.mjs) 保留精确的本地构建审批;[按锁文件部署的决策](../bug-fix/2026-11-01-locked-python-runtime-deploy.zh.md)替代 legacy 部署要求及关闭对等依赖的理由;`hoisted` 提供包目录,再由暂存步骤消除符号链接;只有 pnpm 在载荷根目录记录了相同依赖标识时,暂存步骤才删除对应的嵌套工作区副本,以保留服务单例;关闭工作区提升可排除无关的工作区源码包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 deploy 不会从注册表解析这些未发布名称。 生产部署允许未使用的工作区补丁,因为开发工具可能不属于运行时闭包;闭包内包的补丁应用失败仍会中止部署。工作区安装继续严格校验未使用的补丁。 CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel 包验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)在拉取请求上运行 Linux/Windows x64,在 master 推送上运行 Linux ARM64 与两种 macOS 架构。[公开发布工作流](../../archived/process/2026-08-11-python-publication-workflow.md)调用它构建全部五个目标;`workflow_dispatch` 仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)、macos-arm64、macos-x64(`macos-15-intel`)与 win-x64(`windows-2025`)分别进行原生构建,并在适用平台缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求与 master 推送还会在各自选定的目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则检查 runtime、ripgrep 与 PTY helper 的架构,并验证三个载荷的部署目标都符合 wheel 包标签。完整构建五个目标时保留 6 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 5 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 5 个原生运行时 wheel 包,再由单个串行任务校验并将这 6 个文件发布到项目的 PyPI 注册表。[`python/sdk-runtime` README](../../../../python/sdk-runtime/README.zh.md)负责 Windows 目标及对 Windows arm64 的明确排除。 diff --git a/.agents/notes/implemented/bug-fix/2026-11-01-locked-python-runtime-deploy.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-11-01-locked-python-runtime-deploy.i18n.yaml new file mode 100644 index 0000000000..618dcc06e2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-11-01-locked-python-runtime-deploy.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-11-01-locked-python-runtime-deploy.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-11-01-locked-python-runtime-deploy.md +/agent-note-locked-python-runtime-deployment: + en: 6a06f1078957df51 + zh: d7c2146381443ced +/agent-note-locked-python-runtime-deployment/problem: + en: 2293006c8bae6a1a + zh: 10670f2d703777dd +/agent-note-locked-python-runtime-deployment/decision: + en: 223079c8e1e3a44e + zh: c906266cb0b231bc +/agent-note-locked-python-runtime-deployment/alternatives-considered: + en: 978c94c7013056a5 + zh: afae764120582b6a +/agent-note-locked-python-runtime-deployment/testing: + en: d862dd609725c686 + zh: 8889c5d9002bbc68 +/agent-note-locked-python-runtime-deployment/consequences: + en: 41827db476b30a43 + zh: 5a7652f310a69909 diff --git a/.agents/notes/implemented/bug-fix/2026-11-01-locked-python-runtime-deploy.md b/.agents/notes/implemented/bug-fix/2026-11-01-locked-python-runtime-deploy.md new file mode 100644 index 0000000000..a1763148c8 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-11-01-locked-python-runtime-deploy.md @@ -0,0 +1,33 @@ +# Agent Note: Locked Python runtime deployment + +Status: implemented + +English | [中文](2026-11-01-locked-python-runtime-deploy.zh.md) + +## Problem + +Python executable packaging needs the same dependency versions and integrity records as the workspace lockfile. Legacy hoisted deployment ignores that lockfile and resolves compatible registry versions again: a manifest permitting `tslib@^2.8.1` can install `2.9.1` while the lockfile records `2.8.1`. Office archive preparation then rejects the missing locked integrity. A successful frozen workspace install does not constrain that later legacy deployment. + +## Decision + +The Python builder uses pnpm's default deployment, which derives a dedicated production lockfile from the workspace lockfile and installs it frozen. This replaces only the deployment strategy in the [single-executable distribution decision](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md); its runtime architecture and carrier layout remain active. + +Deployment retains the source lockfile's peer settings, including `autoInstallPeers: true`. Frozen installation preserves already resolved versions and peer relationships instead of resolving new registry peers. Forcing `autoInstallPeers: false` conflicts with the derived lockfile settings and fails frozen installation. The resulting tree need not equal the legacy tree resolved with peers disabled; selected dependencies must come from the locked graph. `verify-runtime-closure.ts` independently requires explicit workspace peers and preset plugins before deployment, and `verifyStagedDependencies` rejects missing direct dependency directories after materialization and deduplication. + +[`scripts/python-runtime-deploy.pnpmfile.mjs`](../../../../scripts/python-runtime-deploy.pnpmfile.mjs) translates reviewed local `file:` approvals to the exact absolute file URLs used in the derived lockfile. Approval, denial, pending decisions, and strict build validation remain unchanged. Default deployment executes approved installation scripts; their generated files can enter the staged payload. Workspace policy and the source lockfile remain unchanged. + +## Alternatives considered + +**Keep legacy deployment and add missing integrity records.** This would accept versions the source lockfile did not select and repeat the failure after another compatible release. + +**Disable automatic peer installation during default deployment.** This conflicts with the source lockfile settings. Re-resolving a separate graph would give up the source lockfile's authority. + +**Disable deployment scripts or approve local packages by name.** Skipping scripts omits required postinstall preparation. Name-only approvals do not authorize non-semver local file dependencies; exact identity translation preserves the existing review decisions. + +## Testing + +A real pnpm comparison with the same manifest and lockfile installs `tslib@2.9.1` through legacy deployment and retains `2.8.1` through default deployment. The Linux executable and locally built installed-wheel SDK smokes pass. Focused tests reject missing dependency directories and deployment flag regressions, preserve approval states, and exercise both metadata path bases. Cross-platform packaging and installed-wheel validation remain owned by CI. + +## Consequences + +Runtime versions remain traceable to the workspace lockfile, and incomplete direct dependency trees fail before executable packaging. Installation script outputs and locked peer selections can differ from legacy deployment, so byte-identical payloads are not promised. SSH helper deployment has a separate closure manifest and native payload validation; its legacy deployment is outside this Python runtime repair. diff --git a/.agents/notes/implemented/bug-fix/2026-11-01-locked-python-runtime-deploy.zh.md b/.agents/notes/implemented/bug-fix/2026-11-01-locked-python-runtime-deploy.zh.md new file mode 100644 index 0000000000..8b24c383e7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-11-01-locked-python-runtime-deploy.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Python 运行时按锁文件部署 + +Status: implemented + +[English](2026-11-01-locked-python-runtime-deploy.md) | 中文 + +## 问题 + +Python 可执行文件打包需要使用与工作区锁文件相同的依赖版本和完整性记录。legacy hoisted 部署忽略该锁文件,重新解析兼容的注册表版本:允许 `tslib@^2.8.1` 的 manifest 可能安装 `2.9.1`,而锁文件记录的是 `2.8.1`。Office 归档准备随后因缺少锁定的完整性记录而拒绝构建。工作区的冻结安装成功,并不能约束后续的 legacy 部署。 + +## 决策 + +Python 构建器使用 pnpm 的默认部署,从工作区锁文件派生独立的生产锁文件,再冻结安装。本记录只替代[单文件可执行程序分发决策](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)中的部署策略;其中的运行时架构和载体布局继续有效。 + +部署保留源锁文件的对等依赖设置,包括 `autoInstallPeers: true`。冻结安装保留已解析的版本和对等依赖关系,不会重新解析新的注册表对等依赖。强制指定 `autoInstallPeers: false` 与派生锁文件的设置冲突,会使冻结安装失败。生成的依赖树不必与关闭对等依赖解析的 legacy 依赖树相同,但所选依赖必须来自锁定的依赖图。`verify-runtime-closure.ts` 独立要求在部署前显式声明工作区对等依赖及 preset 插件;`verifyStagedDependencies` 在链接实体化与去重后拒绝缺失的直接依赖目录。 + +[`scripts/python-runtime-deploy.pnpmfile.mjs`](../../../../scripts/python-runtime-deploy.pnpmfile.mjs) 将已审阅的本地 `file:` 审批转换为派生锁文件使用的精确绝对文件 URL。批准、拒绝、未决定状态和严格构建校验均保持不变。默认部署执行已批准的安装脚本,其生成的文件可能进入暂存载荷。工作区策略和源锁文件保持不变。 + +## 考虑过的替代方案 + +**保留 legacy 部署并补全缺失的完整性记录。** 这会接受源锁文件没有选择的版本,并在另一个兼容版本发布后重复出现故障。 + +**在默认部署中关闭对等依赖自动安装。** 这与源锁文件的设置冲突;重新解析另一套依赖图会放弃源锁文件的决定权。 + +**禁用部署脚本,或按名称批准本地包。** 跳过脚本会遗漏必需的 postinstall 准备。仅按名称批准不能授权非 semver 的本地文件依赖;转换精确标识可以保留原有审批决定。 + +## 测试 + +真实 pnpm 对照使用相同 manifest 和锁文件,legacy 部署安装 `tslib@2.9.1`,默认部署保留 `2.8.1`。Linux 可执行文件及本地构建并安装的 wheel SDK 冒烟测试通过。定向测试拒绝缺失的依赖目录和部署参数回退,保留审批状态,并覆盖两种 metadata 路径基准。跨平台打包及安装后 wheel 校验仍由 CI 负责。 + +## 影响 + +运行时版本可以追溯到工作区锁文件,不完整的直接依赖树会在可执行文件打包前失败。安装脚本产物和锁定的对等依赖选择可能与 legacy 部署不同,因此不承诺载荷逐字节相同。SSH helper 部署使用独立的闭包 manifest 和原生载荷校验,其 legacy 部署不属于本次 Python 运行时修复范围。 diff --git a/scripts/build-exe-for-python-sdk-staging.ts b/scripts/build-exe-for-python-sdk-staging.ts index 123ba52c33..4f766307f9 100644 --- a/scripts/build-exe-for-python-sdk-staging.ts +++ b/scripts/build-exe-for-python-sdk-staging.ts @@ -14,9 +14,9 @@ function isHoistedLocations(value: unknown): value is Record { * Different peer resolutions and third-party packages keep their installed locations. * All metadata paths are checked before deletion; the caller first materializes links. * @param staging - Symlink-free deployed payload root. - * @param workspace - Workspace root relative to which pnpm records hoisted locations. + * @param metadataRoot - Base for pnpm hoistedLocations: the workspace for legacy deploy, the deployment for default deploy. */ -export async function deduplicateStagedWorkspacePackages(staging: string, workspace: string): Promise { +export async function deduplicateStagedWorkspacePackages(staging: string, metadataRoot: string): Promise { const modules = resolve(staging, 'node_modules') const file = join(modules, '.modules.yaml') const manifest: unknown = load(await readFile(file, 'utf8')) @@ -27,7 +27,7 @@ export async function deduplicateStagedWorkspacePackages(staging: string, worksp const duplicates: string[] = [] for (const [identity, locations] of Object.entries(manifest.hoistedLocations)) { const paths = locations.map((location) => { - const path = resolve(workspace, location) + const path = resolve(metadataRoot, location) const local = relative(modules, path) if (local === '' || local === '..' || local.startsWith(`..${sep}`) || isAbsolute(local)) { throw new Error(`${file}: hoistedLocations path is outside deployed node_modules: ${location}`) @@ -62,7 +62,7 @@ async function findSymlink(directory: string): Promise { /** * Replace every deploy-time package link below the staged payload with real files. * - * Legacy deploy links workspace packages into the target's `node_modules`, and + * pnpm deploy can link workspace packages into the target's `node_modules`, and * those links must survive packaging as files. One link is not a package: when * the deploy target sits inside the deployed project, pnpm links that project * into its own `node_modules`. Dereferencing it would copy the payload into diff --git a/scripts/build-exe-for-python-sdk.spec.ts b/scripts/build-exe-for-python-sdk.spec.ts index fdaf7202a7..0d26340944 100644 --- a/scripts/build-exe-for-python-sdk.spec.ts +++ b/scripts/build-exe-for-python-sdk.spec.ts @@ -136,6 +136,9 @@ describe('Python runtime executable builder CLI', () => { const deploy = result.stdout.split('\n').find(line => line.includes(' --filter dsh-python-runtime-closure deploy')) expect(deploy).toContain('--prod --config.allow-unused-patches=true') expect(deploy).toContain('--config.hoist-workspace-packages=false') + expect(deploy).toContain(`--config.pnpmfile=${join(root, 'scripts/python-runtime-deploy.pnpmfile.mjs')}`) + expect(deploy).not.toContain('--legacy') + expect(deploy).not.toContain('--config.auto-install-peers=false') expect(result.stdout.split('--config.allow-unused-patches=true')).toHaveLength(2) expect(result.stdout).not.toContain(resolve(root, 'python/sdk-runtime/runtime-bootstrap.mjs')) expect(result.stdout).toContain('"bin":"runtime-bootstrap.mjs"') diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index 627c13f0e2..0d7b5d5624 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -9,7 +9,7 @@ import { createHash } from 'node:crypto' import { build as bundle } from 'tsdown' import { spawn } from 'node:child_process' -import { pnpmInvocation } from './executable-packaging.ts' +import { pnpmInvocation, verifyStagedDependencies } from './executable-packaging.ts' import { existsSync, statSync } from 'node:fs' import { chmod, copyFile, cp, glob, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { basename, dirname, join, resolve, sep } from 'node:path' @@ -334,6 +334,7 @@ class SingleExeBuild { } await materializeStagedLinks(this.staging) await deduplicateStagedWorkspacePackages(this.staging, this.staging) + await verifyStagedDependencies(this.staging) } /** Add the executable entry and pkg assets to the staged manifest. */ diff --git a/scripts/executable-packaging.spec.ts b/scripts/executable-packaging.spec.ts index 2adcbaaa89..35495f7907 100644 --- a/scripts/executable-packaging.spec.ts +++ b/scripts/executable-packaging.spec.ts @@ -2,7 +2,7 @@ import { lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs import { tmpdir } from 'node:os' import { join } from 'node:path' import { expect, it, onTestFinished } from 'vitest' -import { restoreLegacyHoists } from './executable-packaging.ts' +import { restoreLegacyHoists, verifyStagedDependencies } from './executable-packaging.ts' import { materializeStagedLinks } from './build-exe-for-python-sdk-staging.ts' async function fixture() { @@ -45,3 +45,20 @@ it('materializes a package junction without deleting its source and drops linked expect(await readFile(join(test.source, 'peer/index.js'), 'utf8')).toBe('intact') await expect(lstat(join(test.modules, '.bin'))).rejects.toThrow('ENOENT') }) + +it('accepts complete direct dependency directories without requiring optional dependencies', async () => { + const test = await fixture() + await writeFile(join(test.staging, 'package.json'), JSON.stringify({ + dependencies: { '@scope/present': '1.0.0' }, optionalDependencies: { optional: '1.0.0' }, + })) + await mkdir(join(test.modules, '@scope/present'), { recursive: true }) + await expect(verifyStagedDependencies(test.staging)).resolves.toBeUndefined() +}) + +it.each(['absent', 'file'])('rejects a direct dependency that is %s instead of a directory', async (kind) => { + const test = await fixture() + await writeFile(join(test.staging, 'package.json'), JSON.stringify({ dependencies: { missing: '1.0.0' } })) + if (kind === 'file') await writeFile(join(test.modules, 'missing'), '') + await expect(verifyStagedDependencies(test.staging)) + .rejects.toThrow('staged dependencies remain missing: missing.') +}) diff --git a/scripts/executable-packaging.ts b/scripts/executable-packaging.ts index 10248962bd..0643be28d6 100644 --- a/scripts/executable-packaging.ts +++ b/scripts/executable-packaging.ts @@ -1,5 +1,5 @@ /** Shared production-deploy preparation for the SDK and SSH executable carriers. */ -import { existsSync } from 'node:fs' +import { existsSync, statSync } from 'node:fs' import { cp, mkdir, readFile } from 'node:fs/promises' import { dirname, extname, join, resolve, sep } from 'node:path' @@ -61,10 +61,22 @@ export async function restoreLegacyHoists(staging: string, sourceNodeModules: st }) restored.push(dependency) } - const stillMissing = Object.keys(manifest.dependencies ?? {}) - .filter(dependency => !existsSync(join(staging, 'node_modules', dependency))) - if (stillMissing.length > 0) { - throw new Error(`executable-packaging: staged dependencies remain missing: ${stillMissing.join(', ')}.`) - } + await verifyStagedDependencies(staging) return restored } + +/** + * Reject a deployed payload whose manifest names missing direct dependency directories. + * @param staging - Deployed payload root containing package.json and node_modules. + * @returns Resolves when every declared direct dependency has a directory. + */ +export async function verifyStagedDependencies(staging: string): Promise { + const manifest = JSON.parse(await readFile(join(staging, 'package.json'), 'utf8')) as { + dependencies?: Record + } + const missing = Object.keys(manifest.dependencies ?? {}).sort() + .filter(dependency => !statSync(join(staging, 'node_modules', dependency), { throwIfNoEntry: false })?.isDirectory()) + if (missing.length > 0) { + throw new Error(`executable-packaging: staged dependencies remain missing: ${missing.join(', ')}.`) + } +} diff --git a/scripts/python-runtime-deploy.pnpmfile.mjs b/scripts/python-runtime-deploy.pnpmfile.mjs index d3cd00ff71..bf2c4fa1ee 100644 --- a/scripts/python-runtime-deploy.pnpmfile.mjs +++ b/scripts/python-runtime-deploy.pnpmfile.mjs @@ -1,11 +1,14 @@ -/** Preserve workspace build approvals when pnpm deploy converts local dependencies to file URLs. */ +/** + * Preserve workspace build approvals when pnpm deploy records local dependencies as file:// URLs. + * This hook lives in the workspace root's scripts/ directory; relative file: approvals use that root. + */ import { resolve } from 'node:path' import { pathToFileURL } from 'node:url' /** * Translate local build identities without changing their approved or denied status. - * @param {Record & { allowBuilds?: Record }} config - Source workspace configuration. - * @returns {Record} Deployment configuration with exact file URL approvals. + * @param {import('./python-runtime-deploy.pnpmfile.mjs').RuntimeDeployConfig} config - Source workspace configuration. + * @returns {import('./python-runtime-deploy.pnpmfile.mjs').RuntimeDeployConfig} Deployment configuration with exact file URL approvals. */ function updateConfig(config) { if (config.allowBuilds === undefined) return config diff --git a/scripts/verify-runtime-closure.ts b/scripts/verify-runtime-closure.ts index 8694dcaa7d..84a4edbe72 100644 --- a/scripts/verify-runtime-closure.ts +++ b/scripts/verify-runtime-closure.ts @@ -1,8 +1,8 @@ /** * Verify that the executable deploy manifest supplies every plugin referenced * by a shipped agent preset and every required workspace peer in its dependency - * graph. With auto peer installation disabled, either omission can otherwise - * fail only when Cordis loads the packaged plugin. + * graph. Frozen deployment preserves the locked peer graph; this check also + * requires explicit workspace peers so omissions fail before plugin loading. */ import { globSync } from 'node:fs' import { readFile } from 'node:fs/promises' From e1609fe16495aff3967388f1e67e9f46df341b6a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 2 Nov 2026 14:59:19 +0800 Subject: [PATCH 14/32] fix(fs): temporarily disable apply_patch in shipped defaults --- packages/bundle/base/cordis.patch.yml | 2 +- packages/bundle/web-app/presets/standard.patch.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index ef931753d9..fc83f8160c 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -297,7 +297,7 @@ name: '@deepseek-ai/dsh-tool-fs' config: # Set false to drop the apply_patch tool beside edit. - applyPatch: true + applyPatch: false - id: tool-fs-search name: '@deepseek-ai/dsh-tool-fs-search' diff --git a/packages/bundle/web-app/presets/standard.patch.yml b/packages/bundle/web-app/presets/standard.patch.yml index a1aff022b0..e2e619fddd 100644 --- a/packages/bundle/web-app/presets/standard.patch.yml +++ b/packages/bundle/web-app/presets/standard.patch.yml @@ -28,7 +28,7 @@ name: '@deepseek-ai/dsh-tool-fs' config: # Set false to drop the apply_patch tool beside edit. - applyPatch: true + applyPatch: false - id: tool-fs-search name: '@deepseek-ai/dsh-tool-fs-search' config: From 3def5c9e2437e74ab0354fad5e526e8129d9f5b1 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 2 Nov 2026 15:14:56 +0800 Subject: [PATCH 15/32] test(fs): pin recorded patch tool and cover configurable defaults --- .../subagent-inheritance-snapshot.patch.yml | 5 +++++ apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts | 11 ++++++++--- apps/cli/tests/web-agent-presets.e2e.ts | 2 +- apps/web/tests/scaffold.ts | 15 +++++++++++++++ apps/web/tests/shipped-composition.e2e.ts | 1 - scripts/smoke-python-runtime.py | 1 + snapshots/sdk/background-wait-steer/cordis.yml | 5 +++++ snapshots/sdk/desktop-context/cordis.yml | 5 +++++ snapshots/sdk/desktop-ptc-context/cordis.yml | 5 +++++ snapshots/sdk/legacy-context.patch.yml | 5 +++++ snapshots/session/text-turn/cordis.yml | 5 +++++ 11 files changed, 55 insertions(+), 5 deletions(-) diff --git a/apps/cli/tests/profiles/headless/subagent-inheritance-snapshot.patch.yml b/apps/cli/tests/profiles/headless/subagent-inheritance-snapshot.patch.yml index 7511e92167..83547c6412 100644 --- a/apps/cli/tests/profiles/headless/subagent-inheritance-snapshot.patch.yml +++ b/apps/cli/tests/profiles/headless/subagent-inheritance-snapshot.patch.yml @@ -40,3 +40,8 @@ - id: session-log-deepseek config: enabled: false + +# The recorded tool catalog includes the optional patch tool. +- id: tool-fs + config: + applyPatch: true diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index a232e82874..81c9eb56ea 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -57,6 +57,7 @@ describe('Python SDK dsh profile keyless smoke', () => { { label: 'reports max-token turns with mapping enabled through env', envValue: 'true', editorEnabled: false }, { label: 'reports max-token turns with mapping disabled through env', envValue: 'false', editorEnabled: false }, { label: 'allows an explicit patch to enable str_replace_editor', envValue: undefined, editorEnabled: true }, + { label: 'allows an explicit patch to enable apply_patch', envValue: undefined, editorEnabled: false, patchEnabled: true }, { label: 'uses the PTC executor overlay despite the retired environment selector', envValue: undefined, editorEnabled: false, ptc: true }, // The shipped sdk profile mounts no observation policy: an edit of a file the model never read lands on disk. { label: 'edits a file the model never read through the shipped profile', envValue: undefined, editorEnabled: false, unreadEdit: true }, @@ -64,11 +65,14 @@ describe('Python SDK dsh profile keyless smoke', () => { const { envValue, editorEnabled } = scenario const ptc = 'ptc' in scenario && scenario.ptc const unreadEdit = 'unreadEdit' in scenario && scenario.unreadEdit + const patchEnabled = 'patchEnabled' in scenario && scenario.patchEnabled const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-runtime-smoke-')) const unreadFile = join(root, 'notes.txt') if (unreadEdit) await writeFile(unreadFile, 'alpha\n') const editorPatch = join(root, 'editor.patch.yml') + const fsPatch = join(root, 'fs.patch.yml') + if (patchEnabled) await writeFile(fsPatch, '- {id: tool-fs, config: {applyPatch: true}}\n') if (editorEnabled) await writeFile(editorPatch, [ '- insert:', ' - id: tool-str-replace-editor', @@ -103,6 +107,7 @@ describe('Python SDK dsh profile keyless smoke', () => { '--profile', 'sdk', ...(editorEnabled ? ['--patch', editorPatch] : []), + ...(patchEnabled ? ['--patch', fsPatch] : []), ...(ptc ? ['--patch', join(repoRoot, 'apps/cli/config/examples/ptc.yml')] : []), ], { cwd: repoRoot, @@ -189,16 +194,16 @@ describe('Python SDK dsh profile keyless smoke', () => { const toolNames = tools.map(tool => tool.name) expect(modelRequests[0]?.output_config).toEqual({ effort: 'max' }) expect(modelRequests[0]?.max_tokens).toBe(1234) - const expectedTools = ['read', 'write', 'edit', 'apply_patch', 'web_fetch', 'web_search', 'subagent', 'subagent_fork', + const expectedTools = ['read', 'write', 'edit', 'web_fetch', 'web_search', 'subagent', 'subagent_fork', 'present', 'ask_user_question', 'schedule_create', 'schedule_edit', 'schedule_list', 'schedule_view', 'plugin_manager'] const requestText = JSON.stringify(modelRequests[0]) if (ptc) { expect(toolNames).toEqual(['run_code']) - // The PTC overlay keeps the pre-apply_patch tool set, as the Web ptc preset does. - for (const tool of expectedTools.filter(tool => tool !== 'apply_patch')) expect(requestText.includes(`${tool}:`), tool).toBe(true) + for (const tool of expectedTools) expect(requestText.includes(`${tool}:`), tool).toBe(true) expect(requestText.includes('apply_patch:')).toBe(false) } else { expect(toolNames).toEqual(expect.arrayContaining(expectedTools)) + expect(toolNames.includes('apply_patch')).toBe(patchEnabled) expect(toolNames.includes('str_replace_editor')).toBe(editorEnabled) expect(toolNames).not.toContain('list_subagent_models') } diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 3907997d6a..e7aa033223 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -300,7 +300,7 @@ describe('the shipped Web composition', () => { // excluded for the reason the TUI composition e2e excludes them — they // depend on ripgrep being present on the machine. expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([ - 'apply_patch', 'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode', + 'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'list_subagent_models', 'plugin_manager', 'present', 'read', 'read_image', 'schedule_create', 'schedule_edit', 'schedule_list', 'schedule_view', 'send_message', 'skill', diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 92f307c095..4e45acf037 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -59,6 +59,7 @@ import { type NormalizeContext, } from '@deepseek-ai/dsh-session-snapshot' import type { Profile, ProfileContext } from '@deepseek-ai/dsh-app-boot' +import type { PresetDefinition } from '@deepseek-ai/dsh-agent-preset-registry' import { dshHomePath } from '@deepseek-ai/dsh-home-paths' import { LlmAdapter } from '@deepseek-ai/dsh-llm' import type { @@ -713,6 +714,20 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise row.id === 'preset-standard')!.config as PresetDefinition + overlayPatches.push({ + id: 'preset-standard', + config: { + ...config, + plugins: config.plugins.map(plugin => plugin.id === 'tool-fs' + ? { ...plugin, config: { ...plugin.config, applyPatch: true } } + : plugin), + }, + }) + } + // Live fields use a shared deployment layer; process-specific ports and roots stay in CLI overlays. const formEntries = new Set(['agent-default-model', 'agent-preset-registry', 'llm-deepseek', 'llm-pi-ai', 'web', 'web-search-deepseek', 'web-deepseek-api', 'agent-loop', 'subagent', 'bash-sandbox', 'pwsh-sandbox', diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 39d995e8c9..3ea259d0e3 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -473,7 +473,6 @@ function assertLeanChildRecord(agent: Agent): void { * Agent Note owns the rationale and its sources. */ const EXPECTED_TOOLS = [ - 'apply_patch', 'ask_user_question', 'bash', 'create_goal', diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index d886ed2db9..73ef059fe9 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -1522,6 +1522,7 @@ def smoke_sdk_dynamic_tools(base_url: str, executable: Path, update_snapshots: b task = root / "dynamic-tool-task.txt" task.write_text("Call snapshot_ping once.\n", encoding="utf-8") patch = write_profile_patch(root, "dynamic-tools.patch.yml", sessions, [ + {"id": "tool-fs", "config": {"applyPatch": True}}, {"id": "llm-deepseek", "config": {"models": [ {"id": "smoke-model", "toolUpdate": "addition-only"}, ]}}, diff --git a/snapshots/sdk/background-wait-steer/cordis.yml b/snapshots/sdk/background-wait-steer/cordis.yml index 04e4e08f00..c1c26f5d97 100644 --- a/snapshots/sdk/background-wait-steer/cordis.yml +++ b/snapshots/sdk/background-wait-steer/cordis.yml @@ -37,3 +37,8 @@ - insert: - id: background-wait-steer-fixture name: './wait-fixture.mjs' + +# The recorded tool catalog includes the optional patch tool. +- id: tool-fs + config: + applyPatch: true diff --git a/snapshots/sdk/desktop-context/cordis.yml b/snapshots/sdk/desktop-context/cordis.yml index 165911c611..93031877b9 100644 --- a/snapshots/sdk/desktop-context/cordis.yml +++ b/snapshots/sdk/desktop-context/cordis.yml @@ -34,3 +34,8 @@ - id: time-context config: timeZone: UTC + +# The recorded tool catalog includes the optional patch tool. +- id: tool-fs + config: + applyPatch: true diff --git a/snapshots/sdk/desktop-ptc-context/cordis.yml b/snapshots/sdk/desktop-ptc-context/cordis.yml index b614334b57..f4c5b5c582 100644 --- a/snapshots/sdk/desktop-ptc-context/cordis.yml +++ b/snapshots/sdk/desktop-ptc-context/cordis.yml @@ -40,3 +40,8 @@ - id: tool-executor-ptc disabled: false + +# The recorded tool catalog includes the optional patch tool. +- id: tool-fs + config: + applyPatch: true diff --git a/snapshots/sdk/legacy-context.patch.yml b/snapshots/sdk/legacy-context.patch.yml index a02fe180c2..5166783bd1 100644 --- a/snapshots/sdk/legacy-context.patch.yml +++ b/snapshots/sdk/legacy-context.patch.yml @@ -21,3 +21,8 @@ - id: tool-plugin-manager disabled: true + +# The recorded tool catalog includes the optional patch tool. +- id: tool-fs + config: + applyPatch: true diff --git a/snapshots/session/text-turn/cordis.yml b/snapshots/session/text-turn/cordis.yml index dd4fe733e6..59ced934d8 100644 --- a/snapshots/session/text-turn/cordis.yml +++ b/snapshots/session/text-turn/cordis.yml @@ -79,3 +79,8 @@ - id: session-log-deepseek config: enabled: false + +# The recorded tool catalog includes the optional patch tool. +- id: tool-fs + config: + applyPatch: true From c87fd429e089865a3b5b8cfca5636ac31797d44e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 2 Nov 2026 15:17:01 +0800 Subject: [PATCH 16/32] test(web): align optional tools for recording and replay --- apps/web/tests/scaffold.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 4e45acf037..407f29883e 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -715,7 +715,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise row.id === 'preset-standard')!.config as PresetDefinition overlayPatches.push({ id: 'preset-standard', From 3841ba6aad6fcfc2bedb1fed020628887c02c427 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 2 Nov 2026 15:20:07 +0800 Subject: [PATCH 17/32] test(acp): pin the recorded optional patch tool --- snapshots/acp/escalation-approved/cordis.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/snapshots/acp/escalation-approved/cordis.yml b/snapshots/acp/escalation-approved/cordis.yml index 08e9986ee3..2c75610603 100644 --- a/snapshots/acp/escalation-approved/cordis.yml +++ b/snapshots/acp/escalation-approved/cordis.yml @@ -83,3 +83,8 @@ - id: session-log-deepseek config: enabled: false + +# The recorded tool catalog includes the optional patch tool. +- id: tool-fs + config: + applyPatch: true From d3f7b8abe77cbdd44ea179d88a4a26ed9dfc0ebb Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:05:09 +0800 Subject: [PATCH 18/32] Merge pull request #7701 from deepseek-harness/fix/empty-session-sidebar-0.2.2 [0.2.2] fix(session-projection): compute exactly the requested projections for hot session summaries --- ...session-list-summary-projections.i18n.yaml | 4 +- ...-10-18-session-list-summary-projections.md | 4 +- ...-18-session-list-summary-projections.zh.md | 4 +- docs/event-producer-consumer.md | 10 +- docs/event-producer-consumer.zh.md | 10 +- docs/persistence-catalog.i18n.yaml | 8 +- docs/persistence-catalog.md | 4 +- docs/persistence-catalog.zh.md | 4 +- docs/persistence-schema.json | 4 +- docs/subsystems/session-projection.i18n.yaml | 4 +- docs/subsystems/session-projection.md | 20 +-- docs/subsystems/session-projection.zh.md | 20 +-- .../projection-snapshot-scope/guide.i18n.yaml | 16 +++ .../projection-snapshot-scope/guide.md | 19 +++ .../projection-snapshot-scope/guide.zh.md | 19 +++ .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 4 +- packages/api/session-controller/README.zh.md | 4 +- packages/api/session-controller/src/list.ts | 24 ++-- packages/api/session-controller/src/types.ts | 7 +- .../tests/session-cold.host.spec.ts | 3 +- .../tests/session-list-blank.host.spec.ts | 132 +++++++++++++++++- .../tests/session-projections.host.spec.ts | 33 +++-- .../tests/summary-list.host.spec.ts | 9 +- .../forge-session/src/projection.ts | 10 +- .../forge-session/src/references.ts | 4 + .../extensions/tool-cordis/src/api-catalog.ts | 12 +- .../token-meter/src/breakdown-projection.ts | 1 + packages/llm/token-meter/src/projection.ts | 6 +- .../llm/token-meter/src/usage-projection.ts | 1 + .../session-projection/README.i18n.yaml | 8 +- packages/session/session-projection/README.md | 8 +- .../session/session-projection/README.zh.md | 8 +- .../session/session-projection/src/index.ts | 12 +- .../tests/compatibility.spec.ts | 2 + .../session-projection/tests/registry.spec.ts | 124 ++++++++++++++++ .../session-projection/tests/summary.spec.ts | 5 + vitest.config.ts | 6 +- 38 files changed, 457 insertions(+), 120 deletions(-) create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/projection-snapshot-scope/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/projection-snapshot-scope/guide.md create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/projection-snapshot-scope/guide.zh.md diff --git a/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.i18n.yaml index 954a507a27..998bf5a661 100644 --- a/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.i18n.yaml @@ -9,8 +9,8 @@ en: 9f84c9bd85b6aace zh: 8ebb10962c219624 /agent-note-serve-session-list-rows-from-a-summary-key-set/decision: - en: 6e8f43d7a53c35fd - zh: a6e2bccc20474515 + en: aada3ca481120bff + zh: 1fa45a276a47e921 /agent-note-serve-session-list-rows-from-a-summary-key-set/alternatives-considered: en: ce8320f1032018ed zh: 4c5fad5e605bf524 diff --git a/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.md b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.md index 89e7b2c37d..6f66a06299 100644 --- a/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.md +++ b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.md @@ -13,10 +13,10 @@ A Session-list row carries every registered client view. Four of those views are The Session summary is every registered client view except the keys whose `SessionProjectionMap` entry carries the summary-exclusion mark. - [`packages/session/session-projection/src/types.ts`](../../../../packages/session/session-projection/src/types.ts) declares the type-only mark `SessionProjectionSummaryExcluded` over a unique symbol that is never exported and never present at runtime. `SessionProjectionViewValue` strips it distributively, so a nullable entry keeps its `null`; `SessionProjectionSummaryExcludedKey` tests the non-nullish part; `SessionProjectionSummaryValues` is every view value minus those keys. -- Each owning package wraps only its own map entry: `turnOutline` (session-turn-outline), `sessionStats` (session-stats), `todos` (tool-todo), and `imageLimits` (session-controller's boot-constant attachment limits, which every Session shares). The todo entry is `SessionProjectionSummaryExcluded | null`, because an intersection with `null` would absorb the null member. +- Each owning package wraps only its own map entry: `turnOutline` (session-turn-outline), `sessionStats` (session-stats), `todos` (tool-todo), `imageLimits` (session-controller's boot-constant attachment limits, which every Session shares), and `contextPressure` and `contextBreakdown` (token-meter). The todo entry is `SessionProjectionSummaryExcluded | null`, because an intersection with `null` would absorb the null member. - Registration carries the same decision as a plain boolean: `wire.summary` says whether a Session summary includes the unit, and omitting it means `true`. A key whose map entry carries the mark declares `false` so the runtime key set and the client view type agree. The mark itself has no runtime representation — `wire.view` and `viewSchema` are typed with `SessionProjectionViewValue`, which strips it, so the erase in `register` only widens the state type. - [`packages/session/session-projection/src/index.ts`](../../../../packages/session/session-projection/src/index.ts) exposes `summaryKeys(): ReadonlySet`, derived from the live registrations and rebuilt only when a new key registers or a key's last registrant disposes. `cachedSnapshot`, `viewCheckpoint`, and `snapshot` accept `ProjectionKeySelection` (`readonly Key[] | ReadonlySet`) and use a passed set as-is. -- [`packages/session/session-projection-cache/src/index.ts`](../../../../packages/session/session-projection-cache/src/index.ts) forwards that selection unchanged, and [`packages/api/session-controller/src/list.ts`](../../../../packages/api/session-controller/src/list.ts) passes `summaryKeys()` to the live and cold cached faces. `cachedPredecessorTitle` keeps its title-only read. +- [`packages/session/session-projection-cache/src/index.ts`](../../../../packages/session/session-projection-cache/src/index.ts) forwards that selection unchanged, and [`packages/api/session-controller/src/list.ts`](../../../../packages/api/session-controller/src/list.ts) passes `summaryKeys()` to the live registry's `snapshot` and to the cold cache read. `cachedPredecessorTitle` keeps its title-only read. - Client types only: `SessionSummary.projectionValues` narrows to `Partial`, while `SessionProjectionSnapshot.values` keeps every view value. No wire field, function, or UI name changes, and an opened Session still receives every key through `session/follow`, the control baseline, and `session.projections`. Estimated effect: with logs whose outlines dominate the projection payload, the four keys are estimated at roughly two thirds of a Session-list response for a few hundred Sessions — the outline alone near 57%, the figures and todo list near 5% together, and the shared image limits near 5% — and at roughly 85% of the same response after transport compression, where the outline's unique text barely compresses while the remaining keys do. diff --git a/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.zh.md b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.zh.md index 75c753c345..d3212fe6ce 100644 --- a/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.zh.md @@ -13,10 +13,10 @@ Session 列表行携带每个已注册的客户端视图。其中四个视图从 Session 摘要就是每个已注册客户端视图,减去 `SessionProjectionMap` 条目带摘要排除标记的键。 - [`packages/session/session-projection/src/types.ts`](../../../../packages/session/session-projection/src/types.ts) 声明类型层标记 `SessionProjectionSummaryExcluded`,它基于一个从不导出、运行时从不存在的 unique symbol。`SessionProjectionViewValue` 以分发方式去掉标记,可空条目因此保留 `null`;`SessionProjectionSummaryExcludedKey` 取非空部分判断;`SessionProjectionSummaryValues` 是全部视图值减去这些键。 -- 每个 owning package 只包装自己的 map 条目:`turnOutline`(session-turn-outline)、`sessionStats`(session-stats)、`todos`(tool-todo),以及 `imageLimits`(session-controller 的启动常量附件上限,每个 Session 共享)。todo 条目写作 `SessionProjectionSummaryExcluded | null`,因为与 `null` 求交会吞掉 null 成员。 +- 每个 owning package 只包装自己的 map 条目:`turnOutline`(session-turn-outline)、`sessionStats`(session-stats)、`todos`(tool-todo)、`imageLimits`(session-controller 的启动常量附件上限,每个 Session 共享),以及 `contextPressure` 和 `contextBreakdown`(token-meter)。todo 条目写作 `SessionProjectionSummaryExcluded | null`,因为与 `null` 求交会吞掉 null 成员。 - 注册用普通布尔值承载同一决定:`wire.summary` 表示 Session 摘要是否包含该单元,省略即 `true`。map 条目带标记的 key 声明 `false`,让运行时键集合与客户端视图类型一致。标记本身没有运行时表示 —— `wire.view` 与 `viewSchema` 的签名已用 `SessionProjectionViewValue` 去标记,因此 `register` 里的擦除只是放宽状态类型。 - [`packages/session/session-projection/src/index.ts`](../../../../packages/session/session-projection/src/index.ts) 提供 `summaryKeys(): ReadonlySet`,从实时注册派生,只有新键注册或某键最后一个注册者注销时才重建。`cachedSnapshot`、`viewCheckpoint`、`snapshot` 接受 `ProjectionKeySelection`(`readonly Key[] | ReadonlySet`),传入 Set 时直接使用。 -- [`packages/session/session-projection-cache/src/index.ts`](../../../../packages/session/session-projection-cache/src/index.ts) 原样转发该选择,[`packages/api/session-controller/src/list.ts`](../../../../packages/api/session-controller/src/list.ts) 把 `summaryKeys()` 传给 live 与 cold 两个缓存读取面。`cachedPredecessorTitle` 保持只读 title。 +- [`packages/session/session-projection-cache/src/index.ts`](../../../../packages/session/session-projection-cache/src/index.ts) 原样转发该选择,[`packages/api/session-controller/src/list.ts`](../../../../packages/api/session-controller/src/list.ts) 把 `summaryKeys()` 传给实时注册表的 `snapshot` 与冷会话的缓存读取。`cachedPredecessorTitle` 保持只读 title。 - 客户端只有类型变化:`SessionSummary.projectionValues` 收窄为 `Partial`,而 `SessionProjectionSnapshot.values` 保留全部视图值。wire 字段、函数名与 UI 名称均不变;打开的 Session 仍通过 `session/follow`、控制基线与 `session.projections` 收到每个键。 估算效果:当 outline 在投影负载中占主导时,四个键估计占几百个 Session 时列表响应的约三分之二 —— outline 单项约 57%,统计与 todo 合计约 5%,共享的图片上限约 5% —— 占传输压缩后同一响应的约 85%,因为 outline 的唯一文本几乎无法压缩,而其余键可以。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 976971787d..f2f81a5362 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -23,11 +23,11 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:700`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:680`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:707`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:686`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:693`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:701`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:681`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:708`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:687`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:694`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:55`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes`, `server` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 8f2a0d34a9..8cfc85b875 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -25,11 +25,11 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:700`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:680`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:707`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:686`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:693`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:701`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:681`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:708`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:687`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:694`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:55`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes`, `server` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 3b4f1d1275..53c7d45680 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -1353,8 +1353,8 @@ en: 2786fef560851bb4 zh: 5d787475356b2606 /session-persistence-event-catalog/resolved-persistence-types/string: - en: 1e484bd12747db9f - zh: 1e6fc6aa9066a702 + en: 0179cc082ac8637c + zh: e740ade45db5c263 /session-persistence-event-catalog/resolved-persistence-types/string~2: en: 0089620bf4638263 zh: 1922bef1713094bb @@ -1869,8 +1869,8 @@ en: 4e102391d685eff5 zh: 9762243a13014f3d /session-persistence-event-catalog/resolved-persistence-types/kind-user: - en: 984cac7296874653 - zh: 13b4ee707a8ce305 + en: eaecebdbcd5cc4e4 + zh: f519d7ade666f48a /session-persistence-event-catalog/resolved-persistence-types/kind-user-approval: en: a52d2dfbaf844127 zh: 4c5306ca40f20873 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 73baf8afad..94f5b932b6 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -5611,7 +5611,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:388`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:389`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7705,7 +7705,7 @@ Sources: [`packages/core/tools/src/index.ts:28`](../packages/core/tools/src/inde SHA-256: `a4afec59b75aad5d13ef82d6931f35cc2d98918503916cf069355b77c5e42302` -Sources: [`packages/api/session-controller/src/types.ts:397`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:398`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 46eded7418..4a9ff92ca4 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -5613,7 +5613,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:388`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:389`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7707,7 +7707,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` SHA-256: `a4afec59b75aad5d13ef82d6931f35cc2d98918503916cf069355b77c5e42302` -来源:[`packages/api/session-controller/src/types.ts:397`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:398`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 75aa6950eb..3563fb4cf5 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -252,7 +252,7 @@ {"digest":"4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731","names":["packages/llm/llm/src/message.ts#ToolResultMessage"],"sources":["packages/llm/llm/src/message.ts:173"]}, {"digest":"4b7ddaf3710b88ff9b8df66304f5f97bf03688fd19730eedc2e9535203c820ea","names":[],"sources":[]}, {"digest":"4bb451330ff699f36b45539ceb0eb2606ead72084e2c5bb637c36cc80e2252db","names":[],"sources":["packages/core/session/src/types.ts:198"]}, - {"digest":"4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b","names":["packages/api/session-controller/src/types.ts#SessionRequestId","packages/attachment/attachment/src/brand.ts#AttachmentId","packages/compaction/compaction/src/brand.ts#CompactionId","packages/core/session/src/types.ts#SessionId","packages/experimental/agent-team/src/types.ts#TeamId","packages/experimental/agent-team/src/types.ts#TeamMessageId","packages/experimental/agent-team/src/types.ts#TeamTaskId","packages/experimental/webhook/src/brand.ts#WebhookDeliveryId","packages/experimental/webhook/src/brand.ts#WebhookRuleId","packages/experimental/webhook/src/brand.ts#WebhookSourceId","packages/feedback/message-feedback/src/types.ts#MessageFeedbackVersion","packages/goal/goal/src/types.ts#GoalId","packages/interaction/commands/src/brand.ts#CommandId","packages/interaction/user-approval/src/types.ts#ApprovalRequestId","packages/jobs/jobs/src/brand.ts#JobId","packages/llm/llm-retry/src/brand.ts#RetryId","packages/llm/llm/src/brand.ts#MessageId","packages/llm/llm/src/brand.ts#ProviderRequestId","packages/llm/llm/src/brand.ts#ReasoningEffortId","packages/llm/llm/src/brand.ts#ToolCallId","packages/schedule/schedule/src/types.ts#ScheduleId","packages/session/session-title/src/index.ts#SessionTitleProviderId","packages/session/session-title/src/types.ts#SessionTitleProviderId","packages/workflow/workflow/src/types.ts#WorkflowRunId"],"sources":["packages/api/session-controller/src/types.ts:388","packages/attachment/attachment/src/brand.ts:6","packages/compaction/compaction/src/brand.ts:4","packages/core/session/src/types.ts:21","packages/experimental/agent-team/src/types.ts:21","packages/experimental/agent-team/src/types.ts:33","packages/experimental/agent-team/src/types.ts:9","packages/experimental/webhook/src/brand.ts:12","packages/experimental/webhook/src/brand.ts:6","packages/experimental/webhook/src/brand.ts:9","packages/feedback/message-feedback/src/types.ts:14","packages/goal/goal/src/types.ts:17","packages/interaction/commands/src/brand.ts:31","packages/interaction/user-approval/src/types.ts:17","packages/jobs/jobs/src/brand.ts:19","packages/llm/llm-retry/src/brand.ts:4","packages/llm/llm/src/brand.ts:16","packages/llm/llm/src/brand.ts:31","packages/llm/llm/src/brand.ts:43","packages/llm/llm/src/brand.ts:67","packages/schedule/schedule/src/types.ts:14","packages/session/session-title/src/index.ts:42","packages/session/session-title/src/types.ts:17","packages/workflow/workflow/src/types.ts:13"]}, + {"digest":"4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b","names":["packages/api/session-controller/src/types.ts#SessionRequestId","packages/attachment/attachment/src/brand.ts#AttachmentId","packages/compaction/compaction/src/brand.ts#CompactionId","packages/core/session/src/types.ts#SessionId","packages/experimental/agent-team/src/types.ts#TeamId","packages/experimental/agent-team/src/types.ts#TeamMessageId","packages/experimental/agent-team/src/types.ts#TeamTaskId","packages/experimental/webhook/src/brand.ts#WebhookDeliveryId","packages/experimental/webhook/src/brand.ts#WebhookRuleId","packages/experimental/webhook/src/brand.ts#WebhookSourceId","packages/feedback/message-feedback/src/types.ts#MessageFeedbackVersion","packages/goal/goal/src/types.ts#GoalId","packages/interaction/commands/src/brand.ts#CommandId","packages/interaction/user-approval/src/types.ts#ApprovalRequestId","packages/jobs/jobs/src/brand.ts#JobId","packages/llm/llm-retry/src/brand.ts#RetryId","packages/llm/llm/src/brand.ts#MessageId","packages/llm/llm/src/brand.ts#ProviderRequestId","packages/llm/llm/src/brand.ts#ReasoningEffortId","packages/llm/llm/src/brand.ts#ToolCallId","packages/schedule/schedule/src/types.ts#ScheduleId","packages/session/session-title/src/index.ts#SessionTitleProviderId","packages/session/session-title/src/types.ts#SessionTitleProviderId","packages/workflow/workflow/src/types.ts#WorkflowRunId"],"sources":["packages/api/session-controller/src/types.ts:389","packages/attachment/attachment/src/brand.ts:6","packages/compaction/compaction/src/brand.ts:4","packages/core/session/src/types.ts:21","packages/experimental/agent-team/src/types.ts:21","packages/experimental/agent-team/src/types.ts:33","packages/experimental/agent-team/src/types.ts:9","packages/experimental/webhook/src/brand.ts:12","packages/experimental/webhook/src/brand.ts:6","packages/experimental/webhook/src/brand.ts:9","packages/feedback/message-feedback/src/types.ts:14","packages/goal/goal/src/types.ts:17","packages/interaction/commands/src/brand.ts:31","packages/interaction/user-approval/src/types.ts:17","packages/jobs/jobs/src/brand.ts:19","packages/llm/llm-retry/src/brand.ts:4","packages/llm/llm/src/brand.ts:16","packages/llm/llm/src/brand.ts:31","packages/llm/llm/src/brand.ts:43","packages/llm/llm/src/brand.ts:67","packages/schedule/schedule/src/types.ts:14","packages/session/session-title/src/index.ts:42","packages/session/session-title/src/types.ts:17","packages/workflow/workflow/src/types.ts:13"]}, {"digest":"4d60dddaa65d3da8d4cd014d85ce3d2c2875664dd0433e331d8e9d395289686b","names":["packages/experimental/liveshare/src/host/viewer-principal.ts#ShareViewerPrincipal"],"sources":["packages/experimental/liveshare/src/host/viewer-principal.ts:24"]}, {"digest":"4e011753f8263f436efaabfbb3b4db53a98fc55342c5ded2daf7208b217be159","names":[],"sources":[]}, {"digest":"4e44c9118152f6829634bfd508e47669ab0497caffa380c94b4be91f9390d35d","names":[],"sources":[]}, @@ -472,7 +472,7 @@ {"digest":"a3c6f1ea38e9a61d7a9706057ba65aabe02dfe6d9dd90d4736aa62325d874531","names":[],"sources":[]}, {"digest":"a3edd1efd1fef21b72c55e213c8a8b24196b091abde2b89db52be0b2aaae3cfa","names":[],"sources":[]}, {"digest":"a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c","names":[],"sources":["packages/experimental/agent-team/src/types.ts:232"]}, - {"digest":"a4afec59b75aad5d13ef82d6931f35cc2d98918503916cf069355b77c5e42302","names":[],"sources":["packages/api/session-controller/src/types.ts:397"]}, + {"digest":"a4afec59b75aad5d13ef82d6931f35cc2d98918503916cf069355b77c5e42302","names":[],"sources":["packages/api/session-controller/src/types.ts:398"]}, {"digest":"a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc","names":[],"sources":[]}, {"digest":"a6b41f5bede8094d7bf635be5d69b1bd7a25e006ca9cf9415bceb9322f1a7feb","names":["packages/experimental/hook-protocol/src/types.ts#HookDialect"],"sources":["packages/experimental/hook-protocol/src/types.ts:48"]}, {"digest":"a6da9ad5d42ae83a6833a8288b076688e8e0801633ea36cbdd8a30a6d9f3160c","names":["packages/workflow/tool-workflow/src/types.ts#ToolWorkflowRunStartData"],"sources":["packages/workflow/tool-workflow/src/types.ts:14"]}, diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 917f2152fb..a1fe995e3b 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.i18n.yaml @@ -12,8 +12,8 @@ en: bc9d438b90fc2baa zh: df7cbea2dad3786b /session-projections/summary-reads: - en: d2bc9bcc775acfb7 - zh: b1d2ba74496305b8 + en: fcc9d07e5c41abb7 + zh: 798004ad1c66787b /session-projections/summary-reads/summary-vocabulary: en: 6a93a4e1682fd6e6 zh: 28729129c3d8a3b1 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 7ea7c06716..6284a8fb95 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -156,9 +156,9 @@ type ProjectionEventChangeListener = ( A Session summary carries every registered client view except the keys whose `SessionProjectionMap` entry carries the `SessionProjectionSummaryExcluded` mark. The mark has no runtime representation: it decides the client view type, while `wire.summary` decides the runtime key set, with omission meaning `true`. A key whose entry carries the mark declares `wire.summary: false` so the two agree; the field is a plain boolean, so the type does not enforce it. -`summaryKeys()` derives the key set from the live registrations and keeps returning the same instance until a registration or its disposal changes the key table. A summary consumer passes it to the existing key-taking faces — `cachedSnapshot(session, keys)` for cached cells and `viewCheckpoint(rows, keys)` for checkpoint rows, both keeping their watermark rules and both using a set without copying — so there is no summary-specific read face and no second definition of the set. +`summaryKeys()` derives the key set from the live registrations and keeps returning the same instance until a registration or its disposal changes the key table. A summary consumer passes it to the existing key-taking faces — `snapshot(session, keys)` for live Sessions and `viewCheckpoint(rows, keys)` for checkpoint rows, both using a set without copying — so there is no summary-specific read face and no second definition of the set. -A list row carries that summary projection, never the full projection: it promises the summary key set and nothing more. `snapshot`, `cachedSnapshot`, and `viewCheckpoint` remain complete when called without a key subset, which is how an opened Session, its follow baseline, and the control baseline keep every projected value; the `@` reference reads only its label keys through the same key-taking faces. +A list row carries that summary projection, never the full projection: it promises the summary key set and nothing more. `snapshot(session, keys)` computes only the units it returns. Without a key subset, `snapshot` computes every client-visible unit and no Host-only unit, and `viewCheckpoint` serves every wire key, so an opened Session, its follow baseline, and the control baseline carry every projected value; the `@` reference reads only its label keys through the same key-taking faces. The registry's `cachedSnapshot(session, keys)` is deprecated: it returns only values another read already computed and may omit keys. ### Summary vocabulary @@ -376,30 +376,22 @@ onEventChanged(listener: ProjectionEventChangeListener): () => void /** * One consistent cut over every registered client-visible unit for one session, read from * the watermark cache (missing cells fold lazily over the in-memory log). + * Only the returned units are computed: other units, including Host-only + * units without `wire`, are neither folded nor advanced by this read. * Fully synchronous — every value and `asOfSeq` reflect the same log * position. Each value passes its unit's `viewSchema` before leaving. * @param session - the session whose projection values are read. - * @param keys - optional client-visible outputs; state materialization remains complete. + * @param keys - optional client-visible outputs; only these units are computed. * @returns the snapshot; `values` is empty when no selected client-visible unit is registered. */ snapshot( session: Session, keys?: ProjectionKeySelection, ): ProjectionSnapshot -/** - * Read only already-materialized client-visible cells without folding history. - * Values may trail the live Session and are therefore hints, not a complete - * baseline. Missing cells are omitted. - * @param session - attached Session whose cached cells are inspected. - * @param keys - optional wire keys to view. - * @returns the lowest common cached cut, or `undefined` when no wire cell exists. - */ -cachedSnapshot( session: Session, keys?: ProjectionKeySelection, ): ProjectionSnapshot | undefined - /** * The keys a Session summary carries: every registered client-visible key * whose unit does not declare `summary: false`. The returned set is stable — * the same instance on every call — until a registration or its disposal * changes the key table, so a consumer may hold it across rows. Callers pass - * it to a key-taking read face such as `cachedSnapshot(session, summaryKeys())` + * it to a key-taking read face such as `snapshot(session, summaryKeys())` * or `viewCheckpoint(rows, summaryKeys())`; those faces use the set as-is. * @returns the current summary key set; do not mutate it. */ diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index b84720e804..b924cc78a0 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -156,9 +156,9 @@ type ProjectionEventChangeListener = ( Session 摘要包含每个已注册客户端视图,但排除 `SessionProjectionMap` 条目带 `SessionProjectionSummaryExcluded` 标记的 key。标记没有运行时表示:它决定客户端视图类型,`wire.summary` 决定运行时键集合,省略即 `true`。map 条目带标记的 key 应声明 `wire.summary: false` 以保持一致;该字段是普通布尔值,类型层不做强制。 -`summaryKeys()` 从实时注册派生该集合,并在注册或注销改变 key 表之前持续返回同一实例。摘要消费方把它传给既有的带 key 读取面 —— 缓存 cell 用 `cachedSnapshot(session, keys)`,checkpoint 行用 `viewCheckpoint(rows, keys)`,两者保持各自的水位规则且直接使用 Set、不做复制 —— 因此不存在摘要专用读取面,集合也只有一处定义。 +`summaryKeys()` 从实时注册派生该集合,并在注册或注销改变 key 表之前持续返回同一实例。摘要消费方把它传给既有的带 key 读取面 —— 实时 Session 用 `snapshot(session, keys)`,checkpoint 行用 `viewCheckpoint(rows, keys)`,两者都直接使用 Set、不做复制 —— 因此不存在摘要专用读取面,集合也只有一处定义。 -列表行携带的是这个摘要投影,绝不是全量投影:它只承诺摘要键集合,不承诺更多。`snapshot`、`cachedSnapshot` 与 `viewCheckpoint` 在不传 key 子集时仍是完整的,打开的 Session、其 follow 基线与控制基线因此保留每个投影值;`@` 引用只通过同一组带 key 的读取面读取其标签 key。 +列表行携带的是这个摘要投影,绝不是全量投影:它只承诺摘要键集合,不承诺更多。`snapshot(session, keys)` 只计算它返回的单元。不传 key 子集时,`snapshot` 计算每个客户端可见单元、不计算 Host 专用单元,`viewCheckpoint` 返回每个 wire key,因此打开的 Session、其 follow 基线与控制基线携带每个投影值;`@` 引用只通过同一组带 key 的读取面读取其标签 key。注册表的 `cachedSnapshot(session, keys)` 已废弃:它只返回其他读取已经算好的值,可能缺少 key。 ### 摘要词汇 @@ -376,30 +376,22 @@ onEventChanged(listener: ProjectionEventChangeListener): () => void /** * One consistent cut over every registered client-visible unit for one session, read from * the watermark cache (missing cells fold lazily over the in-memory log). + * Only the returned units are computed: other units, including Host-only + * units without `wire`, are neither folded nor advanced by this read. * Fully synchronous — every value and `asOfSeq` reflect the same log * position. Each value passes its unit's `viewSchema` before leaving. * @param session - the session whose projection values are read. - * @param keys - optional client-visible outputs; state materialization remains complete. + * @param keys - optional client-visible outputs; only these units are computed. * @returns the snapshot; `values` is empty when no selected client-visible unit is registered. */ snapshot( session: Session, keys?: ProjectionKeySelection, ): ProjectionSnapshot -/** - * Read only already-materialized client-visible cells without folding history. - * Values may trail the live Session and are therefore hints, not a complete - * baseline. Missing cells are omitted. - * @param session - attached Session whose cached cells are inspected. - * @param keys - optional wire keys to view. - * @returns the lowest common cached cut, or `undefined` when no wire cell exists. - */ -cachedSnapshot( session: Session, keys?: ProjectionKeySelection, ): ProjectionSnapshot | undefined - /** * The keys a Session summary carries: every registered client-visible key * whose unit does not declare `summary: false`. The returned set is stable — * the same instance on every call — until a registration or its disposal * changes the key table, so a consumer may hold it across rows. Callers pass - * it to a key-taking read face such as `cachedSnapshot(session, summaryKeys())` + * it to a key-taking read face such as `snapshot(session, summaryKeys())` * or `viewCheckpoint(rows, summaryKeys())`; those faces use the set as-is. * @returns the current summary key set; do not mutate it. */ diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/projection-snapshot-scope/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/projection-snapshot-scope/guide.i18n.yaml new file mode 100644 index 0000000000..b1cc4b5f88 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/projection-snapshot-scope/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/projection-snapshot-scope/guide.md +/: + en: 82a6e0261297097c + zh: c86e496c258e72b2 +/projection-snapshot-scope: + en: c8b08322a943d6b2 + zh: 31862a259b7738bb +/projection-snapshot-scope/change: + en: d95cd644b01419a1 + zh: 89038a6de9b74e8a +/projection-snapshot-scope/migration: + en: 408d88a4ea6fe3a9 + zh: 397de0dc2d8318df diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/projection-snapshot-scope/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/projection-snapshot-scope/guide.md new file mode 100644 index 0000000000..66138aa3f2 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/projection-snapshot-scope/guide.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "Projection snapshot reads compute only the returned units, the registry cached snapshot read is deprecated, and Session list summaries omit context pressure and breakdown." +--- + +# Projection snapshot scope + +English | [中文](guide.zh.md) + +## Change + +`ctx.sessionProjections.snapshot(session, keys)` from `@deepseek-ai/dsh-session-projection` computes only the units it returns: with `keys` it computes only the selected units, and without `keys` it computes every unit that defines `wire` and no Host-only unit. A plugin can no longer rely on other units being computed after it calls `snapshot`. The registry's `cachedSnapshot(session, keys)` is deprecated; it returns only values another read already computed and may omit keys. The persisted cache read `ctx.sessionProjectionCache.cachedSnapshot(header, keys)` is unchanged. `session.list` summary `projections` no longer carry `contextBreakdown` or `contextPressure`; the `@deepseek-ai/dsh-token-meter` entries are now `SessionProjectionSummaryExcluded`. Plugins and SDK or wire clients that read these values are affected. + +## Migration + +1. Read Host state of your own unit through the handle that `await ctx.sessionProjections.register(definition)` returns, with `handle.stateOf(session)`, instead of calling `snapshot` first and reading cells afterwards. +2. Replace calls to the registry's `cachedSnapshot(session, keys)` with `snapshot(session, keys)`. +3. Read `contextBreakdown` and `contextPressure` from an opened Session, for example the Session projection snapshot or follow baseline, instead of from `session.list` rows. +4. Rebuild your plugins and clients, confirm that type checks pass without `no-deprecated` findings for `cachedSnapshot`, and confirm that an opened Session still shows its context meter. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/projection-snapshot-scope/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/projection-snapshot-scope/guide.zh.md new file mode 100644 index 0000000000..f11e97173b --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/projection-snapshot-scope/guide.zh.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "投影 snapshot 读取只计算返回的单元,注册表的 cached snapshot 读取已废弃,Session 列表摘要不再包含 context pressure 和 breakdown。" +--- + +# 投影 snapshot 的计算范围 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-session-projection` 的 `ctx.sessionProjections.snapshot(session, keys)` 只计算它返回的单元:传 `keys` 时只计算选中的单元,不传时计算每个定义了 `wire` 的单元、不计算 Host 专用单元。插件不能再依赖调用 `snapshot` 之后其他单元已经算好。注册表的 `cachedSnapshot(session, keys)` 已废弃,它只返回其他读取已经算好的值,可能缺少 key。持久化缓存的读取 `ctx.sessionProjectionCache.cachedSnapshot(header, keys)` 不变。`session.list` 摘要的 `projections` 不再携带 `contextBreakdown` 和 `contextPressure`,`@deepseek-ai/dsh-token-meter` 的这两项改为 `SessionProjectionSummaryExcluded`。读取这些值的插件以及 SDK 或 wire 客户端受影响。 + +## 迁移 + +1. 通过 `await ctx.sessionProjections.register(definition)` 返回的句柄,用 `handle.stateOf(session)` 读取自己单元的 Host 状态,不要先调用 `snapshot` 再读取 cell。 +2. 把对注册表 `cachedSnapshot(session, keys)` 的调用替换为 `snapshot(session, keys)`。 +3. 从打开的 Session 读取 `contextBreakdown` 和 `contextPressure`,例如 Session 投影 snapshot 或 follow 基线,不要从 `session.list` 行读取。 +4. 重新构建插件和客户端,确认类型检查通过、`cachedSnapshot` 没有 `no-deprecated` 告警,并确认打开的 Session 仍显示上下文用量。 diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 010c41bf94..8bea04d9db 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1fdf707322fb6996 zh: 0204f7e9d4aa3cce /session-controller/use-this-package: - en: d3d53e899960d96e - zh: 16b2bc7fe74215d8 + en: 22136176884a239b + zh: b8eeea5908b9a6fd /session-controller/client-references: en: 41bdda1ac32df558 zh: aa5d0e325924066b diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 6fd7b24561..3a9dc2cd6b 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -33,7 +33,7 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se The Client journal validates current Session event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host. -Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. A receipt names a staged file or a staged image, and an image receipt subjects the prompt to the same image-input check as an encoded image. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values. +Each endpoint states its activation policy. For a cold Session, list reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. An attached Session is summarized from its in-memory log. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. A receipt names a staged file or a staged image, and an image receipt subjects the prompt to the same image-input check as an encoded image. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values. Every Remote endpoint is access-controlled: a Peer whose principal is not the owner reaches an endpoint only when, on every resource the call touches, a binding of that principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) grants a role listing the action ([Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)). The controller injects `remoteAccess` and registers a default resolver for the `session` resource kind through `typert.resources`. It reads a named `sessionId` parameter, otherwise the first object argument's `sessionId` or `address`; unrelated string parameters name no Session. An object containing both `sessionId` and `address` is invalid, even when they name the same Session. `session/events`, the deprecated `session/page`, `session/observeEvent`, and `session/follow` use this resolver to read `address.sessionId` for an ordinary Session address and `address.childSessionId` for a subagent address; whether access to the parent Session covers its children is the policy's rule. `session/projections`, `session/observeProjections`, `session/state`, `session/observeState`, and `session/wait` read the Session resource from `request.sessionId`, so each call and subscription names only the Sessions it may read. `session/list`, `session/search`, and `session/control` read no resource, so their calls touch the `global` resource and need a `global` role; for a non-owner caller, `session/list` returns only the rows, `session/search` only the hits, and `session/control` only the baseline projections and projection frames, whose Session `ctx.remoteAccess.allows()` admits for the same action, so that action also belongs to a `session` role; owner results are unfiltered. `session/prompt` builds the durable `user-rpc` message source in its Remote method and records `principal` only for a non-owner caller, read through `principalOf(this.ctx.invocation)`; a source without `principal` belongs to the owner. @@ -41,7 +41,7 @@ Every Remote endpoint is access-controlled: a Peer whose principal is not the ow List rows carry `formatStatus` when available, so Clients can identify migration-required Sessions without requesting their projections. -Host list generation yields between complete rows only when its configured work slice is exhausted; cheap remaining work returns without a forced yield. Request cancellation is checked per row and after each yield and rejects without partial results. Each row is synchronous; the list does not promise one cross-Session snapshot. A Session already queued as cold remains a cached row if it attaches during a yield: Client in-flight mutation replay corrects its availability, and sequenced projections supersede cached hints. +Host list generation yields between complete rows only when its configured work slice is exhausted; cheap remaining work returns without a forced yield. Request cancellation is checked per row and after each yield and rejects without partial results. Each row is synchronous; the list does not promise one cross-Session snapshot. A Session already queued as cold remains a cached row if it attaches during a yield: Client in-flight mutation replay corrects its availability, and sequenced projections supersede cached hints. An attached Session's summary reads its blankness and last-prompt recency through the `sessionListMetadata` projection handle, and its projection hints carry every summary key computed through `snapshot`. The first summary of an attached Session computes these projections over its whole in-memory log, and later summaries advance them incrementally; this computation runs inside one synchronous row, so `listWorkSliceMs` cannot split it. Client list rows and resident Sessions use the current `sessionListMetadata` projection to reject stale blank-session hints; recency is the later of the summary timestamp and the projected last user prompt. When SessionManager creates an instance before its list row arrives, it reconciles blankness with metadata already retained for that Session. An opened conversation is therefore not reused by New Session even when an older list response still marks it blank. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 8729f555ba..9259bdad17 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" Client journal 在发布 follow 快照、live entry 或历史页之前验证当前 Session 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。 -每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。凭证指向一个暂存文件或一张暂存图片;图片凭证与编码图片一样要经过图片输入检查。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。 +每个 endpoint 都声明自己的激活策略。对冷 Session,列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。已接入 Session 从其内存日志生成摘要。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。凭证指向一个暂存文件或一张暂存图片;图片凭证与编码图片一样要经过图片输入检查。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。 每个 Remote 端点都受访问控制:principal 不是 owner 的 Peer 只有在本次调用访问的每个资源上,该 principal kind 在 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 都有某个 binding 授予列出该 action 的 role 时,才能调用该端点([Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md))。控制器注入 `remoteAccess`,并经 `typert.resources` 为 `session` 资源种类登记默认 resolver。它读取具名 `sessionId` 参数,否则读取第一个对象参数的 `sessionId` 或 `address`;无关字符串参数不指明 Session。对象同时包含 `sessionId` 和 `address` 时无效,即使两者指向同一 Session。`session/events`、已弃用的 `session/page`、`session/observeEvent` 与 `session/follow` 使用该 resolver:对普通 Session 地址读 `address.sessionId`,对 subagent 地址读 `address.childSessionId`;父会话的访问权是否覆盖其子会话由 policy 决定。`session/projections`、`session/observeProjections`、`session/state`、`session/observeState` 与 `session/wait` 从 `request.sessionId` 读取会话资源,因此每次调用与订阅都只点名它可读的会话。`session/list`、`session/search` 与 `session/control` 不读取资源,其调用访问 `global` 资源,需要 `global` role;对非 owner 调用方,`session/list` 只返回其 Session 被 `ctx.remoteAccess.allows()` 按同一 action 允许的行,`session/search` 只返回这类 Session 的命中,`session/control` 也只返回这类 Session 的 baseline projection 与 projection 帧,因此该 action 也要列在 `session` role 中;owner 的结果不过滤。`session/prompt` 在其 Remote 方法内构造持久的 `user-rpc` 消息 source,且只为非 owner 调用方记录经 `principalOf(this.ctx.invocation)` 读取的 `principal`;不带 `principal` 的 source 属于 owner。 @@ -41,7 +41,7 @@ Client journal 在发布 follow 快照、live entry 或历史页之前验证当 列表行在状态可用时携带 `formatStatus`,Client 无需请求其投影即可识别需要迁移的 Session。 -Host 仅在配置的工作时间片耗尽时,于完整条目之间让出事件循环;剩余工作较少时直接返回,不强制让出。每行及每次让出之后检查请求取消,取消时拒绝返回部分结果。每行同步计算;列表不承诺跨 Session 的统一快照。已经进入冷态队列的 Session 若在让出期间接入,仍返回缓存行:Client 对进行中请求的变更重放会纠正其可用性,带序号的投影会覆盖缓存提示。 +Host 仅在配置的工作时间片耗尽时,于完整条目之间让出事件循环;剩余工作较少时直接返回,不强制让出。每行及每次让出之后检查请求取消,取消时拒绝返回部分结果。每行同步计算;列表不承诺跨 Session 的统一快照。已经进入冷态队列的 Session 若在让出期间接入,仍返回缓存行:Client 对进行中请求的变更重放会纠正其可用性,带序号的投影会覆盖缓存提示。已接入 Session 的摘要通过 `sessionListMetadata` 投影句柄读取是否为空和最后一次提示词的最近活动时间,其投影提示携带通过 `snapshot` 计算的每个摘要 key。已接入 Session 的首次摘要在其完整内存日志上计算这些投影,之后的摘要增量推进;这次计算在单行的同步工作内进行,`listWorkSliceMs` 无法切分它。 Client 列表行和驻留 Session 使用当前 `sessionListMetadata` 投影纠正过期的空白会话提示;最近活动时间取摘要时间戳与投影中最后一次用户提示词时间的较晚值。当 SessionManager 在列表行到达前创建实例时,会使用已保留的该 Session 元数据对账空白状态。因此,即使旧列表响应仍将已有对话标为空白,新会话操作也不会复用已经打开过的对话。 diff --git a/packages/api/session-controller/src/list.ts b/packages/api/session-controller/src/list.ts index d258ace5c8..830ebe21a9 100644 --- a/packages/api/session-controller/src/list.ts +++ b/packages/api/session-controller/src/list.ts @@ -6,7 +6,7 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent-preset-registry' import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' -import type { ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection' +import type { ProjectionHandle, ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-session-projection-cache' import { SessionQueryError, type SessionRecord, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' @@ -76,6 +76,8 @@ export function truncateUnicodeCodePoints(value: string, maximum: number): strin /** Owns list projection registration, bounded cold summaries, and authorized search. */ export class ApiSessionList { + private listMetadata!: ProjectionHandle + /** * @param ctx - Host context carrying Session, query, persistence, and projection services. * @param workSliceMs - Resolved positive integral list-work budget in milliseconds. @@ -87,7 +89,7 @@ export class ApiSessionList { * @returns when all required registrations are ready. */ async initialize(): Promise { - await this.ctx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({ + this.listMetadata = await this.ctx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({ key: 'sessionListMetadata', stateSchema: sessionListMetadataSchema, init: () => ({ blank: true, lastPromptAt: null }), @@ -111,19 +113,25 @@ export class ApiSessionList { /** * Build one current attached-Session summary. + * Blankness and recency come from the `sessionListMetadata` handle; projection + * hints carry every summary key computed through `snapshot`. The first call + * for a Session computes these projections over its whole in-memory log, and + * later calls advance them incrementally. The handle read and the hint with + * the same key come from one cell; the handle read does not depend on any + * other unit's view. * @param session - attached Session to summarize. * @returns current list metadata and available projections. */ summaryFor(session: Session): SessionSummary { + const metadata = this.listMetadata.stateOf(session) const projections = this.projectionsFor(session.header, session) - const metadata = projections?.values.sessionListMetadata return { sessionId: session.id, updatedAt: updatedAt(session.header, metadata), agentAvailable: this.ctx.agents.get(session.id)?.session === session, formatStatus: 'current', running: this.ctx.agents.get(session.id)?.status === 'running', - blank: metadata?.blank ?? session.seq === 0, + blank: metadata.blank, ...listFields(session.header), ...(projections === undefined ? {} : { projections }), } @@ -304,9 +312,9 @@ export class ApiSessionList { // itself summary-excluded. const keys = this.ctx.sessionProjections.summaryKeys() if (session !== undefined) { - // The live registry computed the block for this Session: its watermark + // The live registry computes the block for this Session: its watermark // shares the sequence space of the Session's baselines and frames. - return hintsOf('sequenced', this.ctx.sessionProjections.cachedSnapshot(session, keys)) + return hintsOf('sequenced', this.ctx.sessionProjections.snapshot(session, keys)) } // A cold row reads the persisted cache by header alone; the cache serves // seeded and unseeded lifecycles alike because a listing never seeds a @@ -334,8 +342,8 @@ function hintsOf( block: ProjectionSnapshot | undefined, ): SessionProjectionHints | undefined { if (block === undefined || Object.keys(block.values).length === 0) return undefined - // Listing hints contain every summary wire value the source currently holds - // but remain partial: missing cells and cache rows are never materialized here. + // Listing hints contain every summary wire value the source holds; cached + // hints remain partial because missing cache rows are never folded here. return { kind, asOfSeq: block.asOfSeq, values: block.values } } diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 6c3c737cb6..1d846d4c9a 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -36,9 +36,10 @@ export interface SessionListMetadata { } /** - * Every available summary wire value a Session-list row carries as partial, - * possibly stale hints. The block omits the keys whose unit declares - * `summary: false`. `kind` and `asOfSeq` are independent facts: `kind` says + * Summary wire values a Session-list row carries. A `sequenced` block carries + * every summary key computed at its `asOfSeq`; a `cached` block carries the + * values the persisted cache holds and may omit keys or trail the Session. + * The block omits the keys whose unit declares `summary: false`. `kind` and `asOfSeq` are independent facts: `kind` says * which sequence space `asOfSeq` belongs to, and therefore how a client may * merge the block; `asOfSeq` is the producer's watermark in that space. */ diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index 560c05514f..7cfc874c14 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -275,7 +275,8 @@ describe('attached updatedAt tracks human prompts', () => { const listed = await remote.list(request({})) if (!listed.ok) throw new Error('list failed') const summary = listed.value.items.find(item => item.sessionId === 'resumed-untouched') - expect(summary?.updatedAt).toBe(500) + // The resumed log's last human prompt, not its creation time or the later pickup. + expect(summary?.updatedAt).toBe(worked) // A lifecycle boundary is not a human update. resumed.append('turn/start', { turn: 2 }) diff --git a/packages/api/session-controller/tests/session-list-blank.host.spec.ts b/packages/api/session-controller/tests/session-list-blank.host.spec.ts index f433d20c1b..e91fd6689e 100644 --- a/packages/api/session-controller/tests/session-list-blank.host.spec.ts +++ b/packages/api/session-controller/tests/session-list-blank.host.spec.ts @@ -5,21 +5,40 @@ * fresh session keeps it list-hidden and reusable, while the first accepted * prompt's turn/start clears it. The host/session-added frame shares the * same predicate function (covered by the workspace spec's frame assertion). + * A resumed Session computes its own list metadata (blankness and last-prompt + * recency) when it is published, independent of which other projections were + * read before publication. */ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' -import SessionStore from '@deepseek-ai/dsh-session' -import type { Session } from '@deepseek-ai/dsh-session' +import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionSeq } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { CommandId } from '@deepseek-ai/dsh-commands/brand' // Side-effect type imports: the configuration-event SessionEventMap merges. import type {} from '@deepseek-ai/dsh-permission-presets' import type {} from '@deepseek-ai/dsh-sandbox-policy' +import type { SessionSummary } from '../src/types.ts' import { createSessionTestRemote, type TestSessionRemote } from './test-remote.ts' +const ownedContexts = new Set() +const ownedRoots = new Set() +afterEach(async () => { + await Promise.all([...ownedContexts].map(ctx => ctx.fiber.dispose())) + ownedContexts.clear() + await Promise.all([...ownedRoots].map(root => rm(root, { recursive: true, force: true }))) + ownedRoots.clear() +}) + async function harness(): Promise<{ ctx: Context; remote: TestSessionRemote; attach: (session: Session) => Promise }> { const ctx = new Context() await ctx.plugin(SessionStore) @@ -74,3 +93,110 @@ describe('summary blank = conversation not started', () => { expect(await listBlank(remote, session.id)).toBe(false) }) }) + +/** + * Mount the real AgentLoop over JSONL persistence without the projection + * cache, whose creation checkpoint would otherwise compute every unit before + * the Session Controller observes the published Session. + */ +async function resumeHarness(): Promise<{ ctx: Context; remote: TestSessionRemote; cwd: string }> { + const root = await mkdtemp(join(tmpdir(), 'dsh-list-blank-')) + ownedRoots.add(root) + const ctx = new Context() + ownedContexts.add(ctx) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(JsonlSessionPersistence, { root }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: root }) + await mountAgentLoopTestHarness(ctx) + return { ctx, remote, cwd: root } +} + +/** Store one Session through persistence so a later resume seeds it from disk. */ +async function storeSession(ctx: Context, id: SessionId, cwd: string, events: readonly SessionEvent[]): Promise { + const prepared = ctx.sessions.prepare(id, { meta: { cwd } }) + const handle = await ctx.sessionPersistence.create(prepared.header) + await handle.append(events) + await handle.close() +} + +/** Configuration events a Session records at creation, before any prompt. */ +const configurationEvents = [ + { type: 'permission/preset', seq: SessionSeq(0), time: 1, data: { preset: 'danger-full-access' } }, + { type: 'sandbox/mode', seq: SessionSeq(1), time: 2, data: { mode: 'danger-full-access' } }, + { type: 'approval/policy', seq: SessionSeq(2), time: 3, data: { policy: 'never' } }, +] as SessionEvent[] + +/** Resume one stored Session and collect the summaries published for it. */ +async function resumeAndObserve(ctx: Context, remote: TestSessionRemote, id: SessionId): Promise<{ + added: SessionSummary[] + listed: SessionSummary | undefined +}> { + const added: SessionSummary[] = [] + ctx.on('api-session/added', (summary) => { + if (summary.sessionId === id) added.push(summary) + }) + await ctx.agents.resume({ resumeSessionId: id }) + const listed = await remote.list({}) + if (!listed.ok) throw new Error('list failed') + return { added, listed: listed.value.items.find(item => item.sessionId === id) } +} + +describe('resumed Session summaries', () => { + it('publish a turnless resumed Session as blank', async () => { + const { ctx, remote, cwd } = await resumeHarness() + const id = 'session-resumed-blank' as SessionId + await storeSession(ctx, id, cwd, configurationEvents) + + const { added, listed } = await resumeAndObserve(ctx, remote, id) + + expect(added.length).toBeGreaterThan(0) + expect(added.map(summary => summary.blank)).toEqual(added.map(() => true)) + expect(listed?.blank).toBe(true) + }) + + it('publish a resumed conversation as non-blank', async () => { + const { ctx, remote, cwd } = await resumeHarness() + const id = 'session-resumed-conversation' as SessionId + await storeSession(ctx, id, cwd, [ + ...configurationEvents, + { type: 'turn/start', seq: SessionSeq(3), time: 4, data: { turn: 1 } }, + { + type: 'user/message', seq: SessionSeq(4), time: 4, + data: createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }), + surfaceOp: 'append', + }, + { type: 'turn/end', seq: SessionSeq(5), time: 5, data: { turn: 1, reason: { kind: 'completed' } } }, + ] as SessionEvent[]) + + const { added, listed } = await resumeAndObserve(ctx, remote, id) + + expect(added.length).toBeGreaterThan(0) + expect(added.map(summary => summary.blank)).toEqual(added.map(() => false)) + expect(listed?.blank).toBe(false) + }) + + it('publish a resumed conversation with its last prompt time from the first frame', async () => { + const { ctx, remote, cwd } = await resumeHarness() + const id = 'session-resumed-recency' as SessionId + const promptAt = Date.now() + 60_000 + await storeSession(ctx, id, cwd, [ + ...configurationEvents, + { type: 'turn/start', seq: SessionSeq(3), time: promptAt, data: { turn: 1 } }, + { + type: 'user/message', seq: SessionSeq(4), time: promptAt, + data: createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }), + surfaceOp: 'append', + }, + { type: 'turn/end', seq: SessionSeq(5), time: promptAt + 1, data: { turn: 1, reason: { kind: 'completed' } } }, + ] as SessionEvent[]) + + const { added, listed } = await resumeAndObserve(ctx, remote, id) + + expect(added.length).toBeGreaterThan(0) + expect(added.map(summary => [summary.updatedAt, summary.blank])).toEqual(added.map(() => [promptAt, false])) + expect(Object.keys(added[0]?.projections?.values ?? {})).toContain('sessionListMetadata') + expect(Object.keys(added[0]?.projections?.values ?? {})).toEqual(expect.arrayContaining([...ctx.sessionProjections.summaryKeys()])) + expect(listed?.updatedAt).toBe(promptAt) + expect(listed?.blank).toBe(false) + }) +}) diff --git a/packages/api/session-controller/tests/session-projections.host.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts index 89dc6d6963..fe8fc245c2 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -21,6 +21,7 @@ import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, Sess import type { Session, SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import type { SessionProjectionSummaryExcluded } from '@deepseek-ai/dsh-session-projection/types' import SessionProjectionCache, { projectionCacheDomainSpec } from '@deepseek-ai/dsh-session-projection-cache' import { titleProjectionDefinition } from '@deepseek-ai/dsh-session-title' import Storage from '@deepseek-ai/dsh-storage' @@ -43,6 +44,7 @@ let nextHarnessSession = 1 declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionMap { 'test/last-user': { text: string } | null + 'test/summary-excluded': SessionProjectionSummaryExcluded } } @@ -468,7 +470,7 @@ describe('session.history projections block', () => { }) describe('session.list projections column', () => { - it('serves every already-materialized wire value from the live registry without folding', async () => { + it('serves every summary wire value from the live registry', async () => { const { ctx, session } = await harness(true) await ctx.sessionProjections.register(lastUserUnit()) const gateway = await remote(ctx) @@ -502,29 +504,38 @@ describe('session.list projections column', () => { expect(row?.projections?.values.agentPreset).toBe('minimal') }) - it('omits an unmaterialized live projection instead of folding history for listing', async () => { + it('computes every summary key for a live row without computing summary-excluded units', async () => { const { ctx, session } = await harness(true) seedMessages(session, 1) - const unit = lastUserUnit() - const apply = vi.fn(unit.apply) - await ctx.sessionProjections.register({ ...unit, apply }) + await ctx.sessionProjections.register(lastUserUnit()) + const excludedApply = vi.fn((state: number) => state + 1) + await ctx.sessionProjections.register({ + key: 'test/summary-excluded', + stateSchema: z.number().int().nonnegative(), + init: () => 0, + apply: excludedApply, + wire: { viewSchema: z.number().int().nonnegative(), view: state => state, summary: false }, + stateVersion: 1, + }) const response = await (await remote(ctx)).list(request({})) if (!response.ok) throw new Error('unreachable') const row = response.value.items.find(item => item.sessionId === session.id) - expect(row).toBeDefined() - expect('test/last-user' in (row?.projections?.values ?? {})).toBe(false) - expect(apply).not.toHaveBeenCalled() + expect(row?.projections?.values['test/last-user']).toEqual({ text: 'm0' }) + expect(row?.projections?.asOfSeq).toBe(session.seq - 1) + expect('test/summary-excluded' in (row?.projections?.values ?? {})).toBe(false) + expect(excludedApply).not.toHaveBeenCalled() }) - it('omits the column entirely when no registry is mounted', async () => { + it('carries every registered summary key, which is only its own list metadata when no other unit is registered', async () => { const { ctx, session } = await harness(false) seedMessages(session, 1) const response = await (await remote(ctx)).list(request({})) if (!response.ok) throw new Error('unreachable') const row = response.value.items.find(item => item.sessionId === session.id) - expect(row).toBeDefined() - expect(row !== undefined && 'projections' in row).toBe(false) + expect(row?.projections?.kind).toBe('sequenced') + expect(Object.keys(row?.projections?.values ?? {})).toEqual([...ctx.sessionProjections.summaryKeys()]) + expect(Object.keys(row?.projections?.values ?? {})).toEqual(['sessionListMetadata']) }) it('serves the summary projection hints from the cache with zero log loads', async () => { diff --git a/packages/api/session-controller/tests/summary-list.host.spec.ts b/packages/api/session-controller/tests/summary-list.host.spec.ts index d54c2a5821..19237931d4 100644 --- a/packages/api/session-controller/tests/summary-list.host.spec.ts +++ b/packages/api/session-controller/tests/summary-list.host.spec.ts @@ -1,7 +1,8 @@ /** * Session-list summary payloads: a `session.list` row carries every registered * wire projection except the units that declare `summary: false` - * (`turnOutline`, `sessionStats`, `todos`, `imageLimits`), while an opened Session's + * (`turnOutline`, `sessionStats`, `todos`, `imageLimits`, `contextPressure`, + * `contextBreakdown`), while an opened Session's * `session/follow` opening snapshot keeps serving them. */ @@ -21,9 +22,10 @@ import { import { apply as toolTodoApply, Config as toolTodoConfig, inject as toolTodoInject, name as toolTodoName, } from '@deepseek-ai/dsh-tool-todo' +import TokenMeter from '@deepseek-ai/dsh-token-meter' import { createSessionTestRemote } from './test-remote.ts' -const SUMMARY_EXCLUDED_KEYS = ['turnOutline', 'sessionStats', 'todos', 'imageLimits'] as const +const SUMMARY_EXCLUDED_KEYS = ['turnOutline', 'sessionStats', 'todos', 'imageLimits', 'contextPressure', 'contextBreakdown'] as const const ownedContexts = new Set() afterEach(async () => { @@ -51,7 +53,7 @@ async function opening( } /** - * Mount the registry plus the four summary-excluded units and one ordinary + * Mount the registry plus the summary-excluded units and one ordinary * wire unit. The tool plugin needs a tool registry; the list path never * executes a tool, so a registration sink is enough. */ @@ -71,6 +73,7 @@ async function harness(): Promise<{ ctx: Context; sessionContext: Context }> { Config: toolTodoConfig, apply: toolTodoApply, }, { allowParallelInProgress: false }) + await ctx.plugin(TokenMeter) // Build the remote (and with it the list owner that registers // `sessionListMetadata`) before any Session exists, so every registered unit // seeds a cell at `session/created`. diff --git a/packages/experimental/forge-session/src/projection.ts b/packages/experimental/forge-session/src/projection.ts index f9d514b457..1f1ab54a43 100644 --- a/packages/experimental/forge-session/src/projection.ts +++ b/packages/experimental/forge-session/src/projection.ts @@ -15,6 +15,14 @@ export const BINDING_RECORD = 'plugin:forge-session/binding' /** Tools whose calls run shell commands with a string `command` argument. */ const SHELL_TOOLS: ReadonlySet = new Set(['bash', 'pwsh']) +/** + * Literal text checked on the raw JSON arguments before parsing: recognized + * `gh pr` forms name `gh`, and `git push` names `push`. Arguments whose JSON + * text contains neither are not parsed, so a command spelled only through JSON + * escapes is not recognized. + */ +const EVIDENCE_HINT = /gh|push/ + const EMPTY: ForgeChangeRequestsState = Object.freeze({ pending: Object.freeze([]), bindings: Object.freeze([]), @@ -84,7 +92,7 @@ function apply(state: ForgeChangeRequestsState, event: SessionEvent): ForgeChang } function applyCall(state: ForgeChangeRequestsState, call: SessionEventMap['tool/call']): ForgeChangeRequestsState { - if (!SHELL_TOOLS.has(call.name)) return state + if (!SHELL_TOOLS.has(call.name) || !EVIDENCE_HINT.test(call.arguments)) return state const command = commandOf(call.arguments) const evidence = command === undefined ? undefined : commandEvidence(command) if (evidence === undefined) return state diff --git a/packages/experimental/forge-session/src/references.ts b/packages/experimental/forge-session/src/references.ts index c499aa4850..1b8d502550 100644 --- a/packages/experimental/forge-session/src/references.ts +++ b/packages/experimental/forge-session/src/references.ts @@ -19,6 +19,8 @@ const VALUE_OPTIONS = new Set([ const BOOLEAN_OPTIONS = new Set(['--comments', '-c', '--web', '-w', '--detach', '-d', '--force', '-f', '--recurse-submodules', '--remove-milestone']) const SEPARATORS = new Set(['&&', '||', ';', '|', '|&', '&']) const REPOSITORY = /^(?:([a-z\d.-]+(?::\d+)?)\/)?([a-z\d_-]+)\/([a-z\d_.-]+)$/i +/** A literal `gh` word; a command that names `gh` only after shell quoting is not recognized. */ +const GH_WORD = /\bgh\b/ /** * Read explicit URL selectors or numeric selectors with `--repo`/`-R`. @@ -30,6 +32,8 @@ const REPOSITORY = /^(?:([a-z\d.-]+(?::\d+)?)\/)?([a-z\d_-]+)\/([a-z\d_.-]+)$/i * @returns references in command order; no process or environment is read. */ export function ghReferences(command: string): GhReference[] { + // A reference needs a `gh` word; skip tokenizing commands that never name it. + if (!GH_WORD.test(command)) return [] if (/[\r\n`]|\$\(/.test(command) || !/^(?:[^'"\\]|\\.|'(?:[^']*)'|"(?:[^"\\]|\\.)*")*$/.test(command)) return [] let tokens: parse.ParseEntry[] try { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 19527474cd..8267448232 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2980,19 +2980,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'snapshot( session: Session, keys?: ProjectionKeySelection, ): ProjectionSnapshot', - description: 'One consistent cut over every registered client-visible unit for one session, read from the watermark cache (missing cells fold lazily over the in-memory log). Fully synchronous — every value and `asOfSeq` reflect the same log position. Each value passes its unit\'s `viewSchema` before leaving.', - parameters: [{ name: 'session', description: 'the session whose projection values are read.' }, { name: 'keys', description: 'optional client-visible outputs; state materialization remains complete.' }], + description: 'One consistent cut over every registered client-visible unit for one session, read from the watermark cache (missing cells fold lazily over the in-memory log). Only the returned units are computed: other units, including Host-only units without `wire`, are neither folded nor advanced by this read. Fully synchronous — every value and `asOfSeq` reflect the same log position. Each value passes its unit\'s `viewSchema` before leaving.', + parameters: [{ name: 'session', description: 'the session whose projection values are read.' }, { name: 'keys', description: 'optional client-visible outputs; only these units are computed.' }], returns: 'the snapshot; `values` is empty when no selected client-visible unit is registered.', }, - { - signature: 'cachedSnapshot( session: Session, keys?: ProjectionKeySelection, ): ProjectionSnapshot | undefined', - description: 'Read only already-materialized client-visible cells without folding history. Values may trail the live Session and are therefore hints, not a complete baseline. Missing cells are omitted.', - parameters: [{ name: 'session', description: 'attached Session whose cached cells are inspected.' }, { name: 'keys', description: 'optional wire keys to view.' }], - returns: 'the lowest common cached cut, or `undefined` when no wire cell exists.', - }, { signature: 'summaryKeys(): ReadonlySet>', - description: 'The keys a Session summary carries: every registered client-visible key whose unit does not declare `summary: false`. The returned set is stable — the same instance on every call — until a registration or its disposal changes the key table, so a consumer may hold it across rows. Callers pass it to a key-taking read face such as `cachedSnapshot(session, summaryKeys())` or `viewCheckpoint(rows, summaryKeys())`; those faces use the set as-is.', + description: 'The keys a Session summary carries: every registered client-visible key whose unit does not declare `summary: false`. The returned set is stable — the same instance on every call — until a registration or its disposal changes the key table, so a consumer may hold it across rows. Callers pass it to a key-taking read face such as `snapshot(session, summaryKeys())` or `viewCheckpoint(rows, summaryKeys())`; those faces use the set as-is.', parameters: [], returns: 'the current summary key set; do not mutate it.', }, diff --git a/packages/llm/token-meter/src/breakdown-projection.ts b/packages/llm/token-meter/src/breakdown-projection.ts index 43ce1b04f7..3dd540e184 100644 --- a/packages/llm/token-meter/src/breakdown-projection.ts +++ b/packages/llm/token-meter/src/breakdown-projection.ts @@ -179,5 +179,6 @@ export const contextBreakdownProjectionDefinition = { wire: { viewSchema: breakdownSchema, view: state => state.breakdown, + summary: false, }, } satisfies ProjectionDefinition<'contextBreakdown', ContextBreakdownState> diff --git a/packages/llm/token-meter/src/projection.ts b/packages/llm/token-meter/src/projection.ts index 5f23d266d3..d26fc72506 100644 --- a/packages/llm/token-meter/src/projection.ts +++ b/packages/llm/token-meter/src/projection.ts @@ -4,6 +4,8 @@ * @module @deepseek-ai/dsh-token-meter/projection */ +import type { SessionProjectionSummaryExcluded } from '@deepseek-ai/dsh-session-projection/types' + /** * Durable cumulative provider usage for a complete session log. * @@ -70,8 +72,8 @@ declare module '@deepseek-ai/dsh-session-projection/types' { /** Provider-reported usage accumulated across the complete durable log. */ tokenUsage: TokenUsageProjection /** Newest request pressure paired with the newest known route capacity. */ - contextPressure: ContextPressureProjection + contextPressure: SessionProjectionSummaryExcluded /** Heuristic system/tools/message composition of the next request. */ - contextBreakdown: ContextBreakdownProjection + contextBreakdown: SessionProjectionSummaryExcluded } } diff --git a/packages/llm/token-meter/src/usage-projection.ts b/packages/llm/token-meter/src/usage-projection.ts index f07fa676a9..52268889ec 100644 --- a/packages/llm/token-meter/src/usage-projection.ts +++ b/packages/llm/token-meter/src/usage-projection.ts @@ -208,5 +208,6 @@ export const contextPressureProjectionDefinition = { ? {} : { projectedTokens: Math.max(0, pressureTokens + surfaceTokens - sampledSurfaceTokens) }, }), + summary: false, }, } satisfies ProjectionDefinition<'contextPressure', ContextPressureState> diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index 93f6b54a6f..53d259092a 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/README.i18n.yaml @@ -30,14 +30,14 @@ en: dac4ea4d67ce057d zh: 1cf3e5c7af5a5203 /deepseek-ai-dsh-session-projection/use-this-package/deprecated-plugin-compatibility: - en: eed1ad9d62755251 - zh: 88a07c787528009a + en: bfbeab7e33fc7f8f + zh: 74e0b871f1639f14 /deepseek-ai-dsh-session-projection/use-this-package/persisted-checkpoints: en: 3bbc61a438aeabd0 zh: bd1824f4da278764 /deepseek-ai-dsh-session-projection/use-this-package/session-summaries: - en: bbdefde61fb3b02f - zh: c6871e05e63ff4ed + en: ee868b44413a126f + zh: f98c66d8e4da9a72 /deepseek-ai-dsh-session-projection/understand-the-implementation: en: 3397f58d6f49bfda zh: efb85d382e25574d diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index a8dcdaebff..e17c14ad77 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -91,6 +91,8 @@ Legacy `stateOf(session, key)` is deprecated and returns `unknown`. Reading a re The first legacy `stateOf(session, key)` read of each key logs one `ctx.logger.warn` deprecation warning per registry instance. Registration, calls to its result, handle methods, and owner cleanup do not warn. +The registry's `cachedSnapshot(session, keys)` is deprecated. It views only cells another read already computed, may omit selected keys, and returns `undefined` when no selected client-visible cell exists; existing callers keep working. Use `snapshot(session, keys)` for complete values at the Session cursor, or the registration's `handle.stateOf(session)` for Host state. The persisted cache read `ctx.sessionProjectionCache.cachedSnapshot(header, keys)` is a separate method and is unchanged. + Definitions install synchronously, and handle delivery remains asynchronous. Registration errors reject the returned value. When no caller observes a rejection before the current macrotask and its microtasks finish, the registry logs it as an error instead of leaving an unhandled rejection, and the plugin stays active; after a key conflict, its keyed reads return the other registrant's state; a rejection whose owner is no longer loading or active is dropped. New plugins await registration before exposing readers. Synchronous `try/catch` cannot catch asynchronous registration errors. ### Persisted checkpoints @@ -99,11 +101,11 @@ Every unit's state is checkpointed — client-visible and host-only alike — th ### Session summaries -A Session summary is every registered client view except the keys whose non-nullish `SessionProjectionMap` entry carries the `SessionProjectionSummaryExcluded` mark. `summaryKeys()` returns that set, derived from the live registrations and stable — the same instance — until a registration or its disposal changes the key table. A summary consumer passes it to the existing key-taking reads — `cachedSnapshot(session, keys)` for live cells, `viewCheckpoint(rows, keys)` for checkpoint rows, and `cachedSnapshot(header, keys)` through the persisted cache — which treat a set as already selected, so there is no summary-specific read face, no copy per read, and no second definition of the set. Because the registry derives it, a summary consumer cannot request an excluded key through the derived set; a complete face still serves an explicitly named key, which is how an opened Session keeps every value. A list row carries the summary projection and never the full projection. +A Session summary is every registered client view except the keys whose non-nullish `SessionProjectionMap` entry carries the `SessionProjectionSummaryExcluded` mark. `summaryKeys()` returns that set, derived from the live registrations and stable — the same instance — until a registration or its disposal changes the key table. A summary consumer passes it to the existing key-taking reads — `snapshot(session, keys)` for live Sessions, `viewCheckpoint(rows, keys)` for checkpoint rows, and `cachedSnapshot(header, keys)` through the persisted cache — which treat a set as already selected, so there is no summary-specific read face, no copy per read, and no second definition of the set. Because the registry derives it, a summary consumer cannot request an excluded key through the derived set; a complete face still serves an explicitly named key, which is how an opened Session keeps every value. A list row carries the summary projection and never the full projection. -The mark decides the client view type; `wire.summary` decides the runtime key set, and omitting it means `true`. Declare `wire.summary: false` on a key whose map entry carries the mark so the two agree; the field is a plain boolean, not a type-level interlock. A nullable entry keeps `null` outside the wrapper (`SessionProjectionSummaryExcluded | null`) so the pre-first-write value stays expressible. The keys currently marked are `turnOutline`, `sessionStats`, `todos`, and `imageLimits`: an outline grows with the whole log, no list row renders the stats or todo list, and the image limits are one host-wide boot constant every Session shares. +The mark decides the client view type; `wire.summary` decides the runtime key set, and omitting it means `true`. Declare `wire.summary: false` on a key whose map entry carries the mark so the two agree; the field is a plain boolean, not a type-level interlock. A nullable entry keeps `null` outside the wrapper (`SessionProjectionSummaryExcluded | null`) so the pre-first-write value stays expressible. The keys currently marked are `turnOutline`, `sessionStats`, `todos`, `imageLimits`, `contextPressure`, and `contextBreakdown`: an outline grows with the whole log, no list row renders the stats, todo list, or context meter, and the image limits are one host-wide boot constant every Session shares. -Summaries serve the Session list rows, which list Sessions without opening them. The `@` reference lists Sessions the same way but reads only its `title` and `subagent` label keys through the key-taking `snapshot(session, keys)` and `cachedSnapshot(header, keys)` faces. `snapshot`, `cachedSnapshot`, and `viewCheckpoint` still serve every wire key when called without a key subset, so an opened Session receives every projected value. +Summaries serve the Session list rows, which list Sessions without opening them. The `@` reference lists Sessions the same way but reads only its `title` and `subagent` label keys through the key-taking `snapshot(session, keys)` and `cachedSnapshot(header, keys)` faces. `snapshot(session, keys)` computes only the units it returns; without a key subset it computes every client-visible unit and no Host-only unit, so an opened Session receives every projected value, and `viewCheckpoint` likewise serves every wire key without a key subset. ----- diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index 744af01bed..addea20856 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -91,6 +91,8 @@ const todo = projections.todo() 每个 registry 实例对每个键的首次旧式 `stateOf(session, key)` 读取输出一次 `ctx.logger.warn` 弃用警告。注册、调用其返回值、句柄方法和 owner 清理不产生警告。 +注册表的 `cachedSnapshot(session, keys)` 已弃用。它只读取其他读取已经算好的 cell,可能缺少选中的 key,没有选中的客户端可见 cell 时返回 `undefined`;已有调用方仍可继续使用。需要 Session 当前游标处的完整值时使用 `snapshot(session, keys)`,读取 Host 状态时使用注册返回的 `handle.stateOf(session)`。持久化缓存的读取 `ctx.sessionProjectionCache.cachedSnapshot(header, keys)` 是另一个方法,本次未变。 + 定义同步安装,句柄交付保持异步。注册错误使返回值拒绝。当前宏任务及其微任务结束前没有调用方观察该拒绝时,registry 将其记录为错误,而不留下未处理的拒绝,插件保持激活;发生键冲突后,其按键读取返回另一注册方的状态;owner 已不在加载或激活状态的拒绝会被丢弃。新插件在提供读取操作前等待注册完成。同步 `try/catch` 无法捕获异步注册错误。 ### 持久检查点 @@ -99,11 +101,11 @@ const todo = projections.todo() ### Session 摘要 -Session 摘要包含每个已注册客户端视图,但排除 `SessionProjectionMap` 条目(非空值)带 `SessionProjectionSummaryExcluded` 标记的 key。`summaryKeys()` 返回该集合,从实时注册派生;在注册或注销改变 key 表之前,它保持同一实例。摘要消费方把它传给既有的带 key 读取面 —— 实时 cell 用 `cachedSnapshot(session, keys)`,checkpoint 行用 `viewCheckpoint(rows, keys)`,持久化缓存用 `cachedSnapshot(header, keys)` —— 这些读取面把 Set 视为已选好的集合,因此不存在摘要专用读取面、每次读取无需复制,集合也只有一处定义。由于集合由注册表派生,摘要消费方无法通过该集合请求被排除的 key;完整读取面仍会按显式命名的 key 提供值,打开的 Session 因此保留全部取值。列表行携带的是摘要投影,绝不是全量投影。 +Session 摘要包含每个已注册客户端视图,但排除 `SessionProjectionMap` 条目(非空值)带 `SessionProjectionSummaryExcluded` 标记的 key。`summaryKeys()` 返回该集合,从实时注册派生;在注册或注销改变 key 表之前,它保持同一实例。摘要消费方把它传给既有的带 key 读取面 —— 实时 Session 用 `snapshot(session, keys)`,checkpoint 行用 `viewCheckpoint(rows, keys)`,持久化缓存用 `cachedSnapshot(header, keys)` —— 这些读取面把 Set 视为已选好的集合,因此不存在摘要专用读取面、每次读取无需复制,集合也只有一处定义。由于集合由注册表派生,摘要消费方无法通过该集合请求被排除的 key;完整读取面仍会按显式命名的 key 提供值,打开的 Session 因此保留全部取值。列表行携带的是摘要投影,绝不是全量投影。 -标记决定客户端视图类型,`wire.summary` 决定运行时键集合,省略即 `true`。map 条目带标记的 key 应声明 `wire.summary: false` 以保持一致;该字段就是普通布尔值,而不是类型层的互锁。可空条目把 `null` 放在包装之外(`SessionProjectionSummaryExcluded | null`),以便首次写入前的值仍可表达。当前被标记的 key 是 `turnOutline`、`sessionStats`、`todos` 和 `imageLimits`:outline 随整份日志增长,列表行不渲染统计或 todo 清单,而图片上限是每个 Session 共享的同一个宿主级启动常量。 +标记决定客户端视图类型,`wire.summary` 决定运行时键集合,省略即 `true`。map 条目带标记的 key 应声明 `wire.summary: false` 以保持一致;该字段就是普通布尔值,而不是类型层的互锁。可空条目把 `null` 放在包装之外(`SessionProjectionSummaryExcluded | null`),以便首次写入前的值仍可表达。当前被标记的 key 是 `turnOutline`、`sessionStats`、`todos`、`imageLimits`、`contextPressure` 和 `contextBreakdown`:outline 随整份日志增长,列表行不渲染统计、todo 清单或上下文用量,而图片上限是每个 Session 共享的同一个宿主级启动常量。 -摘要服务于 Session 列表行,列表无需打开 Session。`@` 引用同样以列表方式枚举 Session,但只通过带 key 的 `snapshot(session, keys)` 与 `cachedSnapshot(header, keys)` 读取其 `title` 和 `subagent` 标签 key。`snapshot`、`cachedSnapshot` 和 `viewCheckpoint` 在不传 key 子集时仍返回每个 wire key,因此打开的 Session 仍会收到全部投影值。 +摘要服务于 Session 列表行,列表无需打开 Session。`@` 引用同样以列表方式枚举 Session,但只通过带 key 的 `snapshot(session, keys)` 与 `cachedSnapshot(header, keys)` 读取其 `title` 和 `subagent` 标签 key。`snapshot(session, keys)` 只计算它返回的单元;不传 key 子集时计算每个客户端可见单元、不计算 Host 专用单元,因此打开的 Session 仍会收到全部投影值,`viewCheckpoint` 不传 key 子集时同样返回每个 wire key。 ----- diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 4ce8408a90..500cae4233 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -493,10 +493,12 @@ export class SessionProjectionRegistry extends Service { /** * One consistent cut over every registered client-visible unit for one session, read from * the watermark cache (missing cells fold lazily over the in-memory log). + * Only the returned units are computed: other units, including Host-only + * units without `wire`, are neither folded nor advanced by this read. * Fully synchronous — every value and `asOfSeq` reflect the same log * position. Each value passes its unit's `viewSchema` before leaving. * @param session - the session whose projection values are read. - * @param keys - optional client-visible outputs; state materialization remains complete. + * @param keys - optional client-visible outputs; only these units are computed. * @returns the snapshot; `values` is empty when no selected client-visible unit is registered. */ snapshot( @@ -505,7 +507,6 @@ export class SessionProjectionRegistry extends Service { ): ProjectionSnapshot { const values: Record = {} const selected = selectedKeys(keys) - this.materializeCells(session) for (const registration of this.registrations.values()) { if (registration.def.wire === undefined) continue if (selected !== undefined && !selected.has(registration.def.key)) continue @@ -522,6 +523,8 @@ export class SessionProjectionRegistry extends Service { * @param session - attached Session whose cached cells are inspected. * @param keys - optional wire keys to view. * @returns the lowest common cached cut, or `undefined` when no wire cell exists. + * @deprecated Returns only values another read already computed and may omit keys; never use it to decide behavior. + * Use snapshot(session, keys) for complete values, or a retained handle for host state. */ cachedSnapshot( session: Session, @@ -548,7 +551,7 @@ export class SessionProjectionRegistry extends Service { * whose unit does not declare `summary: false`. The returned set is stable — * the same instance on every call — until a registration or its disposal * changes the key table, so a consumer may hold it across rows. Callers pass - * it to a key-taking read face such as `cachedSnapshot(session, summaryKeys())` + * it to a key-taking read face such as `snapshot(session, summaryKeys())` * or `viewCheckpoint(rows, summaryKeys())`; those faces use the set as-is. * @returns the current summary key set; do not mutate it. */ @@ -770,6 +773,7 @@ export class SessionProjectionRegistry extends Service { ) for (const registration of this.registrations.values()) { const row = restored.checkpoint[registration.def.key] + /* v8 ignore next -- restore() writes a row for every registration in the same synchronous pass. */ if (row === undefined) continue const current = registration.cells.get(session) if (current !== undefined && current.observedSeq > row.seq) continue @@ -828,6 +832,7 @@ export class SessionProjectionRegistry extends Service { for (let seq = cell.observedSeq + 1; seq <= throughSeq; seq++) { // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = session.eventAt(SessionSeq(seq)) + /* v8 ignore next 3 -- throughSeq never exceeds the committed log, whose seqs are contiguous by the Session invariant. */ if (event === undefined || event.seq !== seq) { throw new Error(`session projection ${JSON.stringify(def.key)} cannot advance across missing seq ${String(seq)}`) } @@ -911,6 +916,7 @@ export class SessionProjectionRegistry extends Service { /** Return one schema-validated wire value. */ private viewCell(registration: Registration, cell: UnitCell): unknown { const wire = registration.def.wire + /* v8 ignore next -- every caller filters to units with wire before calling viewCell. */ if (wire === undefined) throw new Error(`session projection ${JSON.stringify(registration.def.key)} has no wire view`) return wire.viewSchema.parse(wire.view(cell.state)) } diff --git a/packages/session/session-projection/tests/compatibility.spec.ts b/packages/session/session-projection/tests/compatibility.spec.ts index 99beaeb74c..e5d1e1dbc6 100644 --- a/packages/session/session-projection/tests/compatibility.spec.ts +++ b/packages/session/session-projection/tests/compatibility.spec.ts @@ -129,11 +129,13 @@ describe('projection registration compatibility', () => { apply: definition.apply, }) expect(ctx.sessionProjections.stateOf(session, 'test/missing-legacy')).toBeUndefined() + // oxlint-disable-next-line typescript/no-deprecated -- Only the deprecated cached face observes cells without computing them. expect(ctx.sessionProjections.cachedSnapshot(session)).toBeUndefined() expect(hostInit).not.toHaveBeenCalled() expect(ctx.sessionProjections.stateOf(session, 'test/legacy')).toBe(2) expect(unrelatedView).not.toHaveBeenCalled() + // oxlint-disable-next-line typescript/no-deprecated -- Only the deprecated cached face observes cells without computing them. expect(ctx.sessionProjections.cachedSnapshot(session)).toEqual({ asOfSeq: 1, values: { 'test/legacy': 2, 'test/legacy-unread-wire': 2 }, diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts index a761519e08..cd0f9d65c1 100644 --- a/packages/session/session-projection/tests/registry.spec.ts +++ b/packages/session/session-projection/tests/registry.spec.ts @@ -628,6 +628,38 @@ describe('SessionProjectionRegistry drive', () => { expect('test/unregistered' in values).toBe(false) }) + it('snapshot with keys computes only the selected units', async () => { + const { ctx, session } = await harness() + mark(session, ['a']) + await ctx.sessionProjections.register(marksUnit()) + const unselectedApply = vi.fn((state: StableViewState) => state) + await ctx.sessionProjections.register({ ...stableViewUnit(state => state.value), apply: unselectedApply }) + expect(ctx.sessionProjections.snapshot(session, ['test/marks']).values).toEqual({ 'test/marks': { marks: ['a'] } }) + expect(unselectedApply).not.toHaveBeenCalled() + }) + + it('snapshot without keys does not compute host-only units', async () => { + const { ctx, session } = await harness() + mark(session, ['a']) + await ctx.sessionProjections.register(marksUnit()) + const hostApply = vi.fn((state: number) => state + 1) + await ctx.sessionProjections.register({ ...countUnit(), apply: hostApply }) + expect(ctx.sessionProjections.snapshot(session).values).toEqual({ 'test/marks': { marks: ['a'] } }) + expect(hostApply).not.toHaveBeenCalled() + }) + + it('snapshot with keys succeeds while an unselected unit fails to apply', async () => { + const { ctx, session } = await harness() + mark(session, ['a']) + await ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register({ + ...stableViewUnit(state => state.value), + apply: (): StableViewState => { throw new Error('unselected unit failed') }, + }) + expect(ctx.sessionProjections.snapshot(session, ['test/marks']).values).toEqual({ 'test/marks': { marks: ['a'] } }) + expect(() => ctx.sessionProjections.snapshot(session)).toThrow('unselected unit failed') + }) + it('checkpoints every persisted unit with its stateVersion and per-cell watermark', async () => { const { ctx, session } = await harness() await ctx.sessionProjections.register(marksUnit()) @@ -865,4 +897,96 @@ describe('SessionProjectionRegistry drive', () => { }) expect(() => ctx.sessionProjections.snapshot(session)).toThrow() }) + + it('keeps a cell built before a fresh Session is announced', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + const stable = await ctx.sessionProjections.register(stableViewUnit(state => state.value)) + const session = ctx.sessions.prepare(SessionId('projection-early-cell')) + const before = stable.stateOf(session) + const detach = ctx.sessions.enter(session) + onTestFinished(detach) + ctx.sessions.announce(session) + expect(stable.stateOf(session)).toBe(before) + }) + + it('restore rejects events that are not contiguous from baseSeq', async () => { + const { ctx } = await harness() + await ctx.sessionProjections.register(countUnit()) + const gapped: SessionEvent[] = [ + { type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 } }, + { type: 'turn/start', seq: SessionSeq(2), time: 2, data: { turn: 2 } }, + ] + expect(() => ctx.sessionProjections.restore( + {}, + gapped, + SessionLogOffset(0), + RESTORE_HEADER, + SessionLogOffset(0), + )).toThrow('cannot restore across missing seq 1') + }) + + it('hydrate with an empty event list serves the cut before baseSeq', async () => { + const { ctx } = await harness() + const marks = await ctx.sessionProjections.register(marksUnit()) + const prepared = ctx.sessions.prepare(SessionId('projection-hydrate-empty')) + const hydrated = ctx.sessionProjections.hydrate(prepared, {}, [], SessionLogOffset(0)) + expect(hydrated).toEqual({ asOfSeq: -1, values: { 'test/marks': { marks: [] } } }) + expect(marks.stateOf(prepared)).toBeNull() + }) + + it('hydrate at an already hydrated cut serves the installed cells', async () => { + const { ctx } = await harness() + await ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(countUnit()) + const events: SessionEvent[] = [ + { type: 'test/mark', seq: SessionSeq(0), time: 0, data: { marks: ['seed'] } }, + { type: 'turn/start', seq: SessionSeq(1), time: 1, data: { turn: 1 } }, + ] + const prepared = ctx.sessions.prepare(SessionId('projection-hydrate-twice'), { seed: events }) + const first = ctx.sessionProjections.hydrate(prepared, {}, events, SessionLogOffset(0)) + const restore = vi.spyOn(ctx.sessionProjections, 'restore') + const second = ctx.sessionProjections.hydrate(prepared, {}, events, SessionLogOffset(0)) + expect(restore).not.toHaveBeenCalled() + expect(second).toEqual(first) + expect(second).toEqual({ asOfSeq: 1, values: { 'test/marks': { marks: ['seed'] } } }) + }) + + it('hydrate keeps a cell that is already past the hydrated cut', async () => { + const { ctx } = await harness() + const marks = await ctx.sessionProjections.register(marksUnit()) + const events: SessionEvent[] = [ + { type: 'test/mark', seq: SessionSeq(0), time: 0, data: { marks: ['first'] } }, + { type: 'test/mark', seq: SessionSeq(1), time: 1, data: { marks: ['second'] } }, + ] + const prepared = ctx.sessions.prepare(SessionId('projection-hydrate-behind'), { seed: events }) + expect(marks.stateOf(prepared)).toEqual({ marks: ['second'] }) + const hydrated = ctx.sessionProjections.hydrate(prepared, {}, events.slice(0, 1), SessionLogOffset(0)) + expect(hydrated).toEqual({ asOfSeq: 0, values: { 'test/marks': { marks: ['first'] } } }) + expect(marks.stateOf(prepared)).toEqual({ marks: ['second'] }) + expect(ctx.sessionProjections.checkpoint(prepared)['test/marks']?.seq).toBe(prepared.seq - 1) + }) + + it('drive skips a cell that an earlier session/event listener already advanced', async () => { + const { ctx, session } = await harness() + const apply = vi.fn((state: number, _event: SessionEvent) => state + 1) + const count = await ctx.sessionProjections.register({ ...countUnit(), apply }) + await ctx.sessionProjections.register(marksUnit()) + const seen: number[] = [] + ctx.on('session/event', (target: Session) => { + seen.push(count.stateOf(target)) + }, { prepend: true }) + const changed = vi.fn() + ctx.sessionProjections.onChanged(changed) + const first = mark(session, ['a']) + const second = mark(session, ['b']) + expect(seen).toEqual([1, 2]) + expect(apply.mock.calls.map(([, event]) => event)).toEqual([first, second]) + expect(count.stateOf(session)).toBe(2) + expect(changed.mock.calls).toEqual([ + [session, 'test/marks', { marks: ['a'] }, first.seq], + [session, 'test/marks', { marks: ['b'] }, second.seq], + ]) + }) }) diff --git a/packages/session/session-projection/tests/summary.spec.ts b/packages/session/session-projection/tests/summary.spec.ts index 2fd66ce944..1244a85495 100644 --- a/packages/session/session-projection/tests/summary.spec.ts +++ b/packages/session/session-projection/tests/summary.spec.ts @@ -152,9 +152,11 @@ describe('session projection summary keys', () => { session.append('turn/start', { turn: 1 }) // The derived set is passed as a set: no key-list copy inside the read. + // oxlint-disable-next-line typescript/no-deprecated -- This case pins the deprecated cached face itself. expect(registry.cachedSnapshot(session, registry.summaryKeys())?.values).toEqual({ 'test/summary-kept': { text: 'turn-1' }, }) + // oxlint-disable-next-line typescript/no-deprecated -- This case pins the deprecated cached face itself. expect(registry.cachedSnapshot(session)?.values).toEqual({ 'test/summary-kept': { text: 'turn-1' }, 'test/summary-excluded': 1, @@ -173,7 +175,9 @@ describe('session projection summary keys', () => { session.append('turn/start', { turn: 1 }) expect([...registry.summaryKeys()]).toEqual([]) + // oxlint-disable-next-line typescript/no-deprecated -- This case pins the deprecated cached face itself. expect(registry.cachedSnapshot(session, [])).toBeUndefined() + // oxlint-disable-next-line typescript/no-deprecated -- This case pins the deprecated cached face itself. expect(registry.cachedSnapshot(session)?.values).toEqual({ 'test/summary-excluded': 1, 'test/summary-excluded-nullable': ['turn-1'], @@ -199,6 +203,7 @@ describe('session projection summary keys', () => { try { await registry.register(keptUnit()) const session = ctx.sessions.create(SessionId('summary-unmaterialized'), { meta: { cwd: '/workspace' } }) + // oxlint-disable-next-line typescript/no-deprecated -- This case pins the deprecated cached face itself. expect(registry.cachedSnapshot(session, registry.summaryKeys())?.values).toEqual({ 'test/summary-kept': null, }) diff --git a/vitest.config.ts b/vitest.config.ts index 34086431c7..d31f4fe82c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -406,11 +406,9 @@ export default defineConfig({ // by decision: its correctness signal is its uninstrumented suite and // the packer's end-to-end image spec. 'packages/experimental/webworker-runtime/src/**/*.ts', - // Projection/command round: executor lifecycle branches and the - // registry's drive tails need the same maturing lanes. TODO(gui): - // cover and remove with the client test lane above. + // Command executor lifecycle branches need the same maturing lanes. + // TODO(gui): cover and remove with the client test lane above. 'packages/interaction/commands/src/index.ts', - 'packages/session/session-projection/src/index.ts', ...windowsUnsupportedCoveragePackages.map(path => `${path}/src/**/*.ts`), ...windowsOnlyCoverageExclusions, ...windowsRunnerCoverageExclusions, From 86e2cc8277701eba36f80f924b4d7ecad27da915 Mon Sep 17 00:00:00 2001 From: Magolor Date: Mon, 2 Nov 2026 17:59:03 +0800 Subject: [PATCH 19/32] feat(schedule): prefill task creation on release 0.2.2 (#7702) * feat(schedule): prefill task creation on release 0.2.2 * fix(schedule): remove blank line from creation prompt * docs(schedule): clarify creation flow and review checks * fix(schedule): append task creation prompt to drafts --- apps/web/tests/schedule-manual.e2e.ts | 46 ++++++++++++++++++- packages/client/ui-schedule/README.i18n.yaml | 8 ++-- packages/client/ui-schedule/README.md | 4 +- packages/client/ui-schedule/README.zh.md | 4 +- .../client/ui-schedule/src/client/index.ts | 2 +- .../src/client/task-manager-locales.ts | 8 ++++ .../tests/browser-plugin.client.spec.ts | 13 +++++- 7 files changed, 73 insertions(+), 12 deletions(-) diff --git a/apps/web/tests/schedule-manual.e2e.ts b/apps/web/tests/schedule-manual.e2e.ts index 60ca701a4d..eb21e8be63 100644 --- a/apps/web/tests/schedule-manual.e2e.ts +++ b/apps/web/tests/schedule-manual.e2e.ts @@ -1,7 +1,51 @@ -/** Manual task creation and editing through the shipped Web composition. */ +/** Schedule creation, prompt prefill, and editing through the shipped Web composition. */ import { chromium } from 'playwright' import { expect, it } from 'vitest' import { launchWebScaffold, watchConsole } from './scaffold.ts' +import { writeComposerDraft } from './support.ts' + +it.each([ + { locale: 'en', panel: 'Automation tasks', create: 'New', menu: 'Choose a creation method', chat: 'Create in a session', + prompt: 'Please create an automation task based on the requirements.' }, + { locale: 'zh', panel: '自动化任务', create: '新建', menu: '选择创建方式', chat: '在会话中创建', + prompt: '请根据需求创建一个自动化任务。' }, +])('prefills an unsent Schedule creation draft in $locale through both conversation entries', async (copy) => { + const scaffold = await launchWebScaffold({ firstUse: true }) + let browser: Awaited> | undefined + try { + browser = await chromium.launch() + const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: copy.locale }) + await page.addInitScript((locale) => { localStorage.setItem('dsh.locale', locale) }, copy.locale) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const input = page.locator('[data-composer-input][contenteditable="true"]').first() + for (const { entry, draft } of [ + { entry: 'primary', draft: '' }, + { entry: 'primary', draft: 'Existing unsent draft' }, + { entry: 'menu', draft: '' }, + { entry: 'menu', draft: 'Existing unsent draft' }, + ]) { + await writeComposerDraft(page, input, draft) + await page.getByRole('button', { name: copy.panel, exact: true }).click() + const manager = page.getByTestId('task-manager-page') + if (entry === 'primary') await manager.getByRole('button', { name: copy.create, exact: true }).click() + else { + await manager.getByRole('button', { name: copy.menu, exact: true }).click() + await page.getByRole('menuitem', { name: copy.chat, exact: true }).click() + } + await manager.waitFor({ state: 'detached' }) + await input.getByText(copy.prompt, { exact: true }).waitFor() + expect(await input.evaluate(element => [...element.children].map(paragraph => paragraph.textContent).join('\n'))) + .toBe(draft === '' ? copy.prompt : `${draft}\n${copy.prompt}`) + const events = scaffold.ctx.agents.list().flatMap(agent => agent.session.snapshotEvents()) + expect(events.filter(event => event.type === 'user/message' || event.type === 'turn/start')).toEqual([]) + expect(await scaffold.ctx.schedule.catalog()).toEqual([]) + } + expect(tripwire.pageErrors).toEqual([]) + } finally { + try { await browser?.close() } finally { await scaffold.close() } + } +}) it('creates from a paused empty state, selects the task, and saves an inline edit without starting an Agent', async () => { // First use registers the default Workspace, which a new task's directory starts with. diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index bd2c8e8209..2dffe468b7 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 405b30feaf48c087 /deepseek-ai-dsh-client-ui-schedule/use-this-package: - en: 928ae699661089c5 - zh: 9895b75c5a30771b + en: 5cd34ed47dea2d59 + zh: c77b078e9e2c2197 /deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source: en: 4005b800e930f581 zh: ccb9318dad7f7e8f @@ -27,8 +27,8 @@ en: 2fd968202916b197 zh: f1ab66194846562b /deepseek-ai-dsh-client-ui-schedule/model-experience: - en: 064b67705e5da4ca - zh: 2163217c14878c59 + en: 6f528a72fe45af1b + zh: 8272529f6e324315 /deepseek-ai-dsh-client-ui-schedule/model-experience/kv-cache-effect: en: 52239e5688dfebe1 zh: 37343f5dbde010e8 diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index 2bb348ba16..74f426f76c 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -27,7 +27,7 @@ The Automation tasks page and right Sidebar task tab use the same editor for tas Below 768px, the task list uses 20px horizontal insets, matching the Plugins page. -The shipped Web composition mounts `ui-schedule` with Host Schedule. The Automation tasks sidebar entry opens the task list without selecting or activating a Session. New retains its existing behavior: open a new conversation and let the user enter an instruction. Its adjacent menu offers Create in conversation and Create manually. Manual creation opens the same editor as an existing task, validates required fields, and saves a task without starting an Agent or sending the instruction. A failed request leaves the form and its values visible. The empty state shows a decorative document-and-clock illustration whose colors follow the theme. +The shipped Web composition mounts `ui-schedule` with Host Schedule. The Automation tasks sidebar entry opens the task list without selecting or activating a Session. New and the adjacent menu's Create in a session action both open a new conversation with a short task-creation prompt in the current UI language, ready for the user to complete and send. When a blank conversation is reused, the prompt is appended on a new line, preserving its text, inline references, and attachments. The same menu offers Create manually. Manual creation opens the same editor as an existing task, validates required fields, and saves a task without starting an Agent or sending the instruction. A failed request leaves the form and its values visible. The empty state shows a decorative document-and-clock illustration whose colors follow the theme. Enabled, Paused, Ended, and All filters combine with text search over the title, prompt, and linked Session id; the page starts on Enabled. The search-row menu clears all ended tasks after confirmation, preserving active and paused tasks. Selecting a task opens its editable Rules / Records page. Save is enabled only for valid, meaningful changes and sends only changed fields with `expectedUpdatedAt`, or `null` when no stored revision exists. The fields stay editable after saving. Unsaved drafts survive navigation between tasks and away from the page. A conflict retains the whole draft and disables Save. Review with my edits reapplies the changed field groups to the latest values for review before saving; Discard replaces the draft with the latest stored values. For a new task, Discard closes the editor. Late responses cannot change another task's detail. Coarse-pointer search, instruction, and timing fields use 16px text to prevent focus zoom on iOS Safari. @@ -72,7 +72,7 @@ Sidebar layout records omit opener parameters. `TaskTabBindings` therefore persi ## Model Experience -None, as this browser package registers no model tools or messages. +None, as this browser package registers no model tools or automatic messages; the creation prompt stays an unsent draft. #### KV Cache effect diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index f7f4ff4d9e..3696ed4a3c 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" 宽度小于 768px 时,任务列表使用 20px 的左右内边距,与插件页一致。 -发布的 Web 组合将 `ui-schedule` 与宿主 Schedule 一起挂载。自动化任务侧栏入口打开任务列表,不选择或激活会话。“新建”保留现有行为:进入新会话,由用户输入指令。相邻下拉提供“在对话中创建”和“手动创建”。手动创建与已有任务打开同一编辑页,校验必填项并保存任务,不启动 Agent 或发送指令。请求失败时保留表单和已填内容。空态显示装饰性的文档与时钟插图,其颜色随主题变化。 +发布的 Web 组合将 `ui-schedule` 与宿主 Schedule 一起挂载。自动化任务侧栏入口打开任务列表,不选择或激活会话。“新建”和相邻菜单的“在会话中创建”都会进入新会话,按当前界面语言预填简短的任务创建提示,供用户补充并发送。若复用空白会话,提示会另起一行追加,保留原有文本、行内引用和附件。该菜单还提供“手动创建”。手动创建与已有任务打开同一编辑页,校验必填项并保存任务,不启动 Agent 或发送指令。请求失败时保留表单和已填内容。空态显示装饰性的文档与时钟插图,其颜色随主题变化。 “已开启”“已暂停”“已结束”“全部”筛选结合标题、指令和关联会话 id 搜索,默认显示已开启任务。搜索行菜单在确认后清除全部已结束任务,保留已开启和已暂停任务。选择任务直接打开可编辑的“规则 / 任务运行记录”页面。只有字段有效且有实际修改时才能保存;保存只发送变更字段及 `expectedUpdatedAt`,存储版本缺失时发送 `null`。保存后字段保持可编辑。切换任务或离开页面时保留未保存的草稿。冲突保留整份草稿并禁用保存。“保留修改并复核”将改过的字段组应用到最新值,供保存前复核;“放弃”用最新存储值替换草稿。对于新任务,“放弃”关闭编辑页。迟到响应不会改变其他任务详情。粗指针设备的搜索、指令和时间字段使用 16px 字号,避免 iOS Safari 聚焦时缩放。 @@ -72,7 +72,7 @@ Peer 可读取 `schedule/catalog` 时,插件注册现有 `schedules` 主面板 ## 模型体验 -无:此浏览器包不注册模型工具或消息,Schedule 拥有提醒投递。 +无:本浏览器包不注册模型工具或自动消息;创建提示保留为未发送的草稿。 #### KV Cache 影响 diff --git a/packages/client/ui-schedule/src/client/index.ts b/packages/client/ui-schedule/src/client/index.ts index 4b93b8577c..5df353095f 100644 --- a/packages/client/ui-schedule/src/client/index.ts +++ b/packages/client/ui-schedule/src/client/index.ts @@ -222,7 +222,7 @@ export function apply(ctx: ClientContext): void { store: createEditorStore(), inject: (): TaskManagerInjected => ({ ...detail, - onNewTask: () => { ctx.uiWorkspace.startSession() }, + onNewTask: () => { ctx.uiWorkspace.startSession(undefined, { prompt: t('host.createPrompt'), whenDraftExists: 'append' }) }, onClearEnded: async () => { const outcome = await clearEnded() deleteToast.reportClear(outcome) diff --git a/packages/client/ui-schedule/src/client/task-manager-locales.ts b/packages/client/ui-schedule/src/client/task-manager-locales.ts index 1cecda0550..fb036ea0fa 100644 --- a/packages/client/ui-schedule/src/client/task-manager-locales.ts +++ b/packages/client/ui-schedule/src/client/task-manager-locales.ts @@ -6,6 +6,7 @@ export const en = { 'host.create': 'Create', 'host.createMethod': 'Choose a creation method', 'host.chatCreate': 'Create in a session', + 'host.createPrompt': 'Please create an automation task based on the requirements.', 'host.manualCreate': 'Create manually', 'host.closeCreate': 'Close creation form', 'host.creating': 'Creating…', @@ -164,6 +165,7 @@ export const zh: Record = { 'host.create': '创建', 'host.createMethod': '选择创建方式', 'host.chatCreate': '在会话中创建', + 'host.createPrompt': '请根据需求创建一个自动化任务。', 'host.manualCreate': '手动创建', 'host.closeCreate': '关闭创建页', 'host.creating': '创建中…', @@ -318,6 +320,7 @@ export const ja = { 'host.create': '作成', 'host.createMethod': '作成方法を選択', 'host.chatCreate': '会話で作成', + 'host.createPrompt': '要件に基づいて自動化タスクを作成してください。', 'host.manualCreate': '手動で作成', 'host.closeCreate': '作成フォームを閉じる', 'host.creating': '作成中…', @@ -573,6 +576,7 @@ export const es = { 'card.openLabel': 'Abrir detalles de la tarea: {title}', 'card.deleted': 'Eliminado', 'host.chatCreate': 'Crear en una conversación', + 'host.createPrompt': 'Crea una tarea automatizada según los requisitos.', 'host.conflict': 'Esta tarea cambió en otro lugar. Revisa los valores actuales con tus cambios o descártalos.', 'host.reapply': 'Revisar con mis cambios', 'host.create': 'Crear', @@ -726,6 +730,7 @@ export const fr = { 'card.openLabel': 'Détails de la tâche ouverte : {title}', 'card.deleted': 'Supprimé', 'host.chatCreate': 'Créer dans une conversation', + 'host.createPrompt': 'Crée une tâche automatisée selon les exigences.', 'host.conflict': 'Cette tâche a été modifiée ailleurs. Vérifiez les valeurs actuelles avec vos modifications, ou abandonnez-les.', 'host.reapply': 'Vérifier avec mes modifications', 'host.create': 'Créer', @@ -879,6 +884,7 @@ export const de = { 'card.openLabel': 'Aufgabendetails öffnen: {title}', 'card.deleted': 'Gelöscht', 'host.chatCreate': 'In einer Unterhaltung erstellen', + 'host.createPrompt': 'Erstelle eine automatisierte Aufgabe anhand der Anforderungen.', 'host.conflict': 'Diese Aufgabe wurde andernorts geändert. Prüfe die aktuellen Werte mit deinen Änderungen oder verwirf sie.', 'host.reapply': 'Mit meinen Änderungen prüfen', 'host.create': 'Erstellen', @@ -1032,6 +1038,7 @@ export const ru = { 'card.openLabel': 'Открыть сведения о задаче: {title}.', 'card.deleted': 'Удалено', 'host.chatCreate': 'Создать в беседе', + 'host.createPrompt': 'Создай автоматизированную задачу на основе требований.', 'host.conflict': 'Задача изменена в другом месте. Проверьте актуальные значения со своими правками или отмените изменения.', 'host.reapply': 'Проверить со своими правками', 'host.create': 'Создать', @@ -1185,6 +1192,7 @@ export const pt = { 'card.openLabel': 'Detalhes da tarefa aberta: {title}', 'card.deleted': 'Excluído', 'host.chatCreate': 'Criar em uma conversa', + 'host.createPrompt': 'Crie uma tarefa automatizada com base nos requisitos.', 'host.conflict': 'Esta tarefa foi alterada em outro lugar. Revise os valores atuais com suas alterações ou descarte-as.', 'host.reapply': 'Revisar com minhas alterações', 'host.create': 'Criar', diff --git a/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts b/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts index 81ab2f4166..517a0ed0df 100644 --- a/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts @@ -28,6 +28,7 @@ import { SessionScheduleHover } from '../src/client/SessionScheduleHover.tsx' import { SessionScheduleMark } from '../src/client/SessionScheduleMark.tsx' import { ScheduleTurnCard, type ScheduleTurnCardInjected } from '../src/client/ScheduleTurnCard.tsx' import { en, NS, zh } from '../src/client/locales.ts' +import * as managerLocales from '../src/client/task-manager-locales.ts' const Empty = () => null @@ -425,8 +426,16 @@ describe('ui-schedule browser half', () => { expect(chip.taskBindings).toBe(body.taskBindings) expect(chip.hooks.catalog).toBe(body.hooks.catalog) const panel = injectedFace(ctx.slots.entries('main')[0]!, undefined as never) as TaskManagerInjected - panel.onNewTask() - expect(startSession).toHaveBeenCalledOnce() + expect(Object.keys(managerLocales).sort()).toEqual(['de', 'en', 'es', 'fr', 'ja', 'pt', 'ru', 'zh']) + for (const [language, dictionary] of Object.entries(managerLocales)) { + expect(dictionary['host.createPrompt']).not.toContain('\n') + ctx.locale.setLocale(language) + panel.onNewTask() + expect(startSession).toHaveBeenLastCalledWith(undefined, { + prompt: dictionary['host.createPrompt'], whenDraftExists: 'append', + }) + } + expect(startSession).toHaveBeenCalledTimes(8) } finally { await fiber.dispose() owner() From 156dad153d3d95475273d2eb61b8a558cb1a63c1 Mon Sep 17 00:00:00 2001 From: Yixiang Tu Date: Thu, 22 Oct 2026 15:28:54 +0800 Subject: [PATCH 20/32] fix(llm-deepseek): anchor all-deferred tools with a placeholder Preserve deferred activation when every initial tool has been removed. Add serializer, provider, live-API, and recorded-session regression coverage. Fixes #6953 --- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- packages/llm/llm-deepseek/src/serialize.ts | 28 ++++++--- .../llm/llm-deepseek/tests/adapter.e2e.ts | 18 +++++- .../llm/llm-deepseek/tests/adapter.spec.ts | 26 +++++++- .../tests/fixtures/deferred-tools.ts | 59 +++++++++++++++++++ .../llm/llm-deepseek/tests/serialize.spec.ts | 24 ++++++++ .../cordis.snapshot.yml | 18 ++++++ .../deepseek-deferred-tools/cordis.yml | 3 + .../deepseek-deferred-tools/session.v4.jsonl | 29 +++++++++ .../deepseek-deferred-tools/snapshot.yml | 9 +++ .../system-prompt.expected.md | 14 +++++ .../tool-schemas.expected.json | 24 ++++++++ 14 files changed, 246 insertions(+), 14 deletions(-) create mode 100644 packages/llm/llm-deepseek/tests/fixtures/deferred-tools.ts create mode 100644 snapshots/session/deepseek-deferred-tools/cordis.snapshot.yml create mode 100644 snapshots/session/deepseek-deferred-tools/cordis.yml create mode 100644 snapshots/session/deepseek-deferred-tools/session.v4.jsonl create mode 100644 snapshots/session/deepseek-deferred-tools/snapshot.yml create mode 100644 snapshots/session/deepseek-deferred-tools/system-prompt.expected.md create mode 100644 snapshots/session/deepseek-deferred-tools/tool-schemas.expected.json diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 432eadbd4e..c0abd8378f 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -63,8 +63,8 @@ en: 1dc50052999253c6 zh: 7abf968fc18bffcd /deepseek-ai-dsh-llm-deepseek/model-experience/deepseek-request/what-the-model-sees: - en: 14110610860656e8 - zh: 995e28d6931ebe4e + en: f475b3adb2d61982 + zh: 6d8913a294f11de3 /deepseek-ai-dsh-llm-deepseek/model-experience/deepseek-request/token-effect: en: 67f4bfa42cbee911 zh: 5e9dc9bc07253686 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 0c374c60ce..6433ff8caf 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -170,7 +170,7 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config (`maxTokens`, `reasoningEffort`, `temperature`) without adapter-authored prompt prose. Provider-specific request-extension fields remain outside model input. The vision model normally receives retained user and tool-result images as Files API references beside attachment handles and request-preview dimensions. It also receives a normalized-object path when the current execution filesystem maps the attachment provider's host object; the descriptor marks this copy read-only and warns that normalization may have resized or re-encoded the upload. A Files resolution failure sends all retained images as inline base64 instead, and an over-budget older image keeps the access resolved for that request in its placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. Messages sends `{}` for historical tool arguments that are malformed JSON or are not objects. This argument fallback preserves call ids, tool names, and tool results; the original arguments stay in the Session log. Newly generated Messages tool arguments still require valid JSON objects. Messages omits `reasoning` and `tool-call` blocks inside user messages and tool results. This also permits replay of saved subagent notices containing assistant output; the original Session content remains intact. Empty user messages are skipped after conversion, while empty tool results retain their call ids and error flags. Other unsupported input blocks still fail with `UNSUPPORTED_CONTENT`. +The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config (`maxTokens`, `reasoningEffort`, `temperature`) without adapter-authored prompt prose. Provider-specific request-extension fields remain outside model input. The vision model normally receives retained user and tool-result images as Files API references beside attachment handles and request-preview dimensions. It also receives a normalized-object path when the current execution filesystem maps the attachment provider's host object; the descriptor marks this copy read-only and warns that normalization may have resized or re-encoded the upload. A Files resolution failure sends all retained images as inline base64 instead, and an over-budget older image keeps the access resolved for that request in its placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. Messages sends `{}` for historical tool arguments that are malformed JSON or are not objects. This argument fallback preserves call ids, tool names, and tool results; the original arguments stay in the Session log. Newly generated Messages tool arguments still require valid JSON objects. Messages omits `reasoning` and `tool-call` blocks inside user messages and tool results. This also permits replay of saved subagent notices containing assistant output; the original Session content remains intact. Empty user messages are skipped after conversion, while empty tool results retain their call ids and error flags. Other unsupported input blocks still fail with `UNSUPPORTED_CONTENT`. When every declared tool is deferred, the adapter prepends a non-deferred `DeferredToolPlaceholder` with the description `Reserved placeholder that keeps deferred tool loading active; never call this tool.` A numeric suffix avoids collisions with declared tools. The placeholder has no executor; calls receive the ordinary unavailable-tool result. Real tools remain deferred until their recorded additions, and removed tools stay absent on addition-only routes. Empty tool lists receive no placeholder. #### Token effect diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 690a964840..eeabe9d876 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -170,7 +170,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 #### 模型看到什么 -所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、停止序列与调用配置(`maxTokens`、`reasoningEffort`、`temperature`),不包含适配器撰写的提示词散文。提供方专用请求扩展字段留在模型输入之外。视觉模型通常接收 Files API 引用形式的用户与工具结果图片,其旁带附件句柄和请求预览尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,它还会收到规范化对象路径;描述符会把该副本标记为只读,并警告规范化可能缩放或重新编码上传内容。Files 解析失败时,全部保留图片改用内联 base64;超出预算的较旧图片则在占位文本中保留当前请求已解析的访问方式。此前 assistant 轮次的推理内容会原样传回,无论该轮次是否调用了工具。 对于非法 JSON 或非对象的历史工具参数,Messages 发送 `{}`。此参数兜底保留调用 ID、工具名和工具结果;原始参数仍保留在 Session 日志中。新生成的 Messages 工具参数仍须是有效 JSON 对象。 Messages 会省略用户消息和工具结果中的 `reasoning` 与 `tool-call` 块。这也允许回放包含助手输出的已保存子 Agent 通知;原始 Session 内容保持完整。转换后的空用户消息会被跳过,空工具结果则保留调用 id 和错误标记。其他不支持的输入块仍会以 `UNSUPPORTED_CONTENT` 失败。 +所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、停止序列与调用配置(`maxTokens`、`reasoningEffort`、`temperature`),不包含适配器撰写的提示词散文。提供方专用请求扩展字段留在模型输入之外。视觉模型通常接收 Files API 引用形式的用户与工具结果图片,其旁带附件句柄和请求预览尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,它还会收到规范化对象路径;描述符会把该副本标记为只读,并警告规范化可能缩放或重新编码上传内容。Files 解析失败时,全部保留图片改用内联 base64;超出预算的较旧图片则在占位文本中保留当前请求已解析的访问方式。此前 assistant 轮次的推理内容会原样传回,无论该轮次是否调用了工具。 对于非法 JSON 或非对象的历史工具参数,Messages 发送 `{}`。此参数兜底保留调用 ID、工具名和工具结果;原始参数仍保留在 Session 日志中。新生成的 Messages 工具参数仍须是有效 JSON 对象。 Messages 会省略用户消息和工具结果中的 `reasoning` 与 `tool-call` 块。这也允许回放包含助手输出的已保存子 Agent 通知;原始 Session 内容保持完整。转换后的空用户消息会被跳过,空工具结果则保留调用 id 和错误标记。其他不支持的输入块仍会以 `UNSUPPORTED_CONTENT` 失败。 当所有已声明工具都为延迟加载时,适配器会在列表开头添加非延迟的 `DeferredToolPlaceholder`,描述为 `Reserved placeholder that keeps deferred tool loading active; never call this tool.`。如与已声明工具重名,则添加数字后缀。占位工具没有执行器;调用会收到常规的工具不可用结果。真实工具仍在记录的添加位置才激活,addition-only 路由不会恢复已移除工具。空工具列表不会添加占位工具。 #### Token 影响 diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index f08412c0e9..6f173a6f01 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -45,6 +45,8 @@ function assistant(message: Message, model: string, onReplayDegrade?: (reason: s * Developer messages, already projected for this route by `LlmRuntime`, become * system-role updates after the preceding user turn, with tool changes as * `tool_addition` and `tool_removal` references to the declared tool. + * Nonempty all-deferred tool lists receive an immediate, non-executable + * placeholder without changing real tools or their activation positions. * @param options - provider-neutral request. * @param connection - validated defaults and thinking policy. * @param history - image-projected history with complete system snapshots; durable messages remain unchanged. @@ -149,6 +151,23 @@ export function serialize( throw new LlmError(`DeepSeek Messages does not support reasoning effort ${effort}`, 'UNSUPPORTED_REASONING_EFFORT') } const system = [options.system, historySystem].filter(Boolean).join('\n\n') + const tools = options.tools?.map(tool => ({ + name: tool.name, + description: tool.description, + input_schema: tool.parameters, + ...tool.deferLoading === true ? { defer_loading: true as const } : {}, + })) + if (tools !== undefined && tools.length > 0 && tools.every(tool => tool.defer_loading === true)) { + // Messages requires an immediate declaration even when every real tool loads later. + const names = new Set(tools.map(tool => tool.name)) + let name = 'DeferredToolPlaceholder' + for (let suffix = 1; names.has(name); suffix++) name = `DeferredToolPlaceholder_${suffix}` + tools.unshift({ + name, + description: 'Reserved placeholder that keeps deferred tool loading active; never call this tool.', + input_schema: { type: 'object', properties: {} }, + }) + } return { model: options.model, stream: true, messages, max_tokens: options.maxTokens ?? model?.maxTokens ?? connection.maxTokens, @@ -157,13 +176,6 @@ export function serialize( ...system.length === 0 ? {} : { system }, ...options.temperature === undefined ? {} : { temperature: options.temperature }, ...options.stop === undefined ? {} : { stop_sequences: options.stop }, - ...options.tools === undefined ? {} : { - tools: options.tools.map(tool => ({ - name: tool.name, - description: tool.description, - input_schema: tool.parameters, - ...tool.deferLoading === true ? { defer_loading: true as const } : {}, - })), - }, + ...tools === undefined ? {} : { tools }, } } diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 6952c493e2..d5a359f986 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -16,7 +16,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import LocalAttachments from '@deepseek-ai/dsh-attachment-local' import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' -import LlmRuntime, { BlockAssembler, createAssistantMessage, createSystemMessage, createToolResultMessage, ReasoningEffortId, ToolCallId } from '@deepseek-ai/dsh-llm' +import LlmRuntime, { BlockAssembler, createAssistantMessage, createDeveloperMessage, createSystemMessage, createToolResultMessage, ReasoningEffortId, ToolCallId } from '@deepseek-ai/dsh-llm' import type { Message } from '@deepseek-ai/dsh-llm' import * as PluginPackageInventoryDeepSeek from '@deepseek-ai/dsh-plugin-package-inventory-deepseek' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -50,6 +50,22 @@ async function boot(models?: Messages.Options['models']) { const tool = { name: 'lookup_value', description: 'Read the requested value. Always call this tool to obtain a value.', parameters: { type: 'object', properties: { key: { type: 'string' } }, required: ['key'] } } describe.skipIf(!process.env.DEEPSEEK_API_KEY)('DeepSeek Messages real API', () => { + it('calls a deferred tool after every baseline tool has been removed', async () => { + const model = 'deepseek-flash' + const ctx = await boot([{ id: model, toolUpdate: 'addition-only' }]) + const update = createDeveloperMessage({ source: { kind: 'tool-registry' }, content: [ + { type: 'tool-removal', toolName: 'old_lookup' }, { type: 'tool-addition', toolName: tool.name }, + ] }) + const response = await assemble(ctx.llm.stream(options({ + model, reasoningEffort: ReasoningEffortId('high'), + messages: [user('Call lookup_value with key "answer" to obtain the value. Do not guess.'), update], + tools: [tool], + toolHistory: { tools: [{ ...tool, name: 'old_lookup' }], updates: [{ messageId: update.id, additions: [tool] }] }, + })), model) + expect(response.assembler.finish).toEqual({ kind: 'tool-calls' }) + expect(response.message.content.filter(block => block.type === 'tool-call').map(call => call.name)).toEqual([tool.name]) + }) + it.skipIf(!IN_HISTORY_MODEL).each([false, true])('updates system instructions during a conversation, in-history=%s', async (inHistory) => { const model = IN_HISTORY_MODEL as string // Each case owns the capability, even for a model with an in-history catalog default. diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index d130844014..a113e2a1ed 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -118,7 +118,7 @@ describe('direct Messages HTTP', () => { await assemble(llm.stream(options({ messages: [user(), changes], tools }))) expect(http.requests[0]).toMatchObject({ headers: { 'anthropic-beta': 'mid-conversation-tool-changes-2026-07-01' }, - body: { tools: [{ name: 'search', defer_loading: true }], messages: [ + body: { tools: [{ name: 'DeferredToolPlaceholder' }, { name: 'search', defer_loading: true }], messages: [ { role: 'user' }, { role: 'system', content: [{ type: 'tool_addition', tool: { type: 'tool_reference', name: 'search' } }] }, ] }, }) @@ -276,6 +276,30 @@ describe('direct Messages HTTP', () => { }) describe('Cordis provider composition', () => { + it('keeps a replacement tool deferred after the last baseline tool is removed', async () => { + const { ctx, http } = await boot() + const baseline = { name: 'old_tool', description: 'Old tool', parameters: {} } + const added = { name: 'new_tool', description: 'New tool', parameters: {} } + const update = createDeveloperMessage({ source: { kind: 'tool-registry' }, content: [ + { type: 'tool-removal', toolName: baseline.name }, { type: 'tool-addition', toolName: added.name }, + ] }) + const request = options({ model: 'deepseek-flash', messages: [user(), update], tools: [added], + toolHistory: { tools: [baseline], updates: [{ messageId: update.id, additions: [added] }] }, + }) + const saved = JSON.stringify(request) + const response = await assemble(ctx.llm.stream(request), 'deepseek-flash') + expect(response.assembler.finish.kind).toBe('stop') + expect(http.requests[0]?.body.tools).toEqual([ + { name: 'DeferredToolPlaceholder', description: 'Reserved placeholder that keeps deferred tool loading active; never call this tool.', input_schema: { type: 'object', properties: {} } }, + { name: added.name, description: added.description, input_schema: {}, defer_loading: true }, + ]) + expect(http.requests[0]?.body.messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + { role: 'system', content: [{ type: 'tool_addition', tool: { type: 'tool_reference', name: added.name } }] }, + ]) + expect(JSON.stringify(request)).toBe(saved) + }) + it('resolves an attachment service loaded after the adapter and maps its read-only path', async () => { const http = await endpoint() const { ctx, home } = await context() diff --git a/packages/llm/llm-deepseek/tests/fixtures/deferred-tools.ts b/packages/llm/llm-deepseek/tests/fixtures/deferred-tools.ts new file mode 100644 index 0000000000..eb58b2c54e --- /dev/null +++ b/packages/llm/llm-deepseek/tests/fixtures/deferred-tools.ts @@ -0,0 +1,59 @@ +/** Replay a complete replacement of the initially available tools through Messages serialization. */ +import assert from 'node:assert/strict' +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-agent' +import { projectToolUpdates } from '@deepseek-ai/dsh-llm' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import { resolveAdapterOptions } from '../../src/config.ts' +import { serialize } from '../../src/serialize.ts' + +export const name = 'messages-deferred-tools-snapshot' +export const inject = ['tools', 'llm'] + +/** + * Replace the baseline after its first call and check the serialized replay requests. + * @param ctx - Snapshot composition context. + */ +export function apply(ctx: Context): void { + ctx.on('agent/created', ({ agent }) => { + agent.ctx.tools.restrict({ allow: [] }) + const removeInitial = agent.ctx.tools.register(defineContentToolFixture({ + name: 'snapshot_initial', description: 'Make snapshot_ping available.', parameters: {}, + async execute() { + removeInitial() + agent.ctx.tools.register(defineContentToolFixture({ + name: 'snapshot_ping', description: 'Return pong once.', parameters: {}, + async execute() { + return [{ type: 'text', text: 'pong' }] + }, + })) + return [{ type: 'text', text: 'snapshot_ping is available.' }] + }, + })) + }) + const connection = resolveAdapterOptions({}) + let requests = 0 + ctx.on('llm/stream', (options, next) => { + const projected = projectToolUpdates(options.messages, options.tools, 'addition-only', options.toolHistory) + assert.ok(projected.tools) + const request = serialize({ ...options, tools: [...projected.tools] }, connection, projected.messages, new Map(), () => undefined) + switch (++requests) { + case 1: + assert.deepEqual(request.tools?.map(tool => tool.name), ['snapshot_initial']) + break + case 2: + case 3: + assert.deepEqual(request.tools, [ + { name: 'DeferredToolPlaceholder', description: 'Reserved placeholder that keeps deferred tool loading active; never call this tool.', input_schema: { type: 'object', properties: {} } }, + { name: 'snapshot_ping', description: 'Return pong once.', input_schema: { type: 'object', properties: {} }, defer_loading: true }, + ]) + assert.deepEqual(request.messages.filter(message => message.role === 'system'), [ + { role: 'system', content: [{ type: 'tool_addition', tool: { type: 'tool_reference', name: 'snapshot_ping' } }] }, + ]) + break + default: + assert.fail('Unexpected request after the replacement tool call completed') + } + return next() + }) +} diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index f30bbb2bac..9194650f10 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -80,6 +80,30 @@ describe('Messages request conversion', () => { ]) }) + it('keeps an all-deferred list inactive and adds one immediate placeholder', () => { + const tools = [{ name: 'search', description: 'Search', parameters: {}, deferLoading: true as const }] + const saved = JSON.stringify(tools) + expect(body([user()], { tools }).tools).toEqual([ + { name: 'DeferredToolPlaceholder', description: 'Reserved placeholder that keeps deferred tool loading active; never call this tool.', input_schema: { type: 'object', properties: {} } }, + { name: 'search', description: 'Search', input_schema: {}, defer_loading: true }, + ]) + expect(JSON.stringify(tools)).toBe(saved) + }) + + it('avoids colliding with real deferred tool names', () => { + const tools = ['DeferredToolPlaceholder', 'DeferredToolPlaceholder_1'].map(name => ({ + name, description: 'Real tool', parameters: {}, deferLoading: true as const, + })) + expect(body([user()], { tools }).tools?.map(tool => [tool.name, tool.defer_loading])).toEqual([ + ['DeferredToolPlaceholder_2', undefined], ['DeferredToolPlaceholder', true], ['DeferredToolPlaceholder_1', true], + ]) + }) + + it('preserves absent and empty tool lists', () => { + expect(body().tools).toBeUndefined() + expect(body([user()], { tools: [] }).tools).toEqual([]) + }) + it('preserves the exact request with request-only text after durable tool results', () => { const prefix = [user(), assistant([call()]), result()] const input: RequestUserInput = { role: 'user', content: [{ type: 'text', text: 'review or summarize this input' }] } diff --git a/snapshots/session/deepseek-deferred-tools/cordis.snapshot.yml b/snapshots/session/deepseek-deferred-tools/cordis.snapshot.yml new file mode 100644 index 0000000000..db9166801d --- /dev/null +++ b/snapshots/session/deepseek-deferred-tools/cordis.snapshot.yml @@ -0,0 +1,18 @@ +- id: llm-deepseek + disabled: true + +- insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + toolUpdate: addition-only + defaultMaxTokens: 256000 + reasoningEfforts: [off, low, high, max] + defaultReasoningEffort: high + - id: messages-deferred-tools-snapshot + name: '../../../packages/llm/llm-deepseek/tests/fixtures/deferred-tools.ts' diff --git a/snapshots/session/deepseek-deferred-tools/cordis.yml b/snapshots/session/deepseek-deferred-tools/cordis.yml new file mode 100644 index 0000000000..9f0404c265 --- /dev/null +++ b/snapshots/session/deepseek-deferred-tools/cordis.yml @@ -0,0 +1,3 @@ +- insert: + - id: messages-deferred-tools-snapshot + name: '../../../packages/llm/llm-deepseek/tests/fixtures/deferred-tools.ts' diff --git a/snapshots/session/deepseek-deferred-tools/session.v4.jsonl b/snapshots/session/deepseek-deferred-tools/session.v4.jsonl new file mode 100644 index 0000000000..b0a9610be2 --- /dev/null +++ b/snapshots/session/deepseek-deferred-tools/session.v4.jsonl @@ -0,0 +1,29 @@ +{"type":"session","version":4,"id":"{{session:1}}","createdAt":0,"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":"Call snapshot_initial, then snapshot_ping, then reply DONE."}],"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":"Call snapshot_initial, then snapshot_ping, then reply DONE."}],"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 working directory: \"{{cwd}}\".\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":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"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","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["snapshot_initial"]},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Call snapshot_initial, then snapshot_pin","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_initial","name":"snapshot_initial","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1790049391235,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1790049391235,"index":0,"dt":[],"id":"call_initial","name":"snapshot_initial","args":["{}"]},{"type":"chunk","time":1790049391235,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_initial","name":"snapshot_initial","arguments":"{}"}}},{"type":"chunk","time":1790049391235,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1790049391235,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_initial","name":"snapshot_initial","arguments":"{}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_initial"},"toolCallId":"call_initial","content":[{"type":"text","text":"snapshot_ping is available."}],"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":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["snapshot_ping"]},"reason":"change"}} +{"type":"developer/message","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"snapshot_ping"},{"type":"tool-removal","toolName":"snapshot_initial"}],"role":"developer","id":"{{message:6}}"},"headerSeq":18},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"stream":[{"type":"chunk","time":1790049391255,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1790049391255,"index":0,"dt":[],"id":"call_dynamic_ping","name":"snapshot_ping","args":["{}"]},{"type":"chunk","time":1790049391255,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}}},{"type":"chunk","time":1790049391255,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_dynamic_ping"},"toolCallId":"call_dynamic_ping","content":[{"type":"text","text":"pong"}],"isError":false,"id":"{{message:8}}"}},"sourceEventSeqs":[21],"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":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:9}}"},"usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10},"stream":[{"type":"chunk","time":1790049391270,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1790049391270,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1790049391270,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1790049391270,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10}}},{"type":"chunk","time":1790049391270,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":3}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/deepseek-deferred-tools/snapshot.yml b/snapshots/session/deepseek-deferred-tools/snapshot.yml new file mode 100644 index 0000000000..a99cb0dcc1 --- /dev/null +++ b/snapshots/session/deepseek-deferred-tools/snapshot.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: deepseek-deferred-tools +profile: headless +composition: deepseek-deferred-tools +recording: authored +header: + class: deepseek-deferred-tools + pin: true + changes: 1 diff --git a/snapshots/session/deepseek-deferred-tools/system-prompt.expected.md b/snapshots/session/deepseek-deferred-tools/system-prompt.expected.md new file mode 100644 index 0000000000..7c0cf23533 --- /dev/null +++ b/snapshots/session/deepseek-deferred-tools/system-prompt.expected.md @@ -0,0 +1,14 @@ +You are an AI agent powered by DeepSeek Harness. + +You are a coding assistant powered by the deepseek-v4-flash model. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + +Verify your work by running the code or tests. Keep answers brief and factual. + + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. + +create_goal may infer goal intent from a direct human request in any language. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. diff --git a/snapshots/session/deepseek-deferred-tools/tool-schemas.expected.json b/snapshots/session/deepseek-deferred-tools/tool-schemas.expected.json new file mode 100644 index 0000000000..ee53a9dda8 --- /dev/null +++ b/snapshots/session/deepseek-deferred-tools/tool-schemas.expected.json @@ -0,0 +1,24 @@ +{ + "initial": [ + { + "name": "snapshot_initial", + "description": "Make snapshot_ping available.", + "parameters": { + "type": "object", + "properties": {} + } + } + ], + "changes": [ + [ + { + "name": "snapshot_ping", + "description": "Return pong once.", + "parameters": { + "type": "object", + "properties": {} + } + } + ] + ] +} From f8217a4fe10a0de2c5bfdf0d7a659b070e45e9d3 Mon Sep 17 00:00:00 2001 From: Yixiang Tu Date: Thu, 22 Oct 2026 16:57:04 +0800 Subject: [PATCH 21/32] fix(llm): rebuild addition-only baselines after tool removals --- .../2026-09-20-dynamic-tool-updates.i18n.yaml | 12 +-- .../2026-09-20-dynamic-tool-updates.md | 6 +- .../2026-09-20-dynamic-tool-updates.zh.md | 6 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- .../llm/llm-deepseek/tests/adapter.e2e.ts | 2 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 6 +- .../tests/fixtures/deferred-tools.ts | 15 +++- .../tests/conversation-updates.spec.ts | 10 ++- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 2 +- packages/llm/llm/README.zh.md | 2 +- packages/llm/llm/src/content.ts | 45 ++++++++++- packages/llm/llm/tests/content.spec.ts | 77 +++++++++++++++++-- .../deepseek-deferred-tools/session.v4.jsonl | 8 +- .../deepseek-deferred-tools/snapshot.yml | 2 +- .../tool-schemas.expected.json | 18 +++++ 18 files changed, 182 insertions(+), 41 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml index c0587791fd..357c16c769 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml @@ -9,11 +9,11 @@ en: 97fe58a13135b4a1 zh: 7490373a8d941da5 /agent-note-active-tool-headers-and-stateful-request-projection/decision: - en: 0c40a9283650c643 - zh: 91a18d752d09a9ce + en: b6770e6bd8ff17ee + zh: cc409f4e9f9be495 /agent-note-active-tool-headers-and-stateful-request-projection/alternatives-considered: - en: b78785f2a603eba8 - zh: b8c268228da7e2e7 + en: 69370c9cb0014985 + zh: ccba65967ab1c34d /agent-note-active-tool-headers-and-stateful-request-projection/consequences: - en: 60d7bf28fa5a8fd1 - zh: a17b222e7aca01e2 + en: cabc2b9d378d186e + zh: 7aa21854f43bf8e8 diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md index a40b85601c..6421cb7e43 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md @@ -14,7 +14,7 @@ Plugin and MCP tools change during conversations. Providers differ in whether th `Session.toolHistory()` caches a capability-independent fold of committed headers and developer messages. First access restores inherited events; later reads consume only unseen events. Immutable snapshots carry initial declarations and ordered update identities with additions resolved through their historical headers. Explicit series starts and changed retained definitions rebuild declarations. Restoring a retained name with an unchanged definition continues the series: the projection keeps the declaration unchanged and lets the recorded addition re-offer the tool after its earlier removal, so a removal never disables a declared tool and a loading mode never rewrites an earlier declaration. -Loop and compaction requests carry this snapshot as `GenerateOptions.toolHistory`. At adapter dispatch, `projectToolUpdates` uses the prepared route's `toolUpdate`. Unsupported routes receive active definitions without `deferLoading` or developer messages. Supported routes defer historical additions; `in-history` retains removed definitions and removal blocks, while `addition-only` omits both. Only current-series update identities survive. Missing history or a request prefix omitting recorded updates uses current declarations without developer updates. Explicit deferred loading remains available on capable routes. +Loop and compaction requests carry this snapshot as `GenerateOptions.toolHistory`. At adapter dispatch, `projectToolUpdates` uses the prepared route's `toolUpdate`. Unsupported routes receive active definitions without `deferLoading` or developer messages. Supported routes defer historical additions; `in-history` retains removed definitions and removal blocks, while `addition-only` omits both. On addition-only routes, removing an immediate baseline tool rebuilds the route's baseline after that update: the initial declarations already change, so retaining superseded activation blocks cannot preserve the full conversation prefix. Previously activated tools become immediate; explicitly deferred declarations with no activation remain deferred. Subsequent additions remain deferred until their recorded activation. Superseded tool blocks are omitted without discarding other content in those messages. The Session fold and durable events remain capability-independent. Missing history or a request prefix omitting recorded updates uses current declarations without developer updates. DeepSeek translates projected updates into system-role `tool_addition` and `tool_removal` blocks and `defer_loading` declarations, sending the tool-changes beta header when update blocks occur. The default `deepseek-flash` entry declares `addition-only`; pi-ai declares no mode. Chat and Trajectory display developer messages as context nodes, using localized tool-change notices: single changes show the tool name; multiple changes show counts and expandable name lists. @@ -26,6 +26,8 @@ DeepSeek translates projected updates into system-role `tool_addition` and `tool **Scan the complete log on every request.** The cached fold consumes new events and supplies stable snapshots to ordinary and auxiliary calls without adding arbitrary synchronous history readers. +**Rebuild whenever additions and removals occur together.** Replacing a later-added tool can preserve the prefix before its activation while the immediate baseline stays unchanged. Conversely, a removal-only update can remove the last immediate baseline tool. The reset therefore follows baseline removals, not the presence of both update kinds. A past reset cannot force every future addition to become immediate: each reset establishes a new baseline from which deferred additions resume. + **Reject same-name schema changes during tool registration.** This restriction is not enforced. Immediate failure for a plugin registering an incompatible tool requires comparing the new registration with historical definitions of tools called on each affected current surface, while excluding calls removed by compaction. That registration-time historical and scope-aware check is deferred because of its implementation complexity. Same-name schema changes remain allowed; they rebuild declarations and invalidate prefix reuse even on `in-history` routes. ## Consequences @@ -34,4 +36,6 @@ Tool lists and logical update events are capability-independent; only outgoing d The authored [headless prompt/tool scenario](../../../../snapshots/session/dynamic-tool-prompt-updates/snapshot.yml) pins an appended prompt beside a deferred tool addition and verifies that the first request's messages stay unchanged. It shares the stable-prompt scenario's tool lifecycle and schema expectations. +The [DeepSeek replacement scenario](../../../../snapshots/session/deepseek-deferred-tools/snapshot.yml) checks the rebuilt baseline and a subsequent deferred addition through Messages serialization. An adapter-local placeholder covers nonempty all-deferred requests that remain, including explicit deferred declarations and direct calls. It does not activate real tools. These checks establish request construction, not measured cache savings from rebuilding. + The [real DeepSeek SDK test](../../../../apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts) measures the first request after a native tool addition with both unchanged and simultaneously updated instructions. Unique user history prevents a shared system-prompt cache hit from satisfying the preceding-input assertion. Removal checks tool availability without requiring cache retention; each case disables retries. diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md index 372dcb27af..86350a0318 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md @@ -14,7 +14,7 @@ Status: implemented `Session.toolHistory()` 缓存对已提交请求头和 developer 消息的归约,不依赖模型能力。首次访问恢复继承事件;后续读取仅处理新增事件。不可变快照携带初始声明和有序更新标识,添加定义通过历史请求头解析。显式新序列和保留定义变化会重建声明。以未变化的定义恢复保留名称时序列继续:投影保持声明不变,由记录的添加在先前移除之后重新提供该工具,因此移除不会禁用已声明的工具,加载模式也不会改写先前声明。 -循环和压缩请求通过 `GenerateOptions.toolHistory` 携带此快照。适配器分发时,`projectToolUpdates` 使用已准备路由的 `toolUpdate`。不支持的路由接收有效定义,不携带 `deferLoading` 或 developer 消息。支持的路由延迟历史添加;`in-history` 保留已移除定义和移除块,`addition-only` 则省略两者。仅保留当前序列的更新标识。缺少历史或请求前缀遗漏已记录更新时,使用当前声明且不发送 developer 更新。显式延迟加载在支持的路由上仍然可用。 +循环和压缩请求通过 `GenerateOptions.toolHistory` 携带此快照。适配器分发时,`projectToolUpdates` 使用已准备路由的 `toolUpdate`。不支持的路由接收有效定义,不携带 `deferLoading` 或 developer 消息。支持的路由延迟历史添加;`in-history` 保留已移除定义和移除块,`addition-only` 则省略两者。在 addition-only 路由移除基线中的立即加载工具时,使用该次更新后的状态重建路由基线:初始声明已发生变化,保留已被取代的激活块无法保留完整对话前缀。此前已激活工具变为立即加载;没有激活记录的显式延迟声明仍保持延迟。后续添加在记录的激活位置前保持延迟加载。已被取代的工具块会被省略,但不会丢弃这些消息中的其他内容。Session 归约和持久事件仍不依赖模型能力。缺少历史或请求前缀遗漏已记录更新时,使用当前声明且不发送 developer 更新。 DeepSeek 把投影后的更新转换为 system 角色的 `tool_addition` 和 `tool_removal` 块以及 `defer_loading` 声明,在存在更新块时发送工具变更 beta 请求头。默认 `deepseek-flash` 条目声明 `addition-only`;pi-ai 不声明模式。Chat 和 Trajectory 将 developer 消息显示为上下文节点,工具变更使用本地化通知:单项变更显示工具名,多项变更显示数量及可展开的名称列表。 @@ -26,6 +26,8 @@ DeepSeek 把投影后的更新转换为 system 角色的 `tool_addition` 和 `to **每次请求扫描完整日志。** 缓存归约处理新事件,为普通和辅助调用提供稳定快照,不增加任意同步历史读取接口。 +**同时出现添加和移除就重建。** 替换后来添加的工具时,只要立即加载的基线未变,就仍可保留该工具激活之前的前缀。反之,仅包含移除的更新也可能移除最后一个立即加载的基线工具。因此,重置取决于基线工具移除,而不是两类更新是否同时出现。一次历史重置不能让所有后续添加都变为立即加载:每次重置确立新基线,后续添加继续采用延迟加载。 + **在工具注册时拒绝同名 schema 变化。** 当前不执行此限制。要让注册不兼容工具的插件立即收到异常,需要将新注册与各受影响当前 surface 中已调用工具的历史定义比较,同时排除已被压缩移除的调用。由于实现复杂,注册时结合历史和作用域的检查留待后续实现。同名 schema 变化仍被允许;它们会重建声明,即使在 `in-history` 路由上也会使前缀复用失效。 ## Consequences @@ -34,4 +36,6 @@ DeepSeek 把投影后的更新转换为 system 角色的 `tool_addition` 和 `to 手写的[无界面提示词/工具场景](../../../../snapshots/session/dynamic-tool-prompt-updates/snapshot.yml)钉住追加提示词与延迟工具添加同时发生的行为,并验证首次请求的消息保持不变。它复用稳定提示词场景的工具生命周期和 schema 期望值。 +[DeepSeek 替换场景](../../../../snapshots/session/deepseek-deferred-tools/snapshot.yml)通过 Messages 序列化验证重建后的基线和后续延迟添加。适配器内的占位工具覆盖仍然存在的非空全延迟请求,包括显式延迟声明和直接调用,不会激活真实工具。这些检查验证请求构造,不代表已经测量重建带来的缓存收益。 + [真实 DeepSeek SDK 测试](../../../../apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts) 分别在指令不变和指令同时更新时测量原生工具新增后的首次请求。独有的用户历史确保仅命中共享系统提示词缓存无法满足前序输入断言。移除场景检查工具可用性,不要求保留缓存;每个用例均禁用重试。 diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index c0abd8378f..eb6b7092a7 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -63,8 +63,8 @@ en: 1dc50052999253c6 zh: 7abf968fc18bffcd /deepseek-ai-dsh-llm-deepseek/model-experience/deepseek-request/what-the-model-sees: - en: f475b3adb2d61982 - zh: 6d8913a294f11de3 + en: fda9111504c5af32 + zh: 6306a70fdafbdb1b /deepseek-ai-dsh-llm-deepseek/model-experience/deepseek-request/token-effect: en: 67f4bfa42cbee911 zh: 5e9dc9bc07253686 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 6433ff8caf..6c3717c4d7 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -170,7 +170,7 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config (`maxTokens`, `reasoningEffort`, `temperature`) without adapter-authored prompt prose. Provider-specific request-extension fields remain outside model input. The vision model normally receives retained user and tool-result images as Files API references beside attachment handles and request-preview dimensions. It also receives a normalized-object path when the current execution filesystem maps the attachment provider's host object; the descriptor marks this copy read-only and warns that normalization may have resized or re-encoded the upload. A Files resolution failure sends all retained images as inline base64 instead, and an over-budget older image keeps the access resolved for that request in its placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. Messages sends `{}` for historical tool arguments that are malformed JSON or are not objects. This argument fallback preserves call ids, tool names, and tool results; the original arguments stay in the Session log. Newly generated Messages tool arguments still require valid JSON objects. Messages omits `reasoning` and `tool-call` blocks inside user messages and tool results. This also permits replay of saved subagent notices containing assistant output; the original Session content remains intact. Empty user messages are skipped after conversion, while empty tool results retain their call ids and error flags. Other unsupported input blocks still fail with `UNSUPPORTED_CONTENT`. When every declared tool is deferred, the adapter prepends a non-deferred `DeferredToolPlaceholder` with the description `Reserved placeholder that keeps deferred tool loading active; never call this tool.` A numeric suffix avoids collisions with declared tools. The placeholder has no executor; calls receive the ordinary unavailable-tool result. Real tools remain deferred until their recorded additions, and removed tools stay absent on addition-only routes. Empty tool lists receive no placeholder. +The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config (`maxTokens`, `reasoningEffort`, `temperature`) without adapter-authored prompt prose. Provider-specific request-extension fields remain outside model input. The vision model normally receives retained user and tool-result images as Files API references beside attachment handles and request-preview dimensions. It also receives a normalized-object path when the current execution filesystem maps the attachment provider's host object; the descriptor marks this copy read-only and warns that normalization may have resized or re-encoded the upload. A Files resolution failure sends all retained images as inline base64 instead, and an over-budget older image keeps the access resolved for that request in its placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. Messages sends `{}` for historical tool arguments that are malformed JSON or are not objects. This argument fallback preserves call ids, tool names, and tool results; the original arguments stay in the Session log. Newly generated Messages tool arguments still require valid JSON objects. Messages omits `reasoning` and `tool-call` blocks inside user messages and tool results. This also permits replay of saved subagent notices containing assistant output; the original Session content remains intact. Empty user messages are skipped after conversion, while empty tool results retain their call ids and error flags. Other unsupported input blocks still fail with `UNSUPPORTED_CONTENT`. When every declared tool is deferred, the adapter prepends a non-deferred `DeferredToolPlaceholder` with the description `Reserved placeholder that keeps deferred tool loading active; never call this tool.` A numeric suffix avoids collisions with declared tools. The placeholder has no executor; calls receive the ordinary unavailable-tool result. The placeholder leaves real declarations unchanged. The shared [tool-update projection](../llm/README.md#known-limitations-and-deferred-work) rebuilds an addition-only baseline when an immediate tool is removed, while keeping later additions deferred. Empty tool lists receive no placeholder. #### Token effect diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index eeabe9d876..4350fe39e9 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -170,7 +170,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 #### 模型看到什么 -所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、停止序列与调用配置(`maxTokens`、`reasoningEffort`、`temperature`),不包含适配器撰写的提示词散文。提供方专用请求扩展字段留在模型输入之外。视觉模型通常接收 Files API 引用形式的用户与工具结果图片,其旁带附件句柄和请求预览尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,它还会收到规范化对象路径;描述符会把该副本标记为只读,并警告规范化可能缩放或重新编码上传内容。Files 解析失败时,全部保留图片改用内联 base64;超出预算的较旧图片则在占位文本中保留当前请求已解析的访问方式。此前 assistant 轮次的推理内容会原样传回,无论该轮次是否调用了工具。 对于非法 JSON 或非对象的历史工具参数,Messages 发送 `{}`。此参数兜底保留调用 ID、工具名和工具结果;原始参数仍保留在 Session 日志中。新生成的 Messages 工具参数仍须是有效 JSON 对象。 Messages 会省略用户消息和工具结果中的 `reasoning` 与 `tool-call` 块。这也允许回放包含助手输出的已保存子 Agent 通知;原始 Session 内容保持完整。转换后的空用户消息会被跳过,空工具结果则保留调用 id 和错误标记。其他不支持的输入块仍会以 `UNSUPPORTED_CONTENT` 失败。 当所有已声明工具都为延迟加载时,适配器会在列表开头添加非延迟的 `DeferredToolPlaceholder`,描述为 `Reserved placeholder that keeps deferred tool loading active; never call this tool.`。如与已声明工具重名,则添加数字后缀。占位工具没有执行器;调用会收到常规的工具不可用结果。真实工具仍在记录的添加位置才激活,addition-only 路由不会恢复已移除工具。空工具列表不会添加占位工具。 +所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、停止序列与调用配置(`maxTokens`、`reasoningEffort`、`temperature`),不包含适配器撰写的提示词散文。提供方专用请求扩展字段留在模型输入之外。视觉模型通常接收 Files API 引用形式的用户与工具结果图片,其旁带附件句柄和请求预览尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,它还会收到规范化对象路径;描述符会把该副本标记为只读,并警告规范化可能缩放或重新编码上传内容。Files 解析失败时,全部保留图片改用内联 base64;超出预算的较旧图片则在占位文本中保留当前请求已解析的访问方式。此前 assistant 轮次的推理内容会原样传回,无论该轮次是否调用了工具。 对于非法 JSON 或非对象的历史工具参数,Messages 发送 `{}`。此参数兜底保留调用 ID、工具名和工具结果;原始参数仍保留在 Session 日志中。新生成的 Messages 工具参数仍须是有效 JSON 对象。 Messages 会省略用户消息和工具结果中的 `reasoning` 与 `tool-call` 块。这也允许回放包含助手输出的已保存子 Agent 通知;原始 Session 内容保持完整。转换后的空用户消息会被跳过,空工具结果则保留调用 id 和错误标记。其他不支持的输入块仍会以 `UNSUPPORTED_CONTENT` 失败。 当所有已声明工具都为延迟加载时,适配器会在列表开头添加非延迟的 `DeferredToolPlaceholder`,描述为 `Reserved placeholder that keeps deferred tool loading active; never call this tool.`。如与已声明工具重名,则添加数字后缀。占位工具没有执行器;调用会收到常规的工具不可用结果。占位工具不会改变真实工具声明。共享的[工具更新投影](../llm/README.zh.md#known-limitations-and-deferred-work)在移除立即加载工具时重建 addition-only 基线,后续添加仍保持延迟加载。空工具列表不会添加占位工具。 #### Token 影响 diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index d5a359f986..6c5264c2a8 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -50,7 +50,7 @@ async function boot(models?: Messages.Options['models']) { const tool = { name: 'lookup_value', description: 'Read the requested value. Always call this tool to obtain a value.', parameters: { type: 'object', properties: { key: { type: 'string' } }, required: ['key'] } } describe.skipIf(!process.env.DEEPSEEK_API_KEY)('DeepSeek Messages real API', () => { - it('calls a deferred tool after every baseline tool has been removed', async () => { + it('calls a replacement tool after every baseline tool has been removed', async () => { const model = 'deepseek-flash' const ctx = await boot([{ id: model, toolUpdate: 'addition-only' }]) const update = createDeveloperMessage({ source: { kind: 'tool-registry' }, content: [ diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index a113e2a1ed..f36cb3cdeb 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -276,7 +276,7 @@ describe('direct Messages HTTP', () => { }) describe('Cordis provider composition', () => { - it('keeps a replacement tool deferred after the last baseline tool is removed', async () => { + it('declares the replacement immediately after the last baseline tool is removed', async () => { const { ctx, http } = await boot() const baseline = { name: 'old_tool', description: 'Old tool', parameters: {} } const added = { name: 'new_tool', description: 'New tool', parameters: {} } @@ -290,12 +290,10 @@ describe('Cordis provider composition', () => { const response = await assemble(ctx.llm.stream(request), 'deepseek-flash') expect(response.assembler.finish.kind).toBe('stop') expect(http.requests[0]?.body.tools).toEqual([ - { name: 'DeferredToolPlaceholder', description: 'Reserved placeholder that keeps deferred tool loading active; never call this tool.', input_schema: { type: 'object', properties: {} } }, - { name: added.name, description: added.description, input_schema: {}, defer_loading: true }, + { name: added.name, description: added.description, input_schema: {} }, ]) expect(http.requests[0]?.body.messages).toEqual([ { role: 'user', content: [{ type: 'text', text: 'hello' }] }, - { role: 'system', content: [{ type: 'tool_addition', tool: { type: 'tool_reference', name: added.name } }] }, ]) expect(JSON.stringify(request)).toBe(saved) }) diff --git a/packages/llm/llm-deepseek/tests/fixtures/deferred-tools.ts b/packages/llm/llm-deepseek/tests/fixtures/deferred-tools.ts index eb58b2c54e..b1eb397763 100644 --- a/packages/llm/llm-deepseek/tests/fixtures/deferred-tools.ts +++ b/packages/llm/llm-deepseek/tests/fixtures/deferred-tools.ts @@ -24,6 +24,10 @@ export function apply(ctx: Context): void { agent.ctx.tools.register(defineContentToolFixture({ name: 'snapshot_ping', description: 'Return pong once.', parameters: {}, async execute() { + agent.ctx.tools.register(defineContentToolFixture({ + name: 'snapshot_later', description: 'Return later.', parameters: {}, + async execute() { return [{ type: 'text', text: 'later' }] }, + })) return [{ type: 'text', text: 'pong' }] }, })) @@ -42,13 +46,18 @@ export function apply(ctx: Context): void { assert.deepEqual(request.tools?.map(tool => tool.name), ['snapshot_initial']) break case 2: + assert.deepEqual(request.tools, [ + { name: 'snapshot_ping', description: 'Return pong once.', input_schema: { type: 'object', properties: {} } }, + ]) + assert.deepEqual(request.messages.filter(message => message.role === 'system'), []) + break case 3: assert.deepEqual(request.tools, [ - { name: 'DeferredToolPlaceholder', description: 'Reserved placeholder that keeps deferred tool loading active; never call this tool.', input_schema: { type: 'object', properties: {} } }, - { name: 'snapshot_ping', description: 'Return pong once.', input_schema: { type: 'object', properties: {} }, defer_loading: true }, + { name: 'snapshot_ping', description: 'Return pong once.', input_schema: { type: 'object', properties: {} } }, + { name: 'snapshot_later', description: 'Return later.', input_schema: { type: 'object', properties: {} }, defer_loading: true }, ]) assert.deepEqual(request.messages.filter(message => message.role === 'system'), [ - { role: 'system', content: [{ type: 'tool_addition', tool: { type: 'tool_reference', name: 'snapshot_ping' } }] }, + { role: 'system', content: [{ type: 'tool_addition', tool: { type: 'tool_reference', name: 'snapshot_later' } }] }, ]) break default: diff --git a/packages/llm/llm-pi-ai/tests/conversation-updates.spec.ts b/packages/llm/llm-pi-ai/tests/conversation-updates.spec.ts index 467d691603..508eb39d82 100644 --- a/packages/llm/llm-pi-ai/tests/conversation-updates.spec.ts +++ b/packages/llm/llm-pi-ai/tests/conversation-updates.spec.ts @@ -289,7 +289,7 @@ describe('historical tool availability on the wire', () => { ] }) }) - it('anchors additions in Responses without emitting removal updates', async () => { + it('rebuilds the Responses baseline after its immediate tool is removed', async () => { const update = addition() const remove = createDeveloperMessage({ source: { kind: 'tool-registry' }, content: [{ type: 'tool-removal', toolName: 'read' }] }) const { requests } = await send('openai', 'gpt-5.4', { @@ -299,9 +299,11 @@ describe('historical tool availability on the wire', () => { ] }, }) expect(requests).toHaveLength(1) - expect(requests[0]).toHaveProperty('input', expect.arrayContaining([ - expect.objectContaining({ type: 'additional_tools', tools: [expect.objectContaining({ name: 'search' })] }), - ])) + expect(requests[0]).toHaveProperty('tools', [expect.objectContaining({ name: 'search' })]) + expect(requests[0]).toHaveProperty('input', [ + { role: 'user', content: [{ type: 'input_text', text: 'first' }] }, + { role: 'developer', content: 'Instruction one.\nInstruction two.' }, + ]) expect(JSON.stringify(requests[0])).not.toContain('tool_removal') expect(JSON.stringify(requests[0])).not.toContain('Read a file') }) diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index dc90c35071..53db29f075 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -54,8 +54,8 @@ en: a81634136baef72f zh: b92dda883986b0e1 /deepseek-ai-dsh-llm/known-limitations-and-deferred-work: - en: d60b9121545c99ec - zh: 3d4434a0e427470b + en: 94e36b92eb0dcc78 + zh: c8b99bf993411f11 /deepseek-ai-dsh-llm/known-limitations-and-deferred-work/dev-note: en: ecbc333b88e14d88 zh: 77c725d92efd8646 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index e2c319f53d..203dde885d 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -180,7 +180,7 @@ These limits define where this service stops and other packages or future work b - **Variants normally require a producer** — `prefill`, per-tool `strict`, block `cache` hints, and the `agent` message-source variant have no producer ([Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)). - **`BlockAssembler` handles core block kinds only** — a plugin-added block type whose stream is never closed by `block-end` makes `blocks()` throw. - **`GenerateOptions.sessionId` is a locally-declared brand** — importing dsh-session's `SessionId` would create a dependency cycle. -- **Tool updates require session history** — `GenerateOptions.tools` contains active definitions. `toolHistory` supplies the initial declarations and historically resolved additions from `Session.toolHistory()`. At adapter dispatch, `projectToolUpdates` constructs deferred declarations and retains removed definitions for `in-history`; `addition-only` omits removed definitions and removal messages. Unsupported routes receive active tools without developer messages or `deferLoading`. Missing history or a request prefix omitting recorded updates falls back to current declarations without developer messages. Explicitly deferred baseline tools remain deferred until their first retained addition block; declaring a deferred tool does not activate it. +- **Tool updates require session history** — `GenerateOptions.tools` contains active definitions. `toolHistory` supplies the initial declarations and historically resolved additions from `Session.toolHistory()`. At adapter dispatch, `projectToolUpdates` constructs deferred declarations and retains removed definitions for `in-history`; `addition-only` omits removed definitions and removal messages. Removing an immediate baseline tool on an addition-only route rebuilds that route's baseline from the declarations available after that update. Superseded tool blocks are omitted while unrelated content survives; subsequent additions remain deferred. Removing only later-added tools preserves the earlier baseline. Unsupported routes receive active tools without developer messages or `deferLoading`. Missing history or a request prefix omitting recorded updates falls back to current declarations without developer messages. Explicitly deferred tools become immediate during a rebuild only if an addition has already activated them; unactivated declarations remain deferred. ### Dev Note diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 2fd7de3175..38a7f235be 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -180,7 +180,7 @@ const configure = (maxTokens: number): ConfigureCall => - **变体通常要求实际产生方**——`prefill`、逐工具 `strict`、内容块 `cache` 提示和 `agent` 消息来源变体都没有产生方(见 [Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md))。 - **`BlockAssembler` 只处理核心块类型**——插件添加块类型的流若从未由 `block-end` 关闭,`blocks()` 会抛出异常。 - **`GenerateOptions.sessionId` 是本地声明的品牌类型**——导入 dsh-session 的 `SessionId` 会产生依赖循环。 -- **工具更新需要会话历史** — `GenerateOptions.tools` 包含当前有效定义。`toolHistory` 提供 `Session.toolHistory()` 派生的初始声明及已解析历史定义的添加记录。适配器分发时,`projectToolUpdates` 构造延迟声明,并在 `in-history` 模式保留已移除定义;`addition-only` 省略已移除定义和移除消息。不支持更新的路由接收有效工具,不携带 developer 消息或 `deferLoading`。缺少历史或请求前缀遗漏已记录更新时,回退为当前声明且不发送 developer 消息。 显式延迟加载的初始工具在首个保留的添加块出现前保持延迟状态;声明延迟加载工具不会使其激活。 +- **工具更新需要会话历史** — `GenerateOptions.tools` 包含当前有效定义。`toolHistory` 提供 `Session.toolHistory()` 派生的初始声明及已解析历史定义的添加记录。适配器分发时,`projectToolUpdates` 构造延迟声明,并在 `in-history` 模式保留已移除定义;`addition-only` 省略已移除定义和移除消息。在 addition-only 路由移除基线中的立即加载工具时,使用该次更新后的可用声明重建此路由的基线。已被取代的工具块会被省略,其他内容保留;后续添加仍保持延迟加载。仅移除后来添加的工具时保留早期基线。不支持更新的路由接收有效工具,不携带 developer 消息或 `deferLoading`。缺少历史或请求前缀遗漏已记录更新时,回退为当前声明且不发送 developer 消息。显式延迟工具仅在已有添加记录将其激活时,才会在重建时变为立即加载;尚未激活的声明仍保持延迟加载。 ### 开发备注 diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index cecbfba938..bec3190d1a 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -3,6 +3,7 @@ import type { ContentBlock, ImageBlock, LlmImageRequestBudget, ToolSchema, ToolUpdate, ToolHistory } from './types.ts' import type { RequestMessage } from './types.ts' import type { Message } from './message.ts' +import type { MessageId } from './brand.ts' import type { AttachmentStore, FileAttachmentRef, ImageAttachmentRef, ImageMediaType, RequestImageAttachment, } from '@deepseek-ai/dsh-attachment' @@ -411,10 +412,43 @@ function toolDeclarations( } } +/** Reset only when a removal changes the initially loaded definitions; later removals retain the earlier prefix. */ +function rebaseAdditionOnlyHistory( + history: ToolHistory, + messages: ReadonlyMap, +): ToolHistory { + const available = new Map(history.tools.map(tool => [tool.name, tool])) + let baseline = history.tools + let immediate = new Set(baseline.filter(tool => !tool.deferLoading).map(tool => tool.name)) + let retainedFrom = 0 + for (const [index, update] of history.updates.entries()) { + const additions = new Map(update.additions.map(tool => [tool.name, tool])) + let reset = false + // Completeness was checked before projection; Session resolves every addition's definition. + for (const block of messages.get(update.messageId) as readonly ContentBlock[]) { + if (block.type === 'tool-addition') { + const { deferLoading: _loading, ...tool } = additions.get(block.toolName) as ToolSchema + available.set(tool.name, tool) + } else if (block.type === 'tool-removal') { + reset ||= immediate.has(block.toolName) + available.delete(block.toolName) + } + } + if (reset) { + baseline = [...available.values()] + immediate = new Set(baseline.filter(tool => !tool.deferLoading).map(tool => tool.name)) + retainedFrom = index + 1 + } + } + return retainedFrom === 0 ? history : { tools: baseline, updates: history.updates.slice(retainedFrom) } +} + /** * Construct provider declarations from session-folded history without changing logged active tools. * Unsupported routes and incomplete history use current declarations without developer updates. * Explicitly deferred baseline tools become available only after their first retained addition. + * Addition-only routes rebuild their baseline when an initially loaded tool is removed; + * later additions remain deferred and superseded tool blocks are omitted without dropping other content. * @param messages - complete request inputs, or the prefix selected for an auxiliary call. * @param tools - currently active tool schemas. * @param toolUpdate - the resolved route's update mode. @@ -440,16 +474,18 @@ export function projectToolUpdates( // Current schemas alone cannot resolve definitions referenced by past updates. return { messages: withoutDeveloperMessages(messages), tools } } - const messageIds = new Set(messages.flatMap(message => message.role === 'developer' ? [message.id] : [])) - if (history.updates.some(update => !messageIds.has(update.messageId))) { + const updates = new Map(messages.flatMap(message => message.role === 'developer' ? [[message.id, message.content] as const] : [])) + if (history.updates.some(update => !updates.has(update.messageId))) { // An auxiliary prefix may omit updates needed to activate historical declarations. return { messages: withoutDeveloperMessages(messages), tools } } - const declarations = toolDeclarations(tools, toolUpdate, history) + const projectedHistory = toolUpdate === 'addition-only' ? rebaseAdditionOnlyHistory(history, updates) : history + const declarations = toolDeclarations(tools, toolUpdate, projectedHistory) const updateIds = new Set(history.updates.map(update => update.messageId)) + const retainedUpdateIds = new Set(projectedHistory.updates.map(update => update.messageId)) // Deferred baseline tools still need their first addition to become available. - const offered = new Set(history.tools.filter(tool => !tool.deferLoading).map(tool => tool.name)) + const offered = new Set(projectedHistory.tools.filter(tool => !tool.deferLoading).map(tool => tool.name)) const projectedMessages: RequestMessage[] = [] for (const message of messages) { if (message.role !== 'developer') { @@ -462,6 +498,7 @@ export function projectToolUpdates( const content = message.content.filter((block) => { switch (block.type) { case 'tool-addition': + if (!retainedUpdateIds.has(message.id)) return false // Only declared tools that are not already available need activation. if (!declarations.has(block.toolName) || offered.has(block.toolName)) return false offered.add(block.toolName) diff --git a/packages/llm/llm/tests/content.spec.ts b/packages/llm/llm/tests/content.spec.ts index 19bb84e460..cf9d13594f 100644 --- a/packages/llm/llm/tests/content.spec.ts +++ b/packages/llm/llm/tests/content.spec.ts @@ -501,9 +501,8 @@ describe('projectToolUpdates', () => { expect(projected.tools).toEqual([]) expect(projected.messages).toEqual([prompt, prompt, prompt]) const partial = projectToolUpdates([prompt, swapped, prompt], [fetch], 'addition-only', { tools: [search], updates: [history.updates[0]!] }) - expect(partial.tools).toEqual([{ ...fetch, deferLoading: true }]) - expect(partial.messages[1]).not.toBe(swapped) - expect(roles(partial.messages)).toEqual([[{ type: 'tool-addition', toolName: 'fetch' }]]) + expect(partial.tools).toEqual([fetch]) + expect(partial.messages).toEqual([prompt, prompt]) }) it('re-offers an unchanged restored tool through its recorded blocks', () => { @@ -521,8 +520,8 @@ describe('projectToolUpdates', () => { [{ type: 'tool-addition', toolName: 'search' }], ]) const additionOnly = projectToolUpdates(messages, [search, fetch], 'addition-only', history) - expect(additionOnly.tools).toEqual([search, fetch]) - expect(roles(additionOnly.messages)).toEqual([]) + expect(additionOnly.tools).toEqual([fetch, { ...search, deferLoading: true }]) + expect(roles(additionOnly.messages)).toEqual([[{ type: 'tool-addition', toolName: 'search' }]]) }) it('declares a deferred tool once across removal and re-addition', () => { @@ -541,4 +540,72 @@ describe('projectToolUpdates', () => { const additionOnly = projectToolUpdates(messages, [search, fetch], 'addition-only', history) expect(roles(additionOnly.messages)).toEqual([[{ type: 'tool-addition', toolName: 'fetch' }]]) }) + + it('rebases on a baseline removal and defers additions after the reset', () => { + const later = { name: 'later', description: 'Later', parameters: {} } + const added = developer([{ type: 'tool-addition', toolName: 'fetch' }, { type: 'text', text: 'Keep this guidance.' }]) + const removed = developer([{ type: 'tool-removal', toolName: 'search' }, { type: 'text', text: 'Keep this too.' }]) + const next = developer([{ type: 'tool-addition', toolName: 'later' }]) + const history: ToolHistory = { tools: [search], updates: [ + { messageId: added.id, additions: [fetch] }, { messageId: removed.id, additions: [] }, + { messageId: next.id, additions: [later] }, + ] } + const messages = [prompt, added, prompt, removed, prompt, next] + const saved = JSON.stringify({ messages, history }) + const projected = projectToolUpdates(messages, [fetch, later], 'addition-only', history) + expect(projected.tools).toEqual([fetch, { ...later, deferLoading: true }]) + expect(roles(projected.messages)).toEqual([ + [{ type: 'text', text: 'Keep this guidance.' }], [{ type: 'text', text: 'Keep this too.' }], + [{ type: 'tool-addition', toolName: 'later' }], + ]) + expect(projected.messages.at(-1)).toBe(next) + expect(JSON.stringify({ messages, history })).toBe(saved) + }) + + it('keeps the original baseline when only later-added tools are replaced', () => { + const replacement = { name: 'replacement', description: 'Replacement', parameters: {} } + const added = developer([{ type: 'tool-addition', toolName: 'fetch' }]) + const swapped = developer([{ type: 'tool-addition', toolName: 'replacement' }, { type: 'tool-removal', toolName: 'fetch' }]) + const history: ToolHistory = { tools: [search], updates: [ + { messageId: added.id, additions: [fetch] }, { messageId: swapped.id, additions: [replacement] }, + ] } + const projected = projectToolUpdates([prompt, added, prompt, swapped], [search, replacement], 'addition-only', history) + expect(projected.tools).toEqual([search, { ...replacement, deferLoading: true }]) + expect(projected.messages[0]).toBe(prompt) + expect(roles(projected.messages)).toEqual([[{ type: 'tool-addition', toolName: 'replacement' }]]) + }) + + it.each([false, true])('preserves explicitly deferred baseline activation across a reset (activated=%s)', (activated) => { + const deferred = { name: 'pending', description: 'Pending', parameters: {}, deferLoading: true as const } + const activate = developer([{ type: 'tool-addition', toolName: 'pending' }]) + const swapped = developer([{ type: 'tool-removal', toolName: 'search' }, { type: 'tool-addition', toolName: 'fetch' }]) + const history: ToolHistory = { tools: [search, deferred], updates: [ + ...activated ? [{ messageId: activate.id, additions: [deferred] }] : [], + { messageId: swapped.id, additions: [fetch] }, + ] } + const messages = [prompt, ...activated ? [activate] : [], swapped] + const projected = projectToolUpdates(messages, [fetch, deferred], 'addition-only', history) + expect(projected.tools).toEqual([ + { name: 'pending', description: 'Pending', parameters: {}, ...activated ? {} : { deferLoading: true } }, fetch, + ]) + expect(roles(projected.messages)).toEqual([]) + const reoffered = developer([{ type: 'tool-addition', toolName: 'pending' }]) + const later = projectToolUpdates([...messages, reoffered], [fetch, deferred], 'addition-only', { + tools: history.tools, updates: [...history.updates, { messageId: reoffered.id, additions: [deferred] }], + }) + expect(later.tools).toEqual(projected.tools) + expect(roles(later.messages)).toEqual(activated ? [] : [[{ type: 'tool-addition', toolName: 'pending' }]]) + }) + + it('uses each rebuilt baseline to detect subsequent replacements', () => { + const replacement = { name: 'replacement', description: 'Replacement', parameters: {} } + const first = developer([{ type: 'tool-removal', toolName: 'search' }, { type: 'tool-addition', toolName: 'fetch' }]) + const second = developer([{ type: 'tool-removal', toolName: 'fetch' }, { type: 'tool-addition', toolName: 'replacement' }]) + const history: ToolHistory = { tools: [search], updates: [ + { messageId: first.id, additions: [fetch] }, { messageId: second.id, additions: [replacement] }, + ] } + const projected = projectToolUpdates([prompt, first, prompt, second], [replacement], 'addition-only', history) + expect(projected.tools).toEqual([replacement]) + expect(roles(projected.messages)).toEqual([]) + }) }) diff --git a/snapshots/session/deepseek-deferred-tools/session.v4.jsonl b/snapshots/session/deepseek-deferred-tools/session.v4.jsonl index b0a9610be2..85d8585724 100644 --- a/snapshots/session/deepseek-deferred-tools/session.v4.jsonl +++ b/snapshots/session/deepseek-deferred-tools/session.v4.jsonl @@ -12,18 +12,20 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["snapshot_initial"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Call snapshot_initial, then snapshot_pin","messageSeqs":[8],"source":{"kind":"fallback"}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_initial","name":"snapshot_initial","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1790049391235,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1790049391235,"index":0,"dt":[],"id":"call_initial","name":"snapshot_initial","args":["{}"]},{"type":"chunk","time":1790049391235,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_initial","name":"snapshot_initial","arguments":"{}"}}},{"type":"chunk","time":1790049391235,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1790049391235,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_initial","name":"snapshot_initial","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1792658934151,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1792658934151,"index":0,"dt":[],"id":"call_initial","name":"snapshot_initial","args":["{}"]},{"type":"chunk","time":1792658934151,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_initial","name":"snapshot_initial","arguments":"{}"}}},{"type":"chunk","time":1792658934151,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1792658934151,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_initial","name":"snapshot_initial","arguments":"{}"}} {"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_initial"},"toolCallId":"call_initial","content":[{"type":"text","text":"snapshot_ping is available."}],"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":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["snapshot_ping"]},"reason":"change"}} {"type":"developer/message","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"snapshot_ping"},{"type":"tool-removal","toolName":"snapshot_initial"}],"role":"developer","id":"{{message:6}}"},"headerSeq":18},"surfaceOp":"append"} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"stream":[{"type":"chunk","time":1790049391255,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1790049391255,"index":0,"dt":[],"id":"call_dynamic_ping","name":"snapshot_ping","args":["{}"]},{"type":"chunk","time":1790049391255,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}}},{"type":"chunk","time":1790049391255,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"stream":[{"type":"chunk","time":1792658934170,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1792658934170,"index":0,"dt":[],"id":"call_dynamic_ping","name":"snapshot_ping","args":["{}"]},{"type":"chunk","time":1792658934170,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}}},{"type":"chunk","time":1792658934170,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}} {"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_dynamic_ping"},"toolCallId":"call_dynamic_ping","content":[{"type":"text","text":"pong"}],"isError":false,"id":"{{message:8}}"}},"sourceEventSeqs":[21],"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":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:9}}"},"usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10},"stream":[{"type":"chunk","time":1790049391270,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1790049391270,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1790049391270,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1790049391270,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10}}},{"type":"chunk","time":1790049391270,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["snapshot_later","snapshot_ping"]},"reason":"change"}} +{"type":"developer/message","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"snapshot_later"}],"role":"developer","id":"{{message:9}}"},"headerSeq":25},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:10}}"},"usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10},"stream":[{"type":"chunk","time":1792658934186,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1792658934186,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1792658934186,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1792658934186,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10}}},{"type":"chunk","time":1792658934186,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/deepseek-deferred-tools/snapshot.yml b/snapshots/session/deepseek-deferred-tools/snapshot.yml index a99cb0dcc1..6a4e4e6626 100644 --- a/snapshots/session/deepseek-deferred-tools/snapshot.yml +++ b/snapshots/session/deepseek-deferred-tools/snapshot.yml @@ -6,4 +6,4 @@ recording: authored header: class: deepseek-deferred-tools pin: true - changes: 1 + changes: 2 diff --git a/snapshots/session/deepseek-deferred-tools/tool-schemas.expected.json b/snapshots/session/deepseek-deferred-tools/tool-schemas.expected.json index ee53a9dda8..6103ea1762 100644 --- a/snapshots/session/deepseek-deferred-tools/tool-schemas.expected.json +++ b/snapshots/session/deepseek-deferred-tools/tool-schemas.expected.json @@ -19,6 +19,24 @@ "properties": {} } } + ], + [ + { + "name": "snapshot_later", + "description": "Return later.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "snapshot_ping", + "description": "Return pong once.", + "parameters": { + "type": "object", + "properties": {} + } + } ] ] } From be6ed7f0aa8428dcf06ea591cbbd6bfe46f30670 Mon Sep 17 00:00:00 2001 From: Yixiang Tu Date: Thu, 22 Oct 2026 17:37:52 +0800 Subject: [PATCH 22/32] fix(llm): rebuild addition-only history on every removal --- .../2026-09-20-dynamic-tool-updates.i18n.yaml | 12 +++---- .../2026-09-20-dynamic-tool-updates.md | 6 ++-- .../2026-09-20-dynamic-tool-updates.zh.md | 6 ++-- packages/llm/llm-deepseek/README.i18n.yaml | 4 +-- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- .../tests/fixtures/deferred-tools.ts | 18 ++++++++-- packages/llm/llm/README.i18n.yaml | 4 +-- packages/llm/llm/README.md | 2 +- packages/llm/llm/README.zh.md | 2 +- packages/llm/llm/src/content.ts | 33 ++++--------------- packages/llm/llm/tests/content.spec.ts | 25 +++++++++----- .../deepseek-deferred-tools/session.v4.jsonl | 17 +++++++--- .../deepseek-deferred-tools/snapshot.yml | 2 +- .../tool-schemas.expected.json | 18 ++++++++++ 15 files changed, 90 insertions(+), 63 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml index 357c16c769..4215b56930 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml @@ -9,11 +9,11 @@ en: 97fe58a13135b4a1 zh: 7490373a8d941da5 /agent-note-active-tool-headers-and-stateful-request-projection/decision: - en: b6770e6bd8ff17ee - zh: cc409f4e9f9be495 + en: 2bbb6ab17f2cbb1a + zh: bb925627b6798548 /agent-note-active-tool-headers-and-stateful-request-projection/alternatives-considered: - en: 69370c9cb0014985 - zh: ccba65967ab1c34d + en: f1271717cbe0d4a0 + zh: 367ad512ad08b221 /agent-note-active-tool-headers-and-stateful-request-projection/consequences: - en: cabc2b9d378d186e - zh: 7aa21854f43bf8e8 + en: aa3e68c78ac3f666 + zh: 5ec40c83183fc7e0 diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md index 6421cb7e43..2f75132d51 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md @@ -14,7 +14,7 @@ Plugin and MCP tools change during conversations. Providers differ in whether th `Session.toolHistory()` caches a capability-independent fold of committed headers and developer messages. First access restores inherited events; later reads consume only unseen events. Immutable snapshots carry initial declarations and ordered update identities with additions resolved through their historical headers. Explicit series starts and changed retained definitions rebuild declarations. Restoring a retained name with an unchanged definition continues the series: the projection keeps the declaration unchanged and lets the recorded addition re-offer the tool after its earlier removal, so a removal never disables a declared tool and a loading mode never rewrites an earlier declaration. -Loop and compaction requests carry this snapshot as `GenerateOptions.toolHistory`. At adapter dispatch, `projectToolUpdates` uses the prepared route's `toolUpdate`. Unsupported routes receive active definitions without `deferLoading` or developer messages. Supported routes defer historical additions; `in-history` retains removed definitions and removal blocks, while `addition-only` omits both. On addition-only routes, removing an immediate baseline tool rebuilds the route's baseline after that update: the initial declarations already change, so retaining superseded activation blocks cannot preserve the full conversation prefix. Previously activated tools become immediate; explicitly deferred declarations with no activation remain deferred. Subsequent additions remain deferred until their recorded activation. Superseded tool blocks are omitted without discarding other content in those messages. The Session fold and durable events remain capability-independent. Missing history or a request prefix omitting recorded updates uses current declarations without developer updates. +Loop and compaction requests carry this snapshot as `GenerateOptions.toolHistory`. At adapter dispatch, `projectToolUpdates` uses the prepared route's `toolUpdate`. Unsupported routes receive active definitions without `deferLoading` or developer messages. Supported routes defer historical additions; `in-history` retains removed definitions and removal blocks, while `addition-only` omits both. On addition-only routes, removing any tool rebuilds the route's baseline after that update: the request declarations change even when the removed tool was activated later, so retaining superseded activation blocks cannot preserve the full conversation prefix. Previously activated tools become immediate; explicitly deferred declarations with no activation remain deferred. Subsequent additions remain deferred until their recorded activation. Superseded tool blocks are omitted without discarding other content in those messages. The Session fold and durable events remain capability-independent. Missing history or a request prefix omitting recorded updates uses current declarations without developer updates. DeepSeek translates projected updates into system-role `tool_addition` and `tool_removal` blocks and `defer_loading` declarations, sending the tool-changes beta header when update blocks occur. The default `deepseek-flash` entry declares `addition-only`; pi-ai declares no mode. Chat and Trajectory display developer messages as context nodes, using localized tool-change notices: single changes show the tool name; multiple changes show counts and expandable name lists. @@ -26,7 +26,7 @@ DeepSeek translates projected updates into system-role `tool_addition` and `tool **Scan the complete log on every request.** The cached fold consumes new events and supplies stable snapshots to ordinary and auxiliary calls without adding arbitrary synchronous history readers. -**Rebuild whenever additions and removals occur together.** Replacing a later-added tool can preserve the prefix before its activation while the immediate baseline stays unchanged. Conversely, a removal-only update can remove the last immediate baseline tool. The reset therefore follows baseline removals, not the presence of both update kinds. A past reset cannot force every future addition to become immediate: each reset establishes a new baseline from which deferred additions resume. +**Rebuild only for immediate baseline removals or mixed additions and removals.** Deferred tools also have request declarations; deleting a tool changes those declarations regardless of its activation position. Addition-only routes cannot express that deletion in history. Every removal therefore rebuilds the baseline, including removal-only updates and removals of later-activated tools. A past reset cannot force every future addition to become immediate: each reset establishes a new baseline from which deferred additions resume. **Reject same-name schema changes during tool registration.** This restriction is not enforced. Immediate failure for a plugin registering an incompatible tool requires comparing the new registration with historical definitions of tools called on each affected current surface, while excluding calls removed by compaction. That registration-time historical and scope-aware check is deferred because of its implementation complexity. Same-name schema changes remain allowed; they rebuild declarations and invalidate prefix reuse even on `in-history` routes. @@ -36,6 +36,6 @@ Tool lists and logical update events are capability-independent; only outgoing d The authored [headless prompt/tool scenario](../../../../snapshots/session/dynamic-tool-prompt-updates/snapshot.yml) pins an appended prompt beside a deferred tool addition and verifies that the first request's messages stay unchanged. It shares the stable-prompt scenario's tool lifecycle and schema expectations. -The [DeepSeek replacement scenario](../../../../snapshots/session/deepseek-deferred-tools/snapshot.yml) checks the rebuilt baseline and a subsequent deferred addition through Messages serialization. An adapter-local placeholder covers nonempty all-deferred requests that remain, including explicit deferred declarations and direct calls. It does not activate real tools. These checks establish request construction, not measured cache savings from rebuilding. +The [DeepSeek replacement scenario](../../../../snapshots/session/deepseek-deferred-tools/snapshot.yml) checks a rebuilt baseline, a subsequent deferred addition, and removal of that later-activated tool through Messages serialization. An adapter-local placeholder covers nonempty all-deferred requests that remain, including explicit deferred declarations and direct calls. It does not activate real tools. These checks establish request construction, not measured cache savings from rebuilding. The [real DeepSeek SDK test](../../../../apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts) measures the first request after a native tool addition with both unchanged and simultaneously updated instructions. Unique user history prevents a shared system-prompt cache hit from satisfying the preceding-input assertion. Removal checks tool availability without requiring cache retention; each case disables retries. diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md index 86350a0318..36df584a80 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md @@ -14,7 +14,7 @@ Status: implemented `Session.toolHistory()` 缓存对已提交请求头和 developer 消息的归约,不依赖模型能力。首次访问恢复继承事件;后续读取仅处理新增事件。不可变快照携带初始声明和有序更新标识,添加定义通过历史请求头解析。显式新序列和保留定义变化会重建声明。以未变化的定义恢复保留名称时序列继续:投影保持声明不变,由记录的添加在先前移除之后重新提供该工具,因此移除不会禁用已声明的工具,加载模式也不会改写先前声明。 -循环和压缩请求通过 `GenerateOptions.toolHistory` 携带此快照。适配器分发时,`projectToolUpdates` 使用已准备路由的 `toolUpdate`。不支持的路由接收有效定义,不携带 `deferLoading` 或 developer 消息。支持的路由延迟历史添加;`in-history` 保留已移除定义和移除块,`addition-only` 则省略两者。在 addition-only 路由移除基线中的立即加载工具时,使用该次更新后的状态重建路由基线:初始声明已发生变化,保留已被取代的激活块无法保留完整对话前缀。此前已激活工具变为立即加载;没有激活记录的显式延迟声明仍保持延迟。后续添加在记录的激活位置前保持延迟加载。已被取代的工具块会被省略,但不会丢弃这些消息中的其他内容。Session 归约和持久事件仍不依赖模型能力。缺少历史或请求前缀遗漏已记录更新时,使用当前声明且不发送 developer 更新。 +循环和压缩请求通过 `GenerateOptions.toolHistory` 携带此快照。适配器分发时,`projectToolUpdates` 使用已准备路由的 `toolUpdate`。不支持的路由接收有效定义,不携带 `deferLoading` 或 developer 消息。支持的路由延迟历史添加;`in-history` 保留已移除定义和移除块,`addition-only` 则省略两者。在 addition-only 路由移除任何工具时,使用该次更新后的状态重建路由基线:即使被移除工具是后来激活的,请求声明也已发生变化,保留已被取代的激活块无法保留完整对话前缀。此前已激活工具变为立即加载;没有激活记录的显式延迟声明仍保持延迟。后续添加在记录的激活位置前保持延迟加载。已被取代的工具块会被省略,但不会丢弃这些消息中的其他内容。Session 归约和持久事件仍不依赖模型能力。缺少历史或请求前缀遗漏已记录更新时,使用当前声明且不发送 developer 更新。 DeepSeek 把投影后的更新转换为 system 角色的 `tool_addition` 和 `tool_removal` 块以及 `defer_loading` 声明,在存在更新块时发送工具变更 beta 请求头。默认 `deepseek-flash` 条目声明 `addition-only`;pi-ai 不声明模式。Chat 和 Trajectory 将 developer 消息显示为上下文节点,工具变更使用本地化通知:单项变更显示工具名,多项变更显示数量及可展开的名称列表。 @@ -26,7 +26,7 @@ DeepSeek 把投影后的更新转换为 system 角色的 `tool_addition` 和 `to **每次请求扫描完整日志。** 缓存归约处理新事件,为普通和辅助调用提供稳定快照,不增加任意同步历史读取接口。 -**同时出现添加和移除就重建。** 替换后来添加的工具时,只要立即加载的基线未变,就仍可保留该工具激活之前的前缀。反之,仅包含移除的更新也可能移除最后一个立即加载的基线工具。因此,重置取决于基线工具移除,而不是两类更新是否同时出现。一次历史重置不能让所有后续添加都变为立即加载:每次重置确立新基线,后续添加继续采用延迟加载。 +**仅在移除立即加载的基线工具或同时添加和移除时重建。** 延迟工具也具有请求声明;删除工具会改变这些声明,与其激活位置无关。addition-only 路由无法通过历史表达删除。因此,每次移除都重建基线,包括仅移除的更新和移除后来激活的工具。一次历史重置不能让所有后续添加都变为立即加载:每次重置确立新基线,后续添加继续采用延迟加载。 **在工具注册时拒绝同名 schema 变化。** 当前不执行此限制。要让注册不兼容工具的插件立即收到异常,需要将新注册与各受影响当前 surface 中已调用工具的历史定义比较,同时排除已被压缩移除的调用。由于实现复杂,注册时结合历史和作用域的检查留待后续实现。同名 schema 变化仍被允许;它们会重建声明,即使在 `in-history` 路由上也会使前缀复用失效。 @@ -36,6 +36,6 @@ DeepSeek 把投影后的更新转换为 system 角色的 `tool_addition` 和 `to 手写的[无界面提示词/工具场景](../../../../snapshots/session/dynamic-tool-prompt-updates/snapshot.yml)钉住追加提示词与延迟工具添加同时发生的行为,并验证首次请求的消息保持不变。它复用稳定提示词场景的工具生命周期和 schema 期望值。 -[DeepSeek 替换场景](../../../../snapshots/session/deepseek-deferred-tools/snapshot.yml)通过 Messages 序列化验证重建后的基线和后续延迟添加。适配器内的占位工具覆盖仍然存在的非空全延迟请求,包括显式延迟声明和直接调用,不会激活真实工具。这些检查验证请求构造,不代表已经测量重建带来的缓存收益。 +[DeepSeek 替换场景](../../../../snapshots/session/deepseek-deferred-tools/snapshot.yml)通过 Messages 序列化验证重建后的基线、后续延迟添加,以及移除该后来激活的工具。适配器内的占位工具覆盖仍然存在的非空全延迟请求,包括显式延迟声明和直接调用,不会激活真实工具。这些检查验证请求构造,不代表已经测量重建带来的缓存收益。 [真实 DeepSeek SDK 测试](../../../../apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts) 分别在指令不变和指令同时更新时测量原生工具新增后的首次请求。独有的用户历史确保仅命中共享系统提示词缓存无法满足前序输入断言。移除场景检查工具可用性,不要求保留缓存;每个用例均禁用重试。 diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index eb6b7092a7..dae2900fa7 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -63,8 +63,8 @@ en: 1dc50052999253c6 zh: 7abf968fc18bffcd /deepseek-ai-dsh-llm-deepseek/model-experience/deepseek-request/what-the-model-sees: - en: fda9111504c5af32 - zh: 6306a70fdafbdb1b + en: 1b8d726045c386f9 + zh: 8254d9f748decef3 /deepseek-ai-dsh-llm-deepseek/model-experience/deepseek-request/token-effect: en: 67f4bfa42cbee911 zh: 5e9dc9bc07253686 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 6c3717c4d7..ecce4d514b 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -170,7 +170,7 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config (`maxTokens`, `reasoningEffort`, `temperature`) without adapter-authored prompt prose. Provider-specific request-extension fields remain outside model input. The vision model normally receives retained user and tool-result images as Files API references beside attachment handles and request-preview dimensions. It also receives a normalized-object path when the current execution filesystem maps the attachment provider's host object; the descriptor marks this copy read-only and warns that normalization may have resized or re-encoded the upload. A Files resolution failure sends all retained images as inline base64 instead, and an over-budget older image keeps the access resolved for that request in its placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. Messages sends `{}` for historical tool arguments that are malformed JSON or are not objects. This argument fallback preserves call ids, tool names, and tool results; the original arguments stay in the Session log. Newly generated Messages tool arguments still require valid JSON objects. Messages omits `reasoning` and `tool-call` blocks inside user messages and tool results. This also permits replay of saved subagent notices containing assistant output; the original Session content remains intact. Empty user messages are skipped after conversion, while empty tool results retain their call ids and error flags. Other unsupported input blocks still fail with `UNSUPPORTED_CONTENT`. When every declared tool is deferred, the adapter prepends a non-deferred `DeferredToolPlaceholder` with the description `Reserved placeholder that keeps deferred tool loading active; never call this tool.` A numeric suffix avoids collisions with declared tools. The placeholder has no executor; calls receive the ordinary unavailable-tool result. The placeholder leaves real declarations unchanged. The shared [tool-update projection](../llm/README.md#known-limitations-and-deferred-work) rebuilds an addition-only baseline when an immediate tool is removed, while keeping later additions deferred. Empty tool lists receive no placeholder. +The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config (`maxTokens`, `reasoningEffort`, `temperature`) without adapter-authored prompt prose. Provider-specific request-extension fields remain outside model input. The vision model normally receives retained user and tool-result images as Files API references beside attachment handles and request-preview dimensions. It also receives a normalized-object path when the current execution filesystem maps the attachment provider's host object; the descriptor marks this copy read-only and warns that normalization may have resized or re-encoded the upload. A Files resolution failure sends all retained images as inline base64 instead, and an over-budget older image keeps the access resolved for that request in its placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. Messages sends `{}` for historical tool arguments that are malformed JSON or are not objects. This argument fallback preserves call ids, tool names, and tool results; the original arguments stay in the Session log. Newly generated Messages tool arguments still require valid JSON objects. Messages omits `reasoning` and `tool-call` blocks inside user messages and tool results. This also permits replay of saved subagent notices containing assistant output; the original Session content remains intact. Empty user messages are skipped after conversion, while empty tool results retain their call ids and error flags. Other unsupported input blocks still fail with `UNSUPPORTED_CONTENT`. When every declared tool is deferred, the adapter prepends a non-deferred `DeferredToolPlaceholder` with the description `Reserved placeholder that keeps deferred tool loading active; never call this tool.` A numeric suffix avoids collisions with declared tools. The placeholder has no executor; calls receive the ordinary unavailable-tool result. The placeholder leaves real declarations unchanged. The shared [tool-update projection](../llm/README.md#known-limitations-and-deferred-work) rebuilds an addition-only baseline whenever a tool is removed, while keeping later additions deferred. Empty tool lists receive no placeholder. #### Token effect diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 4350fe39e9..2f70619773 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -170,7 +170,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 #### 模型看到什么 -所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、停止序列与调用配置(`maxTokens`、`reasoningEffort`、`temperature`),不包含适配器撰写的提示词散文。提供方专用请求扩展字段留在模型输入之外。视觉模型通常接收 Files API 引用形式的用户与工具结果图片,其旁带附件句柄和请求预览尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,它还会收到规范化对象路径;描述符会把该副本标记为只读,并警告规范化可能缩放或重新编码上传内容。Files 解析失败时,全部保留图片改用内联 base64;超出预算的较旧图片则在占位文本中保留当前请求已解析的访问方式。此前 assistant 轮次的推理内容会原样传回,无论该轮次是否调用了工具。 对于非法 JSON 或非对象的历史工具参数,Messages 发送 `{}`。此参数兜底保留调用 ID、工具名和工具结果;原始参数仍保留在 Session 日志中。新生成的 Messages 工具参数仍须是有效 JSON 对象。 Messages 会省略用户消息和工具结果中的 `reasoning` 与 `tool-call` 块。这也允许回放包含助手输出的已保存子 Agent 通知;原始 Session 内容保持完整。转换后的空用户消息会被跳过,空工具结果则保留调用 id 和错误标记。其他不支持的输入块仍会以 `UNSUPPORTED_CONTENT` 失败。 当所有已声明工具都为延迟加载时,适配器会在列表开头添加非延迟的 `DeferredToolPlaceholder`,描述为 `Reserved placeholder that keeps deferred tool loading active; never call this tool.`。如与已声明工具重名,则添加数字后缀。占位工具没有执行器;调用会收到常规的工具不可用结果。占位工具不会改变真实工具声明。共享的[工具更新投影](../llm/README.zh.md#known-limitations-and-deferred-work)在移除立即加载工具时重建 addition-only 基线,后续添加仍保持延迟加载。空工具列表不会添加占位工具。 +所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、停止序列与调用配置(`maxTokens`、`reasoningEffort`、`temperature`),不包含适配器撰写的提示词散文。提供方专用请求扩展字段留在模型输入之外。视觉模型通常接收 Files API 引用形式的用户与工具结果图片,其旁带附件句柄和请求预览尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,它还会收到规范化对象路径;描述符会把该副本标记为只读,并警告规范化可能缩放或重新编码上传内容。Files 解析失败时,全部保留图片改用内联 base64;超出预算的较旧图片则在占位文本中保留当前请求已解析的访问方式。此前 assistant 轮次的推理内容会原样传回,无论该轮次是否调用了工具。 对于非法 JSON 或非对象的历史工具参数,Messages 发送 `{}`。此参数兜底保留调用 ID、工具名和工具结果;原始参数仍保留在 Session 日志中。新生成的 Messages 工具参数仍须是有效 JSON 对象。 Messages 会省略用户消息和工具结果中的 `reasoning` 与 `tool-call` 块。这也允许回放包含助手输出的已保存子 Agent 通知;原始 Session 内容保持完整。转换后的空用户消息会被跳过,空工具结果则保留调用 id 和错误标记。其他不支持的输入块仍会以 `UNSUPPORTED_CONTENT` 失败。 当所有已声明工具都为延迟加载时,适配器会在列表开头添加非延迟的 `DeferredToolPlaceholder`,描述为 `Reserved placeholder that keeps deferred tool loading active; never call this tool.`。如与已声明工具重名,则添加数字后缀。占位工具没有执行器;调用会收到常规的工具不可用结果。占位工具不会改变真实工具声明。共享的[工具更新投影](../llm/README.zh.md#known-limitations-and-deferred-work)在移除任何工具时重建 addition-only 基线,后续添加仍保持延迟加载。空工具列表不会添加占位工具。 #### Token 影响 diff --git a/packages/llm/llm-deepseek/tests/fixtures/deferred-tools.ts b/packages/llm/llm-deepseek/tests/fixtures/deferred-tools.ts index b1eb397763..ea3e240223 100644 --- a/packages/llm/llm-deepseek/tests/fixtures/deferred-tools.ts +++ b/packages/llm/llm-deepseek/tests/fixtures/deferred-tools.ts @@ -24,9 +24,16 @@ export function apply(ctx: Context): void { agent.ctx.tools.register(defineContentToolFixture({ name: 'snapshot_ping', description: 'Return pong once.', parameters: {}, async execute() { - agent.ctx.tools.register(defineContentToolFixture({ + const removeLater = agent.ctx.tools.register(defineContentToolFixture({ name: 'snapshot_later', description: 'Return later.', parameters: {}, - async execute() { return [{ type: 'text', text: 'later' }] }, + async execute() { + removeLater() + agent.ctx.tools.register(defineContentToolFixture({ + name: 'snapshot_final', description: 'Return final.', parameters: {}, + async execute() { return [{ type: 'text', text: 'final' }] }, + })) + return [{ type: 'text', text: 'later' }] + }, })) return [{ type: 'text', text: 'pong' }] }, @@ -60,6 +67,13 @@ export function apply(ctx: Context): void { { role: 'system', content: [{ type: 'tool_addition', tool: { type: 'tool_reference', name: 'snapshot_later' } }] }, ]) break + case 4: + assert.deepEqual(request.tools, [ + { name: 'snapshot_ping', description: 'Return pong once.', input_schema: { type: 'object', properties: {} } }, + { name: 'snapshot_final', description: 'Return final.', input_schema: { type: 'object', properties: {} } }, + ]) + assert.deepEqual(request.messages.filter(message => message.role === 'system'), []) + break default: assert.fail('Unexpected request after the replacement tool call completed') } diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 53db29f075..78c5071896 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -54,8 +54,8 @@ en: a81634136baef72f zh: b92dda883986b0e1 /deepseek-ai-dsh-llm/known-limitations-and-deferred-work: - en: 94e36b92eb0dcc78 - zh: c8b99bf993411f11 + en: 9b2c811157cb5148 + zh: 94577c68b22f0abb /deepseek-ai-dsh-llm/known-limitations-and-deferred-work/dev-note: en: ecbc333b88e14d88 zh: 77c725d92efd8646 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 203dde885d..a9caca0951 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -180,7 +180,7 @@ These limits define where this service stops and other packages or future work b - **Variants normally require a producer** — `prefill`, per-tool `strict`, block `cache` hints, and the `agent` message-source variant have no producer ([Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)). - **`BlockAssembler` handles core block kinds only** — a plugin-added block type whose stream is never closed by `block-end` makes `blocks()` throw. - **`GenerateOptions.sessionId` is a locally-declared brand** — importing dsh-session's `SessionId` would create a dependency cycle. -- **Tool updates require session history** — `GenerateOptions.tools` contains active definitions. `toolHistory` supplies the initial declarations and historically resolved additions from `Session.toolHistory()`. At adapter dispatch, `projectToolUpdates` constructs deferred declarations and retains removed definitions for `in-history`; `addition-only` omits removed definitions and removal messages. Removing an immediate baseline tool on an addition-only route rebuilds that route's baseline from the declarations available after that update. Superseded tool blocks are omitted while unrelated content survives; subsequent additions remain deferred. Removing only later-added tools preserves the earlier baseline. Unsupported routes receive active tools without developer messages or `deferLoading`. Missing history or a request prefix omitting recorded updates falls back to current declarations without developer messages. Explicitly deferred tools become immediate during a rebuild only if an addition has already activated them; unactivated declarations remain deferred. +- **Tool updates require session history** — `GenerateOptions.tools` contains active definitions. `toolHistory` supplies the initial declarations and historically resolved additions from `Session.toolHistory()`. At adapter dispatch, `projectToolUpdates` constructs deferred declarations and retains removed definitions for `in-history`; `addition-only` omits removed definitions and removal messages. Removing any tool on an addition-only route rebuilds that route's baseline from the declarations available after that update. Superseded tool blocks are omitted while unrelated content survives; subsequent additions remain deferred. Unsupported routes receive active tools without developer messages or `deferLoading`. Missing history or a request prefix omitting recorded updates falls back to current declarations without developer messages. Explicitly deferred tools become immediate during a rebuild only if an addition has already activated them; unactivated declarations remain deferred. ### Dev Note diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 38a7f235be..6b917948c1 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -180,7 +180,7 @@ const configure = (maxTokens: number): ConfigureCall => - **变体通常要求实际产生方**——`prefill`、逐工具 `strict`、内容块 `cache` 提示和 `agent` 消息来源变体都没有产生方(见 [Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md))。 - **`BlockAssembler` 只处理核心块类型**——插件添加块类型的流若从未由 `block-end` 关闭,`blocks()` 会抛出异常。 - **`GenerateOptions.sessionId` 是本地声明的品牌类型**——导入 dsh-session 的 `SessionId` 会产生依赖循环。 -- **工具更新需要会话历史** — `GenerateOptions.tools` 包含当前有效定义。`toolHistory` 提供 `Session.toolHistory()` 派生的初始声明及已解析历史定义的添加记录。适配器分发时,`projectToolUpdates` 构造延迟声明,并在 `in-history` 模式保留已移除定义;`addition-only` 省略已移除定义和移除消息。在 addition-only 路由移除基线中的立即加载工具时,使用该次更新后的可用声明重建此路由的基线。已被取代的工具块会被省略,其他内容保留;后续添加仍保持延迟加载。仅移除后来添加的工具时保留早期基线。不支持更新的路由接收有效工具,不携带 developer 消息或 `deferLoading`。缺少历史或请求前缀遗漏已记录更新时,回退为当前声明且不发送 developer 消息。显式延迟工具仅在已有添加记录将其激活时,才会在重建时变为立即加载;尚未激活的声明仍保持延迟加载。 +- **工具更新需要会话历史** — `GenerateOptions.tools` 包含当前有效定义。`toolHistory` 提供 `Session.toolHistory()` 派生的初始声明及已解析历史定义的添加记录。适配器分发时,`projectToolUpdates` 构造延迟声明,并在 `in-history` 模式保留已移除定义;`addition-only` 省略已移除定义和移除消息。在 addition-only 路由移除任何工具时,使用该次更新后的可用声明重建此路由的基线。已被取代的工具块会被省略,其他内容保留;后续添加仍保持延迟加载。不支持更新的路由接收有效工具,不携带 developer 消息或 `deferLoading`。缺少历史或请求前缀遗漏已记录更新时,回退为当前声明且不发送 developer 消息。显式延迟工具仅在已有添加记录将其激活时,才会在重建时变为立即加载;尚未激活的声明仍保持延迟加载。 ### 开发备注 diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index bec3190d1a..8dd5430169 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -379,11 +379,7 @@ function withoutDeveloperMessages(messages: readonly RequestMessage[]): readonly return retained.length === messages.length ? messages : retained } -function toolDeclarations( - tools: readonly ToolSchema[] | undefined, - mode: ToolUpdate, - history: ToolHistory, -): Map { +function toolDeclarations(history: ToolHistory): Map { const declarations = new Map(history.tools.map(tool => [tool.name, tool])) for (const update of history.updates) { for (const tool of update.additions) { @@ -394,32 +390,16 @@ function toolDeclarations( } } - switch (mode) { - case 'in-history': - // Removal blocks disable tools without discarding their historical definitions. - return declarations - case 'addition-only': { - // Without removal support, the declaration list must omit inactive tools. - const activeNames = new Set(tools?.map(tool => tool.name)) - for (const name of declarations.keys()) { - if (!activeNames.has(name)) declarations.delete(name) - } - return declarations - } - /* v8 ignore next 2 -- closed-union exhaustiveness guard */ - default: - return assertNever(mode) - } + return declarations } -/** Reset only when a removal changes the initially loaded definitions; later removals retain the earlier prefix. */ +/** Rebuild declarations after removals that addition-only routes cannot represent in history. */ function rebaseAdditionOnlyHistory( history: ToolHistory, messages: ReadonlyMap, ): ToolHistory { const available = new Map(history.tools.map(tool => [tool.name, tool])) let baseline = history.tools - let immediate = new Set(baseline.filter(tool => !tool.deferLoading).map(tool => tool.name)) let retainedFrom = 0 for (const [index, update] of history.updates.entries()) { const additions = new Map(update.additions.map(tool => [tool.name, tool])) @@ -430,13 +410,12 @@ function rebaseAdditionOnlyHistory( const { deferLoading: _loading, ...tool } = additions.get(block.toolName) as ToolSchema available.set(tool.name, tool) } else if (block.type === 'tool-removal') { - reset ||= immediate.has(block.toolName) + reset = true available.delete(block.toolName) } } if (reset) { baseline = [...available.values()] - immediate = new Set(baseline.filter(tool => !tool.deferLoading).map(tool => tool.name)) retainedFrom = index + 1 } } @@ -447,7 +426,7 @@ function rebaseAdditionOnlyHistory( * Construct provider declarations from session-folded history without changing logged active tools. * Unsupported routes and incomplete history use current declarations without developer updates. * Explicitly deferred baseline tools become available only after their first retained addition. - * Addition-only routes rebuild their baseline when an initially loaded tool is removed; + * Addition-only routes rebuild their baseline whenever a tool is removed; * later additions remain deferred and superseded tool blocks are omitted without dropping other content. * @param messages - complete request inputs, or the prefix selected for an auxiliary call. * @param tools - currently active tool schemas. @@ -481,7 +460,7 @@ export function projectToolUpdates( } const projectedHistory = toolUpdate === 'addition-only' ? rebaseAdditionOnlyHistory(history, updates) : history - const declarations = toolDeclarations(tools, toolUpdate, projectedHistory) + const declarations = toolDeclarations(projectedHistory) const updateIds = new Set(history.updates.map(update => update.messageId)) const retainedUpdateIds = new Set(projectedHistory.updates.map(update => update.messageId)) // Deferred baseline tools still need their first addition to become available. diff --git a/packages/llm/llm/tests/content.spec.ts b/packages/llm/llm/tests/content.spec.ts index cf9d13594f..c3376c1f70 100644 --- a/packages/llm/llm/tests/content.spec.ts +++ b/packages/llm/llm/tests/content.spec.ts @@ -448,7 +448,7 @@ describe('projectToolUpdates', () => { expect(projected.tools).toEqual([deferred]) expect(projected.messages).toEqual(mode === 'in-history' ? [prompt, added, removed, restored] - : [prompt, added]) + : [prompt, restored]) }) it('uses current declarations without updates when history is missing or the prefix omits an update', () => { @@ -562,17 +562,26 @@ describe('projectToolUpdates', () => { expect(JSON.stringify({ messages, history })).toBe(saved) }) - it('keeps the original baseline when only later-added tools are replaced', () => { + it.each([false, true])('rebuilds after removing a later-activated tool (replacement=%s)', (replace) => { const replacement = { name: 'replacement', description: 'Replacement', parameters: {} } - const added = developer([{ type: 'tool-addition', toolName: 'fetch' }]) - const swapped = developer([{ type: 'tool-addition', toolName: 'replacement' }, { type: 'tool-removal', toolName: 'fetch' }]) + const retained = { name: 'retained', description: 'Retained', parameters: {} } + const later = { name: 'later', description: 'Later', parameters: {} } + const added = developer([{ type: 'tool-addition', toolName: 'fetch' }, { type: 'tool-addition', toolName: 'retained' }]) + const swapped = developer([ + ...replace ? [{ type: 'tool-addition' as const, toolName: 'replacement' }] : [], + { type: 'tool-removal', toolName: 'fetch' }, + ]) + const next = developer([{ type: 'tool-addition', toolName: 'later' }]) const history: ToolHistory = { tools: [search], updates: [ - { messageId: added.id, additions: [fetch] }, { messageId: swapped.id, additions: [replacement] }, + { messageId: added.id, additions: [fetch, retained] }, + { messageId: swapped.id, additions: replace ? [replacement] : [] }, + { messageId: next.id, additions: [later] }, ] } - const projected = projectToolUpdates([prompt, added, prompt, swapped], [search, replacement], 'addition-only', history) - expect(projected.tools).toEqual([search, { ...replacement, deferLoading: true }]) + const active = [search, retained, ...replace ? [replacement] : [], later] + const projected = projectToolUpdates([prompt, added, prompt, swapped, next], active, 'addition-only', history) + expect(projected.tools).toEqual([search, retained, ...replace ? [replacement] : [], { ...later, deferLoading: true }]) expect(projected.messages[0]).toBe(prompt) - expect(roles(projected.messages)).toEqual([[{ type: 'tool-addition', toolName: 'replacement' }]]) + expect(roles(projected.messages)).toEqual([[{ type: 'tool-addition', toolName: 'later' }]]) }) it.each([false, true])('preserves explicitly deferred baseline activation across a reset (activated=%s)', (activated) => { diff --git a/snapshots/session/deepseek-deferred-tools/session.v4.jsonl b/snapshots/session/deepseek-deferred-tools/session.v4.jsonl index 85d8585724..ceb1cb5325 100644 --- a/snapshots/session/deepseek-deferred-tools/session.v4.jsonl +++ b/snapshots/session/deepseek-deferred-tools/session.v4.jsonl @@ -2,30 +2,37 @@ {"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":"Call snapshot_initial, then snapshot_ping, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call snapshot_initial, then snapshot_ping, then snapshot_later, then reply DONE."}],"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":"Call snapshot_initial, then snapshot_ping, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Call snapshot_initial, then snapshot_ping, then snapshot_later, then reply DONE."}],"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 working directory: \"{{cwd}}\".\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":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"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","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["snapshot_initial"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Call snapshot_initial, then snapshot_pin","messageSeqs":[8],"source":{"kind":"fallback"}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_initial","name":"snapshot_initial","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1792658934151,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1792658934151,"index":0,"dt":[],"id":"call_initial","name":"snapshot_initial","args":["{}"]},{"type":"chunk","time":1792658934151,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_initial","name":"snapshot_initial","arguments":"{}"}}},{"type":"chunk","time":1792658934151,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1792658934151,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_initial","name":"snapshot_initial","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1792661643282,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1792661643282,"index":0,"dt":[],"id":"call_initial","name":"snapshot_initial","args":["{}"]},{"type":"chunk","time":1792661643282,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_initial","name":"snapshot_initial","arguments":"{}"}}},{"type":"chunk","time":1792661643282,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1792661643282,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_initial","name":"snapshot_initial","arguments":"{}"}} {"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_initial"},"toolCallId":"call_initial","content":[{"type":"text","text":"snapshot_ping is available."}],"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":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["snapshot_ping"]},"reason":"change"}} {"type":"developer/message","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"snapshot_ping"},{"type":"tool-removal","toolName":"snapshot_initial"}],"role":"developer","id":"{{message:6}}"},"headerSeq":18},"surfaceOp":"append"} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"stream":[{"type":"chunk","time":1792658934170,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1792658934170,"index":0,"dt":[],"id":"call_dynamic_ping","name":"snapshot_ping","args":["{}"]},{"type":"chunk","time":1792658934170,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}}},{"type":"chunk","time":1792658934170,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"stream":[{"type":"chunk","time":1792661643298,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1792661643298,"index":0,"dt":[],"id":"call_dynamic_ping","name":"snapshot_ping","args":["{}"]},{"type":"chunk","time":1792661643298,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}}},{"type":"chunk","time":1792661643298,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}} {"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_dynamic_ping"},"toolCallId":"call_dynamic_ping","content":[{"type":"text","text":"pong"}],"isError":false,"id":"{{message:8}}"}},"sourceEventSeqs":[21],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["snapshot_later","snapshot_ping"]},"reason":"change"}} {"type":"developer/message","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"snapshot_later"}],"role":"developer","id":"{{message:9}}"},"headerSeq":25},"surfaceOp":"append"} -{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:10}}"},"usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10},"stream":[{"type":"chunk","time":1792658934186,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1792658934186,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1792658934186,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1792658934186,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10}}},{"type":"chunk","time":1792658934186,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_later","name":"snapshot_later","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:10}}"},"stream":[{"type":"chunk","time":1792661643311,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1792661643311,"index":0,"dt":[],"id":"call_later","name":"snapshot_later","args":["{}"]},{"type":"chunk","time":1792661643311,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_later","name":"snapshot_later","arguments":"{}"}}},{"type":"chunk","time":1792661643311,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":3,"callId":"call_later","name":"snapshot_later","arguments":"{}"}} +{"type":"tool/result","data":{"turn":1,"step":3,"message":{"role":"tool","source":{"kind":"tool","callId":"call_later"},"toolCallId":"call_later","content":[{"type":"text","text":"later"}],"isError":false,"id":"{{message:11}}"}},"sourceEventSeqs":[28],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} +{"type":"step/start","data":{"turn":1,"step":4}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["snapshot_final","snapshot_ping"]},"reason":"change"}} +{"type":"developer/message","data":{"turn":1,"step":4,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"snapshot_final"},{"type":"tool-removal","toolName":"snapshot_later"}],"role":"developer","id":"{{message:12}}"},"headerSeq":32},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:13}}"},"usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10},"stream":[{"type":"chunk","time":1792661643324,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1792661643324,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1792661643324,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1792661643324,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10}}},{"type":"chunk","time":1792661643324,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":4}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/deepseek-deferred-tools/snapshot.yml b/snapshots/session/deepseek-deferred-tools/snapshot.yml index 6a4e4e6626..ceedc9db93 100644 --- a/snapshots/session/deepseek-deferred-tools/snapshot.yml +++ b/snapshots/session/deepseek-deferred-tools/snapshot.yml @@ -6,4 +6,4 @@ recording: authored header: class: deepseek-deferred-tools pin: true - changes: 2 + changes: 3 diff --git a/snapshots/session/deepseek-deferred-tools/tool-schemas.expected.json b/snapshots/session/deepseek-deferred-tools/tool-schemas.expected.json index 6103ea1762..255e5a96d0 100644 --- a/snapshots/session/deepseek-deferred-tools/tool-schemas.expected.json +++ b/snapshots/session/deepseek-deferred-tools/tool-schemas.expected.json @@ -37,6 +37,24 @@ "properties": {} } } + ], + [ + { + "name": "snapshot_final", + "description": "Return final.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "snapshot_ping", + "description": "Return pong once.", + "parameters": { + "type": "object", + "properties": {} + } + } ] ] } From be93b94e6ec472fc2fcf9302884e58428b6aaa2c Mon Sep 17 00:00:00 2001 From: Yixiang Tu Date: Thu, 22 Oct 2026 17:49:41 +0800 Subject: [PATCH 23/32] docs(notes): state that every tool removal rebuilds the baseline --- .../architecture/2026-09-20-dynamic-tool-updates.i18n.yaml | 4 ++-- .../architecture/2026-09-20-dynamic-tool-updates.md | 2 +- .../architecture/2026-09-20-dynamic-tool-updates.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml index 4215b56930..c26081b911 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml @@ -12,8 +12,8 @@ en: 2bbb6ab17f2cbb1a zh: bb925627b6798548 /agent-note-active-tool-headers-and-stateful-request-projection/alternatives-considered: - en: f1271717cbe0d4a0 - zh: 367ad512ad08b221 + en: 311db1f781e386e7 + zh: 7e3312544154a158 /agent-note-active-tool-headers-and-stateful-request-projection/consequences: en: aa3e68c78ac3f666 zh: 5ec40c83183fc7e0 diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md index 2f75132d51..5ef47d246b 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md @@ -26,7 +26,7 @@ DeepSeek translates projected updates into system-role `tool_addition` and `tool **Scan the complete log on every request.** The cached fold consumes new events and supplies stable snapshots to ordinary and auxiliary calls without adding arbitrary synchronous history readers. -**Rebuild only for immediate baseline removals or mixed additions and removals.** Deferred tools also have request declarations; deleting a tool changes those declarations regardless of its activation position. Addition-only routes cannot express that deletion in history. Every removal therefore rebuilds the baseline, including removal-only updates and removals of later-activated tools. A past reset cannot force every future addition to become immediate: each reset establishes a new baseline from which deferred additions resume. +**Rebuild the baseline on every removal.** Deferred tools also have request declarations; deleting a tool changes those declarations regardless of its activation position. Addition-only routes cannot express that deletion in history. The rebuild therefore also covers removal-only updates and removals of later-activated tools. A past reset cannot force every future addition to become immediate: each reset establishes a new baseline from which deferred additions resume. **Reject same-name schema changes during tool registration.** This restriction is not enforced. Immediate failure for a plugin registering an incompatible tool requires comparing the new registration with historical definitions of tools called on each affected current surface, while excluding calls removed by compaction. That registration-time historical and scope-aware check is deferred because of its implementation complexity. Same-name schema changes remain allowed; they rebuild declarations and invalidate prefix reuse even on `in-history` routes. diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md index 36df584a80..422791a0c4 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md @@ -26,7 +26,7 @@ DeepSeek 把投影后的更新转换为 system 角色的 `tool_addition` 和 `to **每次请求扫描完整日志。** 缓存归约处理新事件,为普通和辅助调用提供稳定快照,不增加任意同步历史读取接口。 -**仅在移除立即加载的基线工具或同时添加和移除时重建。** 延迟工具也具有请求声明;删除工具会改变这些声明,与其激活位置无关。addition-only 路由无法通过历史表达删除。因此,每次移除都重建基线,包括仅移除的更新和移除后来激活的工具。一次历史重置不能让所有后续添加都变为立即加载:每次重置确立新基线,后续添加继续采用延迟加载。 +**每次移除都重建基线。** 延迟工具也具有请求声明;删除工具会改变这些声明,与其激活位置无关。addition-only 路由无法通过历史表达删除。因此,仅移除的更新和移除后来激活的工具也会重建基线。一次历史重置不能让所有后续添加都变为立即加载:每次重置确立新基线,后续添加继续采用延迟加载。 **在工具注册时拒绝同名 schema 变化。** 当前不执行此限制。要让注册不兼容工具的插件立即收到异常,需要将新注册与各受影响当前 surface 中已调用工具的历史定义比较,同时排除已被压缩移除的调用。由于实现复杂,注册时结合历史和作用域的检查留待后续实现。同名 schema 变化仍被允许;它们会重建声明,即使在 `in-history` 路由上也会使前缀复用失效。 From 9d53909bcfcdaf6c9635a899e731b80ccb49b7e4 Mon Sep 17 00:00:00 2001 From: Yixiang Tu Date: Fri, 23 Oct 2026 10:53:55 +0800 Subject: [PATCH 24/32] docs(llm): clarify deferred tool activation and placeholder effects --- .../2026-09-20-dynamic-tool-updates.i18n.yaml | 4 ++-- .../architecture/2026-09-20-dynamic-tool-updates.md | 2 +- .../2026-09-20-dynamic-tool-updates.zh.md | 2 +- packages/llm/llm-deepseek/README.i18n.yaml | 12 ++++++------ packages/llm/llm-deepseek/README.md | 6 +++--- packages/llm/llm-deepseek/README.zh.md | 6 +++--- packages/llm/llm/src/content.ts | 3 ++- 7 files changed, 18 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml index c26081b911..c899f1ca29 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml @@ -9,8 +9,8 @@ en: 97fe58a13135b4a1 zh: 7490373a8d941da5 /agent-note-active-tool-headers-and-stateful-request-projection/decision: - en: 2bbb6ab17f2cbb1a - zh: bb925627b6798548 + en: c1ca2f0657dc02c0 + zh: db63130f49594d44 /agent-note-active-tool-headers-and-stateful-request-projection/alternatives-considered: en: 311db1f781e386e7 zh: 7e3312544154a158 diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md index 5ef47d246b..b255ef500c 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md @@ -16,7 +16,7 @@ Plugin and MCP tools change during conversations. Providers differ in whether th Loop and compaction requests carry this snapshot as `GenerateOptions.toolHistory`. At adapter dispatch, `projectToolUpdates` uses the prepared route's `toolUpdate`. Unsupported routes receive active definitions without `deferLoading` or developer messages. Supported routes defer historical additions; `in-history` retains removed definitions and removal blocks, while `addition-only` omits both. On addition-only routes, removing any tool rebuilds the route's baseline after that update: the request declarations change even when the removed tool was activated later, so retaining superseded activation blocks cannot preserve the full conversation prefix. Previously activated tools become immediate; explicitly deferred declarations with no activation remain deferred. Subsequent additions remain deferred until their recorded activation. Superseded tool blocks are omitted without discarding other content in those messages. The Session fold and durable events remain capability-independent. Missing history or a request prefix omitting recorded updates uses current declarations without developer updates. -DeepSeek translates projected updates into system-role `tool_addition` and `tool_removal` blocks and `defer_loading` declarations, sending the tool-changes beta header when update blocks occur. The default `deepseek-flash` entry declares `addition-only`; pi-ai declares no mode. Chat and Trajectory display developer messages as context nodes, using localized tool-change notices: single changes show the tool name; multiple changes show counts and expandable name lists. +DeepSeek translates projected updates into system-role `tool_addition` and `tool_removal` blocks and `defer_loading` declarations, sending the tool-changes beta header when update blocks occur. The default `deepseek-flash` entry declares `addition-only`; pi-ai selects `in-history` for compatible Anthropic routes and `addition-only` for compatible Chat Completions and Responses routes. Chat and Trajectory display developer messages as context nodes, using localized tool-change notices: single changes show the tool name; multiple changes show counts and expandable name lists. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md index 422791a0c4..4edcc7a061 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md @@ -16,7 +16,7 @@ Status: implemented 循环和压缩请求通过 `GenerateOptions.toolHistory` 携带此快照。适配器分发时,`projectToolUpdates` 使用已准备路由的 `toolUpdate`。不支持的路由接收有效定义,不携带 `deferLoading` 或 developer 消息。支持的路由延迟历史添加;`in-history` 保留已移除定义和移除块,`addition-only` 则省略两者。在 addition-only 路由移除任何工具时,使用该次更新后的状态重建路由基线:即使被移除工具是后来激活的,请求声明也已发生变化,保留已被取代的激活块无法保留完整对话前缀。此前已激活工具变为立即加载;没有激活记录的显式延迟声明仍保持延迟。后续添加在记录的激活位置前保持延迟加载。已被取代的工具块会被省略,但不会丢弃这些消息中的其他内容。Session 归约和持久事件仍不依赖模型能力。缺少历史或请求前缀遗漏已记录更新时,使用当前声明且不发送 developer 更新。 -DeepSeek 把投影后的更新转换为 system 角色的 `tool_addition` 和 `tool_removal` 块以及 `defer_loading` 声明,在存在更新块时发送工具变更 beta 请求头。默认 `deepseek-flash` 条目声明 `addition-only`;pi-ai 不声明模式。Chat 和 Trajectory 将 developer 消息显示为上下文节点,工具变更使用本地化通知:单项变更显示工具名,多项变更显示数量及可展开的名称列表。 +DeepSeek 把投影后的更新转换为 system 角色的 `tool_addition` 和 `tool_removal` 块以及 `defer_loading` 声明,在存在更新块时发送工具变更 beta 请求头。默认 `deepseek-flash` 条目声明 `addition-only`;pi-ai 为兼容的 Anthropic 路由选择 `in-history`,为兼容的 Chat Completions 和 Responses 路由选择 `addition-only`。Chat 和 Trajectory 将 developer 消息显示为上下文节点,工具变更使用本地化通知:单项变更显示工具名,多项变更显示数量及可展开的名称列表。 ## Alternatives considered diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index dae2900fa7..d17e33ac4d 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -63,14 +63,14 @@ en: 1dc50052999253c6 zh: 7abf968fc18bffcd /deepseek-ai-dsh-llm-deepseek/model-experience/deepseek-request/what-the-model-sees: - en: 1b8d726045c386f9 - zh: 8254d9f748decef3 + en: a5534fa423d207d8 + zh: 52c524bc054076fb /deepseek-ai-dsh-llm-deepseek/model-experience/deepseek-request/token-effect: - en: 67f4bfa42cbee911 - zh: 5e9dc9bc07253686 + en: f8891795b5bfd744 + zh: b1235786b04a7558 /deepseek-ai-dsh-llm-deepseek/model-experience/deepseek-request/kv-cache-effect: - en: 87ac92776176ee15 - zh: bb49edf3a68dda07 + en: de048b284b463dfb + zh: b5545081b3df6c53 /deepseek-ai-dsh-llm-deepseek/model-experience/deepseek-response: en: 957d6665495e5ca5 zh: 0aa72b2c2dc07167 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index ecce4d514b..99be446545 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -170,15 +170,15 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config (`maxTokens`, `reasoningEffort`, `temperature`) without adapter-authored prompt prose. Provider-specific request-extension fields remain outside model input. The vision model normally receives retained user and tool-result images as Files API references beside attachment handles and request-preview dimensions. It also receives a normalized-object path when the current execution filesystem maps the attachment provider's host object; the descriptor marks this copy read-only and warns that normalization may have resized or re-encoded the upload. A Files resolution failure sends all retained images as inline base64 instead, and an over-budget older image keeps the access resolved for that request in its placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. Messages sends `{}` for historical tool arguments that are malformed JSON or are not objects. This argument fallback preserves call ids, tool names, and tool results; the original arguments stay in the Session log. Newly generated Messages tool arguments still require valid JSON objects. Messages omits `reasoning` and `tool-call` blocks inside user messages and tool results. This also permits replay of saved subagent notices containing assistant output; the original Session content remains intact. Empty user messages are skipped after conversion, while empty tool results retain their call ids and error flags. Other unsupported input blocks still fail with `UNSUPPORTED_CONTENT`. When every declared tool is deferred, the adapter prepends a non-deferred `DeferredToolPlaceholder` with the description `Reserved placeholder that keeps deferred tool loading active; never call this tool.` A numeric suffix avoids collisions with declared tools. The placeholder has no executor; calls receive the ordinary unavailable-tool result. The placeholder leaves real declarations unchanged. The shared [tool-update projection](../llm/README.md#known-limitations-and-deferred-work) rebuilds an addition-only baseline whenever a tool is removed, while keeping later additions deferred. Empty tool lists receive no placeholder. +The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config (`maxTokens`, `reasoningEffort`, `temperature`). Provider-specific request-extension fields remain outside model input. The vision model normally receives retained user and tool-result images as Files API references beside attachment handles and request-preview dimensions. It also receives a normalized-object path when the current execution filesystem maps the attachment provider's host object; the descriptor marks this copy read-only and warns that normalization may have resized or re-encoded the upload. A Files resolution failure sends all retained images as inline base64 instead, and an over-budget older image keeps the access resolved for that request in its placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. Messages sends `{}` for historical tool arguments that are malformed JSON or are not objects. This argument fallback preserves call ids, tool names, and tool results; the original arguments stay in the Session log. Newly generated Messages tool arguments still require valid JSON objects. Messages omits `reasoning` and `tool-call` blocks inside user messages and tool results. This also permits replay of saved subagent notices containing assistant output; the original Session content remains intact. Empty user messages are skipped after conversion, while empty tool results retain their call ids and error flags. Other unsupported input blocks still fail with `UNSUPPORTED_CONTENT`. When every declared tool is deferred, the adapter prepends a non-deferred `DeferredToolPlaceholder` with the description `Reserved placeholder that keeps deferred tool loading active; never call this tool.` A numeric suffix avoids collisions with declared tools. The placeholder has no executor; calls receive the ordinary unavailable-tool result. The placeholder leaves real declarations unchanged. The shared [tool-update projection](../llm/README.md#known-limitations-and-deferred-work) rebuilds an addition-only baseline whenever a tool is removed, while keeping later additions deferred. Empty tool lists receive no placeholder. #### Token effect -Provider tokenization governs exact text and image-token input. The adapter declares per-route `imageRequestPricing`: it prices each occurrence selected by a logged image-offload decision as its placeholder text and each retained image at its projected dimensions with the published vision accounting (14px patch grid, 3:1 downsampling, 544×544 scale-up floor, 1024-token cap). This lets the token meter price image pressure before a request; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while offloaded images stop costing visual tokens. A request whose retained occurrences exceed the file-mode or inline-fallback budget (`maxRequestFilesBytes`, `maxImagesPerRequest`, both quanta) at their exact request-version bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences to offload, and `dsh-compaction-image-offload` records the selected occurrences in an `image/offload` event and retries. Cache-read usage is reported when available. Messages totals include uncached input, output, cache-read, and cache-write tokens. `imageRequestTarget(provider, model, image)` exposes the same request-image dimensions and byte target to screenshot consumers; adapters without image resizing preserve the normalized source. +Provider tokenization governs exact text and image-token input. When present, the placeholder adds input tokens for one tool declaration, including its name, description, and empty object schema. The adapter declares per-route `imageRequestPricing`: it prices each occurrence selected by a logged image-offload decision as its placeholder text and each retained image at its projected dimensions with the published vision accounting (14px patch grid, 3:1 downsampling, 544×544 scale-up floor, 1024-token cap). This lets the token meter price image pressure before a request; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while offloaded images stop costing visual tokens. A request whose retained occurrences exceed the file-mode or inline-fallback budget (`maxRequestFilesBytes`, `maxImagesPerRequest`, both quanta) at their exact request-version bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences to offload, and `dsh-compaction-image-offload` records the selected occurrences in an `image/offload` event and retries. Cache-read usage is reported when available. Messages totals include uncached input, output, cache-read, and cache-write tokens. `imageRequestTarget(provider, model, image)` exposes the same request-image dimensions and byte target to screenshot consumers; adapters without image resizing preserve the normalized source. #### KV Cache effect -An unchanged assembled prefix is eligible for DeepSeek cache reuse, which this adapter reports in usage. Deterministic request-image bytes do not make the full prefix immutable: a changed execution-world path rewrites historical descriptor text, a refreshed upload can replace a `file_id`, and Files-to-base64 fallback changes the image representation. Any of these, or a model-route, prompt, schema, history, or image-budget change, may prevent reuse from the first affected token; reasoning passback appends on every reasoned turn. On a catalog entry declaring `systemPromptUpdate: in-history`, a system prompt change inside a continuing request series is appended after the cached history, so the prefix through that history stays reusable; a tool-schema change still prevents reuse from the first altered token. +An unchanged assembled prefix is eligible for DeepSeek cache reuse, which this adapter reports in usage. For a nonempty all-deferred tool list, the placeholder is part of the declaration prefix; adding, removing, or renaming it changes that prefix and may affect cache reuse. Deterministic request-image bytes do not make the full prefix immutable: a changed execution-world path rewrites historical descriptor text, a refreshed upload can replace a `file_id`, and Files-to-base64 fallback changes the image representation. Any of these, or a model-route, prompt, schema, history, or image-budget change, may prevent reuse from the first affected token; reasoning passback appends on every reasoned turn. On a catalog entry declaring `systemPromptUpdate: in-history`, a system prompt change inside a continuing request series is appended after the cached history, so the prefix through that history stays reusable; a tool-schema change still prevents reuse from the first altered token. ### DeepSeek response diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 2f70619773..c6e74f18e2 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -170,15 +170,15 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 #### 模型看到什么 -所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、停止序列与调用配置(`maxTokens`、`reasoningEffort`、`temperature`),不包含适配器撰写的提示词散文。提供方专用请求扩展字段留在模型输入之外。视觉模型通常接收 Files API 引用形式的用户与工具结果图片,其旁带附件句柄和请求预览尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,它还会收到规范化对象路径;描述符会把该副本标记为只读,并警告规范化可能缩放或重新编码上传内容。Files 解析失败时,全部保留图片改用内联 base64;超出预算的较旧图片则在占位文本中保留当前请求已解析的访问方式。此前 assistant 轮次的推理内容会原样传回,无论该轮次是否调用了工具。 对于非法 JSON 或非对象的历史工具参数,Messages 发送 `{}`。此参数兜底保留调用 ID、工具名和工具结果;原始参数仍保留在 Session 日志中。新生成的 Messages 工具参数仍须是有效 JSON 对象。 Messages 会省略用户消息和工具结果中的 `reasoning` 与 `tool-call` 块。这也允许回放包含助手输出的已保存子 Agent 通知;原始 Session 内容保持完整。转换后的空用户消息会被跳过,空工具结果则保留调用 id 和错误标记。其他不支持的输入块仍会以 `UNSUPPORTED_CONTENT` 失败。 当所有已声明工具都为延迟加载时,适配器会在列表开头添加非延迟的 `DeferredToolPlaceholder`,描述为 `Reserved placeholder that keeps deferred tool loading active; never call this tool.`。如与已声明工具重名,则添加数字后缀。占位工具没有执行器;调用会收到常规的工具不可用结果。占位工具不会改变真实工具声明。共享的[工具更新投影](../llm/README.zh.md#known-limitations-and-deferred-work)在移除任何工具时重建 addition-only 基线,后续添加仍保持延迟加载。空工具列表不会添加占位工具。 +所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、停止序列与调用配置(`maxTokens`、`reasoningEffort`、`temperature`)。提供方专用请求扩展字段留在模型输入之外。视觉模型通常接收 Files API 引用形式的用户与工具结果图片,其旁带附件句柄和请求预览尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,它还会收到规范化对象路径;描述符会把该副本标记为只读,并警告规范化可能缩放或重新编码上传内容。Files 解析失败时,全部保留图片改用内联 base64;超出预算的较旧图片则在占位文本中保留当前请求已解析的访问方式。此前 assistant 轮次的推理内容会原样传回,无论该轮次是否调用了工具。 对于非法 JSON 或非对象的历史工具参数,Messages 发送 `{}`。此参数兜底保留调用 ID、工具名和工具结果;原始参数仍保留在 Session 日志中。新生成的 Messages 工具参数仍须是有效 JSON 对象。 Messages 会省略用户消息和工具结果中的 `reasoning` 与 `tool-call` 块。这也允许回放包含助手输出的已保存子 Agent 通知;原始 Session 内容保持完整。转换后的空用户消息会被跳过,空工具结果则保留调用 id 和错误标记。其他不支持的输入块仍会以 `UNSUPPORTED_CONTENT` 失败。 当所有已声明工具都为延迟加载时,适配器会在列表开头添加非延迟的 `DeferredToolPlaceholder`,描述为 `Reserved placeholder that keeps deferred tool loading active; never call this tool.`。如与已声明工具重名,则添加数字后缀。占位工具没有执行器;调用会收到常规的工具不可用结果。占位工具不会改变真实工具声明。共享的[工具更新投影](../llm/README.zh.md#known-limitations-and-deferred-work)在移除任何工具时重建 addition-only 基线,后续添加仍保持延迟加载。空工具列表不会添加占位工具。 #### Token 影响 -提供方分词决定精确的文本与图片 token 输入。适配器声明按路由的 `imageRequestPricing`:把日志中的图片省略决策选中的每个出现位置按其占位文本计价,并按投影后的尺寸使用公开的视觉计量规则(14 px patch 网格、3:1 降采样、544×544 放大下限、单图 1024 token 上限)为每张保留图片计价。这使 token 计量服务可以在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个推理轮次的思维链带进后续请求,而已省略的图片不再消耗视觉 token。保留的出现位置按精确请求版本字节超过 file 模式或内联回退预算(`maxRequestFilesBytes`、`maxImagesPerRequest` 与两个量子)的请求,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置,由 `dsh-compaction-image-offload` 用 `image/offload` 事件记录所选位置并重试。可用时报告缓存读取用量。Messages 的 token 总数包含未缓存输入、输出、缓存读取与缓存写入 token。 `imageRequestTarget(provider, model, image)` 向截图调用方提供与模型请求一致的图片尺寸和字节目标;不缩放图片的适配器保留归一化后的原图。 +提供方分词决定精确的文本与图片 token 输入。存在占位工具时,其名称、描述和空对象 schema 构成一个额外工具声明,增加输入 token。适配器声明按路由的 `imageRequestPricing`:把日志中的图片省略决策选中的每个出现位置按其占位文本计价,并按投影后的尺寸使用公开的视觉计量规则(14 px patch 网格、3:1 降采样、544×544 放大下限、单图 1024 token 上限)为每张保留图片计价。这使 token 计量服务可以在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个推理轮次的思维链带进后续请求,而已省略的图片不再消耗视觉 token。保留的出现位置按精确请求版本字节超过 file 模式或内联回退预算(`maxRequestFilesBytes`、`maxImagesPerRequest` 与两个量子)的请求,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置,由 `dsh-compaction-image-offload` 用 `image/offload` 事件记录所选位置并重试。可用时报告缓存读取用量。Messages 的 token 总数包含未缓存输入、输出、缓存读取与缓存写入 token。 `imageRequestTarget(provider, model, image)` 向截图调用方提供与模型请求一致的图片尺寸和字节目标;不缩放图片的适配器保留归一化后的原图。 #### KV Cache 影响 -未改变的已组装前缀有资格获得 DeepSeek 缓存复用,本适配器会在用量中报告。确定性的请求图片字节并不意味着完整前缀固定不变:执行世界路径变化会改写历史描述符文本,刷新上传会替换 `file_id`,Files 到 base64 的回退也会改变图片表示。这些变化以及模型路由、提示词、schema、历史或图片预算变化,都可能从首个受影响 token 起阻止复用;推理回传在每个推理轮次上追加内容。在声明了 `systemPromptUpdate: in-history` 的目录条目上,同一请求序列延续期间的系统提示词变化会追加到已缓存历史之后,因此直到该历史末尾的前缀仍可复用;工具 schema 变化仍会从第一个改变的 token 起阻止复用。 +未改变的已组装前缀有资格获得 DeepSeek 缓存复用,本适配器会在用量中报告。对于非空的全延迟工具列表,占位工具属于声明前缀;添加、移除或重命名占位工具会改变该前缀,并可能影响缓存复用。确定性的请求图片字节并不意味着完整前缀固定不变:执行世界路径变化会改写历史描述符文本,刷新上传会替换 `file_id`,Files 到 base64 的回退也会改变图片表示。这些变化以及模型路由、提示词、schema、历史或图片预算变化,都可能从首个受影响 token 起阻止复用;推理回传在每个推理轮次上追加内容。在声明了 `systemPromptUpdate: in-history` 的目录条目上,同一请求序列延续期间的系统提示词变化会追加到已缓存历史之后,因此直到该历史末尾的前缀仍可复用;工具 schema 变化仍会从第一个改变的 token 起阻止复用。 ### DeepSeek 响应 diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index 8dd5430169..c2e2d33961 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -425,7 +425,8 @@ function rebaseAdditionOnlyHistory( /** * Construct provider declarations from session-folded history without changing logged active tools. * Unsupported routes and incomplete history use current declarations without developer updates. - * Explicitly deferred baseline tools become available only after their first retained addition. + * Explicitly deferred declarations require an addition to activate them. + * A rebuild makes previously activated declarations immediate; unactivated declarations stay deferred. * Addition-only routes rebuild their baseline whenever a tool is removed; * later additions remain deferred and superseded tool blocks are omitted without dropping other content. * @param messages - complete request inputs, or the prefix selected for an auxiliary call. From 523e741a31cfc365218c3b48d20bdf2e9c19de76 Mon Sep 17 00:00:00 2001 From: Yixiang Tu Date: Fri, 23 Oct 2026 11:05:32 +0800 Subject: [PATCH 25/32] test(llm-deepseek): cover all-deferred requests and separate tool updates --- .../llm/llm-deepseek/tests/adapter.e2e.ts | 26 ++++++++----- .../tests/fixtures/all-deferred-tools.ts | 38 +++++++++++++++++++ .../cordis.snapshot.yml | 18 +++++++++ .../deepseek-all-deferred-tools/cordis.yml | 3 ++ .../session.v4.jsonl | 17 +++++++++ .../deepseek-all-deferred-tools/snapshot.yml | 8 ++++ .../system-prompt.expected.md | 14 +++++++ .../tool-schemas.expected.json | 14 +++++++ 8 files changed, 128 insertions(+), 10 deletions(-) create mode 100644 packages/llm/llm-deepseek/tests/fixtures/all-deferred-tools.ts create mode 100644 snapshots/session/deepseek-all-deferred-tools/cordis.snapshot.yml create mode 100644 snapshots/session/deepseek-all-deferred-tools/cordis.yml create mode 100644 snapshots/session/deepseek-all-deferred-tools/session.v4.jsonl create mode 100644 snapshots/session/deepseek-all-deferred-tools/snapshot.yml create mode 100644 snapshots/session/deepseek-all-deferred-tools/system-prompt.expected.md create mode 100644 snapshots/session/deepseek-all-deferred-tools/tool-schemas.expected.json diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 6c5264c2a8..c437d810fd 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -1,6 +1,6 @@ import * as Protocol from '@deepseek-ai/dsh-llm-deepseek' /** - * Real Messages round trips use the official root and require credentials. + * Real Messages round trips require credentials; tool replacement also accepts DEEPSEEK_BASE_URL. * System-update checks additionally require DEEPSEEK_IN_HISTORY_MODEL. */ import { randomUUID } from 'node:crypto' @@ -33,7 +33,7 @@ afterEach(async () => { vi.unstubAllEnvs() vi.unstubAllGlobals() }) -async function boot(models?: Messages.Options['models']) { +async function boot(models?: Messages.Options['models'], baseURL = Protocol.PUBLIC_BASE_URL) { const home = await mkdtemp(join(tmpdir(), 'dsh-messages-e2e-')) cleanups.push(() => rm(home, { recursive: true, force: true })) vi.stubEnv('DSH_HOME', home) @@ -41,7 +41,7 @@ async function boot(models?: Messages.Options['models']) { cleanups.push(() => ctx.fiber.dispose()) await ctx.plugin(LlmRuntime) await ctx.plugin(Messages, { - baseURL: Protocol.PUBLIC_BASE_URL, + baseURL, maxTokens: 4096, ...models === undefined ? {} : { models }, }) @@ -50,17 +50,23 @@ async function boot(models?: Messages.Options['models']) { const tool = { name: 'lookup_value', description: 'Read the requested value. Always call this tool to obtain a value.', parameters: { type: 'object', properties: { key: { type: 'string' } }, required: ['key'] } } describe.skipIf(!process.env.DEEPSEEK_API_KEY)('DeepSeek Messages real API', () => { - it('calls a replacement tool after every baseline tool has been removed', async () => { + it.each([false, true])('calls a replacement tool after every baseline tool has been removed, separate updates=%s', async (separate) => { const model = 'deepseek-flash' - const ctx = await boot([{ id: model, toolUpdate: 'addition-only' }]) - const update = createDeveloperMessage({ source: { kind: 'tool-registry' }, content: [ - { type: 'tool-removal', toolName: 'old_lookup' }, { type: 'tool-addition', toolName: tool.name }, - ] }) + const ctx = await boot([{ id: model, toolUpdate: 'addition-only' }], process.env.DEEPSEEK_BASE_URL ?? Protocol.PUBLIC_BASE_URL) + const updates = [createDeveloperMessage({ source: { kind: 'tool-registry' }, content: [ + { type: 'tool-removal', toolName: 'old_lookup' }, + ...separate ? [] : [{ type: 'tool-addition' as const, toolName: tool.name }], + ] })] + if (separate) updates.push(createDeveloperMessage({ source: { kind: 'tool-registry' }, content: [ + { type: 'tool-addition', toolName: tool.name }, + ] })) const response = await assemble(ctx.llm.stream(options({ model, reasoningEffort: ReasoningEffortId('high'), - messages: [user('Call lookup_value with key "answer" to obtain the value. Do not guess.'), update], + messages: [user('Call lookup_value with key "answer" to obtain the value. Do not guess.'), ...updates], tools: [tool], - toolHistory: { tools: [{ ...tool, name: 'old_lookup' }], updates: [{ messageId: update.id, additions: [tool] }] }, + toolHistory: { tools: [{ ...tool, name: 'old_lookup' }], updates: updates.map(update => ({ + messageId: update.id, additions: update.content.some(block => block.type === 'tool-addition') ? [tool] : [], + })) }, })), model) expect(response.assembler.finish).toEqual({ kind: 'tool-calls' }) expect(response.message.content.filter(block => block.type === 'tool-call').map(call => call.name)).toEqual([tool.name]) diff --git a/packages/llm/llm-deepseek/tests/fixtures/all-deferred-tools.ts b/packages/llm/llm-deepseek/tests/fixtures/all-deferred-tools.ts new file mode 100644 index 0000000000..969347e651 --- /dev/null +++ b/packages/llm/llm-deepseek/tests/fixtures/all-deferred-tools.ts @@ -0,0 +1,38 @@ +/** Replay explicitly deferred declarations without activating them. */ +import assert from 'node:assert/strict' +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-agent' +import { projectToolUpdates } from '@deepseek-ai/dsh-llm' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import { resolveAdapterOptions } from '../../src/config.ts' +import { serialize } from '../../src/serialize.ts' + +export const name = 'messages-all-deferred-tools-snapshot' +export const inject = ['tools', 'llm'] + +/** + * Keep one real tool deferred and inspect the derived Messages request. + * @param ctx - Snapshot composition context. + */ +export function apply(ctx: Context): void { + ctx.on('agent/created', ({ agent }) => { + agent.ctx.tools.restrict({ allow: [] }) + agent.ctx.tools.register(defineContentToolFixture({ + name: 'snapshot_pending', description: 'Wait for activation.', parameters: {}, deferLoading: true, + async execute() { assert.fail('An unactivated tool must not be called') }, + })) + }) + const connection = resolveAdapterOptions({}) + ctx.on('llm/stream', (options, next) => { + const projected = projectToolUpdates(options.messages, options.tools, 'addition-only', options.toolHistory) + assert.ok(projected.tools) + assert.deepEqual(projected.tools, options.tools) + const request = serialize({ ...options, tools: [...projected.tools] }, connection, projected.messages, new Map(), () => undefined) + assert.deepEqual(request.tools, [ + { name: 'DeferredToolPlaceholder', description: 'Reserved placeholder that keeps deferred tool loading active; never call this tool.', input_schema: { type: 'object', properties: {} } }, + { name: 'snapshot_pending', description: 'Wait for activation.', input_schema: { type: 'object', properties: {} }, defer_loading: true }, + ]) + assert.deepEqual(request.messages.filter(message => message.role === 'system'), []) + return next() + }) +} diff --git a/snapshots/session/deepseek-all-deferred-tools/cordis.snapshot.yml b/snapshots/session/deepseek-all-deferred-tools/cordis.snapshot.yml new file mode 100644 index 0000000000..2f1d87deef --- /dev/null +++ b/snapshots/session/deepseek-all-deferred-tools/cordis.snapshot.yml @@ -0,0 +1,18 @@ +- id: llm-deepseek + disabled: true + +- insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + toolUpdate: addition-only + defaultMaxTokens: 256000 + reasoningEfforts: [off, low, high, max] + defaultReasoningEffort: high + - id: messages-all-deferred-tools-snapshot + name: '../../../packages/llm/llm-deepseek/tests/fixtures/all-deferred-tools.ts' diff --git a/snapshots/session/deepseek-all-deferred-tools/cordis.yml b/snapshots/session/deepseek-all-deferred-tools/cordis.yml new file mode 100644 index 0000000000..9bbd9f0e59 --- /dev/null +++ b/snapshots/session/deepseek-all-deferred-tools/cordis.yml @@ -0,0 +1,3 @@ +- insert: + - id: messages-all-deferred-tools-snapshot + name: '../../../packages/llm/llm-deepseek/tests/fixtures/all-deferred-tools.ts' diff --git a/snapshots/session/deepseek-all-deferred-tools/session.v4.jsonl b/snapshots/session/deepseek-all-deferred-tools/session.v4.jsonl new file mode 100644 index 0000000000..c7f585e660 --- /dev/null +++ b/snapshots/session/deepseek-all-deferred-tools/session.v4.jsonl @@ -0,0 +1,17 @@ +{"type":"session","version":4,"id":"{{session:1}}","createdAt":0,"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":"Reply DONE without calling any tools."}],"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":"Reply DONE without calling any tools."}],"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 working directory: \"{{cwd}}\".\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":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"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","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Reply DONE without calling any","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"stream":[{"type":"chunk","time":1792661643324,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1792661643324,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1792661643324,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1792661643324,"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/deepseek-all-deferred-tools/snapshot.yml b/snapshots/session/deepseek-all-deferred-tools/snapshot.yml new file mode 100644 index 0000000000..9473be6a93 --- /dev/null +++ b/snapshots/session/deepseek-all-deferred-tools/snapshot.yml @@ -0,0 +1,8 @@ +version: 1 +scenario: deepseek-all-deferred-tools +profile: headless +composition: deepseek-all-deferred-tools +recording: authored +header: + class: deepseek-all-deferred-tools + pin: true diff --git a/snapshots/session/deepseek-all-deferred-tools/system-prompt.expected.md b/snapshots/session/deepseek-all-deferred-tools/system-prompt.expected.md new file mode 100644 index 0000000000..7c0cf23533 --- /dev/null +++ b/snapshots/session/deepseek-all-deferred-tools/system-prompt.expected.md @@ -0,0 +1,14 @@ +You are an AI agent powered by DeepSeek Harness. + +You are a coding assistant powered by the deepseek-v4-flash model. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + +Verify your work by running the code or tests. Keep answers brief and factual. + + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. + +create_goal may infer goal intent from a direct human request in any language. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. diff --git a/snapshots/session/deepseek-all-deferred-tools/tool-schemas.expected.json b/snapshots/session/deepseek-all-deferred-tools/tool-schemas.expected.json new file mode 100644 index 0000000000..1c2c684312 --- /dev/null +++ b/snapshots/session/deepseek-all-deferred-tools/tool-schemas.expected.json @@ -0,0 +1,14 @@ +{ + "initial": [ + { + "name": "snapshot_pending", + "description": "Wait for activation.", + "parameters": { + "type": "object", + "properties": {} + }, + "deferLoading": true + } + ], + "changes": [] +} From 1f3bc8b22bfcd2c439ae342efa57712515581ac3 Mon Sep 17 00:00:00 2001 From: Yixiang Tu Date: Fri, 23 Oct 2026 13:55:06 +0800 Subject: [PATCH 26/32] fix(llm): rebuild tool history for additions to an empty set --- .../2026-09-20-dynamic-tool-updates.i18n.yaml | 8 ++-- .../2026-09-20-dynamic-tool-updates.md | 4 +- .../2026-09-20-dynamic-tool-updates.zh.md | 4 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- .../llm/llm-deepseek/tests/adapter.e2e.ts | 10 ++-- .../tests/fixtures/deferred-tools.ts | 22 +++++++-- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 2 +- packages/llm/llm/README.zh.md | 2 +- packages/llm/llm/src/content.ts | 9 ++-- packages/llm/llm/tests/content.spec.ts | 40 +++++++++++++++- packages/llm/llm/tests/service.spec.ts | 9 +++- .../session-snapshot/README.i18n.yaml | 4 +- .../test-support/session-snapshot/README.md | 2 +- .../session-snapshot/README.zh.md | 2 +- .../session-snapshot/src/suite.ts | 10 ++-- .../session-snapshot/tests/suite.spec.ts | 9 +++- .../deepseek-deferred-tools/session.v4.jsonl | 48 +++++++++++-------- .../deepseek-deferred-tools/snapshot.yml | 2 +- .../tool-schemas.expected.json | 21 ++++---- 22 files changed, 146 insertions(+), 74 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml index c899f1ca29..9e822eb717 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml @@ -9,11 +9,11 @@ en: 97fe58a13135b4a1 zh: 7490373a8d941da5 /agent-note-active-tool-headers-and-stateful-request-projection/decision: - en: c1ca2f0657dc02c0 - zh: db63130f49594d44 + en: 51773686866c95d1 + zh: 489ea0e1144720e1 /agent-note-active-tool-headers-and-stateful-request-projection/alternatives-considered: en: 311db1f781e386e7 zh: 7e3312544154a158 /agent-note-active-tool-headers-and-stateful-request-projection/consequences: - en: aa3e68c78ac3f666 - zh: 5ec40c83183fc7e0 + en: 94b1037f25176c7d + zh: 141c25306a57e5ea diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md index b255ef500c..23a21d7636 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md @@ -14,7 +14,7 @@ Plugin and MCP tools change during conversations. Providers differ in whether th `Session.toolHistory()` caches a capability-independent fold of committed headers and developer messages. First access restores inherited events; later reads consume only unseen events. Immutable snapshots carry initial declarations and ordered update identities with additions resolved through their historical headers. Explicit series starts and changed retained definitions rebuild declarations. Restoring a retained name with an unchanged definition continues the series: the projection keeps the declaration unchanged and lets the recorded addition re-offer the tool after its earlier removal, so a removal never disables a declared tool and a loading mode never rewrites an earlier declaration. -Loop and compaction requests carry this snapshot as `GenerateOptions.toolHistory`. At adapter dispatch, `projectToolUpdates` uses the prepared route's `toolUpdate`. Unsupported routes receive active definitions without `deferLoading` or developer messages. Supported routes defer historical additions; `in-history` retains removed definitions and removal blocks, while `addition-only` omits both. On addition-only routes, removing any tool rebuilds the route's baseline after that update: the request declarations change even when the removed tool was activated later, so retaining superseded activation blocks cannot preserve the full conversation prefix. Previously activated tools become immediate; explicitly deferred declarations with no activation remain deferred. Subsequent additions remain deferred until their recorded activation. Superseded tool blocks are omitted without discarding other content in those messages. The Session fold and durable events remain capability-independent. Missing history or a request prefix omitting recorded updates uses current declarations without developer updates. +Loop and compaction requests carry this snapshot as `GenerateOptions.toolHistory`. At adapter dispatch, `projectToolUpdates` uses the prepared route's `toolUpdate`. Unsupported routes receive active definitions without `deferLoading` or developer messages. Supported routes defer historical additions; `in-history` retains removed definitions and removal blocks, while `addition-only` omits both. On addition-only routes, removing any tool rebuilds the route's baseline after that update: the request declarations change even when the removed tool was activated later, so retaining superseded activation blocks cannot preserve the full conversation prefix. Adding tools when no declarations exist also rebuilds the baseline. Deferred declarations count as existing tools. The first tools would otherwise require a non-deferred placeholder in DeepSeek, which changes the prefix anyway; declaring them immediately avoids an unnecessary activation message and placeholder. Previously activated tools become immediate; explicitly deferred declarations with no activation remain deferred. Subsequent additions remain deferred until their recorded activation. Superseded tool blocks are omitted without discarding other content in those messages. The Session fold and durable events remain capability-independent. Missing history or a request prefix omitting recorded updates uses current declarations without developer updates. DeepSeek translates projected updates into system-role `tool_addition` and `tool_removal` blocks and `defer_loading` declarations, sending the tool-changes beta header when update blocks occur. The default `deepseek-flash` entry declares `addition-only`; pi-ai selects `in-history` for compatible Anthropic routes and `addition-only` for compatible Chat Completions and Responses routes. Chat and Trajectory display developer messages as context nodes, using localized tool-change notices: single changes show the tool name; multiple changes show counts and expandable name lists. @@ -36,6 +36,6 @@ Tool lists and logical update events are capability-independent; only outgoing d The authored [headless prompt/tool scenario](../../../../snapshots/session/dynamic-tool-prompt-updates/snapshot.yml) pins an appended prompt beside a deferred tool addition and verifies that the first request's messages stay unchanged. It shares the stable-prompt scenario's tool lifecycle and schema expectations. -The [DeepSeek replacement scenario](../../../../snapshots/session/deepseek-deferred-tools/snapshot.yml) checks a rebuilt baseline, a subsequent deferred addition, and removal of that later-activated tool through Messages serialization. An adapter-local placeholder covers nonempty all-deferred requests that remain, including explicit deferred declarations and direct calls. It does not activate real tools. These checks establish request construction, not measured cache savings from rebuilding. +The [DeepSeek replacement scenario](../../../../snapshots/session/deepseek-deferred-tools/snapshot.yml) checks the first additions to an empty set, a rebuilt baseline, a subsequent deferred addition, and removal of that later-activated tool through Messages serialization. An adapter-local placeholder covers nonempty all-deferred requests that remain, including explicit deferred declarations and direct calls. It does not activate real tools. These checks establish request construction, not measured cache savings from rebuilding. The [real DeepSeek SDK test](../../../../apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts) measures the first request after a native tool addition with both unchanged and simultaneously updated instructions. Unique user history prevents a shared system-prompt cache hit from satisfying the preceding-input assertion. Removal checks tool availability without requiring cache retention; each case disables retries. diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md index 4edcc7a061..9f4ee01afd 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md @@ -14,7 +14,7 @@ Status: implemented `Session.toolHistory()` 缓存对已提交请求头和 developer 消息的归约,不依赖模型能力。首次访问恢复继承事件;后续读取仅处理新增事件。不可变快照携带初始声明和有序更新标识,添加定义通过历史请求头解析。显式新序列和保留定义变化会重建声明。以未变化的定义恢复保留名称时序列继续:投影保持声明不变,由记录的添加在先前移除之后重新提供该工具,因此移除不会禁用已声明的工具,加载模式也不会改写先前声明。 -循环和压缩请求通过 `GenerateOptions.toolHistory` 携带此快照。适配器分发时,`projectToolUpdates` 使用已准备路由的 `toolUpdate`。不支持的路由接收有效定义,不携带 `deferLoading` 或 developer 消息。支持的路由延迟历史添加;`in-history` 保留已移除定义和移除块,`addition-only` 则省略两者。在 addition-only 路由移除任何工具时,使用该次更新后的状态重建路由基线:即使被移除工具是后来激活的,请求声明也已发生变化,保留已被取代的激活块无法保留完整对话前缀。此前已激活工具变为立即加载;没有激活记录的显式延迟声明仍保持延迟。后续添加在记录的激活位置前保持延迟加载。已被取代的工具块会被省略,但不会丢弃这些消息中的其他内容。Session 归约和持久事件仍不依赖模型能力。缺少历史或请求前缀遗漏已记录更新时,使用当前声明且不发送 developer 更新。 +循环和压缩请求通过 `GenerateOptions.toolHistory` 携带此快照。适配器分发时,`projectToolUpdates` 使用已准备路由的 `toolUpdate`。不支持的路由接收有效定义,不携带 `deferLoading` 或 developer 消息。支持的路由延迟历史添加;`in-history` 保留已移除定义和移除块,`addition-only` 则省略两者。在 addition-only 路由移除任何工具时,使用该次更新后的状态重建路由基线:即使被移除工具是后来激活的,请求声明也已发生变化,保留已被取代的激活块无法保留完整对话前缀。不存在任何声明时添加工具,也会重建基线。延迟声明也计入已有工具。否则首批工具会使 DeepSeek 需要非延迟占位工具,同样改变前缀;直接声明这些工具可避免不必要的激活消息和占位工具。此前已激活工具变为立即加载;没有激活记录的显式延迟声明仍保持延迟。后续添加在记录的激活位置前保持延迟加载。已被取代的工具块会被省略,但不会丢弃这些消息中的其他内容。Session 归约和持久事件仍不依赖模型能力。缺少历史或请求前缀遗漏已记录更新时,使用当前声明且不发送 developer 更新。 DeepSeek 把投影后的更新转换为 system 角色的 `tool_addition` 和 `tool_removal` 块以及 `defer_loading` 声明,在存在更新块时发送工具变更 beta 请求头。默认 `deepseek-flash` 条目声明 `addition-only`;pi-ai 为兼容的 Anthropic 路由选择 `in-history`,为兼容的 Chat Completions 和 Responses 路由选择 `addition-only`。Chat 和 Trajectory 将 developer 消息显示为上下文节点,工具变更使用本地化通知:单项变更显示工具名,多项变更显示数量及可展开的名称列表。 @@ -36,6 +36,6 @@ DeepSeek 把投影后的更新转换为 system 角色的 `tool_addition` 和 `to 手写的[无界面提示词/工具场景](../../../../snapshots/session/dynamic-tool-prompt-updates/snapshot.yml)钉住追加提示词与延迟工具添加同时发生的行为,并验证首次请求的消息保持不变。它复用稳定提示词场景的工具生命周期和 schema 期望值。 -[DeepSeek 替换场景](../../../../snapshots/session/deepseek-deferred-tools/snapshot.yml)通过 Messages 序列化验证重建后的基线、后续延迟添加,以及移除该后来激活的工具。适配器内的占位工具覆盖仍然存在的非空全延迟请求,包括显式延迟声明和直接调用,不会激活真实工具。这些检查验证请求构造,不代表已经测量重建带来的缓存收益。 +[DeepSeek 替换场景](../../../../snapshots/session/deepseek-deferred-tools/snapshot.yml)通过 Messages 序列化验证向空工具集首次添加工具、重建后的基线、后续延迟添加,以及移除该后来激活的工具。适配器内的占位工具覆盖仍然存在的非空全延迟请求,包括显式延迟声明和直接调用,不会激活真实工具。这些检查验证请求构造,不代表已经测量重建带来的缓存收益。 [真实 DeepSeek SDK 测试](../../../../apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts) 分别在指令不变和指令同时更新时测量原生工具新增后的首次请求。独有的用户历史确保仅命中共享系统提示词缓存无法满足前序输入断言。移除场景检查工具可用性,不要求保留缓存;每个用例均禁用重试。 diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index d17e33ac4d..c3d2e1948c 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -63,8 +63,8 @@ en: 1dc50052999253c6 zh: 7abf968fc18bffcd /deepseek-ai-dsh-llm-deepseek/model-experience/deepseek-request/what-the-model-sees: - en: a5534fa423d207d8 - zh: 52c524bc054076fb + en: 06f3cafb41d8d6bd + zh: 1b031d123a939685 /deepseek-ai-dsh-llm-deepseek/model-experience/deepseek-request/token-effect: en: f8891795b5bfd744 zh: b1235786b04a7558 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 99be446545..5bdea7637b 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -170,7 +170,7 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config (`maxTokens`, `reasoningEffort`, `temperature`). Provider-specific request-extension fields remain outside model input. The vision model normally receives retained user and tool-result images as Files API references beside attachment handles and request-preview dimensions. It also receives a normalized-object path when the current execution filesystem maps the attachment provider's host object; the descriptor marks this copy read-only and warns that normalization may have resized or re-encoded the upload. A Files resolution failure sends all retained images as inline base64 instead, and an over-budget older image keeps the access resolved for that request in its placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. Messages sends `{}` for historical tool arguments that are malformed JSON or are not objects. This argument fallback preserves call ids, tool names, and tool results; the original arguments stay in the Session log. Newly generated Messages tool arguments still require valid JSON objects. Messages omits `reasoning` and `tool-call` blocks inside user messages and tool results. This also permits replay of saved subagent notices containing assistant output; the original Session content remains intact. Empty user messages are skipped after conversion, while empty tool results retain their call ids and error flags. Other unsupported input blocks still fail with `UNSUPPORTED_CONTENT`. When every declared tool is deferred, the adapter prepends a non-deferred `DeferredToolPlaceholder` with the description `Reserved placeholder that keeps deferred tool loading active; never call this tool.` A numeric suffix avoids collisions with declared tools. The placeholder has no executor; calls receive the ordinary unavailable-tool result. The placeholder leaves real declarations unchanged. The shared [tool-update projection](../llm/README.md#known-limitations-and-deferred-work) rebuilds an addition-only baseline whenever a tool is removed, while keeping later additions deferred. Empty tool lists receive no placeholder. +The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config (`maxTokens`, `reasoningEffort`, `temperature`). Provider-specific request-extension fields remain outside model input. The vision model normally receives retained user and tool-result images as Files API references beside attachment handles and request-preview dimensions. It also receives a normalized-object path when the current execution filesystem maps the attachment provider's host object; the descriptor marks this copy read-only and warns that normalization may have resized or re-encoded the upload. A Files resolution failure sends all retained images as inline base64 instead, and an over-budget older image keeps the access resolved for that request in its placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. Messages sends `{}` for historical tool arguments that are malformed JSON or are not objects. This argument fallback preserves call ids, tool names, and tool results; the original arguments stay in the Session log. Newly generated Messages tool arguments still require valid JSON objects. Messages omits `reasoning` and `tool-call` blocks inside user messages and tool results. This also permits replay of saved subagent notices containing assistant output; the original Session content remains intact. Empty user messages are skipped after conversion, while empty tool results retain their call ids and error flags. Other unsupported input blocks still fail with `UNSUPPORTED_CONTENT`. When every declared tool is deferred, the adapter prepends a non-deferred `DeferredToolPlaceholder` with the description `Reserved placeholder that keeps deferred tool loading active; never call this tool.` A numeric suffix avoids collisions with declared tools. The placeholder has no executor; calls receive the ordinary unavailable-tool result. The placeholder leaves real declarations unchanged. The shared [tool-update projection](../llm/README.md#known-limitations-and-deferred-work) rebuilds an addition-only baseline when a tool is removed or tools are added to an empty set, while keeping later additions deferred. Empty tool lists receive no placeholder. #### Token effect diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index c6e74f18e2..009306fb5e 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -170,7 +170,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 #### 模型看到什么 -所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、停止序列与调用配置(`maxTokens`、`reasoningEffort`、`temperature`)。提供方专用请求扩展字段留在模型输入之外。视觉模型通常接收 Files API 引用形式的用户与工具结果图片,其旁带附件句柄和请求预览尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,它还会收到规范化对象路径;描述符会把该副本标记为只读,并警告规范化可能缩放或重新编码上传内容。Files 解析失败时,全部保留图片改用内联 base64;超出预算的较旧图片则在占位文本中保留当前请求已解析的访问方式。此前 assistant 轮次的推理内容会原样传回,无论该轮次是否调用了工具。 对于非法 JSON 或非对象的历史工具参数,Messages 发送 `{}`。此参数兜底保留调用 ID、工具名和工具结果;原始参数仍保留在 Session 日志中。新生成的 Messages 工具参数仍须是有效 JSON 对象。 Messages 会省略用户消息和工具结果中的 `reasoning` 与 `tool-call` 块。这也允许回放包含助手输出的已保存子 Agent 通知;原始 Session 内容保持完整。转换后的空用户消息会被跳过,空工具结果则保留调用 id 和错误标记。其他不支持的输入块仍会以 `UNSUPPORTED_CONTENT` 失败。 当所有已声明工具都为延迟加载时,适配器会在列表开头添加非延迟的 `DeferredToolPlaceholder`,描述为 `Reserved placeholder that keeps deferred tool loading active; never call this tool.`。如与已声明工具重名,则添加数字后缀。占位工具没有执行器;调用会收到常规的工具不可用结果。占位工具不会改变真实工具声明。共享的[工具更新投影](../llm/README.zh.md#known-limitations-and-deferred-work)在移除任何工具时重建 addition-only 基线,后续添加仍保持延迟加载。空工具列表不会添加占位工具。 +所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、停止序列与调用配置(`maxTokens`、`reasoningEffort`、`temperature`)。提供方专用请求扩展字段留在模型输入之外。视觉模型通常接收 Files API 引用形式的用户与工具结果图片,其旁带附件句柄和请求预览尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,它还会收到规范化对象路径;描述符会把该副本标记为只读,并警告规范化可能缩放或重新编码上传内容。Files 解析失败时,全部保留图片改用内联 base64;超出预算的较旧图片则在占位文本中保留当前请求已解析的访问方式。此前 assistant 轮次的推理内容会原样传回,无论该轮次是否调用了工具。 对于非法 JSON 或非对象的历史工具参数,Messages 发送 `{}`。此参数兜底保留调用 ID、工具名和工具结果;原始参数仍保留在 Session 日志中。新生成的 Messages 工具参数仍须是有效 JSON 对象。 Messages 会省略用户消息和工具结果中的 `reasoning` 与 `tool-call` 块。这也允许回放包含助手输出的已保存子 Agent 通知;原始 Session 内容保持完整。转换后的空用户消息会被跳过,空工具结果则保留调用 id 和错误标记。其他不支持的输入块仍会以 `UNSUPPORTED_CONTENT` 失败。 当所有已声明工具都为延迟加载时,适配器会在列表开头添加非延迟的 `DeferredToolPlaceholder`,描述为 `Reserved placeholder that keeps deferred tool loading active; never call this tool.`。如与已声明工具重名,则添加数字后缀。占位工具没有执行器;调用会收到常规的工具不可用结果。占位工具不会改变真实工具声明。共享的[工具更新投影](../llm/README.zh.md#known-limitations-and-deferred-work)在移除任何工具或向空工具集添加工具时重建 addition-only 基线,后续添加仍保持延迟加载。空工具列表不会添加占位工具。 #### Token 影响 diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index c437d810fd..03f2f5fec4 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -50,21 +50,21 @@ async function boot(models?: Messages.Options['models'], baseURL = Protocol.PUBL const tool = { name: 'lookup_value', description: 'Read the requested value. Always call this tool to obtain a value.', parameters: { type: 'object', properties: { key: { type: 'string' } }, required: ['key'] } } describe.skipIf(!process.env.DEEPSEEK_API_KEY)('DeepSeek Messages real API', () => { - it.each([false, true])('calls a replacement tool after every baseline tool has been removed, separate updates=%s', async (separate) => { + it.each(['together', 'separate', 'initially empty'])('calls a tool added to an empty set, updates=%s', async (mode) => { const model = 'deepseek-flash' const ctx = await boot([{ id: model, toolUpdate: 'addition-only' }], process.env.DEEPSEEK_BASE_URL ?? Protocol.PUBLIC_BASE_URL) - const updates = [createDeveloperMessage({ source: { kind: 'tool-registry' }, content: [ + const updates = mode === 'initially empty' ? [] : [createDeveloperMessage({ source: { kind: 'tool-registry' }, content: [ { type: 'tool-removal', toolName: 'old_lookup' }, - ...separate ? [] : [{ type: 'tool-addition' as const, toolName: tool.name }], + ...mode !== 'together' ? [] : [{ type: 'tool-addition' as const, toolName: tool.name }], ] })] - if (separate) updates.push(createDeveloperMessage({ source: { kind: 'tool-registry' }, content: [ + if (mode !== 'together') updates.push(createDeveloperMessage({ source: { kind: 'tool-registry' }, content: [ { type: 'tool-addition', toolName: tool.name }, ] })) const response = await assemble(ctx.llm.stream(options({ model, reasoningEffort: ReasoningEffortId('high'), messages: [user('Call lookup_value with key "answer" to obtain the value. Do not guess.'), ...updates], tools: [tool], - toolHistory: { tools: [{ ...tool, name: 'old_lookup' }], updates: updates.map(update => ({ + toolHistory: { tools: mode === 'initially empty' ? [] : [{ ...tool, name: 'old_lookup' }], updates: updates.map(update => ({ messageId: update.id, additions: update.content.some(block => block.type === 'tool-addition') ? [tool] : [], })) }, })), model) diff --git a/packages/llm/llm-deepseek/tests/fixtures/deferred-tools.ts b/packages/llm/llm-deepseek/tests/fixtures/deferred-tools.ts index ea3e240223..ec74a510e7 100644 --- a/packages/llm/llm-deepseek/tests/fixtures/deferred-tools.ts +++ b/packages/llm/llm-deepseek/tests/fixtures/deferred-tools.ts @@ -1,8 +1,8 @@ -/** Replay a complete replacement of the initially available tools through Messages serialization. */ +/** Replay first additions to an empty tool set and subsequent replacements through Messages serialization. */ import assert from 'node:assert/strict' import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent' -import { projectToolUpdates } from '@deepseek-ai/dsh-llm' +import { createUserMessage, projectToolUpdates } from '@deepseek-ai/dsh-llm' import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { resolveAdapterOptions } from '../../src/config.ts' import { serialize } from '../../src/serialize.ts' @@ -11,12 +11,18 @@ export const name = 'messages-deferred-tools-snapshot' export const inject = ['tools', 'llm'] /** - * Replace the baseline after its first call and check the serialized replay requests. + * Add and replace tools after an empty request, checking each serialized replay request. * @param ctx - Snapshot composition context. */ export function apply(ctx: Context): void { ctx.on('agent/created', ({ agent }) => { agent.ctx.tools.restrict({ allow: [] }) + }) + let initialized = false + ctx.on('agent/turn-stopping', ({ agent }) => { + if (initialized) return + initialized = true + agent.send(createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text: 'Call snapshot_initial, snapshot_ping, and snapshot_later, then reply DONE.' }] }), 'next-step', false) const removeInitial = agent.ctx.tools.register(defineContentToolFixture({ name: 'snapshot_initial', description: 'Make snapshot_ping available.', parameters: {}, async execute() { @@ -43,14 +49,20 @@ export function apply(ctx: Context): void { })) }) const connection = resolveAdapterOptions({}) - let requests = 0 + let requests = -1 ctx.on('llm/stream', (options, next) => { const projected = projectToolUpdates(options.messages, options.tools, 'addition-only', options.toolHistory) assert.ok(projected.tools) const request = serialize({ ...options, tools: [...projected.tools] }, connection, projected.messages, new Map(), () => undefined) switch (++requests) { + case 0: + assert.deepEqual(request.tools, []) + break case 1: - assert.deepEqual(request.tools?.map(tool => tool.name), ['snapshot_initial']) + assert.deepEqual(request.tools, [ + { name: 'snapshot_initial', description: 'Make snapshot_ping available.', input_schema: { type: 'object', properties: {} } }, + ]) + assert.deepEqual(request.messages.filter(message => message.role === 'system'), []) break case 2: assert.deepEqual(request.tools, [ diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 78c5071896..16f199923c 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -54,8 +54,8 @@ en: a81634136baef72f zh: b92dda883986b0e1 /deepseek-ai-dsh-llm/known-limitations-and-deferred-work: - en: 9b2c811157cb5148 - zh: 94577c68b22f0abb + en: ed57783be0df3110 + zh: c0c433dd6a807b89 /deepseek-ai-dsh-llm/known-limitations-and-deferred-work/dev-note: en: ecbc333b88e14d88 zh: 77c725d92efd8646 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index a9caca0951..e635e4f53c 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -180,7 +180,7 @@ These limits define where this service stops and other packages or future work b - **Variants normally require a producer** — `prefill`, per-tool `strict`, block `cache` hints, and the `agent` message-source variant have no producer ([Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)). - **`BlockAssembler` handles core block kinds only** — a plugin-added block type whose stream is never closed by `block-end` makes `blocks()` throw. - **`GenerateOptions.sessionId` is a locally-declared brand** — importing dsh-session's `SessionId` would create a dependency cycle. -- **Tool updates require session history** — `GenerateOptions.tools` contains active definitions. `toolHistory` supplies the initial declarations and historically resolved additions from `Session.toolHistory()`. At adapter dispatch, `projectToolUpdates` constructs deferred declarations and retains removed definitions for `in-history`; `addition-only` omits removed definitions and removal messages. Removing any tool on an addition-only route rebuilds that route's baseline from the declarations available after that update. Superseded tool blocks are omitted while unrelated content survives; subsequent additions remain deferred. Unsupported routes receive active tools without developer messages or `deferLoading`. Missing history or a request prefix omitting recorded updates falls back to current declarations without developer messages. Explicitly deferred tools become immediate during a rebuild only if an addition has already activated them; unactivated declarations remain deferred. +- **Tool updates require session history** — `GenerateOptions.tools` contains active definitions. `toolHistory` supplies the initial declarations and historically resolved additions from `Session.toolHistory()`. At adapter dispatch, `projectToolUpdates` constructs deferred declarations and retains removed definitions for `in-history`; `addition-only` omits removed definitions and removal messages. Removing any tool or adding tools to an empty set on an addition-only route rebuilds the baseline from the declarations available after that update. Deferred declarations count as existing tools. Superseded tool blocks are omitted while unrelated content survives; subsequent additions remain deferred. Unsupported routes receive active tools without developer messages or `deferLoading`. Missing history or a request prefix omitting recorded updates falls back to current declarations without developer messages. Explicitly deferred tools become immediate during a rebuild only if an addition has already activated them; unactivated declarations remain deferred. ### Dev Note diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 6b917948c1..4e07a748f0 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -180,7 +180,7 @@ const configure = (maxTokens: number): ConfigureCall => - **变体通常要求实际产生方**——`prefill`、逐工具 `strict`、内容块 `cache` 提示和 `agent` 消息来源变体都没有产生方(见 [Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md))。 - **`BlockAssembler` 只处理核心块类型**——插件添加块类型的流若从未由 `block-end` 关闭,`blocks()` 会抛出异常。 - **`GenerateOptions.sessionId` 是本地声明的品牌类型**——导入 dsh-session 的 `SessionId` 会产生依赖循环。 -- **工具更新需要会话历史** — `GenerateOptions.tools` 包含当前有效定义。`toolHistory` 提供 `Session.toolHistory()` 派生的初始声明及已解析历史定义的添加记录。适配器分发时,`projectToolUpdates` 构造延迟声明,并在 `in-history` 模式保留已移除定义;`addition-only` 省略已移除定义和移除消息。在 addition-only 路由移除任何工具时,使用该次更新后的可用声明重建此路由的基线。已被取代的工具块会被省略,其他内容保留;后续添加仍保持延迟加载。不支持更新的路由接收有效工具,不携带 developer 消息或 `deferLoading`。缺少历史或请求前缀遗漏已记录更新时,回退为当前声明且不发送 developer 消息。显式延迟工具仅在已有添加记录将其激活时,才会在重建时变为立即加载;尚未激活的声明仍保持延迟加载。 +- **工具更新需要会话历史** — `GenerateOptions.tools` 包含当前有效定义。`toolHistory` 提供 `Session.toolHistory()` 派生的初始声明及已解析历史定义的添加记录。适配器分发时,`projectToolUpdates` 构造延迟声明,并在 `in-history` 模式保留已移除定义;`addition-only` 省略已移除定义和移除消息。在 addition-only 路由移除任何工具或向空工具集添加工具时,使用该次更新后的可用声明重建基线。延迟声明也计入已有工具。已被取代的工具块会被省略,其他内容保留;后续添加仍保持延迟加载。不支持更新的路由接收有效工具,不携带 developer 消息或 `deferLoading`。缺少历史或请求前缀遗漏已记录更新时,回退为当前声明且不发送 developer 消息。显式延迟工具仅在已有添加记录将其激活时,才会在重建时变为立即加载;尚未激活的声明仍保持延迟加载。 ### 开发备注 diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index c2e2d33961..b862381118 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -393,7 +393,7 @@ function toolDeclarations(history: ToolHistory): Map { return declarations } -/** Rebuild declarations after removals that addition-only routes cannot represent in history. */ +/** Rebuild declarations on removals or additions to an empty set, where the request prefix changes. */ function rebaseAdditionOnlyHistory( history: ToolHistory, messages: ReadonlyMap, @@ -403,7 +403,7 @@ function rebaseAdditionOnlyHistory( let retainedFrom = 0 for (const [index, update] of history.updates.entries()) { const additions = new Map(update.additions.map(tool => [tool.name, tool])) - let reset = false + let reset = available.size === 0 && additions.size > 0 // Completeness was checked before projection; Session resolves every addition's definition. for (const block of messages.get(update.messageId) as readonly ContentBlock[]) { if (block.type === 'tool-addition') { @@ -427,8 +427,9 @@ function rebaseAdditionOnlyHistory( * Unsupported routes and incomplete history use current declarations without developer updates. * Explicitly deferred declarations require an addition to activate them. * A rebuild makes previously activated declarations immediate; unactivated declarations stay deferred. - * Addition-only routes rebuild their baseline whenever a tool is removed; - * later additions remain deferred and superseded tool blocks are omitted without dropping other content. + * Addition-only routes rebuild when a tool is removed or tools are added to an empty set; + * deferred declarations count as existing tools. + * Later additions remain deferred and superseded tool blocks are omitted without dropping other content. * @param messages - complete request inputs, or the prefix selected for an auxiliary call. * @param tools - currently active tool schemas. * @param toolUpdate - the resolved route's update mode. diff --git a/packages/llm/llm/tests/content.spec.ts b/packages/llm/llm/tests/content.spec.ts index c3376c1f70..14f6684f3b 100644 --- a/packages/llm/llm/tests/content.spec.ts +++ b/packages/llm/llm/tests/content.spec.ts @@ -445,10 +445,46 @@ describe('projectToolUpdates', () => { const projected = projectToolUpdates([prompt, added, duplicate, removed, restored], [deferred], mode, history) - expect(projected.tools).toEqual([deferred]) + expect(projected.tools).toEqual(mode === 'in-history' ? [deferred] : [search]) expect(projected.messages).toEqual(mode === 'in-history' ? [prompt, added, removed, restored] - : [prompt, restored]) + : [prompt]) + }) + + it.each([false, true])('rebuilds when adding the first tools to an empty set (removed=%s)', (removed) => { + const removal = developer([{ type: 'tool-removal', toolName: 'search' }]) + const added = developer([{ type: 'tool-addition', toolName: 'fetch' }, { type: 'text', text: 'Keep this guidance.' }]) + const later = developer([{ type: 'tool-addition', toolName: 'search' }]) + const history: ToolHistory = { + tools: removed ? [search] : [], + updates: [ + ...removed ? [{ messageId: removal.id, additions: [] }] : [], + { messageId: added.id, additions: [fetch] }, + { messageId: later.id, additions: [search] }, + ], + } + const messages = [prompt, ...removed ? [removal] : [], added, prompt, later] + const saved = JSON.stringify({ messages, history }) + const projected = projectToolUpdates(messages, [fetch, search], 'addition-only', history) + expect(projected.tools).toEqual([fetch, { ...search, deferLoading: true }]) + expect(roles(projected.messages)).toEqual([ + [{ type: 'text', text: 'Keep this guidance.' }], + [{ type: 'tool-addition', toolName: 'search' }], + ]) + expect(JSON.stringify({ messages, history })).toBe(saved) + const inHistory = projectToolUpdates(messages, [fetch, search], 'in-history', history) + expect(roles(inHistory.messages)).toContainEqual(added.content) + }) + + it('keeps additions deferred when an unactivated deferred declaration already exists', () => { + const deferred = { ...search, deferLoading: true as const } + const added = developer([{ type: 'tool-addition', toolName: 'fetch' }]) + const messages = [prompt, added] + const projected = projectToolUpdates(messages, [deferred, fetch], 'addition-only', { + tools: [deferred], updates: [{ messageId: added.id, additions: [fetch] }], + }) + expect(projected.tools).toEqual([deferred, { ...fetch, deferLoading: true }]) + expect(projected.messages).toBe(messages) }) it('uses current declarations without updates when history is missing or the prefix omits an update', () => { diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index a5d8be4413..f320f6fc3c 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -239,8 +239,13 @@ describe('LlmRuntime', () => { tools: [search], toolHistory: { tools: [], updates: [{ messageId: added.id, additions: [search] }] }, })) - expect(adapter.lastOptions?.tools).toEqual([{ ...search, deferLoading: true }]) - expect(adapter.lastOptions?.messages).toEqual([added]) + if (mode === 'in-history') { + expect(adapter.lastOptions?.tools).toEqual([{ ...search, deferLoading: true }]) + expect(adapter.lastOptions?.messages).toEqual([added]) + } else { + expect(adapter.lastOptions?.tools).toEqual([search]) + expect(adapter.lastOptions?.messages).toEqual([]) + } const afterRemoval = await ctx.llm.prepareCall({ provider: 'test-provider', model: 'test-model' }) await collect(afterRemoval.stream({ diff --git a/packages/test-support/session-snapshot/README.i18n.yaml b/packages/test-support/session-snapshot/README.i18n.yaml index 10a22d3a98..1425edc812 100644 --- a/packages/test-support/session-snapshot/README.i18n.yaml +++ b/packages/test-support/session-snapshot/README.i18n.yaml @@ -24,8 +24,8 @@ en: bd9dcb62ea4e77db zh: be9615f0ae662c9e /deepseek-ai-dsh-session-snapshot/use-this-package/pinning-request-headers-and-system-prompts: - en: 6b11d5111e1a3222 - zh: de4d3889b1a5ebb4 + en: fe59b0197fa5e34c + zh: dfa1844c2c965358 /deepseek-ai-dsh-session-snapshot/use-this-package/platform-and-composition-variants: en: 64666ea2894acc4a zh: 0e78df32bcfd3633 diff --git a/packages/test-support/session-snapshot/README.md b/packages/test-support/session-snapshot/README.md index e96bcf3b03..09aed7ac61 100644 --- a/packages/test-support/session-snapshot/README.md +++ b/packages/test-support/session-snapshot/README.md @@ -90,7 +90,7 @@ A nested `workspace.expected//.empty` marker represents an empty dire ### Pinning request headers and system prompts -A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` sidecar by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The system prompt is surface node 0, logged as a `system/message` event before the step's first `request/header`; every fixture stores its text block as `"text":"{{system}}"` and the prompt sidecar keeps the full text. The pin's `request/header` events store `"tools":"{{tools}}"` while retaining config and reason, and the structured schema sidecar keeps the full catalogs. Logs containing developer messages retain ordered tool names in each header instead of the token so historical addition references remain verifiable. A child Session whose own scope composes a different request declares it per fixture index with `pinsChildToolSchemas` and `pinsChildSystemPrompts`. A scenario that changes the request header mid-run declares `expectedHeaderChanges`; a scenario whose prompt changes mid-run — replacing node 0, or appending after the cached history on an `in-history` route — declares `expectedPromptChanges`, and each change adds a `` section to the prompt sidecar. The manifest spells these `header.changes` and `header.promptChanges`. +A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` sidecar by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The system prompt is surface node 0, logged as a `system/message` event before the step's first `request/header`; every fixture stores its text block as `"text":"{{system}}"` and the prompt sidecar keeps the full text. The pin's `request/header` events store `"tools":"{{tools}}"` while retaining config and reason, and the structured schema sidecar keeps the full catalogs. Logs containing developer messages retain ordered tool names in each header instead of the token so historical addition references remain verifiable. A child Session whose own scope composes a different request declares it per fixture index with `pinsChildToolSchemas` and `pinsChildSystemPrompts`. A scenario that changes the request header mid-run declares `expectedHeaderChanges`; a scenario whose prompt changes mid-run — replacing node 0, or appending after the cached history on an `in-history` route — declares `expectedPromptChanges`, and each change adds a `` section to the prompt sidecar. The manifest spells these `header.changes` and `header.promptChanges`. Headers without tools retain an empty schema set in the sidecar, so the first later addition stays aligned with its header. ### Platform and composition variants diff --git a/packages/test-support/session-snapshot/README.zh.md b/packages/test-support/session-snapshot/README.zh.md index f4400c602d..01089dfd4f 100644 --- a/packages/test-support/session-snapshot/README.zh.md +++ b/packages/test-support/session-snapshot/README.zh.md @@ -90,7 +90,7 @@ Headless/ACP 与 SDK 适配器在规范化之前,将原始目录中的子创 ### 固定请求 header 与系统提示 -每个 pin 默认拥有其生成的 `system-prompt.expected.md` 或 `tool-schemas.expected.json` 伴随文件;当完整的对应序列相同时,`systemPromptSource` 与 `toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。系统提示是 surface 节点 0,作为 `system/message` 事件记录在该步骤第一个 `request/header` 之前;每个 fixture 把其文本块存储为 `"text":"{{system}}"`,提示词伴随文件保留完整文本。该 pin 的 `request/header` 事件存储 `"tools":"{{tools}}"`,同时保留配置与原因,结构化 schema 伴随文件保留完整目录。包含 developer 消息的日志会在各 header 中保留有序工具名,而不使用 token,以便继续校验历史工具添加引用。自身作用域组合出不同请求的 child Session 按 fixture 索引以 `pinsChildToolSchemas` 与 `pinsChildSystemPrompts` 单独声明。运行中改变请求 header 的场景声明 `expectedHeaderChanges`;运行中提示词发生变化的场景——替换节点 0,或在 `in-history` 路由上追加到已缓存历史之后——声明 `expectedPromptChanges`,每次变化在提示词伴随文件中增加一个 `` 小节。manifest 中对应字段为 `header.changes` 与 `header.promptChanges`。 +每个 pin 默认拥有其生成的 `system-prompt.expected.md` 或 `tool-schemas.expected.json` 伴随文件;当完整的对应序列相同时,`systemPromptSource` 与 `toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。系统提示是 surface 节点 0,作为 `system/message` 事件记录在该步骤第一个 `request/header` 之前;每个 fixture 把其文本块存储为 `"text":"{{system}}"`,提示词伴随文件保留完整文本。该 pin 的 `request/header` 事件存储 `"tools":"{{tools}}"`,同时保留配置与原因,结构化 schema 伴随文件保留完整目录。包含 developer 消息的日志会在各 header 中保留有序工具名,而不使用 token,以便继续校验历史工具添加引用。自身作用域组合出不同请求的 child Session 按 fixture 索引以 `pinsChildToolSchemas` 与 `pinsChildSystemPrompts` 单独声明。运行中改变请求 header 的场景声明 `expectedHeaderChanges`;运行中提示词发生变化的场景——替换节点 0,或在 `in-history` 路由上追加到已缓存历史之后——声明 `expectedPromptChanges`,每次变化在提示词伴随文件中增加一个 `` 小节。manifest 中对应字段为 `header.changes` 与 `header.promptChanges`。没有工具的请求头在 sidecar 中保留空 schema 集,使后续首次添加仍与其请求头对齐。 ### 平台与组合变体 diff --git a/packages/test-support/session-snapshot/src/suite.ts b/packages/test-support/session-snapshot/src/suite.ts index d2a25af19c..ccaf94d7a8 100644 --- a/packages/test-support/session-snapshot/src/suite.ts +++ b/packages/test-support/session-snapshot/src/suite.ts @@ -419,6 +419,7 @@ function toolSchemasFrom(headers: readonly unknown[]): unknown[][] { return headers.flatMap((header) => { if (header === null || typeof header !== 'object') return [] const tools = (header as { tools?: unknown }).tools + if (tools === undefined) return [[]] return Array.isArray(tools) ? [tools] : [] }) } @@ -473,8 +474,8 @@ export function systemPromptPrecedesRequests(rawLog: string): boolean { /** * The normalized tool-schema arrays carried by request headers in a session - * JSONL, in log order. Headers without an array-valued tools field are omitted - * so callers can assert one schema set per header explicitly. + * JSONL, in log order. An absent tools field yields an empty schema set; + * invalid non-array fields are omitted. * * @param rawLog The session `.jsonl` content to inspect. * @param ctx The volatile values of the run that produced it. @@ -524,15 +525,16 @@ export function parseToolSchemasSnapshot(snapshot: string): ToolSchemasSnapshot /** * Restore one sidecar schema set into a tokenized pinned header. * - * @param header The parsed request header carrying a tools token or ordered tool names matching the sidecar. + * @param header The parsed header with a tools token, matching ordered names, or no tools for an empty sidecar. * @param schemas The complete schemas for this full header snapshot. - * @returns A copy of the header with its complete schemas restored. + * @returns A copy with complete schemas restored, or the original header when tools are absent. */ export function restorePinnedToolSchemas(header: unknown, schemas: readonly unknown[]): unknown { if (header === null || typeof header !== 'object' || Array.isArray(header)) { throw new Error('acp-snapshot: pinned request header must be an object') } const tools = (header as { tools?: unknown }).tools + if (tools === undefined && schemas.length === 0) return header const namesMatch = Array.isArray(tools) && tools.length === schemas.length && tools.every((name, index) => { const schema = schemas[index] diff --git a/packages/test-support/session-snapshot/tests/suite.spec.ts b/packages/test-support/session-snapshot/tests/suite.spec.ts index 6f07d10604..e7ae84442f 100644 --- a/packages/test-support/session-snapshot/tests/suite.spec.ts +++ b/packages/test-support/session-snapshot/tests/suite.spec.ts @@ -782,7 +782,7 @@ describe('systemPromptPrecedesRequests', () => { }) describe('normalizedToolSchemas', () => { - it('extracts normalized schema arrays and omits absent or non-array fields', () => { + it('extracts normalized schema arrays, preserves empty headers, and omits invalid fields', () => { const log = [ '{"type":"session","id":"a","createdAt":5,"cwd":"/w"}', '{"type":"request/header","seq":0,"time":9,"data":{"header":{"tools":[{"name":"read","description":"work in /w"}]}}}', @@ -794,6 +794,7 @@ describe('normalizedToolSchemas', () => { ].join('\n') expect(normalizedToolSchemas(log, { sessionIds: [], cwd: '/w' })).toEqual([ [{ name: 'read', description: 'work in {{cwd}}' }], + [], ]) }) }) @@ -893,6 +894,12 @@ describe('tool-schema snapshots', () => { .toEqual({ system: '{{system}}', tools: snapshot.initial }) }) + it('preserves absent tools only for an empty sidecar', () => { + const header = { config: { model: 'test' } } + expect(restorePinnedToolSchemas(header, [])).toBe(header) + expect(() => restorePinnedToolSchemas(header, snapshot.initial)).toThrow(/must equal/) + }) + it('restores ordered tool names only when they match the sidecar', () => { const schemas = [{ name: 'read' }, { name: 'write' }] expect(restorePinnedToolSchemas({ tools: ['read', 'write'] }, schemas)).toEqual({ tools: schemas }) diff --git a/snapshots/session/deepseek-deferred-tools/session.v4.jsonl b/snapshots/session/deepseek-deferred-tools/session.v4.jsonl index ceb1cb5325..734954689b 100644 --- a/snapshots/session/deepseek-deferred-tools/session.v4.jsonl +++ b/snapshots/session/deepseek-deferred-tools/session.v4.jsonl @@ -2,37 +2,45 @@ {"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":"Call snapshot_initial, then snapshot_ping, then snapshot_later, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply READY."}],"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":"Call snapshot_initial, then snapshot_ping, then snapshot_later, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply READY."}],"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 working directory: \"{{cwd}}\".\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":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"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","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["snapshot_initial"]},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true}},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"session/title","data":{"title":"Call snapshot_initial, then snapshot_pin","messageSeqs":[8],"source":{"kind":"fallback"}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_initial","name":"snapshot_initial","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1792661643282,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1792661643282,"index":0,"dt":[],"id":"call_initial","name":"snapshot_initial","args":["{}"]},{"type":"chunk","time":1792661643282,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_initial","name":"snapshot_initial","arguments":"{}"}}},{"type":"chunk","time":1792661643282,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1792661643282,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_initial","name":"snapshot_initial","arguments":"{}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_initial"},"toolCallId":"call_initial","content":[{"type":"text","text":"snapshot_ping is available."}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Reply READY.","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"READY"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"stream":[{"type":"chunk","time":1792734528886,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1792734528887,"index":0,"dt":[],"texts":["READY"]},{"type":"chunk","time":1792734528887,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"READY"}}},{"type":"chunk","time":1792734528887,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"source":{"kind":"user"},"content":[{"type":"text","text":"Call snapshot_initial, snapshot_ping, and snapshot_later, then reply DONE."}],"role":"user","id":"{{message:5}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":2}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["snapshot_ping"]},"reason":"change"}} -{"type":"developer/message","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"snapshot_ping"},{"type":"tool-removal","toolName":"snapshot_initial"}],"role":"developer","id":"{{message:6}}"},"headerSeq":18},"surfaceOp":"append"} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"stream":[{"type":"chunk","time":1792661643298,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1792661643298,"index":0,"dt":[],"id":"call_dynamic_ping","name":"snapshot_ping","args":["{}"]},{"type":"chunk","time":1792661643298,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}}},{"type":"chunk","time":1792661643298,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_dynamic_ping"},"toolCallId":"call_dynamic_ping","content":[{"type":"text","text":"pong"}],"isError":false,"id":"{{message:8}}"}},"sourceEventSeqs":[21],"surfaceOp":"append"} +{"type":"user/message","data":{"source":{"kind":"user"},"content":[{"type":"text","text":"Call snapshot_initial, snapshot_ping, and snapshot_later, then reply DONE."}],"role":"user","id":"{{message:5}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["snapshot_initial"]},"reason":"change"}} +{"type":"developer/message","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"snapshot_initial"}],"role":"developer","id":"{{message:6}}"},"headerSeq":19},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_initial","name":"snapshot_initial","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1792734528899,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1792734528899,"index":0,"dt":[],"id":"call_initial","name":"snapshot_initial","args":["{}"]},{"type":"chunk","time":1792734528899,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_initial","name":"snapshot_initial","arguments":"{}"}}},{"type":"chunk","time":1792734528899,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1792734528899,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_initial","name":"snapshot_initial","arguments":"{}"}} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_initial"},"toolCallId":"call_initial","content":[{"type":"text","text":"snapshot_ping is available."}],"isError":false,"id":"{{message:8}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["snapshot_later","snapshot_ping"]},"reason":"change"}} -{"type":"developer/message","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"snapshot_later"}],"role":"developer","id":"{{message:9}}"},"headerSeq":25},"surfaceOp":"append"} -{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_later","name":"snapshot_later","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:10}}"},"stream":[{"type":"chunk","time":1792661643311,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1792661643311,"index":0,"dt":[],"id":"call_later","name":"snapshot_later","args":["{}"]},{"type":"chunk","time":1792661643311,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_later","name":"snapshot_later","arguments":"{}"}}},{"type":"chunk","time":1792661643311,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":3,"callId":"call_later","name":"snapshot_later","arguments":"{}"}} -{"type":"tool/result","data":{"turn":1,"step":3,"message":{"role":"tool","source":{"kind":"tool","callId":"call_later"},"toolCallId":"call_later","content":[{"type":"text","text":"later"}],"isError":false,"id":"{{message:11}}"}},"sourceEventSeqs":[28],"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["snapshot_ping"]},"reason":"change"}} +{"type":"developer/message","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"snapshot_ping"},{"type":"tool-removal","toolName":"snapshot_initial"}],"role":"developer","id":"{{message:9}}"},"headerSeq":26},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:10}}"},"stream":[{"type":"chunk","time":1792734528913,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1792734528913,"index":0,"dt":[],"id":"call_dynamic_ping","name":"snapshot_ping","args":["{}"]},{"type":"chunk","time":1792734528913,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}}},{"type":"chunk","time":1792734528913,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":3,"callId":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}} +{"type":"tool/result","data":{"turn":1,"step":3,"message":{"role":"tool","source":{"kind":"tool","callId":"call_dynamic_ping"},"toolCallId":"call_dynamic_ping","content":[{"type":"text","text":"pong"}],"isError":false,"id":"{{message:11}}"}},"sourceEventSeqs":[29],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} {"type":"step/start","data":{"turn":1,"step":4}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["snapshot_final","snapshot_ping"]},"reason":"change"}} -{"type":"developer/message","data":{"turn":1,"step":4,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"snapshot_final"},{"type":"tool-removal","toolName":"snapshot_later"}],"role":"developer","id":"{{message:12}}"},"headerSeq":32},"surfaceOp":"append"} -{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:13}}"},"usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10},"stream":[{"type":"chunk","time":1792661643324,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1792661643324,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1792661643324,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1792661643324,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10}}},{"type":"chunk","time":1792661643324,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["snapshot_later","snapshot_ping"]},"reason":"change"}} +{"type":"developer/message","data":{"turn":1,"step":4,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"snapshot_later"}],"role":"developer","id":"{{message:12}}"},"headerSeq":33},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_later","name":"snapshot_later","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:13}}"},"stream":[{"type":"chunk","time":1792734528931,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1792734528931,"index":0,"dt":[],"id":"call_later","name":"snapshot_later","args":["{}"]},{"type":"chunk","time":1792734528931,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_later","name":"snapshot_later","arguments":"{}"}}},{"type":"chunk","time":1792734528931,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":4,"callId":"call_later","name":"snapshot_later","arguments":"{}"}} +{"type":"tool/result","data":{"turn":1,"step":4,"message":{"role":"tool","source":{"kind":"tool","callId":"call_later"},"toolCallId":"call_later","content":[{"type":"text","text":"later"}],"isError":false,"id":"{{message:14}}"}},"sourceEventSeqs":[36],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":4}} +{"type":"step/start","data":{"turn":1,"step":5}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["snapshot_final","snapshot_ping"]},"reason":"change"}} +{"type":"developer/message","data":{"turn":1,"step":5,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"snapshot_final"},{"type":"tool-removal","toolName":"snapshot_later"}],"role":"developer","id":"{{message:15}}"},"headerSeq":40},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:16}}"},"usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10},"stream":[{"type":"chunk","time":1792734528950,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1792734528950,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1792734528950,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1792734528950,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10}}},{"type":"chunk","time":1792734528950,"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/deepseek-deferred-tools/snapshot.yml b/snapshots/session/deepseek-deferred-tools/snapshot.yml index ceedc9db93..711ea1ae83 100644 --- a/snapshots/session/deepseek-deferred-tools/snapshot.yml +++ b/snapshots/session/deepseek-deferred-tools/snapshot.yml @@ -6,4 +6,4 @@ recording: authored header: class: deepseek-deferred-tools pin: true - changes: 3 + changes: 4 diff --git a/snapshots/session/deepseek-deferred-tools/tool-schemas.expected.json b/snapshots/session/deepseek-deferred-tools/tool-schemas.expected.json index 255e5a96d0..1f8ef7a8ec 100644 --- a/snapshots/session/deepseek-deferred-tools/tool-schemas.expected.json +++ b/snapshots/session/deepseek-deferred-tools/tool-schemas.expected.json @@ -1,15 +1,16 @@ { - "initial": [ - { - "name": "snapshot_initial", - "description": "Make snapshot_ping available.", - "parameters": { - "type": "object", - "properties": {} - } - } - ], + "initial": [], "changes": [ + [ + { + "name": "snapshot_initial", + "description": "Make snapshot_ping available.", + "parameters": { + "type": "object", + "properties": {} + } + } + ], [ { "name": "snapshot_ping", From a5c44b9384505fad2281b83e6e048c92f6fd0ca8 Mon Sep 17 00:00:00 2001 From: Yixiang Tu Date: Mon, 2 Nov 2026 20:34:08 +0800 Subject: [PATCH 27/32] test(llm-deepseek): refresh snapshots for root agent identity --- snapshots/session/deepseek-all-deferred-tools/session.v4.jsonl | 2 +- snapshots/session/deepseek-deferred-tools/session.v4.jsonl | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/snapshots/session/deepseek-all-deferred-tools/session.v4.jsonl b/snapshots/session/deepseek-all-deferred-tools/session.v4.jsonl index c7f585e660..761d883f55 100644 --- a/snapshots/session/deepseek-all-deferred-tools/session.v4.jsonl +++ b/snapshots/session/deepseek-all-deferred-tools/session.v4.jsonl @@ -8,7 +8,7 @@ {"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":"Reply DONE without calling any tools."}],"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 working directory: \"{{cwd}}\".\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":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"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":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\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 the root agent. Your agent id is \"{{session:1}}\"."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"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:identity","text":"You are the root agent. Your agent id is \"{{session:1}}\"."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Reply DONE without calling any","messageSeqs":[8],"source":{"kind":"fallback"}}} diff --git a/snapshots/session/deepseek-deferred-tools/session.v4.jsonl b/snapshots/session/deepseek-deferred-tools/session.v4.jsonl index 734954689b..c0822bb1d2 100644 --- a/snapshots/session/deepseek-deferred-tools/session.v4.jsonl +++ b/snapshots/session/deepseek-deferred-tools/session.v4.jsonl @@ -8,7 +8,7 @@ {"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":"Reply READY."}],"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 working directory: \"{{cwd}}\".\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":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"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":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\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 the root agent. Your agent id is \"{{session:1}}\"."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"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:identity","text":"You are the root agent. Your agent id is \"{{session:1}}\"."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true}},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Reply READY.","messageSeqs":[8],"source":{"kind":"fallback"}}} From 624c3e91a99694feb18210fba9b1303a6348ed96 Mon Sep 17 00:00:00 2001 From: Yixiang Tu Date: Mon, 2 Nov 2026 22:04:53 +0800 Subject: [PATCH 28/32] fix(llm-deepseek): expose all-deferred request failures --- .../2026-09-20-dynamic-tool-updates.i18n.yaml | 8 +++--- .../2026-09-20-dynamic-tool-updates.md | 4 +-- .../2026-09-20-dynamic-tool-updates.zh.md | 4 +-- ...-deepseek-deferred-tool-failures.i18n.yaml | 19 +++++++++++++ ...6-11-02-deepseek-deferred-tool-failures.md | 25 +++++++++++++++++ ...1-02-deepseek-deferred-tool-failures.zh.md | 25 +++++++++++++++++ packages/llm/llm-deepseek/README.i18n.yaml | 12 ++++---- packages/llm/llm-deepseek/README.md | 6 ++-- packages/llm/llm-deepseek/README.zh.md | 6 ++-- packages/llm/llm-deepseek/src/serialize.ts | 28 ++++++------------- .../llm/llm-deepseek/tests/adapter.spec.ts | 17 ++++++++++- .../tests/fixtures/all-deferred-tools.ts | 3 +- .../llm/llm-deepseek/tests/serialize.spec.ts | 12 +------- 13 files changed, 115 insertions(+), 54 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.md create mode 100644 .agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.zh.md diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml index 9e822eb717..e4acbc9423 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml @@ -9,11 +9,11 @@ en: 97fe58a13135b4a1 zh: 7490373a8d941da5 /agent-note-active-tool-headers-and-stateful-request-projection/decision: - en: 51773686866c95d1 - zh: 489ea0e1144720e1 + en: 24138152d511bf12 + zh: 95516d7566b6db7c /agent-note-active-tool-headers-and-stateful-request-projection/alternatives-considered: en: 311db1f781e386e7 zh: 7e3312544154a158 /agent-note-active-tool-headers-and-stateful-request-projection/consequences: - en: 94b1037f25176c7d - zh: 141c25306a57e5ea + en: 8a64dba42c01f647 + zh: 9e7ba7a5bd855031 diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md index 23a21d7636..8a8976aacb 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md @@ -14,7 +14,7 @@ Plugin and MCP tools change during conversations. Providers differ in whether th `Session.toolHistory()` caches a capability-independent fold of committed headers and developer messages. First access restores inherited events; later reads consume only unseen events. Immutable snapshots carry initial declarations and ordered update identities with additions resolved through their historical headers. Explicit series starts and changed retained definitions rebuild declarations. Restoring a retained name with an unchanged definition continues the series: the projection keeps the declaration unchanged and lets the recorded addition re-offer the tool after its earlier removal, so a removal never disables a declared tool and a loading mode never rewrites an earlier declaration. -Loop and compaction requests carry this snapshot as `GenerateOptions.toolHistory`. At adapter dispatch, `projectToolUpdates` uses the prepared route's `toolUpdate`. Unsupported routes receive active definitions without `deferLoading` or developer messages. Supported routes defer historical additions; `in-history` retains removed definitions and removal blocks, while `addition-only` omits both. On addition-only routes, removing any tool rebuilds the route's baseline after that update: the request declarations change even when the removed tool was activated later, so retaining superseded activation blocks cannot preserve the full conversation prefix. Adding tools when no declarations exist also rebuilds the baseline. Deferred declarations count as existing tools. The first tools would otherwise require a non-deferred placeholder in DeepSeek, which changes the prefix anyway; declaring them immediately avoids an unnecessary activation message and placeholder. Previously activated tools become immediate; explicitly deferred declarations with no activation remain deferred. Subsequent additions remain deferred until their recorded activation. Superseded tool blocks are omitted without discarding other content in those messages. The Session fold and durable events remain capability-independent. Missing history or a request prefix omitting recorded updates uses current declarations without developer updates. +Loop and compaction requests carry this snapshot as `GenerateOptions.toolHistory`. At adapter dispatch, `projectToolUpdates` uses the prepared route's `toolUpdate`. Unsupported routes receive active definitions without `deferLoading` or developer messages. Supported routes defer historical additions; `in-history` retains removed definitions and removal blocks, while `addition-only` omits both. On addition-only routes, removing any tool rebuilds the route's baseline after that update: the request declarations change even when the removed tool was activated later, so retaining superseded activation blocks cannot preserve the full conversation prefix. Adding tools when no declarations exist also rebuilds the baseline. Deferred declarations count as existing tools. The first native tool changes the empty declaration prefix; deferring it provides no cache advantage over declaring it immediately. Previously activated tools become immediate; explicitly deferred declarations with no activation remain deferred. Subsequent additions remain deferred until their recorded activation. Superseded tool blocks are omitted without discarding other content in those messages. The Session fold and durable events remain capability-independent. Missing history or a request prefix omitting recorded updates uses current declarations without developer updates. DeepSeek translates projected updates into system-role `tool_addition` and `tool_removal` blocks and `defer_loading` declarations, sending the tool-changes beta header when update blocks occur. The default `deepseek-flash` entry declares `addition-only`; pi-ai selects `in-history` for compatible Anthropic routes and `addition-only` for compatible Chat Completions and Responses routes. Chat and Trajectory display developer messages as context nodes, using localized tool-change notices: single changes show the tool name; multiple changes show counts and expandable name lists. @@ -36,6 +36,6 @@ Tool lists and logical update events are capability-independent; only outgoing d The authored [headless prompt/tool scenario](../../../../snapshots/session/dynamic-tool-prompt-updates/snapshot.yml) pins an appended prompt beside a deferred tool addition and verifies that the first request's messages stay unchanged. It shares the stable-prompt scenario's tool lifecycle and schema expectations. -The [DeepSeek replacement scenario](../../../../snapshots/session/deepseek-deferred-tools/snapshot.yml) checks the first additions to an empty set, a rebuilt baseline, a subsequent deferred addition, and removal of that later-activated tool through Messages serialization. An adapter-local placeholder covers nonempty all-deferred requests that remain, including explicit deferred declarations and direct calls. It does not activate real tools. These checks establish request construction, not measured cache savings from rebuilding. +The [DeepSeek replacement scenario](../../../../snapshots/session/deepseek-deferred-tools/snapshot.yml) checks the first additions to an empty set, a rebuilt baseline, a subsequent deferred addition, and removal of that later-activated tool through Messages serialization. Explicit deferred-only requests remain unchanged under the [DeepSeek failure policy](../simplification/2026-11-02-deepseek-deferred-tool-failures.md). These checks establish request construction, not measured cache savings from rebuilding. The [real DeepSeek SDK test](../../../../apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts) measures the first request after a native tool addition with both unchanged and simultaneously updated instructions. Unique user history prevents a shared system-prompt cache hit from satisfying the preceding-input assertion. Removal checks tool availability without requiring cache retention; each case disables retries. diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md index 9f4ee01afd..e9f48c8a02 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md @@ -14,7 +14,7 @@ Status: implemented `Session.toolHistory()` 缓存对已提交请求头和 developer 消息的归约,不依赖模型能力。首次访问恢复继承事件;后续读取仅处理新增事件。不可变快照携带初始声明和有序更新标识,添加定义通过历史请求头解析。显式新序列和保留定义变化会重建声明。以未变化的定义恢复保留名称时序列继续:投影保持声明不变,由记录的添加在先前移除之后重新提供该工具,因此移除不会禁用已声明的工具,加载模式也不会改写先前声明。 -循环和压缩请求通过 `GenerateOptions.toolHistory` 携带此快照。适配器分发时,`projectToolUpdates` 使用已准备路由的 `toolUpdate`。不支持的路由接收有效定义,不携带 `deferLoading` 或 developer 消息。支持的路由延迟历史添加;`in-history` 保留已移除定义和移除块,`addition-only` 则省略两者。在 addition-only 路由移除任何工具时,使用该次更新后的状态重建路由基线:即使被移除工具是后来激活的,请求声明也已发生变化,保留已被取代的激活块无法保留完整对话前缀。不存在任何声明时添加工具,也会重建基线。延迟声明也计入已有工具。否则首批工具会使 DeepSeek 需要非延迟占位工具,同样改变前缀;直接声明这些工具可避免不必要的激活消息和占位工具。此前已激活工具变为立即加载;没有激活记录的显式延迟声明仍保持延迟。后续添加在记录的激活位置前保持延迟加载。已被取代的工具块会被省略,但不会丢弃这些消息中的其他内容。Session 归约和持久事件仍不依赖模型能力。缺少历史或请求前缀遗漏已记录更新时,使用当前声明且不发送 developer 更新。 +循环和压缩请求通过 `GenerateOptions.toolHistory` 携带此快照。适配器分发时,`projectToolUpdates` 使用已准备路由的 `toolUpdate`。不支持的路由接收有效定义,不携带 `deferLoading` 或 developer 消息。支持的路由延迟历史添加;`in-history` 保留已移除定义和移除块,`addition-only` 则省略两者。在 addition-only 路由移除任何工具时,使用该次更新后的状态重建路由基线:即使被移除工具是后来激活的,请求声明也已发生变化,保留已被取代的激活块无法保留完整对话前缀。不存在任何声明时添加工具,也会重建基线。延迟声明也计入已有工具。首个原生工具会改变空声明前缀;将其延迟加载并不比直接声明具有缓存优势。此前已激活工具变为立即加载;没有激活记录的显式延迟声明仍保持延迟。后续添加在记录的激活位置前保持延迟加载。已被取代的工具块会被省略,但不会丢弃这些消息中的其他内容。Session 归约和持久事件仍不依赖模型能力。缺少历史或请求前缀遗漏已记录更新时,使用当前声明且不发送 developer 更新。 DeepSeek 把投影后的更新转换为 system 角色的 `tool_addition` 和 `tool_removal` 块以及 `defer_loading` 声明,在存在更新块时发送工具变更 beta 请求头。默认 `deepseek-flash` 条目声明 `addition-only`;pi-ai 为兼容的 Anthropic 路由选择 `in-history`,为兼容的 Chat Completions 和 Responses 路由选择 `addition-only`。Chat 和 Trajectory 将 developer 消息显示为上下文节点,工具变更使用本地化通知:单项变更显示工具名,多项变更显示数量及可展开的名称列表。 @@ -36,6 +36,6 @@ DeepSeek 把投影后的更新转换为 system 角色的 `tool_addition` 和 `to 手写的[无界面提示词/工具场景](../../../../snapshots/session/dynamic-tool-prompt-updates/snapshot.yml)钉住追加提示词与延迟工具添加同时发生的行为,并验证首次请求的消息保持不变。它复用稳定提示词场景的工具生命周期和 schema 期望值。 -[DeepSeek 替换场景](../../../../snapshots/session/deepseek-deferred-tools/snapshot.yml)通过 Messages 序列化验证向空工具集首次添加工具、重建后的基线、后续延迟添加,以及移除该后来激活的工具。适配器内的占位工具覆盖仍然存在的非空全延迟请求,包括显式延迟声明和直接调用,不会激活真实工具。这些检查验证请求构造,不代表已经测量重建带来的缓存收益。 +[DeepSeek 替换场景](../../../../snapshots/session/deepseek-deferred-tools/snapshot.yml)通过 Messages 序列化验证向空工具集首次添加工具、重建后的基线、后续延迟添加,以及移除该后来激活的工具。根据 [DeepSeek 失败策略](../simplification/2026-11-02-deepseek-deferred-tool-failures.zh.md),显式全延迟请求保持原样。这些检查验证请求构造,不代表已经测量重建带来的缓存收益。 [真实 DeepSeek SDK 测试](../../../../apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts) 分别在指令不变和指令同时更新时测量原生工具新增后的首次请求。独有的用户历史确保仅命中共享系统提示词缓存无法满足前序输入断言。移除场景检查工具可用性,不要求保留缓存;每个用例均禁用重试。 diff --git a/.agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.i18n.yaml b/.agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.i18n.yaml new file mode 100644 index 0000000000..e4e2d36818 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-11-02-deepseek-deferred-tool-failures.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.md +/agent-note-expose-deepseek-deferred-only-request-failures: + en: 1c4af8b4e2ec0e46 + zh: fcb806d33973025e +/agent-note-expose-deepseek-deferred-only-request-failures/problem: + en: c974406f21854541 + zh: 701cd64cabdfee79 +/agent-note-expose-deepseek-deferred-only-request-failures/decision: + en: 6878caaaebd91a41 + zh: 2ebd5da04dfb1ac6 +/agent-note-expose-deepseek-deferred-only-request-failures/alternatives-considered: + en: d10efff97379cfbb + zh: 572b124ddbb2d86d +/agent-note-expose-deepseek-deferred-only-request-failures/consequences: + en: d60323c595b076f4 + zh: 1bf1068424777b7e diff --git a/.agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.md b/.agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.md new file mode 100644 index 0000000000..78bc8d4a5c --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.md @@ -0,0 +1,25 @@ +# Agent Note: Expose DeepSeek deferred-only request failures + +Status: implemented + +English | [中文](2026-11-02-deepseek-deferred-tool-failures.zh.md) + +## Problem + +A synthetic immediate tool can make an all-deferred DeepSeek request acceptable while hiding an invalid tool-update projection. It also changes the request prefix and advertises a tool with no executor. + +## Decision + +The DeepSeek adapter sends deferred declarations unchanged and propagates provider rejection. This replaces the placeholder accommodation described by the [tool-update decision](../architecture/2026-09-20-dynamic-tool-updates.md), whose shared history projection remains authoritative. + +Ordinary addition-only histories start with native tools or an empty set. Removals and first additions to an empty set rebuild native declarations; later deferred additions retain a native baseline. Explicit deferred-only registrations, direct adapter calls, and custom in-history routes can still produce all-deferred requests. Those configurations receive no adapter workaround. + +## Alternatives considered + +**Keep a synthetic immediate tool.** It conceals unexpected deferred-only requests and cannot preserve a prefix that lacked that tool. Automatically activating a real tool instead changes its declared availability. + +**Reject locally.** Provider validation owns acceptance; another local restriction would duplicate it. Reconsider accommodation only for a supported workflow that requires deferred-only requests and cannot retain a native declaration. + +## Consequences + +Deferred-only compatibility is deliberately given up so request errors remain visible. Serializer and HTTP tests verify exact declarations and propagation of a provider rejection. The all-deferred keyless snapshot verifies request construction only; replay success does not establish provider acceptance. Replacement and empty-set scenarios verify the supported addition-only path. diff --git a/.agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.zh.md b/.agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.zh.md new file mode 100644 index 0000000000..f075aff3ad --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 显露 DeepSeek 全延迟请求失败 + +Status: implemented + +[English](2026-11-02-deepseek-deferred-tool-failures.md) | 中文 + +## Problem + +合成的立即加载工具可使 DeepSeek 接受全延迟请求,却掩盖无效的工具更新投影。它还会改变请求前缀,并声明一个没有执行器的工具。 + +## Decision + +DeepSeek 适配器原样发送延迟声明,并传递提供方的拒绝错误。这取代了[工具更新决策](../architecture/2026-09-20-dynamic-tool-updates.zh.md)中描述的占位工具兼容机制;其中的共享历史投影仍是权威依据。 + +普通 addition-only 历史以原生工具或空集开始。移除工具或首次向空集添加工具会重建原生声明;后续延迟添加保留原生基线。显式全延迟注册、直接调用适配器和自定义 in-history 路由仍可产生全延迟请求。适配器不为这些配置提供变通处理。 + +## Alternatives considered + +**保留合成的立即加载工具。** 它会掩盖意外的全延迟请求,也无法保留原本没有该工具的前缀。改为自动激活真实工具则会改变其声明的可用性。 + +**在本地拒绝。** 是否接受请求由提供方验证决定;额外的本地限制会重复该验证。只有受支持工作流确实需要全延迟请求,且无法保留原生声明时,才重新考虑兼容处理。 + +## Consequences + +有意放弃全延迟兼容性,使请求错误保持可见。序列化和 HTTP 测试验证精确声明以及提供方拒绝错误的传递。全延迟无密钥快照仅验证请求构造;回放成功不代表提供方接受。替换和空集场景验证受支持的 addition-only 路径。 diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index c3d2e1948c..2143878501 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -63,14 +63,14 @@ en: 1dc50052999253c6 zh: 7abf968fc18bffcd /deepseek-ai-dsh-llm-deepseek/model-experience/deepseek-request/what-the-model-sees: - en: 06f3cafb41d8d6bd - zh: 1b031d123a939685 + en: fd508ab91893e9dc + zh: c653e55a47353d08 /deepseek-ai-dsh-llm-deepseek/model-experience/deepseek-request/token-effect: - en: f8891795b5bfd744 - zh: b1235786b04a7558 + en: 67f4bfa42cbee911 + zh: 5e9dc9bc07253686 /deepseek-ai-dsh-llm-deepseek/model-experience/deepseek-request/kv-cache-effect: - en: de048b284b463dfb - zh: b5545081b3df6c53 + en: 87ac92776176ee15 + zh: bb49edf3a68dda07 /deepseek-ai-dsh-llm-deepseek/model-experience/deepseek-response: en: 957d6665495e5ca5 zh: 0aa72b2c2dc07167 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 5bdea7637b..396248805e 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -170,15 +170,15 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config (`maxTokens`, `reasoningEffort`, `temperature`). Provider-specific request-extension fields remain outside model input. The vision model normally receives retained user and tool-result images as Files API references beside attachment handles and request-preview dimensions. It also receives a normalized-object path when the current execution filesystem maps the attachment provider's host object; the descriptor marks this copy read-only and warns that normalization may have resized or re-encoded the upload. A Files resolution failure sends all retained images as inline base64 instead, and an over-budget older image keeps the access resolved for that request in its placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. Messages sends `{}` for historical tool arguments that are malformed JSON or are not objects. This argument fallback preserves call ids, tool names, and tool results; the original arguments stay in the Session log. Newly generated Messages tool arguments still require valid JSON objects. Messages omits `reasoning` and `tool-call` blocks inside user messages and tool results. This also permits replay of saved subagent notices containing assistant output; the original Session content remains intact. Empty user messages are skipped after conversion, while empty tool results retain their call ids and error flags. Other unsupported input blocks still fail with `UNSUPPORTED_CONTENT`. When every declared tool is deferred, the adapter prepends a non-deferred `DeferredToolPlaceholder` with the description `Reserved placeholder that keeps deferred tool loading active; never call this tool.` A numeric suffix avoids collisions with declared tools. The placeholder has no executor; calls receive the ordinary unavailable-tool result. The placeholder leaves real declarations unchanged. The shared [tool-update projection](../llm/README.md#known-limitations-and-deferred-work) rebuilds an addition-only baseline when a tool is removed or tools are added to an empty set, while keeping later additions deferred. Empty tool lists receive no placeholder. +The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config (`maxTokens`, `reasoningEffort`, `temperature`) without adapter-authored prompt prose. Provider-specific request-extension fields remain outside model input. The vision model normally receives retained user and tool-result images as Files API references beside attachment handles and request-preview dimensions. It also receives a normalized-object path when the current execution filesystem maps the attachment provider's host object; the descriptor marks this copy read-only and warns that normalization may have resized or re-encoded the upload. A Files resolution failure sends all retained images as inline base64 instead, and an over-budget older image keeps the access resolved for that request in its placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. Messages sends `{}` for historical tool arguments that are malformed JSON or are not objects. This argument fallback preserves call ids, tool names, and tool results; the original arguments stay in the Session log. Newly generated Messages tool arguments still require valid JSON objects. Messages omits `reasoning` and `tool-call` blocks inside user messages and tool results. This also permits replay of saved subagent notices containing assistant output; the original Session content remains intact. Empty user messages are skipped after conversion, while empty tool results retain their call ids and error flags. Other unsupported input blocks still fail with `UNSUPPORTED_CONTENT`. The adapter sends all-deferred tool lists unchanged and propagates provider rejection without adding a placeholder or activating real tools. The shared [tool-update projection](../llm/README.md#known-limitations-and-deferred-work) rebuilds an addition-only baseline when a tool is removed or tools are added to an empty set, while keeping later additions deferred. #### Token effect -Provider tokenization governs exact text and image-token input. When present, the placeholder adds input tokens for one tool declaration, including its name, description, and empty object schema. The adapter declares per-route `imageRequestPricing`: it prices each occurrence selected by a logged image-offload decision as its placeholder text and each retained image at its projected dimensions with the published vision accounting (14px patch grid, 3:1 downsampling, 544×544 scale-up floor, 1024-token cap). This lets the token meter price image pressure before a request; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while offloaded images stop costing visual tokens. A request whose retained occurrences exceed the file-mode or inline-fallback budget (`maxRequestFilesBytes`, `maxImagesPerRequest`, both quanta) at their exact request-version bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences to offload, and `dsh-compaction-image-offload` records the selected occurrences in an `image/offload` event and retries. Cache-read usage is reported when available. Messages totals include uncached input, output, cache-read, and cache-write tokens. `imageRequestTarget(provider, model, image)` exposes the same request-image dimensions and byte target to screenshot consumers; adapters without image resizing preserve the normalized source. +Provider tokenization governs exact text and image-token input. The adapter declares per-route `imageRequestPricing`: it prices each occurrence selected by a logged image-offload decision as its placeholder text and each retained image at its projected dimensions with the published vision accounting (14px patch grid, 3:1 downsampling, 544×544 scale-up floor, 1024-token cap). This lets the token meter price image pressure before a request; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while offloaded images stop costing visual tokens. A request whose retained occurrences exceed the file-mode or inline-fallback budget (`maxRequestFilesBytes`, `maxImagesPerRequest`, both quanta) at their exact request-version bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences to offload, and `dsh-compaction-image-offload` records the selected occurrences in an `image/offload` event and retries. Cache-read usage is reported when available. Messages totals include uncached input, output, cache-read, and cache-write tokens. `imageRequestTarget(provider, model, image)` exposes the same request-image dimensions and byte target to screenshot consumers; adapters without image resizing preserve the normalized source. #### KV Cache effect -An unchanged assembled prefix is eligible for DeepSeek cache reuse, which this adapter reports in usage. For a nonempty all-deferred tool list, the placeholder is part of the declaration prefix; adding, removing, or renaming it changes that prefix and may affect cache reuse. Deterministic request-image bytes do not make the full prefix immutable: a changed execution-world path rewrites historical descriptor text, a refreshed upload can replace a `file_id`, and Files-to-base64 fallback changes the image representation. Any of these, or a model-route, prompt, schema, history, or image-budget change, may prevent reuse from the first affected token; reasoning passback appends on every reasoned turn. On a catalog entry declaring `systemPromptUpdate: in-history`, a system prompt change inside a continuing request series is appended after the cached history, so the prefix through that history stays reusable; a tool-schema change still prevents reuse from the first altered token. +An unchanged assembled prefix is eligible for DeepSeek cache reuse, which this adapter reports in usage. Deterministic request-image bytes do not make the full prefix immutable: a changed execution-world path rewrites historical descriptor text, a refreshed upload can replace a `file_id`, and Files-to-base64 fallback changes the image representation. Any of these, or a model-route, prompt, schema, history, or image-budget change, may prevent reuse from the first affected token; reasoning passback appends on every reasoned turn. On a catalog entry declaring `systemPromptUpdate: in-history`, a system prompt change inside a continuing request series is appended after the cached history, so the prefix through that history stays reusable; a tool-schema change still prevents reuse from the first altered token. ### DeepSeek response diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 009306fb5e..f11c8c3e13 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -170,15 +170,15 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 #### 模型看到什么 -所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、停止序列与调用配置(`maxTokens`、`reasoningEffort`、`temperature`)。提供方专用请求扩展字段留在模型输入之外。视觉模型通常接收 Files API 引用形式的用户与工具结果图片,其旁带附件句柄和请求预览尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,它还会收到规范化对象路径;描述符会把该副本标记为只读,并警告规范化可能缩放或重新编码上传内容。Files 解析失败时,全部保留图片改用内联 base64;超出预算的较旧图片则在占位文本中保留当前请求已解析的访问方式。此前 assistant 轮次的推理内容会原样传回,无论该轮次是否调用了工具。 对于非法 JSON 或非对象的历史工具参数,Messages 发送 `{}`。此参数兜底保留调用 ID、工具名和工具结果;原始参数仍保留在 Session 日志中。新生成的 Messages 工具参数仍须是有效 JSON 对象。 Messages 会省略用户消息和工具结果中的 `reasoning` 与 `tool-call` 块。这也允许回放包含助手输出的已保存子 Agent 通知;原始 Session 内容保持完整。转换后的空用户消息会被跳过,空工具结果则保留调用 id 和错误标记。其他不支持的输入块仍会以 `UNSUPPORTED_CONTENT` 失败。 当所有已声明工具都为延迟加载时,适配器会在列表开头添加非延迟的 `DeferredToolPlaceholder`,描述为 `Reserved placeholder that keeps deferred tool loading active; never call this tool.`。如与已声明工具重名,则添加数字后缀。占位工具没有执行器;调用会收到常规的工具不可用结果。占位工具不会改变真实工具声明。共享的[工具更新投影](../llm/README.zh.md#known-limitations-and-deferred-work)在移除任何工具或向空工具集添加工具时重建 addition-only 基线,后续添加仍保持延迟加载。空工具列表不会添加占位工具。 +所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、停止序列与调用配置(`maxTokens`、`reasoningEffort`、`temperature`),不包含适配器撰写的提示词散文。提供方专用请求扩展字段留在模型输入之外。视觉模型通常接收 Files API 引用形式的用户与工具结果图片,其旁带附件句柄和请求预览尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,它还会收到规范化对象路径;描述符会把该副本标记为只读,并警告规范化可能缩放或重新编码上传内容。Files 解析失败时,全部保留图片改用内联 base64;超出预算的较旧图片则在占位文本中保留当前请求已解析的访问方式。此前 assistant 轮次的推理内容会原样传回,无论该轮次是否调用了工具。 对于非法 JSON 或非对象的历史工具参数,Messages 发送 `{}`。此参数兜底保留调用 ID、工具名和工具结果;原始参数仍保留在 Session 日志中。新生成的 Messages 工具参数仍须是有效 JSON 对象。 Messages 会省略用户消息和工具结果中的 `reasoning` 与 `tool-call` 块。这也允许回放包含助手输出的已保存子 Agent 通知;原始 Session 内容保持完整。转换后的空用户消息会被跳过,空工具结果则保留调用 id 和错误标记。其他不支持的输入块仍会以 `UNSUPPORTED_CONTENT` 失败。 适配器原样发送全延迟工具列表,并传递提供方的拒绝错误,不添加占位工具或激活真实工具。共享的[工具更新投影](../llm/README.zh.md#known-limitations-and-deferred-work)在移除任何工具或向空工具集添加工具时重建 addition-only 基线,后续添加仍保持延迟加载。 #### Token 影响 -提供方分词决定精确的文本与图片 token 输入。存在占位工具时,其名称、描述和空对象 schema 构成一个额外工具声明,增加输入 token。适配器声明按路由的 `imageRequestPricing`:把日志中的图片省略决策选中的每个出现位置按其占位文本计价,并按投影后的尺寸使用公开的视觉计量规则(14 px patch 网格、3:1 降采样、544×544 放大下限、单图 1024 token 上限)为每张保留图片计价。这使 token 计量服务可以在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个推理轮次的思维链带进后续请求,而已省略的图片不再消耗视觉 token。保留的出现位置按精确请求版本字节超过 file 模式或内联回退预算(`maxRequestFilesBytes`、`maxImagesPerRequest` 与两个量子)的请求,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置,由 `dsh-compaction-image-offload` 用 `image/offload` 事件记录所选位置并重试。可用时报告缓存读取用量。Messages 的 token 总数包含未缓存输入、输出、缓存读取与缓存写入 token。 `imageRequestTarget(provider, model, image)` 向截图调用方提供与模型请求一致的图片尺寸和字节目标;不缩放图片的适配器保留归一化后的原图。 +提供方分词决定精确的文本与图片 token 输入。适配器声明按路由的 `imageRequestPricing`:把日志中的图片省略决策选中的每个出现位置按其占位文本计价,并按投影后的尺寸使用公开的视觉计量规则(14 px patch 网格、3:1 降采样、544×544 放大下限、单图 1024 token 上限)为每张保留图片计价。这使 token 计量服务可以在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个推理轮次的思维链带进后续请求,而已省略的图片不再消耗视觉 token。保留的出现位置按精确请求版本字节超过 file 模式或内联回退预算(`maxRequestFilesBytes`、`maxImagesPerRequest` 与两个量子)的请求,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置,由 `dsh-compaction-image-offload` 用 `image/offload` 事件记录所选位置并重试。可用时报告缓存读取用量。Messages 的 token 总数包含未缓存输入、输出、缓存读取与缓存写入 token。 `imageRequestTarget(provider, model, image)` 向截图调用方提供与模型请求一致的图片尺寸和字节目标;不缩放图片的适配器保留归一化后的原图。 #### KV Cache 影响 -未改变的已组装前缀有资格获得 DeepSeek 缓存复用,本适配器会在用量中报告。对于非空的全延迟工具列表,占位工具属于声明前缀;添加、移除或重命名占位工具会改变该前缀,并可能影响缓存复用。确定性的请求图片字节并不意味着完整前缀固定不变:执行世界路径变化会改写历史描述符文本,刷新上传会替换 `file_id`,Files 到 base64 的回退也会改变图片表示。这些变化以及模型路由、提示词、schema、历史或图片预算变化,都可能从首个受影响 token 起阻止复用;推理回传在每个推理轮次上追加内容。在声明了 `systemPromptUpdate: in-history` 的目录条目上,同一请求序列延续期间的系统提示词变化会追加到已缓存历史之后,因此直到该历史末尾的前缀仍可复用;工具 schema 变化仍会从第一个改变的 token 起阻止复用。 +未改变的已组装前缀有资格获得 DeepSeek 缓存复用,本适配器会在用量中报告。确定性的请求图片字节并不意味着完整前缀固定不变:执行世界路径变化会改写历史描述符文本,刷新上传会替换 `file_id`,Files 到 base64 的回退也会改变图片表示。这些变化以及模型路由、提示词、schema、历史或图片预算变化,都可能从首个受影响 token 起阻止复用;推理回传在每个推理轮次上追加内容。在声明了 `systemPromptUpdate: in-history` 的目录条目上,同一请求序列延续期间的系统提示词变化会追加到已缓存历史之后,因此直到该历史末尾的前缀仍可复用;工具 schema 变化仍会从第一个改变的 token 起阻止复用。 ### DeepSeek 响应 diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index 6f173a6f01..f08412c0e9 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -45,8 +45,6 @@ function assistant(message: Message, model: string, onReplayDegrade?: (reason: s * Developer messages, already projected for this route by `LlmRuntime`, become * system-role updates after the preceding user turn, with tool changes as * `tool_addition` and `tool_removal` references to the declared tool. - * Nonempty all-deferred tool lists receive an immediate, non-executable - * placeholder without changing real tools or their activation positions. * @param options - provider-neutral request. * @param connection - validated defaults and thinking policy. * @param history - image-projected history with complete system snapshots; durable messages remain unchanged. @@ -151,23 +149,6 @@ export function serialize( throw new LlmError(`DeepSeek Messages does not support reasoning effort ${effort}`, 'UNSUPPORTED_REASONING_EFFORT') } const system = [options.system, historySystem].filter(Boolean).join('\n\n') - const tools = options.tools?.map(tool => ({ - name: tool.name, - description: tool.description, - input_schema: tool.parameters, - ...tool.deferLoading === true ? { defer_loading: true as const } : {}, - })) - if (tools !== undefined && tools.length > 0 && tools.every(tool => tool.defer_loading === true)) { - // Messages requires an immediate declaration even when every real tool loads later. - const names = new Set(tools.map(tool => tool.name)) - let name = 'DeferredToolPlaceholder' - for (let suffix = 1; names.has(name); suffix++) name = `DeferredToolPlaceholder_${suffix}` - tools.unshift({ - name, - description: 'Reserved placeholder that keeps deferred tool loading active; never call this tool.', - input_schema: { type: 'object', properties: {} }, - }) - } return { model: options.model, stream: true, messages, max_tokens: options.maxTokens ?? model?.maxTokens ?? connection.maxTokens, @@ -176,6 +157,13 @@ export function serialize( ...system.length === 0 ? {} : { system }, ...options.temperature === undefined ? {} : { temperature: options.temperature }, ...options.stop === undefined ? {} : { stop_sequences: options.stop }, - ...tools === undefined ? {} : { tools }, + ...options.tools === undefined ? {} : { + tools: options.tools.map(tool => ({ + name: tool.name, + description: tool.description, + input_schema: tool.parameters, + ...tool.deferLoading === true ? { defer_loading: true as const } : {}, + })), + }, } } diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index f36cb3cdeb..ea30717e92 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -118,7 +118,7 @@ describe('direct Messages HTTP', () => { await assemble(llm.stream(options({ messages: [user(), changes], tools }))) expect(http.requests[0]).toMatchObject({ headers: { 'anthropic-beta': 'mid-conversation-tool-changes-2026-07-01' }, - body: { tools: [{ name: 'DeferredToolPlaceholder' }, { name: 'search', defer_loading: true }], messages: [ + body: { tools: [{ name: 'search', defer_loading: true }], messages: [ { role: 'user' }, { role: 'system', content: [{ type: 'tool_addition', tool: { type: 'tool_reference', name: 'search' } }] }, ] }, }) @@ -126,6 +126,21 @@ describe('direct Messages HTTP', () => { expect(http.requests[1]?.headers).not.toHaveProperty('anthropic-beta') }) + it('surfaces provider rejection of an all-deferred request without adding tools', async () => { + const message = 'At least one tool must not be deferred' + const http = await endpoint((response) => { + response.statusCode = 400 + response.end(JSON.stringify({ error: { type: 'invalid_request_error', message } })) + }) + const tools = [{ name: 'search', description: 'Search', parameters: {}, deferLoading: true as const }] + await expect(chunks(adapter({ baseURL: http.url }).stream(options({ tools })))) + .rejects.toMatchObject({ code: 'INVALID_REQUEST', message, failure: { status: 400 } }) + expect(http.requests).toHaveLength(1) + expect(http.requests[0]?.body.tools).toEqual([ + { name: 'search', description: 'Search', input_schema: {}, defer_loading: true }, + ]) + }) + it('uses the Messages endpoint, authentication, attribution and final usage', async () => { const http = await endpoint() const llm = adapter({ baseURL: http.url }) diff --git a/packages/llm/llm-deepseek/tests/fixtures/all-deferred-tools.ts b/packages/llm/llm-deepseek/tests/fixtures/all-deferred-tools.ts index 969347e651..a485c57022 100644 --- a/packages/llm/llm-deepseek/tests/fixtures/all-deferred-tools.ts +++ b/packages/llm/llm-deepseek/tests/fixtures/all-deferred-tools.ts @@ -1,4 +1,4 @@ -/** Replay explicitly deferred declarations without activating them. */ +/** Verify serialized deferred-only declarations; keyless replay does not validate provider acceptance. */ import assert from 'node:assert/strict' import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent' @@ -29,7 +29,6 @@ export function apply(ctx: Context): void { assert.deepEqual(projected.tools, options.tools) const request = serialize({ ...options, tools: [...projected.tools] }, connection, projected.messages, new Map(), () => undefined) assert.deepEqual(request.tools, [ - { name: 'DeferredToolPlaceholder', description: 'Reserved placeholder that keeps deferred tool loading active; never call this tool.', input_schema: { type: 'object', properties: {} } }, { name: 'snapshot_pending', description: 'Wait for activation.', input_schema: { type: 'object', properties: {} }, defer_loading: true }, ]) assert.deepEqual(request.messages.filter(message => message.role === 'system'), []) diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 9194650f10..6751ed5b44 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -80,25 +80,15 @@ describe('Messages request conversion', () => { ]) }) - it('keeps an all-deferred list inactive and adds one immediate placeholder', () => { + it('preserves an all-deferred list without synthesizing tools', () => { const tools = [{ name: 'search', description: 'Search', parameters: {}, deferLoading: true as const }] const saved = JSON.stringify(tools) expect(body([user()], { tools }).tools).toEqual([ - { name: 'DeferredToolPlaceholder', description: 'Reserved placeholder that keeps deferred tool loading active; never call this tool.', input_schema: { type: 'object', properties: {} } }, { name: 'search', description: 'Search', input_schema: {}, defer_loading: true }, ]) expect(JSON.stringify(tools)).toBe(saved) }) - it('avoids colliding with real deferred tool names', () => { - const tools = ['DeferredToolPlaceholder', 'DeferredToolPlaceholder_1'].map(name => ({ - name, description: 'Real tool', parameters: {}, deferLoading: true as const, - })) - expect(body([user()], { tools }).tools?.map(tool => [tool.name, tool.defer_loading])).toEqual([ - ['DeferredToolPlaceholder_2', undefined], ['DeferredToolPlaceholder', true], ['DeferredToolPlaceholder_1', true], - ]) - }) - it('preserves absent and empty tool lists', () => { expect(body().tools).toBeUndefined() expect(body([user()], { tools: [] }).tools).toEqual([]) From d721353f86ff36bb48ee5e2a29eeea127cfa8823 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 2 Nov 2026 22:34:01 +0800 Subject: [PATCH 29/32] feat(forge): unify plugin entry for reviews and worktrees (#7721) * feat(forge): unify the worktree plugin entry and add user previews * docs(forge): show session-linked GitHub pull requests in preview * refactor(forge): keep plugin unification out of profile loading * docs(forge): document manual upgrade to the unified plugin entry * fix(forge): localize preview descriptions and address review feedback * test(web): refresh creation recommendation catalog for the forge description --------- Co-authored-by: imccyu <276526105+imccyu@users.noreply.github.com> --- .../feature/2026-10-09-forge-panel.i18n.yaml | 8 +-- .../feature/2026-10-09-forge-panel.md | 6 ++- .../feature/2026-10-09-forge-panel.zh.md | 6 ++- apps/web/tests/additional-locales.e2e.ts | 34 ++++++++++++ .../plugin-config/official.expected.md | 2 +- .../plugin-manager/forge-preview.expected.md | 54 +++++++++++++++++++ .../plugin-manager/live-enabled.expected.md | 2 +- .../plugin-manager/manager.expected.md | 2 +- apps/web/tests/forge-panel.e2e.ts | 2 + apps/web/tests/plugin-config.e2e.ts | 10 ++-- apps/web/tests/plugin-manager.e2e.ts | 51 ++++++++++++++++-- apps/web/tests/worktree-opt-in.e2e.ts | 29 +++++++--- docs/subsystems/forge.i18n.yaml | 4 +- docs/subsystems/forge.md | 2 +- docs/subsystems/forge.zh.md | 2 +- docs/subsystems/worktrees.i18n.yaml | 4 +- docs/subsystems/worktrees.md | 2 +- docs/subsystems/worktrees.zh.md | 2 +- docs/tool-catalog.i18n.yaml | 8 +-- docs/tool-catalog.md | 4 +- docs/tool-catalog.zh.md | 4 +- .../forge-worktree-entry/guide.i18n.yaml | 16 ++++++ .../forge-worktree-entry/guide.md | 29 ++++++++++ .../forge-worktree-entry/guide.zh.md | 29 ++++++++++ .../tests/components.client.spec.tsx | 28 +++++++--- .../forge-bundle/README.i18n.yaml | 20 +++---- packages/experimental/forge-bundle/README.md | 10 ++-- .../experimental/forge-bundle/README.zh.md | 10 ++-- .../forge-bundle/cordis.patch.yml | 4 ++ .../forge-bundle/docs/DETAIL.de.md | 1 + .../forge-bundle/docs/DETAIL.es.md | 1 + .../forge-bundle/docs/DETAIL.fr.md | 1 + .../forge-bundle/docs/DETAIL.ja.md | 1 + .../experimental/forge-bundle/docs/DETAIL.md | 1 + .../forge-bundle/docs/DETAIL.pt.md | 1 + .../forge-bundle/docs/DETAIL.ru.md | 1 + .../forge-bundle/docs/DETAIL.zh.md | 1 + .../forge-bundle/docs/preview.svg | 28 ++++++++++ .../forge-bundle/docs/preview.zh.svg | 28 ++++++++++ .../experimental/forge-bundle/locale/de.json | 3 +- .../experimental/forge-bundle/locale/en.json | 3 +- .../experimental/forge-bundle/locale/es.json | 3 +- .../experimental/forge-bundle/locale/fr.json | 3 +- .../experimental/forge-bundle/locale/ja.json | 3 +- .../experimental/forge-bundle/locale/pt.json | 3 +- .../experimental/forge-bundle/locale/ru.json | 3 +- .../experimental/forge-bundle/locale/zh.json | 3 +- .../experimental/forge-bundle/package.json | 2 + .../tool-worktree/README.i18n.yaml | 8 +-- packages/experimental/tool-worktree/README.md | 4 +- .../experimental/tool-worktree/README.zh.md | 4 +- .../official-bundles/src/plugin-manager.ts | 4 +- pnpm-lock.yaml | 3 ++ scripts/gen-tool-catalog.ts | 2 +- scripts/optional-bundles.spec.ts | 28 +++++----- .../verify-package-readme-model-experience.ts | 2 +- .../creation-recommendations/session.v4.jsonl | 2 +- 57 files changed, 429 insertions(+), 102 deletions(-) create mode 100644 apps/web/tests/expected/plugin-manager/forge-preview.expected.md create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/forge-worktree-entry/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/forge-worktree-entry/guide.md create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/forge-worktree-entry/guide.zh.md create mode 100644 packages/experimental/forge-bundle/docs/DETAIL.de.md create mode 100644 packages/experimental/forge-bundle/docs/DETAIL.es.md create mode 100644 packages/experimental/forge-bundle/docs/DETAIL.fr.md create mode 100644 packages/experimental/forge-bundle/docs/DETAIL.ja.md create mode 100644 packages/experimental/forge-bundle/docs/DETAIL.md create mode 100644 packages/experimental/forge-bundle/docs/DETAIL.pt.md create mode 100644 packages/experimental/forge-bundle/docs/DETAIL.ru.md create mode 100644 packages/experimental/forge-bundle/docs/DETAIL.zh.md create mode 100644 packages/experimental/forge-bundle/docs/preview.svg create mode 100644 packages/experimental/forge-bundle/docs/preview.zh.svg diff --git a/.agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml b/.agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml index 53d2b74f3a..f779b7be73 100644 --- a/.agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml @@ -9,8 +9,8 @@ en: 344bcc7e9825bbc8 zh: c1d0f4b54f5b3786 /agent-note-change-request-panel-and-worktree-sessions/decision: - en: 6a0dc836205863ba - zh: 3d4feb0385953dda + en: 66bde24e7c601eb6 + zh: 5091591f3dc1ac85 /agent-note-change-request-panel-and-worktree-sessions/deferred: en: 32ef8af85f4d2f66 zh: b82b67f94346399e @@ -18,5 +18,5 @@ en: e471e16b6b6450ca zh: 5fbc9242edc8d112 /agent-note-change-request-panel-and-worktree-sessions/consequences: - en: 81790c448f0a7d3b - zh: ec63ccb2ad315ff8 + en: 0b785a2204963c4f + zh: be4bd8db40b801a1 diff --git a/.agents/notes/implemented/feature/2026-10-09-forge-panel.md b/.agents/notes/implemented/feature/2026-10-09-forge-panel.md index 1285ba6cdf..2b852ad824 100644 --- a/.agents/notes/implemented/feature/2026-10-09-forge-panel.md +++ b/.agents/notes/implemented/feature/2026-10-09-forge-panel.md @@ -10,7 +10,9 @@ English | [中文](2026-10-09-forge-panel.zh.md) ## Decision -[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.md) is a two-face package. Its Host half, the `forgePanel` Remote, assembles everything the panel shows for one Session into one streamed state; the forge registry and the binding service keep only their domain operations. Its Client half is a right-sidebar tab, a Session header button that shows or hides it, a New Session option, a creation card above the composer, a toast, and the [Session list icons and hover card section](2026-10-20-forge-session-list.md). Every surface calls a bound item a change request, the forge registry's term, whatever its code host calls it. The [forge bundle](../../../../packages/experimental/forge-bundle/README.md) ships the panel, the forge registry, the GitHub provider, the bindings, and an isolated worktree service as one default-disabled Plugins page entry. +[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.md) is a two-face package. Its Host half, the `forgePanel` Remote, assembles everything the panel shows for one Session into one streamed state; the forge registry and the binding service keep only their domain operations. Its Client half is a right-sidebar tab, a Session header button that shows or hides it, a New Session option, a creation card above the composer, a toast, and the [Session list icons and hover card section](2026-10-20-forge-session-list.md). Every surface calls a bound item a change request, the forge registry's term, whatever its code host calls it. The [forge bundle](../../../../packages/experimental/forge-bundle/README.md) ships the panel, the forge registry, the GitHub provider, the bindings, the worktree model tool, and an isolated worktree service for the panel as one default-disabled Plugins page entry. + +Review and worktree controls serve one code-change workflow. One Plugins entry lets users enable that workflow without having to distinguish the underlying package split. Packages and the two worktree services remain independent so each owns its before-Session or in-conversation creation flow. The Remote inspects the checkout through the Session's subprocess provider under its [resolved permissions and query restrictions](../bug-fix/2026-10-28-forge-git-session-permissions.md), so it reads what the Session's commands see. Inspection reads the top-level, Git, and common Git directories, the branch or the abbreviated HEAD commit, the upstream, the remotes, and the size of the branch's changes against its fork point. It runs when a Session is followed, after each `working-directory/change`, `tool/result`, and `sandbox/mode` event, and on refresh, one at a time with one trailing rerun. @@ -89,4 +91,4 @@ The changes view offers no scope for one commit or for the last turn yet; both w ## Consequences -The panel shows current status only while it is open; checks that finish while nothing is mounted produce no notification. Changes made outside the Session appear after Refresh or the next tool result. Every inspection spawns up to twelve short Git processes in the Session's execution world with one remote, and two more for each further remote it tries for a fork point. A worktree exists only for a Session that requested it before its first step, and a failed or cancelled attempt can leave its branch or checkout behind. A message that enters while the Agent runs a turn or other maintenance gets no hold, so its worktree is created within the first turn and a removed worktree is not checked. While a message waits, the Agent stays idle in maintenance: the composer offers no stop, and callers waiting for the Agent to become idle wait as well. Restoring loses changes that were never committed. A base branch that requires a merge queue cannot merge from the panel. With Git Worktrees also enabled, two independent worktree services exist; the model's tool never sees the panel's service. A local or change request diff over `maxDiffBytes`, or one the host refuses, shows no files. Expanding unchanged lines of a change request reads each file from the code host once. +The panel shows current status only while it is open; checks that finish while nothing is mounted produce no notification. Changes made outside the Session appear after Refresh or the next tool result. Every inspection spawns up to twelve short Git processes in the Session's execution world with one remote, and two more for each further remote it tries for a fork point. A worktree exists only for a Session that requested it before its first step, and a failed or cancelled attempt can leave its branch or checkout behind. A message that enters while the Agent runs a turn or other maintenance gets no hold, so its worktree is created within the first turn and a removed worktree is not checked. While a message waits, the Agent stays idle in maintenance: the composer offers no stop, and callers waiting for the Agent to become idle wait as well. Restoring loses changes that were never committed. A base branch that requires a merge queue cannot merge from the panel. The bundle mounts two independent worktree services; the model's tool never sees the panel's service. A local or change request diff over `maxDiffBytes`, or one the host refuses, shows no files. Expanding unchanged lines of a change request reads each file from the code host once. diff --git a/.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md b/.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md index 432f2b10a8..660bd81d38 100644 --- a/.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md +++ b/.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md @@ -10,7 +10,9 @@ Status: implemented ## Decision -[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.zh.md) 是一个双端包。宿主端的 `forgePanel` Remote 把面板为一个 Session 展示的全部内容组装成一条流式状态;forge 注册表与绑定服务只保留各自的领域操作。客户端包括一个右侧边栏标签页、一个显示或隐藏它的 Session 标题栏按钮、一个新会话选项、一张位于输入框上方的创建卡片、一个 toast,以及[会话列表中的图标与悬浮卡片部分](2026-10-20-forge-session-list.zh.md)。所有界面都把已绑定的条目称为变更请求(forge 注册表的术语),不随代码托管平台的叫法变化。[forge 套件](../../../../packages/experimental/forge-bundle/README.zh.md)把面板、forge 注册表、GitHub 提供方、绑定服务与一个隔离的 worktree 服务作为插件页中默认禁用的一个条目发布。 +[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.zh.md) 是一个双端包。宿主端的 `forgePanel` Remote 把面板为一个 Session 展示的全部内容组装成一条流式状态;forge 注册表与绑定服务只保留各自的领域操作。客户端包括一个右侧边栏标签页、一个显示或隐藏它的 Session 标题栏按钮、一个新会话选项、一张位于输入框上方的创建卡片、一个 toast,以及[会话列表中的图标与悬浮卡片部分](2026-10-20-forge-session-list.zh.md)。所有界面都把已绑定的条目称为变更请求(forge 注册表的术语),不随代码托管平台的叫法变化。[forge 套件](../../../../packages/experimental/forge-bundle/README.zh.md)把面板、forge 注册表、GitHub 提供方、绑定服务、工作树模型工具与面板使用的一个隔离 worktree 服务作为插件页中默认禁用的一个条目发布。 + +评审与工作树控件服务于同一个代码修改流程。统一插件入口让用户可以一次启用这些能力,无需根据底层包的拆分判断应该开启哪张卡片。包与两套工作树服务仍保持独立,分别负责会话开始前和对话中创建工作树的流程。 Remote 通过 Session 的子进程提供方,按其[解析出的权限与查询限制](../bug-fix/2026-10-28-forge-git-session-permissions.zh.md)检查检出,因此它读到的与 Session 命令看到的相同。检查读取顶层目录、Git 目录与公共 Git 目录、分支或缩写的 HEAD 提交、上游与远端,以及分支相对于其分叉点的变更规模。它在 Session 被关注时、每个 `working-directory/change`、`tool/result` 与 `sandbox/mode` 事件之后以及刷新时运行,一次只运行一个,最多再追加一次。 @@ -89,4 +91,4 @@ Session 所在的 worktree 可能在 Session 之外被删除。每次检查都 ## Consequences -面板只在打开时显示当前状态;没有任何界面挂载时检查结束不会有通知。Session 之外的改动要等到刷新或下一个工具结果才会出现。只有一个远端时,每次检查最多在 Session 的执行环境中启动十二个短暂的 Git 进程;为寻找分叉点每多尝试一个远端,就多启动两个。只有在第一步之前提出请求的 Session 才会有 worktree,失败或被取消的尝试可能留下它的分支或检出。Agent 正在运行一轮或其他维护任务时进入的消息不会被暂留,因此它的 worktree 在第一轮之内创建,已删除的 worktree 也不会被检查。消息等待期间,Agent 以空闲状态处于维护任务中:输入框不提供停止,等待 Agent 空闲的调用方也会一并等待。恢复会丢失从未提交的修改。要求合并队列的基准分支无法从面板合并。同时启用 Git Worktrees 时会存在两个互不影响的 worktree 服务;模型的工具看不到面板的服务。本地或变更请求的 diff 超过 `maxDiffBytes`,或被托管平台拒绝时,不显示任何文件。展开变更请求中未变更的行时,每个文件只从托管平台读取一次。 +面板只在打开时显示当前状态;没有任何界面挂载时检查结束不会有通知。Session 之外的改动要等到刷新或下一个工具结果才会出现。只有一个远端时,每次检查最多在 Session 的执行环境中启动十二个短暂的 Git 进程;为寻找分叉点每多尝试一个远端,就多启动两个。只有在第一步之前提出请求的 Session 才会有 worktree,失败或被取消的尝试可能留下它的分支或检出。Agent 正在运行一轮或其他维护任务时进入的消息不会被暂留,因此它的 worktree 在第一轮之内创建,已删除的 worktree 也不会被检查。消息等待期间,Agent 以空闲状态处于维护任务中:输入框不提供停止,等待 Agent 空闲的调用方也会一并等待。恢复会丢失从未提交的修改。要求合并队列的基准分支无法从面板合并。组合包挂载两个互不影响的 worktree 服务;模型的工具看不到面板的服务。本地或变更请求的 diff 超过 `maxDiffBytes`,或被托管平台拒绝时,不显示任何文件。展开变更请求中未变更的行时,每个文件只从托管平台读取一次。 diff --git a/apps/web/tests/additional-locales.e2e.ts b/apps/web/tests/additional-locales.e2e.ts index 007aab3e14..2cd04ce30a 100644 --- a/apps/web/tests/additional-locales.e2e.ts +++ b/apps/web/tests/additional-locales.e2e.ts @@ -59,3 +59,37 @@ it.each(languages)('selects %s from the browser, localizes metadata and persists expect(tripwire.warnings).toEqual([]) expect(tripwire.pageErrors).toEqual([]) }) + +const forgePreviews = [ + ['de', 'de-DE', 'Plugins', 'Eine Unterhaltung mit verknüpftem GitHub-Pull-Request, Prüfungen, Reviews, Codeunterschieden und einem separaten Arbeitsbaum'], + ['es', 'es-MX', 'Complementos', 'Una conversación vinculada a un pull request de GitHub, con comprobaciones, revisiones, diferencias de código y un árbol de trabajo aislado'], + ['fr', 'fr-CA', 'Plugins', 'Une conversation liée à une pull request GitHub, avec vérifications, revues, différences de code et un arbre de travail isolé'], + ['ja', 'ja-JP', 'プラグイン', 'GitHub のプルリクエストに関連付けられた会話、チェックとレビューの状況、コードの差分、独立したワークツリー'], + ['pt', 'pt-BR', 'Plug-ins', 'Uma conversa vinculada a um pull request do GitHub, com verificações, revisões, diferenças de código e uma árvore de trabalho isolada'], + ['ru', 'ru-RU', 'Плагины', 'Беседа, связанная с pull request GitHub: проверки, рецензии, различия в коде и отдельное рабочее дерево'], +] as const + +it.each(forgePreviews)('reads the Forge preview in %s before activation', async (id, tag, plugins, alt) => { + const resources: { scaffold?: WebScaffold; browser?: Browser } = {} + onTestFinished(async () => { + try { await resources.browser?.close() } finally { await resources.scaffold?.close() } + }) + const scaffold = await launchWebScaffold() + resources.scaffold = scaffold + const browser = await chromium.launch() + resources.browser = browser + const page = await browser.newPage({ locale: tag, viewport: { width: 1440, height: 960 } }) + const tripwire = watchConsole(page) + onTestFailed(() => saveFailureShot(page, `forge-preview-${id}`)) + await page.goto(scaffold.authenticatedUrl) + await expect.poll(() => page.locator('html').getAttribute('lang')).toBe(id) + await page.getByRole('button', { name: plugins, exact: true }).click() + const card = page.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-forge-bundle"]') + expect(await card.getByRole('switch').getAttribute('aria-checked')).toBe('false') + await card.getByRole('button').click() + const image = page.locator('[data-detail-document]').getByRole('img', { name: alt, exact: true }) + await image.waitFor() + await image.evaluate(async (node: HTMLImageElement) => { await node.decode() }) + expect(await image.evaluate((node: HTMLImageElement) => [node.naturalWidth, node.naturalHeight])).toEqual([640, 300]) + expect(tripwire.pageErrors).toEqual([]) +}) diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md index 1060826d8a..70fbddd1f1 100644 --- a/apps/web/tests/expected/plugin-config/official.expected.md +++ b/apps/web/tests/expected/plugin-config/official.expected.md @@ -44,7 +44,7 @@ - switch "启用 自动授权审查" - listitem: - button "查看 变更请求与工作树": 变更请求与工作树 - - text: 实验性 跟踪每个会话的变更请求及其检查与评审,新会话也可以在 Git 工作树中开始;GitHub 通过 gh 命令行访问。 + - text: 实验性 在会话侧栏集中跟踪关联的 GitHub PR:查看检查状态、评审意见与代码差异,并把修改建议交给智能体继续处理。也可查看本地改动,用独立工作树隔离任务。GitHub 功能需要本机安装 gh 并登录。 - status - switch "启用 变更请求与工作树" - heading "已安装插件" [level=3] diff --git a/apps/web/tests/expected/plugin-manager/forge-preview.expected.md b/apps/web/tests/expected/plugin-manager/forge-preview.expected.md new file mode 100644 index 0000000000..afcab828fc --- /dev/null +++ b/apps/web/tests/expected/plugin-manager/forge-preview.expected.md @@ -0,0 +1,54 @@ +- button "返回插件": 插件 +- status +- switch "启用 变更请求与工作树" +- heading "变更请求与工作树" [level=3] +- text: v{{version}} 实验性 +- paragraph: + - code: "@deepseek-ai/dsh-experimental-forge-bundle" +- paragraph: 在会话侧栏集中跟踪关联的 GitHub PR:查看检查状态、评审意见与代码差异,并把修改建议交给智能体继续处理。也可查看本地改动,用独立工作树隔离任务。GitHub 功能需要本机安装 gh 并登录。 +- region "详细介绍": + - paragraph: + - img "会话关联 GitHub PR,跟踪检查、评审与代码差异,并用工作树隔离任务改动" +- heading "包含的组件" [level=4] +- text: 共 7 个 · 7 已停用 +- list: + - listitem: + - text: 工作树服务 创建命名的 Git 工作树,并切换当前会话的工作目录。 + - code: worktree + - code: "@deepseek-ai/dsh-experimental-worktree" + - text: 已关闭 + - listitem: + - text: Git 工作树 要求在新分支上工作时,智能体会创建单独的 Git 工作树并在其中继续,你当前的工作树保持原样。 + - code: tool-worktree + - code: "@deepseek-ai/dsh-experimental-tool-worktree" + - text: 已关闭 + - listitem: + - text: 变更请求 通过一个与提供方无关的服务注册代码托管提供方,并轮询变更请求状态。 + - code: forge + - code: "@deepseek-ai/dsh-experimental-forge" + - text: 已关闭 + - listitem: + - text: GitHub 变更请求 通过宿主上的 GitHub CLI 为 forge 注册表读取 GitHub 变更请求。 + - code: forge-github + - code: "@deepseek-ai/dsh-experimental-forge-github" + - text: 已关闭 + - listitem: + - text: 会话变更请求 把变更请求绑定到创建、推送或跟踪它们的会话,并监听其状态。 + - code: forge-session + - code: "@deepseek-ai/dsh-experimental-forge-session" + - text: 已关闭 + - listitem: + - text: 工作树服务 创建命名的 Git 工作树,并切换当前会话的工作目录。 + - code: forge-worktree + - code: "@deepseek-ai/dsh-experimental-worktree" + - text: 已关闭 + - listitem: + - text: 变更请求面板 在侧栏标签页中显示会话的变更请求,并让新会话可以在工作树中开始。 + - code: forge-panel + - code: "@deepseek-ai/dsh-experimental-client-ui-forge" + - text: 已关闭 +- heading "来源信息" [level=4] +- term: 代码来源 +- definition: 内置 +- term: 当前版本 +- definition: {{version}} diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md index 561ee7ae23..5f30f09e75 100644 --- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -44,7 +44,7 @@ - switch "启用 自动授权审查" - listitem: - button "查看 变更请求与工作树": 变更请求与工作树 - - text: 实验性 跟踪每个会话的变更请求及其检查与评审,新会话也可以在 Git 工作树中开始;GitHub 通过 gh 命令行访问。 + - text: 实验性 在会话侧栏集中跟踪关联的 GitHub PR:查看检查状态、评审意见与代码差异,并把修改建议交给智能体继续处理。也可查看本地改动,用独立工作树隔离任务。GitHub 功能需要本机安装 gh 并登录。 - status - switch "启用 变更请求与工作树" - heading "已安装插件" [level=3] diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index 88dd772e51..69c0998762 100644 --- a/apps/web/tests/expected/plugin-manager/manager.expected.md +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -44,7 +44,7 @@ - switch "启用 自动授权审查" - listitem: - button "查看 变更请求与工作树": 变更请求与工作树 - - text: 实验性 跟踪每个会话的变更请求及其检查与评审,新会话也可以在 Git 工作树中开始;GitHub 通过 gh 命令行访问。 + - text: 实验性 在会话侧栏集中跟踪关联的 GitHub PR:查看检查状态、评审意见与代码差异,并把修改建议交给智能体继续处理。也可查看本地改动,用独立工作树隔离任务。GitHub 功能需要本机安装 gh 并登录。 - status - switch "启用 变更请求与工作树" - heading "已安装插件" [level=3] diff --git a/apps/web/tests/forge-panel.e2e.ts b/apps/web/tests/forge-panel.e2e.ts index 76da6c115e..2c5487b1d1 100644 --- a/apps/web/tests/forge-panel.e2e.ts +++ b/apps/web/tests/forge-panel.e2e.ts @@ -447,6 +447,8 @@ it.skipIf(MODE === 'record')('automatically shows a numbered PR when gh JSON omi const { settled } = await sendPrompt(page, scaffold) const sessionId = await settled const events = await readPersistedEvents(scaffold, sessionId) + const request = events.find(event => event.type === 'request/header') + expect(request?.data.header.tools?.filter(tool => tool.name === 'create_worktree')).toHaveLength(1) const toolOutput = events.flatMap(event => event.type === 'tool/result' ? event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []).join('\n') expect(toolOutput).toContain('Add the pull request panel') diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index 8bba474e11..7152ad1ef8 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -9,6 +9,7 @@ import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' +import { HIDDEN_BUNDLES } from '@deepseek-ai/dsh-official-bundles' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { join } from 'node:path' import { @@ -115,17 +116,14 @@ describe('web e2e: plugin configuration pages', () => { // The More list omits the shipped bundles whose own rows do not run (the visibility rule lives in // ui-plugin-manager), so it holds fewer cards than the installed optional-bundle list. const bundles = await scaffold.ctx.pluginManager.listBundles() - const hiddenBundles = new Set([ - '@deepseek-ai/dsh-experimental-terminal-bundle', - '@deepseek-ai/dsh-experimental-liveshare', - ]) expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package]').count()) - .toBe(bundles.filter(bundle => !hiddenBundles.has(bundle.name) + .toBe(bundles.filter(bundle => !HIDDEN_BUNDLES.has(bundle.name) + && bundle.name !== '@deepseek-ai/dsh-experimental-liveshare' && bundle.official && (bundle.listedByDefault !== false || bundle.enabled || bundle.installed)).length) expect(await basic.count()).toBe(0) expect(await extensions.count()).toBe(0) expect(await panel.locator('[data-plugin-group="bundles"]').count()).toBe(0) - await panel.getByRole('button', { name: '查看 Git 工作树', exact: true }).click() + await panel.getByRole('button', { name: '查看 开发者工具', exact: true }).click() await panel.locator('[data-plugin-detail]').waitFor() await panel.getByRole('button', { name: '返回 实验性插件', exact: true }).click() await panel.getByRole('heading', { name: '实验性插件', exact: true, level: 1 }).waitFor() diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 93db3c1179..527a1bcc73 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -10,6 +10,7 @@ import { chromium } from 'playwright' import { FiberState } from '@deepseek-ai/cordis' import { readProfileManifest } from '@deepseek-ai/dsh-app-boot' import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { HIDDEN_BUNDLES } from '@deepseek-ai/dsh-official-bundles' import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished, vi } from 'vitest' import { join } from 'node:path' import { @@ -853,7 +854,7 @@ describe('web e2e: plugin manager', () => { // the eligible installed optional-bundle list. expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package="@deepseek-ai/dsh-experimental-liveshare"]').count()).toBe(0) expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package]').count()) - .toBe(bundles.filter(bundle => bundle.name !== '@deepseek-ai/dsh-experimental-terminal-bundle' + .toBe(bundles.filter(bundle => !HIDDEN_BUNDLES.has(bundle.name) && bundle.name !== '@deepseek-ai/dsh-experimental-liveshare' && bundle.official && (bundle.listedByDefault !== false || bundle.enabled || bundle.installed)).length) expect(bundles.find(bundle => bundle.name === '@deepseek-ai/dsh-cua-bundle')) @@ -876,7 +877,6 @@ describe('web e2e: plugin manager', () => { ['@deepseek-ai/dsh-experimental-forge-bundle', 1], ['@deepseek-ai/dsh-personalized-recommendations', 1], ['@deepseek-ai/dsh-experimental-inspector-profile', 1], - ['@deepseek-ai/dsh-experimental-tool-worktree', 1], ['@deepseek-ai/dsh-experimental-cot-translation-bundle', 1], ['@deepseek-ai/dsh-experimental-session-titles-bundle', 1], ['@deepseek-ai/dsh-experimental-badge-skill-bundle', 1], @@ -1063,6 +1063,51 @@ describe('web e2e: plugin manager', () => { expect(tripwire.pageErrors).toEqual([]) }) + it('introduces code review and worktrees with localized artwork before activation', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-forge-preview')) + const panel = await openPluginsPanel() + await panel.getByRole('button', { name: '查看 变更请求与工作树', exact: true }).click() + const detail = panel.locator('[data-plugin-detail]') + expect(await detail.getByRole('switch').getAttribute('aria-checked')).toBe('false') + const sources: (string | null)[] = [] + let english = false + try { + for (const [language, alt] of [ + ['zh', '会话关联 GitHub PR,跟踪检查、评审与代码差异,并用工作树隔离任务改动'], + ['en', 'A conversation linked to a GitHub pull request, with checks, reviews, code diffs, and an isolated worktree'], + ] as const) { + if (language === 'en') { + await setLanguage(language) + english = true + } + const document = detail.locator('[data-detail-document]') + const image = document.getByRole('img', { name: alt, exact: true }) + await image.waitFor() + await image.evaluate(async (node: HTMLImageElement) => { await node.decode() }) + expect(await image.evaluate((node: HTMLImageElement) => [node.naturalWidth, node.naturalHeight])).toEqual([640, 300]) + sources.push(await image.getAttribute('src')) + for (const width of [1680, 390]) { + await page.setViewportSize({ width, height: 1000 }) + expect(await image.evaluate((node: HTMLImageElement) => { + const bounds = node.getBoundingClientRect() + return bounds.width > 0 && bounds.left >= 0 && bounds.right <= window.innerWidth + && window.document.documentElement.scrollWidth <= window.innerWidth + })).toBe(true) + } + await page.setViewportSize({ width: 1680, height: 1000 }) + } + expect(sources[0]).not.toBe(sources[1]) + } finally { + await page.setViewportSize({ width: 1680, height: 1000 }) + if (english) await setLanguage('zh') + } + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'forge-preview.expected.md'), + await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, { + replacements: [[badgeVersion, '{{version}}']], + }), MODE) + expect(tripwire.pageErrors).toEqual([]) + }) + it('localizes independent exports and falls back per field without activating the plugins', async () => { onTestFinished(closeSettings) onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-exports')) @@ -1531,7 +1576,7 @@ describe('web e2e: plugin manager', () => { await assertFixtureInventory(SNAPSHOT_DIR, [ 'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', 'icons.expected.md', 'loading.expected.md', 'refresh.expected.md', 'ime-enter.expected.md', 'header-responsive.expected.md', 'header-natural-motion.expected.md', 'preview.expected.md', - 'touch-tooltips.expected.md', + 'touch-tooltips.expected.md', 'forge-preview.expected.md', ]) }) }) diff --git a/apps/web/tests/worktree-opt-in.e2e.ts b/apps/web/tests/worktree-opt-in.e2e.ts index 37b94a16ec..341d72c68f 100644 --- a/apps/web/tests/worktree-opt-in.e2e.ts +++ b/apps/web/tests/worktree-opt-in.e2e.ts @@ -1,4 +1,4 @@ -/** Git Worktrees opt-in through the shipped plugin manager, profile, and Loader. */ +/** Unified review and worktree opt-in through the shipped plugin manager, profile, and Loader. */ import { execFile } from 'node:child_process' import { readFile, writeFile } from 'node:fs/promises' @@ -6,16 +6,19 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { FiberState } from '@deepseek-ai/cordis' +import { ToolCallId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-experimental-worktree' +import type {} from '@deepseek-ai/dsh-experimental-client-ui-forge' import { chromium } from 'playwright' import { expect, it, onTestFailed, onTestFinished } from 'vitest' import { launchWebScaffold, watchConsole } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, PLUGIN_TOGGLE_SETTLE_MS, saveFailureShot } from './support.ts' -const BUNDLE = '@deepseek-ai/dsh-experimental-tool-worktree' +const BUNDLE = '@deepseek-ai/dsh-experimental-forge-bundle' +const STANDALONE = '@deepseek-ai/dsh-experimental-tool-worktree' const exec = promisify(execFile) -it('persists the Git Worktrees switch and removes its capability while retaining checkouts', async () => { +it('persists the unified review and worktree switch and retains checkouts after disabling', async () => { const scaffold = await launchWebScaffold({ extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), }) @@ -70,13 +73,15 @@ it('persists the Git Worktrees switch and removes its capability while retaining } let panel = await openPlugins() let card = panel.locator(`[data-plugin-group="more"] [data-plugin-package="${BUNDLE}"]`) - await card.getByRole('button', { name: 'View Git worktrees', exact: true }).waitFor() + await card.getByRole('button', { name: 'View Change requests & worktrees', exact: true }).waitFor() + expect(await panel.locator(`[data-plugin-package="${STANDALONE}"]`).count()).toBe(0) expect(await card.getByText('Experimental', { exact: true }).count()).toBe(1) - let toggle = card.getByRole('switch', { name: 'Enable Git worktrees', exact: true }) + let toggle = card.getByRole('switch', { name: 'Enable Change requests & worktrees', exact: true }) expect(await toggle.getAttribute('aria-checked')).toBe('false') expect(await selectedBundles()).not.toContain(BUNDLE) expect(rows()).toEqual([]) expect(scaffold.ctx.get('worktrees')).toBeUndefined() + expect(scaffold.ctx.get('forgePanel')).toBeUndefined() expect(tools()).toContain('working_directory') expect(tools()).not.toContain('create_worktree') @@ -85,14 +90,21 @@ it('persists the Git Worktrees switch and removes its capability while retaining await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('true') expect(await selectedBundles()).toContain(BUNDLE) expect(tools()).toContain('create_worktree') - const created = await scaffold.ctx.worktrees.create(agent, { name: 'gui-opt-in' }) + expect(scaffold.ctx.get('forgePanel')).toBeDefined() + const created = { path: join(repository, '.agents/worktrees/gui-opt-in'), baseCommit: (await git('rev-parse', 'HEAD')).stdout.trim() } + const result = await scaffold.ctx.tools.execute({ + agent, signal: new AbortController().signal, + calls: [{ type: 'tool-call', id: ToolCallId('gui-opt-in'), name: 'create_worktree', arguments: JSON.stringify({ name: 'gui-opt-in' }) }], + }) + expect(result.results[0]?.result.isError).not.toBe(true) + expect(result.results[0]?.result.value).toEqual({ ...created, branch: 'gui-opt-in', repositoryRoot: repository }) expect(await readFile(join(created.path, 'retained.txt'), 'utf8')).toBe('retained checkout\n') expect(scaffold.ctx.workingDirectory.get(agent.session)).toBe(created.path) await page.reload({ waitUntil: 'load' }) panel = await openPlugins() card = panel.locator(`[data-plugin-group="more"] [data-plugin-package="${BUNDLE}"]`) - toggle = card.getByRole('switch', { name: 'Enable Git worktrees', exact: true }) + toggle = card.getByRole('switch', { name: 'Enable Change requests & worktrees', exact: true }) await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') expect(tools()).toContain('create_worktree') await toggle.click() @@ -100,6 +112,7 @@ it('persists the Git Worktrees switch and removes its capability while retaining await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('false') expect(await selectedBundles()).not.toContain(BUNDLE) expect(scaffold.ctx.get('worktrees')).toBeUndefined() + expect(scaffold.ctx.get('forgePanel')).toBeUndefined() expect(tools()).not.toContain('create_worktree') expect(tools()).toContain('working_directory') expect(await readFile(join(created.path, 'retained.txt'), 'utf8')).toBe('retained checkout\n') @@ -108,7 +121,7 @@ it('persists the Git Worktrees switch and removes its capability while retaining await page.reload({ waitUntil: 'load' }) panel = await openPlugins() - await expect.poll(() => panel.getByRole('switch', { name: 'Enable Git worktrees', exact: true }) + await expect.poll(() => panel.getByRole('switch', { name: 'Enable Change requests & worktrees', exact: true }) .getAttribute('aria-checked')).toBe('false') expect(tools()).not.toContain('create_worktree') expect(await selectedBundles()).not.toContain(BUNDLE) diff --git a/docs/subsystems/forge.i18n.yaml b/docs/subsystems/forge.i18n.yaml index 92bc2298cd..379e181766 100644 --- a/docs/subsystems/forge.i18n.yaml +++ b/docs/subsystems/forge.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/forge.md /forges: - en: 1b51f9d385dafe76 - zh: 4bb73627aa69d652 + en: 3ee521b77b4601e2 + zh: 4170c33e49248f8f /forges/providers-and-change-requests: en: 4ec7d1892b4067db zh: 6bb70d21ae3d7a89 diff --git a/docs/subsystems/forge.md b/docs/subsystems/forge.md index 2a3cdf2087..f56d88b848 100644 --- a/docs/subsystems/forge.md +++ b/docs/subsystems/forge.md @@ -2,7 +2,7 @@ English | [中文](forge.zh.md) -The experimental forge capability seam reads change requests, meaning pull requests and merge requests, from code hosts. [`dsh-experimental-forge`](../../packages/experimental/forge/README.md) is the Service Definition and provides `ctx.forge`; [`dsh-experimental-forge-github`](../../packages/experimental/forge-github/README.md) is the Service Provider for GitHub hosts; [`dsh-experimental-forge-session`](../../packages/experimental/forge-session/README.md) is the Consumer that binds change requests to Sessions and provides `ctx.forgeSession`; [`dsh-experimental-client-ui-forge`](../../packages/experimental/client-ui-forge/README.md) is the GUI Consumer that provides `ctx.forgePanel`. The default-disabled [forge bundle](../../packages/experimental/forge-bundle/README.md) composes all four, with an isolated [worktree service](../../packages/experimental/worktree/README.md) for the panel, from the Plugins page; otherwise they load only through explicit composition. +The experimental forge capability seam reads change requests, meaning pull requests and merge requests, from code hosts. [`dsh-experimental-forge`](../../packages/experimental/forge/README.md) is the Service Definition and provides `ctx.forge`; [`dsh-experimental-forge-github`](../../packages/experimental/forge-github/README.md) is the Service Provider for GitHub hosts; [`dsh-experimental-forge-session`](../../packages/experimental/forge-session/README.md) is the Consumer that binds change requests to Sessions and provides `ctx.forgeSession`; [`dsh-experimental-client-ui-forge`](../../packages/experimental/client-ui-forge/README.md) is the GUI Consumer that provides `ctx.forgePanel`. The default-disabled [forge bundle](../../packages/experimental/forge-bundle/README.md) composes all four, with the [worktree model tool](../../packages/experimental/tool-worktree/README.md) and an isolated [worktree service](../../packages/experimental/worktree/README.md) for the panel, from the Plugins page; otherwise they load only through explicit composition. ## Providers and change requests diff --git a/docs/subsystems/forge.zh.md b/docs/subsystems/forge.zh.md index 5c78d7bb02..bbcf8ace8f 100644 --- a/docs/subsystems/forge.zh.md +++ b/docs/subsystems/forge.zh.md @@ -2,7 +2,7 @@ [English](forge.md) | 中文 -实验性的 forge 能力 seam 从代码托管平台读取变更请求(change request,即 pull request 与 merge request)。[`dsh-experimental-forge`](../../packages/experimental/forge/README.zh.md) 是 Service Definition,提供 `ctx.forge`;[`dsh-experimental-forge-github`](../../packages/experimental/forge-github/README.zh.md) 是服务 GitHub 主机的 Service Provider;[`dsh-experimental-forge-session`](../../packages/experimental/forge-session/README.zh.md) 是把变更请求绑定到 Session 的 Consumer,提供 `ctx.forgeSession`;[`dsh-experimental-client-ui-forge`](../../packages/experimental/client-ui-forge/README.zh.md) 是提供 `ctx.forgePanel` 的 GUI Consumer。默认禁用的 [forge Bundle](../../packages/experimental/forge-bundle/README.zh.md) 可从插件页面组合这四个包,并为面板附带一个隔离的 [worktree 服务](../../packages/experimental/worktree/README.zh.md);除此之外,它们只能通过显式组合加载。 +实验性的 forge 能力 seam 从代码托管平台读取变更请求(change request,即 pull request 与 merge request)。[`dsh-experimental-forge`](../../packages/experimental/forge/README.zh.md) 是 Service Definition,提供 `ctx.forge`;[`dsh-experimental-forge-github`](../../packages/experimental/forge-github/README.zh.md) 是服务 GitHub 主机的 Service Provider;[`dsh-experimental-forge-session`](../../packages/experimental/forge-session/README.zh.md) 是把变更请求绑定到 Session 的 Consumer,提供 `ctx.forgeSession`;[`dsh-experimental-client-ui-forge`](../../packages/experimental/client-ui-forge/README.zh.md) 是提供 `ctx.forgePanel` 的 GUI Consumer。默认禁用的 [forge Bundle](../../packages/experimental/forge-bundle/README.zh.md) 可从插件页面组合这四个包,同时加载[工作树模型工具](../../packages/experimental/tool-worktree/README.zh.md),并为面板附带一个隔离的 [worktree 服务](../../packages/experimental/worktree/README.zh.md);除此之外,它们只能通过显式组合加载。 ## 提供方与变更请求 diff --git a/docs/subsystems/worktrees.i18n.yaml b/docs/subsystems/worktrees.i18n.yaml index f771dd3cf2..23dde9244e 100644 --- a/docs/subsystems/worktrees.i18n.yaml +++ b/docs/subsystems/worktrees.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/worktrees.md /git-worktrees: - en: 21ec4a3b825d7e81 - zh: c841c5ca4b4e8959 + en: 3a3d69d2b12a78f9 + zh: a7a7d12487968bbc /git-worktrees/creation-and-retention: en: 53f643c5d30e00b2 zh: ba9e52efc62c7656 diff --git a/docs/subsystems/worktrees.md b/docs/subsystems/worktrees.md index 8d4fec853a..aefa3e4a3c 100644 --- a/docs/subsystems/worktrees.md +++ b/docs/subsystems/worktrees.md @@ -2,7 +2,7 @@ English | [中文](worktrees.zh.md) -The experimental [`dsh-experimental-worktree`](../../packages/experimental/worktree/README.md) service provides `ctx.worktrees`. Its [`create_worktree`](../../packages/experimental/tool-worktree/README.md) consumer creates a new Git branch and checkout, then selects it through the [working-directory service](working-directory.md). The [Git Worktrees optional bundle](../../packages/experimental/tool-worktree/README.md#use-this-package) loads both packages through the GUI plugin manager. It ships switched off; explicit compositions can also mount the two packages. +The experimental [`dsh-experimental-worktree`](../../packages/experimental/worktree/README.md) service provides `ctx.worktrees`. Its [`create_worktree`](../../packages/experimental/tool-worktree/README.md) consumer creates a new Git branch and checkout, then selects it through the [working-directory service](working-directory.md). The [Change requests & worktrees bundle](../../packages/experimental/forge-bundle/README.md#use-this-package) loads both packages through the GUI plugin manager and ships switched off. Other profiles can select the [standalone worktree bundle](../../packages/experimental/tool-worktree/README.md#use-this-package) or mount the two packages explicitly. ## Creation and retention diff --git a/docs/subsystems/worktrees.zh.md b/docs/subsystems/worktrees.zh.md index e6a7c71ecc..0f7c7b1b02 100644 --- a/docs/subsystems/worktrees.zh.md +++ b/docs/subsystems/worktrees.zh.md @@ -2,7 +2,7 @@ [English](worktrees.md) | 中文 -实验性的 [`dsh-experimental-worktree`](../../packages/experimental/worktree/README.zh.md) 服务提供 `ctx.worktrees`。其消费方 [`create_worktree`](../../packages/experimental/tool-worktree/README.zh.md) 创建新的 Git 分支与检出目录,再通过[工作目录服务](working-directory.zh.md)选中它。[Git 工作树可选 bundle](../../packages/experimental/tool-worktree/README.zh.md#use-this-package)通过 GUI 插件管理器一起加载这两个包。它随安装提供,但默认关闭;显式组合也可以挂载这两个包。 +实验性的 [`dsh-experimental-worktree`](../../packages/experimental/worktree/README.zh.md) 服务提供 `ctx.worktrees`。其消费方 [`create_worktree`](../../packages/experimental/tool-worktree/README.zh.md) 创建新的 Git 分支与检出目录,再通过[工作目录服务](working-directory.zh.md)选中它。[变更请求与工作树组合包](../../packages/experimental/forge-bundle/README.zh.md#use-this-package)通过 GUI 插件管理器一起加载这两个包,随安装提供但默认关闭。其他 profile 可以选择[独立工作树组合包](../../packages/experimental/tool-worktree/README.zh.md#use-this-package),或显式挂载这两个包。 ## 创建与保留 diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 68f3651eaf..22deacea2a 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -9,8 +9,8 @@ en: 6cc2cd9ced70bb09 zh: 1c0e6cb4ba2ed637 /tool-schema-catalog/tool-package-map: - en: 4aa572ac1710a97a - zh: e11431504a2a7398 + en: f790063d536da1bf + zh: 6933b54fc70cd470 /tool-schema-catalog/deepseek-ai-dsh-plugin-manager: en: e0143916bb634762 zh: e0143916bb634762 @@ -321,8 +321,8 @@ en: d5f5679abe86e316 zh: d5f5679abe86e316 /tool-schema-catalog/deepseek-ai-dsh-experimental-tool-worktree/create-worktree: - en: d8fe3c566ba2147c - zh: baf372cb9242670c + en: 41950f76db71e7f7 + zh: 654d2e5505ff3d3c /tool-schema-catalog/deepseek-ai-dsh-tool-workflow: en: bb38a9d29f46563c zh: bb38a9d29f46563c diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 1d7900cf6b..9da0029e4f 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -46,7 +46,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`, `list_agents`, `send_message`, `spawn_teammate`, `team_task_create`, `team_task_get`, `team_task_list`, `team_task_update`, `wait_agent` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agentTeams`, `an exact live Team member Agent` | `tool/call`, `team/member`, `team/task`, `agent/inbox/spliced`, `user/message on inbox claim`, `tool/result` | - | All nine tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. `allowParallelInProgress` is required with no default, so the catalog states its choice: `true`, whose description invites several `in_progress` items. A deployment choosing `false` receives the same tool with a description asking for exactly one active task. | | `@deepseek-ai/dsh-tool-working-directory` | `working_directory` | `ctx.tools`, `ctx.workingDirectory` | `tool/call`, `working-directory/change`, `user context for directory changes`, `tool/result` | - | Reads or changes the calling Session directory. Existing shells and processes retain their own directories; origin metadata and permission roots stay fixed. | -| `@deepseek-ai/dsh-experimental-tool-worktree` | `create_worktree` | `ctx.tools`, `ctx.worktrees`, `a calling Agent` | `tool/call`, `Git branch and checkout`, `working-directory/change`, `tool/result` | - | Explicit experimental composition only. Creates a new branch and checkout from a pinned local commit under existing write permissions, then changes the Session directory. Leaving retains the branch and checkout. | +| `@deepseek-ai/dsh-experimental-tool-worktree` | `create_worktree` | `ctx.tools`, `ctx.worktrees`, `a calling Agent` | `tool/call`, `Git branch and checkout`, `working-directory/change`, `tool/result` | - | Enabled through the Change requests & worktrees bundle on Web and Desktop; other profiles can select the standalone bundle. Creates a new branch and checkout from a pinned local commit under existing write permissions, then changes the Session directory. Leaving retains the branch and checkout. | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflowEngine`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-workspace-dependencies` | `load_workspace_dependencies` | `ctx.tools` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-web` | `web_fetch`, `web_search` | `ctx.tools`, `ctx.web`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps. | @@ -2629,7 +2629,7 @@ Create a new Git branch and worktree from a local commit, branch, or tag, then c Source: [`packages/experimental/tool-worktree/src/index.ts`](../packages/experimental/tool-worktree/src/index.ts) -Explicit experimental composition only. Creates a new branch and checkout from a pinned local commit under existing write permissions, then changes the Session directory. Leaving retains the branch and checkout. +Enabled through the Change requests & worktrees bundle on Web and Desktop; other profiles can select the standalone bundle. Creates a new branch and checkout from a pinned local commit under existing write permissions, then changes the Session directory. Leaving retains the branch and checkout. diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index cc5c16d779..19e6f8beb5 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -50,7 +50,7 @@ | `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`、`list_agents`、`send_message`、`spawn_teammate`、`team_task_create`、`team_task_get`、`team_task_list`、`team_task_update`、`wait_agent` | `ctx.tools`、`ctx.systemPrompt`、`ctx.agentTeams`、`an exact live Team member Agent` | `tool/call`、`team/member`、`team/task`、`agent/inbox/spliced`、`user/message on inbox claim`、`tool/result` | - | 这 9 个工具限定于隐式 Team Lead 与持久 teammate 作用域。随产品发布的 dsh-base bundle 默认禁用该包;文档中的 Agent Teams profile patch 会启用它,并禁用旧 continuable child 的同名控制工具。 | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`、`owning Agent session` | `tool/call`、`todo/write`、`tool/result` | - | todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为检查清单。`allowParallelInProgress` 是没有默认值的必填项,因此本目录明确选择 `true`,对应描述允许同时存在多个 `in_progress` 项。选择 `false` 的部署会获得同一工具,但描述会要求只能有 1 个活动任务。 | | `@deepseek-ai/dsh-tool-working-directory` | `working_directory` | `ctx.tools`, `ctx.workingDirectory` | `tool/call`, `working-directory/change`, `user context for directory changes`, `tool/result` | - | 读取或改变调用方 Session 的目录。已有 shell 和进程保留自己的目录;原始元数据和权限根目录保持不变。 | -| `@deepseek-ai/dsh-experimental-tool-worktree` | `create_worktree` | `ctx.tools`, `ctx.worktrees`, `a calling Agent` | `tool/call`, `Git branch and checkout`, `working-directory/change`, `tool/result` | - | 仅通过显式实验组合加载。在现有写权限下从固定的本地提交创建新分支与检出目录,再改变 Session 工作目录。离开时保留分支与检出目录。 | +| `@deepseek-ai/dsh-experimental-tool-worktree` | `create_worktree` | `ctx.tools`, `ctx.worktrees`, `a calling Agent` | `tool/call`, `Git branch and checkout`, `working-directory/change`, `tool/result` | - | 在 Web 和 Desktop 上通过变更请求与工作树组合包启用;其他 profile 可以选择独立组合包。在现有写权限下从固定的本地提交创建新分支与检出目录,再改变 Session 工作目录。离开时保留分支与检出目录。 | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`、`ctx.workflowEngine`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents the script children)` | `tool/call`、`tool/result` | - | - | | `@deepseek-ai/dsh-tool-workspace-dependencies` | `load_workspace_dependencies` | `ctx.tools` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-web` | `web_fetch`、`web_search` | `ctx.tools`、`ctx.web`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | web_search 和 web_fetch 将提供方选择置于 ctx.web 之后,使模型可见 schema 在更换后端时保持稳定。 | @@ -2636,7 +2636,7 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为 来源:[`packages/experimental/tool-worktree/src/index.ts`](../packages/experimental/tool-worktree/src/index.ts) -仅通过显式实验组合加载。在现有写权限下从固定的本地提交创建新分支与检出目录,再改变 Session 工作目录。离开时保留分支与检出目录。 +在 Web 和 Desktop 上通过变更请求与工作树组合包启用;其他 profile 可以选择独立组合包。在现有写权限下从固定的本地提交创建新分支与检出目录,再改变 Session 工作目录。离开时保留分支与检出目录。 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/forge-worktree-entry/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/forge-worktree-entry/guide.i18n.yaml new file mode 100644 index 0000000000..a5bd96e947 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/forge-worktree-entry/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/forge-worktree-entry/guide.md +/: + en: e25e3adac59e3584 + zh: 1a16ac4c16ab3a0d +/one-plugin-entry-for-change-requests-and-worktrees: + en: 2eb2f0805d09f0c4 + zh: a7b02646515b39df +/one-plugin-entry-for-change-requests-and-worktrees/change: + en: b814d167dea20941 + zh: a8e3c416acd4fb65 +/one-plugin-entry-for-change-requests-and-worktrees/migration: + en: 08b0e3bae9e9697e + zh: 62f66d48328f1037 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/forge-worktree-entry/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/forge-worktree-entry/guide.md new file mode 100644 index 0000000000..3b8bf683bd --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/forge-worktree-entry/guide.md @@ -0,0 +1,29 @@ +--- +kind: upgrade-guide +description: "The Plugins page replaces the standalone Git worktrees card with Change requests & worktrees." +--- + +# One plugin entry for change requests and worktrees + +English | [中文](guide.zh.md) + +## Change + +Web and Desktop use **Change requests & worktrees** to enable the review panel, the new-Session worktree option, and the model's `create_worktree` tool together. The separate **Git worktrees** card is hidden. Profiles that already enabled the Forge bundle also gain the model tool. + +Existing profiles are not migrated automatically. A selected `@deepseek-ai/dsh-experimental-tool-worktree` bundle stays active even though its card is hidden, so turning off Forge does not disable that standalone tool. Selecting both it and `@deepseek-ai/dsh-experimental-forge-bundle` inserts duplicate `worktree` and `tool-worktree` ids, making those component controls read-only. Remove the standalone selection before using the unified switch. + +## Migration + +1. Stop Web or quit Desktop. Back up `$DSH_HOME/profiles//package.json` and its sibling `cordis.patch.yml`. Use `~/.dsh` when `DSH_HOME` is unset; the standard profile names are `web` and `desktop`. For a custom profile, use its name. +2. Edit `dsh.profile.bundles` in that `package.json`. Remove every `@deepseek-ai/dsh-experimental-tool-worktree` entry. To keep worktree support through the unified switch, add `@deepseek-ai/dsh-experimental-forge-bundle` once; if it is already selected, keep that entry. To disable both features, remove both bundle names. Preserve all other entries and fields. +3. Keep existing `cordis.patch.yml` overrides: the `worktree`, `tool-worktree`, and `optional-forge-panel` ids are unchanged. To use the Forge panel without exposing the model tool, add or update this entry in the existing patch list: + + ```yaml + - id: tool-worktree + disabled: true + ``` + +4. Restart the application. In Plugins, confirm that **Change requests & worktrees** reflects your selection. With no disabling row override, enabling it provides the panel and `create_worktree`; turning it off removes both. Confirm that the profile lists no standalone worktree bundle. Existing worktrees, branches, and Session records are retained. + +Headless profiles and custom compositions that need only the standalone tool can keep it; do not also select the Forge bundle. In Web or Desktop, that standalone selection remains hidden and must be managed through the profile file. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/forge-worktree-entry/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/forge-worktree-entry/guide.zh.md new file mode 100644 index 0000000000..118f00bd22 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/forge-worktree-entry/guide.zh.md @@ -0,0 +1,29 @@ +--- +kind: upgrade-guide +description: "插件页将独立的 Git 工作树卡片合入变更请求与工作树。" +--- + +# 变更请求与工作树使用同一个插件入口 + +[English](guide.md) | 中文 + +## 变更 + +Web 和 Desktop 通过**变更请求与工作树**同时启用评审面板、新会话的工作树选项和模型的 `create_worktree` 工具。独立的 **Git 工作树**卡片被隐藏。已经启用 Forge 组合包的 profile 也会新增模型工具。 + +已有 profile 不会自动迁移。选中的 `@deepseek-ai/dsh-experimental-tool-worktree` 组合包仍会运行,即使其卡片已隐藏,因此关闭 Forge 不会停用这个独立工具。同时选择它和 `@deepseek-ai/dsh-experimental-forge-bundle` 会插入重复的 `worktree` 与 `tool-worktree` id,使对应组件控件变为只读。使用统一开关前,请移除独立组合包的选择。 + +## 迁移 + +1. 停止 Web 或退出 Desktop。备份 `$DSH_HOME/profiles//package.json` 及同目录下的 `cordis.patch.yml`。未设置 `DSH_HOME` 时使用 `~/.dsh`;标准 profile 名称为 `web` 和 `desktop`。自定义 profile 使用其自身名称。 +2. 编辑该 `package.json` 的 `dsh.profile.bundles`。移除所有 `@deepseek-ai/dsh-experimental-tool-worktree` 条目。若要通过统一开关保留工作树功能,添加一次 `@deepseek-ai/dsh-experimental-forge-bundle`;若已选中它,则保留该条目。若要关闭两项功能,则移除这两个组合包名称。保留其他所有条目和字段。 +3. 保留现有的 `cordis.patch.yml` 覆盖配置:`worktree`、`tool-worktree` 和 `optional-forge-panel` 的 id 均未改变。若只使用 Forge 面板,不向模型提供工作树工具,在现有 patch 列表中添加或更新以下条目: + + ```yaml + - id: tool-worktree + disabled: true + ``` + +4. 重启应用。在插件页确认**变更请求与工作树**的状态符合你的选择。没有禁用插件行的覆盖配置时,启用它会提供面板与 `create_worktree`,关闭后两者均移除。确认 profile 不再列出独立的工作树组合包。已有工作树、分支和 Session 记录均保留。 + +仅需要独立工具的 Headless profile 和自定义组合可以继续保留它,但不要同时选择 Forge 组合包。在 Web 或 Desktop 中,该独立选择仍然隐藏,需要通过 profile 文件管理。 diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index adacb15d71..29dd9ffd63 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -300,30 +300,30 @@ describe('PluginManagerPage', () => { }) it('keeps the Installed tab panel through loading and the More list out of the tabs, and returns a More plugin opened from Discover to Discover', () => { - const worktree = '@deepseek-ai/dsh-experimental-tool-worktree' + const inspector = '@deepseek-ai/dsh-experimental-inspector-profile' const { navigation, set } = renderTab({ status: 'loading', packages: [] }, {}, { 'plugins.tab:discover': (_view, owner) => ( - + ), }, {}, [{ id: 'discover', label: 'Discover' }]) const installed = screen.getByRole('tab', { name: en.installedTab }) expect(document.getElementById(installed.getAttribute('aria-controls') as string)?.getAttribute('role')).toBe('tabpanel') set({ status: 'ready', packages: [] }) expect(screen.getByRole('tabpanel', { name: en.installedTab }).textContent).toBe(en.empty) - set({ packages: [pkg({ name: worktree, official: true, optional: true, installed: false, enabled: false })] }) + set({ packages: [pkg({ name: inspector, official: true, optional: true, installed: false, enabled: false })] }) act(() => { navigation.actions.setView({ kind: 'more' }) }) expect(screen.queryByRole('tablist')).toBeNull() expect(screen.queryByRole('tabpanel')).toBeNull() act(() => { navigation.actions.setView({ kind: 'list' }) }) fireEvent.click(screen.getByRole('tab', { name: 'Discover' })) fireEvent.click(screen.getByRole('button', { name: 'Discover open' })) - expect(document.querySelector(`[data-plugin-detail="${worktree}"]`)).not.toBeNull() + expect(document.querySelector(`[data-plugin-detail="${inspector}"]`)).not.toBeNull() // An official plugin of the More list opened from Discover returns to Discover, not to the More list. fireEvent.click(screen.getByRole('button', { name: en.backToList })) expect(navigation.getSnapshot()).toEqual({ tab: 'discover', view: { kind: 'list' } }) fireEvent.click(screen.getByRole('tab', { name: en.installedTab })) act(() => { navigation.actions.setView({ kind: 'more' }) }) - fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', worktree) })) + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', inspector) })) fireEvent.click(screen.getByRole('button', { name: en.backToPackage.replace('{name}', en.moreTitle) })) expect(screen.getByRole('heading', { name: en.moreTitle, level: 1 })).toBeTruthy() }) @@ -1237,7 +1237,7 @@ describe('PluginManagerPage', () => { .toEqual(['more']) const more = document.querySelector('[data-plugin-group="more"]') as HTMLElement expect(within(more).getAllByRole('listitem').map(card => card.getAttribute('data-plugin-package'))) - .toEqual([team, '@acme/new-official', '@deepseek-ai/dsh-experimental-tool-worktree', '@deepseek-ai/dsh-experimental-badge-skill-bundle', '@deepseek-ai/dsh-subagent-codex', '@deepseek-ai/dsh-subagent-claude-code']) + .toEqual([team, '@acme/new-official', '@deepseek-ai/dsh-experimental-badge-skill-bundle', '@deepseek-ai/dsh-subagent-codex', '@deepseek-ai/dsh-subagent-claude-code']) expect(document.querySelector('[data-plugin-package="dsh-better-sidebar"]')).toBeNull() fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', team) })) fireEvent.click(screen.getByRole('button', { name: en.backToPackage.replace('{name}', en.moreTitle) })) @@ -1255,6 +1255,20 @@ describe('PluginManagerPage', () => { expect(screen.getByRole('heading', { name: en.basicTitle })).toBeTruthy() }) + it.each([false, true])('shows one forge card and hides the standalone worktree card when enabled is %s', (enabled) => { + const forge = '@deepseek-ai/dsh-experimental-forge-bundle' + const worktree = '@deepseek-ai/dsh-experimental-tool-worktree' + renderTab({ packages: [ + pkg({ name: forge, official: true, enabled }), + pkg({ name: worktree, official: true, enabled }), + ] }) + expect(document.querySelectorAll(`[data-plugin-package="${forge}"]`)).toHaveLength(1) + expect(document.querySelector(`[data-plugin-package="${worktree}"]`)).toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.moreAction })) + expect(document.querySelectorAll(`[data-plugin-package="${forge}"]`)).toHaveLength(1) + expect(document.querySelector(`[data-plugin-package="${worktree}"]`)).toBeNull() + }) + it.each([ '@deepseek-ai/dsh-cua-bundle', '@deepseek-ai/dsh-experimental-session-search', @@ -1361,7 +1375,7 @@ describe('PluginManagerPage', () => { }) it.each([ - '@deepseek-ai/dsh-experimental-tool-worktree', + '@deepseek-ai/dsh-experimental-inspector-profile', '@deepseek-ai/dsh-personalized-recommendations', ])('pins enabled %s onto the main list and removes it when disabled', (first) => { const team = '@deepseek-ai/dsh-experimental-agent-team-profile' diff --git a/packages/experimental/forge-bundle/README.i18n.yaml b/packages/experimental/forge-bundle/README.i18n.yaml index 159422daed..9894b7f03c 100644 --- a/packages/experimental/forge-bundle/README.i18n.yaml +++ b/packages/experimental/forge-bundle/README.i18n.yaml @@ -9,26 +9,26 @@ en: 567570af44a77fa2 zh: 3d7722fe3ade65d6 /deepseek-ai-dsh-experimental-forge-bundle/summary: - en: f6ce7db50e10af0b - zh: 415f509876e88d8a + en: 8d8189bb9a91f049 + zh: 27f02215d6919b3d /deepseek-ai-dsh-experimental-forge-bundle/table-of-contents: en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-forge-bundle/use-this-package: - en: 227f8ec88513be9e - zh: be16cbbb4f3912e2 + en: 33679bed06be4e74 + zh: 77dcfac9570d1fbc /deepseek-ai-dsh-experimental-forge-bundle/understand-the-implementation: - en: 6effcbf40264388b - zh: b4f093955671c650 + en: 5cb4136ef2058890 + zh: 5142828996eef6c7 /deepseek-ai-dsh-experimental-forge-bundle/further-exploration: en: ab1f524b8ad79c55 zh: 137cfcb6d192c269 /deepseek-ai-dsh-experimental-forge-bundle/model-experience: - en: 0e8d555c9ba51844 - zh: 1aeda456da13a9bd + en: 48e871ea5f1238c8 + zh: 6d40dddf8de9d610 /deepseek-ai-dsh-experimental-forge-bundle/model-experience/kv-cache-effect: - en: 06c8b68589e27fc7 - zh: 092fd7649b34bc04 + en: 0192ab064b8000a7 + zh: 1a1de04f87631b9a /deepseek-ai-dsh-experimental-forge-bundle/known-limitations-and-deferred-work: en: ab489084b24ecb16 zh: 6be1686e9741dffa diff --git a/packages/experimental/forge-bundle/README.md b/packages/experimental/forge-bundle/README.md index 57341e2660..236c5a3a6e 100644 --- a/packages/experimental/forge-bundle/README.md +++ b/packages/experimental/forge-bundle/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Show the change requests of each Session with their checks and reviews, and let a new Session start in its own Git worktree. Enable Change requests & worktrees on the Web or Desktop Plugins page; shipped profiles leave it disabled. GitHub status comes from the `gh` CLI signed in on the Host; without it, the panel still shows the branch and the worktree option still works. +Review each Session's change requests, checks, and diffs, start a new Session in a Git worktree, or ask the model to create one during a conversation. Enable Change requests & worktrees on the Web or Desktop Plugins page; shipped profiles leave it disabled. GitHub status comes from the Host's signed-in `gh` CLI; local worktree creation does not require GitHub authentication. ## Table of Contents @@ -27,6 +27,8 @@ Show the change requests of each Session with their checks and reviews, and let Open Plugins and enable Change requests & worktrees in the Official group. In a Git workspace, check Worktree in the composer before the first message to start the Session in a new worktree. In a Session whose directory is inside a Git checkout, the header button opens the panel. GitHub pull requests that the Session creates with `gh pr create`, opens with `gh pr view`, `gh pr edit`, or `gh pr checkout`, or works on as its branch or updates with a later `git push`, appear as change requests without further steps. Run `gh auth login --hostname ` in a terminal to sign in; the panel shows the command when sign-in is missing. The [panel package](../client-ui-forge/README.md#use-this-package) describes each control. +The same switch enables the model's [`create_worktree`](../tool-worktree/README.md) tool. Turning it off removes both the panel and the tool while retaining existing checkouts and branches. For profiles that already selected the standalone worktree bundle, follow the [upgrade guide](../../../docs/upgrade-guide/v0.2.2-alpha.2/forge-worktree-entry/guide.md) before enabling this bundle. + ----- @@ -35,7 +37,7 @@ Open Plugins and enable Change requests & worktrees in the Official group. In a
Maintainer details — click to expand -The bundle patch inserts the forge registry, the GitHub provider, and the Session bindings as Host rows, and a group that isolates the `worktrees` service, holding a worktree service row and the panel's two-face package, whose Client half the Web and Desktop clients load while the row is enabled. The isolation lets the bundle coexist with the Git Worktrees bundle, whose model tool keeps its own service. The rows need the Session projection registry, working-directory, filesystem, subprocess, and sandbox services that every base-backed profile composes. +The bundle mounts the forge registry, GitHub provider, Session bindings, and the worktree model tool. The tool uses the top-level `worktree` service; the panel keeps its isolated `forge-worktree` service inside `optional-forge-panel`. Their configuration ids and creation flows stay independent. The panel records worktrees requested before a Session starts; model-created worktrees do not acquire the panel's recovery records. The rows use the Session projection, working-directory, filesystem, subprocess, and sandbox services from base-backed profiles.
@@ -51,11 +53,11 @@ The bundle patch inserts the forge registry, the GitHub provider, and the Sessio ## Model Experience -None, as the composed packages add no model tool or prompt; the directory notice of a Session that starts in a worktree reaches the model through the working-directory service, as described by the [panel](../client-ui-forge/README.md#model-experience). +Indirectly, through the [`create_worktree`](../tool-worktree/README.md#model-experience) tool and the [panel’s directory notice](../client-ui-forge/README.md#model-experience) for Sessions that start in a worktree. #### KV Cache effect -None from the bundle; see the [panel](../client-ui-forge/README.md#model-experience) for the worktree notice. +Enabling or disabling the bundle changes the model's tool schema list. Calls append tool results and directory context; see the [tool's cache behavior](../tool-worktree/README.md#model-experience). ## Known Limitations and Deferred Work diff --git a/packages/experimental/forge-bundle/README.zh.md b/packages/experimental/forge-bundle/README.zh.md index d65111cc34..955dc43598 100644 --- a/packages/experimental/forge-bundle/README.zh.md +++ b/packages/experimental/forge-bundle/README.zh.md @@ -9,7 +9,7 @@ kind: "package-bundle" ## 概述 -展示每个 Session 的变更请求及其检查与评审状态,并让新 Session 在自己的 Git worktree 中开始。在 Web 或 Desktop 的插件页面启用“变更请求与工作树”;发布的 profile 默认禁用该功能。GitHub 状态来自宿主上已登录的 `gh` 命令行;没有它时,面板仍会显示分支,工作树选项也照常可用。 +查看每个 Session 的变更请求、检查与代码差异,在 Git 工作树中开始新 Session,或在对话中让模型创建工作树。在 Web 或 Desktop 的插件页面启用“变更请求与工作树”;发布的 profile 默认禁用该功能。GitHub 状态来自宿主上已登录的 `gh` 命令行;创建本地工作树无需 GitHub 认证。 ## 目录 @@ -27,6 +27,8 @@ kind: "package-bundle" 打开插件页面,在官方分组中启用“变更请求与工作树”。在 Git 工作区中,发出第一条消息之前在输入框中勾选“工作树”,Session 就会在新的 worktree 中开始。Session 的目录位于 Git 检出中时,标题栏按钮会打开面板。Session 用 `gh pr create` 创建、用 `gh pr view`、`gh pr edit` 或 `gh pr checkout` 打开的 GitHub pull request,以及 Session 所在分支上或之后 `git push` 更新的 pull request,无需额外操作即会作为变更请求出现。在终端运行 `gh auth login --hostname ` 登录;缺少登录时,面板会显示这条命令。[面板包](../client-ui-forge/README.zh.md#use-this-package)说明了每个控件。 +同一个开关也启用模型的 [`create_worktree`](../tool-worktree/README.zh.md) 工具。关闭后面板和工具一起移除,已有检出目录与分支仍然保留。已选中独立工作树组合包的 profile,请在启用本组合包前按[升级指南](../../../docs/upgrade-guide/v0.2.2-alpha.2/forge-worktree-entry/guide.zh.md)调整配置。 + ----- @@ -35,7 +37,7 @@ kind: "package-bundle"
维护者细节——点击展开 -Bundle patch 以宿主行插入 forge 注册表、GitHub 提供方与 Session 绑定,并插入一个隔离 `worktrees` 服务的分组,其中包含一个 worktree 服务行与面板的双端包;该行启用期间,Web 与 Desktop 客户端会加载其客户端部分。隔离让本套件可以与 Git Worktrees 套件共存,后者的模型工具保有自己的服务。这些行依赖每个基于 base 的 profile 都会组合的 Session 投影注册表、工作目录、文件系统、子进程与沙箱服务。 +组合包挂载 Forge 注册表、GitHub 提供方、Session 绑定和工作树模型工具。工具使用顶层 `worktree` 服务;面板在 `optional-forge-panel` 内保留隔离的 `forge-worktree` 服务。两者的配置 id 和创建流程各自独立。面板记录 Session 开始前请求的工作树;模型创建的工作树不会获得面板的恢复记录。这些行使用基于 base 的 profile 提供的 Session 投影、工作目录、文件系统、子进程和沙箱服务。
@@ -51,11 +53,11 @@ Bundle patch 以宿主行插入 forge 注册表、GitHub 提供方与 Session ## 模型体验 -无:组合的包不增加模型工具或提示词;以 worktree 开始的 Session 的目录通知通过工作目录服务告知模型,详见[面板](../client-ui-forge/README.zh.md#model-experience)。 +间接影响:通过 [`create_worktree`](../tool-worktree/README.zh.md#model-experience) 工具,以及以工作树开始的 Session 的[面板目录通知](../client-ui-forge/README.zh.md#model-experience)。 #### KV Cache 影响 -Bundle 本身没有影响;worktree 通知见[面板](../client-ui-forge/README.zh.md#model-experience)。 +启用或关闭组合包会改变模型的工具 schema 列表。调用追加工具结果和目录上下文;参见[工具的缓存行为](../tool-worktree/README.zh.md#model-experience)。 ## 已知限制与待办工作 diff --git a/packages/experimental/forge-bundle/cordis.patch.yml b/packages/experimental/forge-bundle/cordis.patch.yml index d6008c8b4c..112caefaa4 100644 --- a/packages/experimental/forge-bundle/cordis.patch.yml +++ b/packages/experimental/forge-bundle/cordis.patch.yml @@ -1,4 +1,8 @@ - insert: + - id: worktree + name: '@deepseek-ai/dsh-experimental-worktree' + - id: tool-worktree + name: '@deepseek-ai/dsh-experimental-tool-worktree' - id: forge name: '@deepseek-ai/dsh-experimental-forge' - id: forge-github diff --git a/packages/experimental/forge-bundle/docs/DETAIL.de.md b/packages/experimental/forge-bundle/docs/DETAIL.de.md new file mode 100644 index 0000000000..c8ea4413c3 --- /dev/null +++ b/packages/experimental/forge-bundle/docs/DETAIL.de.md @@ -0,0 +1 @@ +![Eine Unterhaltung mit verknüpftem GitHub-Pull-Request, Prüfungen, Reviews, Codeunterschieden und einem separaten Arbeitsbaum](preview.svg) diff --git a/packages/experimental/forge-bundle/docs/DETAIL.es.md b/packages/experimental/forge-bundle/docs/DETAIL.es.md new file mode 100644 index 0000000000..94ccb3ba95 --- /dev/null +++ b/packages/experimental/forge-bundle/docs/DETAIL.es.md @@ -0,0 +1 @@ +![Una conversación vinculada a un pull request de GitHub, con comprobaciones, revisiones, diferencias de código y un árbol de trabajo aislado](preview.svg) diff --git a/packages/experimental/forge-bundle/docs/DETAIL.fr.md b/packages/experimental/forge-bundle/docs/DETAIL.fr.md new file mode 100644 index 0000000000..93f4c53d34 --- /dev/null +++ b/packages/experimental/forge-bundle/docs/DETAIL.fr.md @@ -0,0 +1 @@ +![Une conversation liée à une pull request GitHub, avec vérifications, revues, différences de code et un arbre de travail isolé](preview.svg) diff --git a/packages/experimental/forge-bundle/docs/DETAIL.ja.md b/packages/experimental/forge-bundle/docs/DETAIL.ja.md new file mode 100644 index 0000000000..386c9264f0 --- /dev/null +++ b/packages/experimental/forge-bundle/docs/DETAIL.ja.md @@ -0,0 +1 @@ +![GitHub のプルリクエストに関連付けられた会話、チェックとレビューの状況、コードの差分、独立したワークツリー](preview.svg) diff --git a/packages/experimental/forge-bundle/docs/DETAIL.md b/packages/experimental/forge-bundle/docs/DETAIL.md new file mode 100644 index 0000000000..1da1b27021 --- /dev/null +++ b/packages/experimental/forge-bundle/docs/DETAIL.md @@ -0,0 +1 @@ +![A conversation linked to a GitHub pull request, with checks, reviews, code diffs, and an isolated worktree](preview.svg) diff --git a/packages/experimental/forge-bundle/docs/DETAIL.pt.md b/packages/experimental/forge-bundle/docs/DETAIL.pt.md new file mode 100644 index 0000000000..c08124a106 --- /dev/null +++ b/packages/experimental/forge-bundle/docs/DETAIL.pt.md @@ -0,0 +1 @@ +![Uma conversa vinculada a um pull request do GitHub, com verificações, revisões, diferenças de código e uma árvore de trabalho isolada](preview.svg) diff --git a/packages/experimental/forge-bundle/docs/DETAIL.ru.md b/packages/experimental/forge-bundle/docs/DETAIL.ru.md new file mode 100644 index 0000000000..78dc6bf6ab --- /dev/null +++ b/packages/experimental/forge-bundle/docs/DETAIL.ru.md @@ -0,0 +1 @@ +![Беседа, связанная с pull request GitHub: проверки, рецензии, различия в коде и отдельное рабочее дерево](preview.svg) diff --git a/packages/experimental/forge-bundle/docs/DETAIL.zh.md b/packages/experimental/forge-bundle/docs/DETAIL.zh.md new file mode 100644 index 0000000000..6114b4065e --- /dev/null +++ b/packages/experimental/forge-bundle/docs/DETAIL.zh.md @@ -0,0 +1 @@ +![会话关联 GitHub PR,跟踪检查、评审与代码差异,并用工作树隔离任务改动](preview.zh.svg) diff --git a/packages/experimental/forge-bundle/docs/preview.svg b/packages/experimental/forge-bundle/docs/preview.svg new file mode 100644 index 0000000000..dd278ed0ce --- /dev/null +++ b/packages/experimental/forge-bundle/docs/preview.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/experimental/forge-bundle/docs/preview.zh.svg b/packages/experimental/forge-bundle/docs/preview.zh.svg new file mode 100644 index 0000000000..fc880256f9 --- /dev/null +++ b/packages/experimental/forge-bundle/docs/preview.zh.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/experimental/forge-bundle/locale/de.json b/packages/experimental/forge-bundle/locale/de.json index b3a7562daa..3bcf081796 100644 --- a/packages/experimental/forge-bundle/locale/de.json +++ b/packages/experimental/forge-bundle/locale/de.json @@ -1,6 +1,7 @@ { "meta": { "title": "Änderungsanfragen und Arbeitsbäume", - "description": "Verfolgen Sie die Änderungsanfragen jeder Sitzung mit ihren Prüfungen und Reviews und starten Sie neue Sitzungen in einem Git-Arbeitsbaum; nutzt die gh CLI für GitHub." + "description": "Verknüpfte GitHub-Pull-Requests neben der Unterhaltung verfolgen: Prüfungen und Reviews ansehen, Code vergleichen und Feedback an den Agenten senden. Lokale Änderungen prüfen und Aufgaben mit separaten Arbeitsbäumen isolieren. GitHub-Funktionen erfordern eine installierte gh-CLI und eine Anmeldung auf dem Host.", + "detail": "docs/DETAIL.de.md" } } diff --git a/packages/experimental/forge-bundle/locale/en.json b/packages/experimental/forge-bundle/locale/en.json index f61d885db9..e52e41ba9f 100644 --- a/packages/experimental/forge-bundle/locale/en.json +++ b/packages/experimental/forge-bundle/locale/en.json @@ -1,6 +1,7 @@ { "meta": { "title": "Change requests & worktrees", - "description": "Track each Session's change requests with their checks and reviews, and start new Sessions in a Git worktree; uses the gh CLI for GitHub." + "description": "Keep linked GitHub pull requests beside your conversation: follow checks and reviews, inspect diffs, and send feedback to the agent. Review local changes and use a separate worktree to isolate each task. GitHub features require the gh CLI installed and signed in on the host.", + "detail": "docs/DETAIL.md" } } diff --git a/packages/experimental/forge-bundle/locale/es.json b/packages/experimental/forge-bundle/locale/es.json index ecd059085e..254ebd65d6 100644 --- a/packages/experimental/forge-bundle/locale/es.json +++ b/packages/experimental/forge-bundle/locale/es.json @@ -1,6 +1,7 @@ { "meta": { "title": "Solicitudes de cambio y árboles de trabajo", - "description": "Siga las solicitudes de cambio de cada sesión con sus comprobaciones y revisiones, e inicie nuevas sesiones en un árbol de trabajo de Git; usa la CLI gh para GitHub." + "description": "Sigue los pull requests de GitHub vinculados a la conversación: consulta comprobaciones y revisiones, compara código y envía comentarios al agente. Revisa cambios locales y aísla cada tarea en un árbol de trabajo separado. Las funciones de GitHub requieren instalar la CLI gh e iniciar sesión en el equipo anfitrión.", + "detail": "docs/DETAIL.es.md" } } diff --git a/packages/experimental/forge-bundle/locale/fr.json b/packages/experimental/forge-bundle/locale/fr.json index d86288f126..109301d5ff 100644 --- a/packages/experimental/forge-bundle/locale/fr.json +++ b/packages/experimental/forge-bundle/locale/fr.json @@ -1,6 +1,7 @@ { "meta": { "title": "Demandes de modification et arbres de travail", - "description": "Suivez les demandes de modification de chaque session avec leurs vérifications et revues, et démarrez de nouvelles sessions dans un arbre de travail Git ; utilise la CLI gh pour GitHub." + "description": "Suivez les pull requests GitHub liées à votre conversation : consultez les vérifications et les revues, comparez le code et transmettez vos remarques à l’agent. Examinez les modifications locales et isolez chaque tâche dans un arbre de travail séparé. Les fonctions GitHub nécessitent la CLI gh installée et une connexion sur l’hôte.", + "detail": "docs/DETAIL.fr.md" } } diff --git a/packages/experimental/forge-bundle/locale/ja.json b/packages/experimental/forge-bundle/locale/ja.json index 0885da3f0a..126a8643d2 100644 --- a/packages/experimental/forge-bundle/locale/ja.json +++ b/packages/experimental/forge-bundle/locale/ja.json @@ -1,6 +1,7 @@ { "meta": { "title": "変更リクエストとワークツリー", - "description": "各セッションの変更リクエストとそのチェックやレビューを追跡し、新しいセッションを Git ワークツリーで開始できます。GitHub には gh CLI を使用します。" + "description": "会話のサイドバーで関連付けられた GitHub PR をまとめて確認できます。チェックやレビューの状況、コードの差分を確認し、修正案をエージェントに渡せます。ローカルの変更確認や、独立したワークツリーによるタスクの分離にも対応します。GitHub 機能にはホストでの gh CLI のインストールとログインが必要です。", + "detail": "docs/DETAIL.ja.md" } } diff --git a/packages/experimental/forge-bundle/locale/pt.json b/packages/experimental/forge-bundle/locale/pt.json index 48821e9b5f..e7208c7c9e 100644 --- a/packages/experimental/forge-bundle/locale/pt.json +++ b/packages/experimental/forge-bundle/locale/pt.json @@ -1,6 +1,7 @@ { "meta": { "title": "Solicitações de alteração e árvores de trabalho", - "description": "Acompanhe as solicitações de alteração de cada sessão com suas verificações e revisões e inicie novas sessões em uma árvore de trabalho Git; usa a CLI gh para o GitHub." + "description": "Acompanhe os pull requests do GitHub vinculados à conversa: consulte verificações e revisões, compare código e envie comentários ao agente. Revise alterações locais e isole cada tarefa em uma árvore de trabalho separada. Os recursos do GitHub exigem a CLI gh instalada e uma sessão iniciada no host.", + "detail": "docs/DETAIL.pt.md" } } diff --git a/packages/experimental/forge-bundle/locale/ru.json b/packages/experimental/forge-bundle/locale/ru.json index 750ed7ace0..2541e956ca 100644 --- a/packages/experimental/forge-bundle/locale/ru.json +++ b/packages/experimental/forge-bundle/locale/ru.json @@ -1,6 +1,7 @@ { "meta": { "title": "Запросы на изменение и рабочие деревья", - "description": "Отслеживайте запросы на изменение каждого сеанса вместе с их проверками и ревью и начинайте новые сеансы в рабочем дереве Git; для GitHub используется gh CLI." + "description": "Следите за связанными с беседой pull request GitHub: проверками, рецензиями и различиями в коде, а затем передавайте замечания агенту. Просматривайте локальные изменения и изолируйте задачи в отдельных рабочих деревьях. Для функций GitHub требуется установить CLI gh и войти в аккаунт на хосте.", + "detail": "docs/DETAIL.ru.md" } } diff --git a/packages/experimental/forge-bundle/locale/zh.json b/packages/experimental/forge-bundle/locale/zh.json index 50259f6fe0..7e4786270f 100644 --- a/packages/experimental/forge-bundle/locale/zh.json +++ b/packages/experimental/forge-bundle/locale/zh.json @@ -1,6 +1,7 @@ { "meta": { "title": "变更请求与工作树", - "description": "跟踪每个会话的变更请求及其检查与评审,新会话也可以在 Git 工作树中开始;GitHub 通过 gh 命令行访问。" + "description": "在会话侧栏集中跟踪关联的 GitHub PR:查看检查状态、评审意见与代码差异,并把修改建议交给智能体继续处理。也可查看本地改动,用独立工作树隔离任务。GitHub 功能需要本机安装 gh 并登录。", + "detail": "docs/DETAIL.zh.md" } } diff --git a/packages/experimental/forge-bundle/package.json b/packages/experimental/forge-bundle/package.json index b66fbe47b0..13e3ab5025 100644 --- a/packages/experimental/forge-bundle/package.json +++ b/packages/experimental/forge-bundle/package.json @@ -27,6 +27,7 @@ "files": [ "icon.svg", "locale/*.json", + "docs", "lib/index.js", "cordis.patch.yml", "lib/types/**/*.d.ts" @@ -37,6 +38,7 @@ "@deepseek-ai/dsh-experimental-forge": "workspace:*", "@deepseek-ai/dsh-experimental-forge-github": "workspace:*", "@deepseek-ai/dsh-experimental-forge-session": "workspace:*", + "@deepseek-ai/dsh-experimental-tool-worktree": "workspace:*", "@deepseek-ai/dsh-experimental-worktree": "workspace:*" }, "peerDependencies": { diff --git a/packages/experimental/tool-worktree/README.i18n.yaml b/packages/experimental/tool-worktree/README.i18n.yaml index 33ca340c2f..10910e375f 100644 --- a/packages/experimental/tool-worktree/README.i18n.yaml +++ b/packages/experimental/tool-worktree/README.i18n.yaml @@ -9,14 +9,14 @@ en: db61f6b6ad671f45 zh: 9f3083d7f5605cbb /deepseek-ai-dsh-experimental-tool-worktree/summary: - en: addc1cb3414df6be - zh: 5f2d371d844b0568 + en: 106bccb04bd14134 + zh: 579b316a56bebbb8 /deepseek-ai-dsh-experimental-tool-worktree/table-of-contents: en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-tool-worktree/use-this-package: - en: 06074f9f6989911b - zh: 05c9a85b7cdcfbad + en: f357051b201aa5b9 + zh: ace93e12e8d4de82 /deepseek-ai-dsh-experimental-tool-worktree/understand-the-implementation: en: c03f875211ab69a8 zh: 8f49b82f90a60223 diff --git a/packages/experimental/tool-worktree/README.md b/packages/experimental/tool-worktree/README.md index b614aa6a8e..2f1b293808 100644 --- a/packages/experimental/tool-worktree/README.md +++ b/packages/experimental/tool-worktree/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Add Git worktrees from the GUI plugin manager to let a model create and enter a new Git worktree with one call. This optional bundle is installed with Harness and stays off until selected. Native tool calls and PTC programs receive the same typed result, and the existing file policy governs creation. +Let a model create and enter a new Git worktree with one call. Web and Desktop expose this tool through Change requests & worktrees; other profiles can select this standalone bundle. Native tool calls and PTC programs receive the same typed result, and the existing file policy governs creation. ## Table of Contents @@ -25,7 +25,7 @@ Add Git worktrees from the GUI plugin manager to let a model create and enter a ## Use this package -In the GUI, open **Plugins → Official → Git worktrees** and turn on the switch. The bundle loads the [worktree runtime](../worktree/README.md) and this tool together; it is off in every shipped profile. Turning it off removes `create_worktree` without deleting existing checkouts or branches. The built-in `working_directory` tool remains available. +In the GUI, enable [Change requests & worktrees](../forge-bundle/README.md). The standalone bundle has no Plugins page card. Profiles without the Web application may select it in `dsh.profile.bundles`; it loads the [worktree runtime](../worktree/README.md) and this tool together. Turning it off removes `create_worktree` without deleting existing checkouts or branches. The built-in `working_directory` tool remains available. For an explicit composition, mount this function plugin after the worktree runtime and tool registry. diff --git a/packages/experimental/tool-worktree/README.zh.md b/packages/experimental/tool-worktree/README.zh.md index 46afb3e972..8b37299687 100644 --- a/packages/experimental/tool-worktree/README.zh.md +++ b/packages/experimental/tool-worktree/README.zh.md @@ -9,7 +9,7 @@ kind: "package-bundle" ## 概述 -从 GUI 插件管理器添加 Git 工作树,让模型通过一次调用创建并进入新的 Git 工作树。此可选 bundle 随 Harness 安装,在选中之前保持关闭。原生工具调用与 PTC 程序获得相同的带类型结果,创建操作沿用现有文件策略。 +让模型通过一次调用创建并进入新的 Git 工作树。Web 与 Desktop 通过“变更请求与工作树”提供本工具;其他 profile 可以选择本独立组合包。原生工具调用与 PTC 程序获得相同的带类型结果,创建操作沿用现有文件策略。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-bundle" ## 使用本包 -在 GUI 中打开**插件 → 官方 → Git 工作树**并启用开关。此 bundle 一起加载[工作树运行时](../worktree/README.zh.md)与本工具;所有随附 profile 默认关闭它。关闭后会移除 `create_worktree`,已有检出目录与分支会保留。内置 `working_directory` 工具仍然可用。 +在 GUI 中启用[变更请求与工作树](../forge-bundle/README.zh.md)。独立组合包在插件页没有卡片。未组合 Web 应用的 profile 可以在 `dsh.profile.bundles` 中选择它;它一起加载[工作树运行时](../worktree/README.zh.md)与本工具。关闭后会移除 `create_worktree`,已有检出目录与分支会保留。内置 `working_directory` 工具仍然可用。 在显式组合中,将此函数插件挂载到工作树运行时和工具注册表之后。 diff --git a/packages/util/official-bundles/src/plugin-manager.ts b/packages/util/official-bundles/src/plugin-manager.ts index 48f866a748..510c85699f 100644 --- a/packages/util/official-bundles/src/plugin-manager.ts +++ b/packages/util/official-bundles/src/plugin-manager.ts @@ -18,7 +18,7 @@ export const PROTECTED_PLUGINS: ReadonlySet = new Set([ ]) /** - * Built-in profile bundles and persistent terminals, which have no plugin manager page. + * Built-in profile bundles, persistent terminals, and the standalone worktree tool, which have no plugin manager page. * Their Host availability and activation stay intact. */ export const HIDDEN_BUNDLES: ReadonlySet = new Set([ @@ -29,6 +29,7 @@ export const HIDDEN_BUNDLES: ReadonlySet = new Set([ '@deepseek-ai/dsh-acp-app', '@deepseek-ai/dsh-sdk-minimal', '@deepseek-ai/dsh-experimental-terminal-bundle', + '@deepseek-ai/dsh-experimental-tool-worktree', ]) /** @@ -50,7 +51,6 @@ export const MORE_BUNDLES: readonly string[] = [ '@deepseek-ai/dsh-personalized-recommendations', '@deepseek-ai/dsh-experimental-inspector-profile', '@deepseek-ai/dsh-cua-bundle', - '@deepseek-ai/dsh-experimental-tool-worktree', '@deepseek-ai/dsh-experimental-cot-translation-bundle', '@deepseek-ai/dsh-experimental-session-search', '@deepseek-ai/dsh-experimental-session-titles-bundle', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 451d80179f..78bf276a8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10028,6 +10028,9 @@ importers: '@deepseek-ai/dsh-experimental-forge-session': specifier: workspace:* version: link:../forge-session + '@deepseek-ai/dsh-experimental-tool-worktree': + specifier: workspace:* + version: link:../tool-worktree '@deepseek-ai/dsh-experimental-worktree': specifier: workspace:* version: link:../worktree diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index a40c08fca0..5713199b1b 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -676,7 +676,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ }) await ctx.plugin(ToolWorktree) }, - note: 'Explicit experimental composition only. Creates a new branch and checkout from a pinned local commit under existing write permissions, then changes the Session directory. Leaving retains the branch and checkout.', + note: 'Enabled through the Change requests & worktrees bundle on Web and Desktop; other profiles can select the standalone bundle. Creates a new branch and checkout from a pinned local commit under existing write permissions, then changes the Session directory. Leaving retains the branch and checkout.', }, { pkg: '@deepseek-ai/dsh-tool-workflow', diff --git a/scripts/optional-bundles.spec.ts b/scripts/optional-bundles.spec.ts index 7423aa0c9f..e5c30c6c84 100644 --- a/scripts/optional-bundles.spec.ts +++ b/scripts/optional-bundles.spec.ts @@ -133,7 +133,7 @@ describe('optional bundles', () => { } }) - it('offers Git Worktrees as one disabled bundle with its runtime and tool', () => { + it('retains the standalone worktree bundle for explicit profiles', () => { const name = '@deepseek-ai/dsh-experimental-tool-worktree' expect(OPTIONAL_BUNDLES).toContain(name) for (const template of Object.values(PROFILE_TEMPLATES)) expect(template.bundles).not.toContain(name) @@ -144,7 +144,7 @@ describe('optional bundles', () => { } }) - it('offers the forge panel as one disabled bundle with its registry, provider, bindings, panel, and isolated worktree service', () => { + it('offers the forge panel and model worktree tool together with their separate worktree services', () => { const name = '@deepseek-ai/dsh-experimental-forge-bundle' expect(OPTIONAL_BUNDLES).toContain(name) for (const template of Object.values(PROFILE_TEMPLATES)) expect(template.bundles).not.toContain(name) @@ -154,24 +154,28 @@ describe('optional bundles', () => { '@deepseek-ai/dsh-experimental-forge-github', '@deepseek-ai/dsh-experimental-forge-session', '@deepseek-ai/dsh-experimental-client-ui-forge', - '@deepseek-ai/dsh-experimental-worktree', + '@deepseek-ai/dsh-experimental-tool-worktree', ]) { expect(flattenRows(composeEntries(shipped)).some(row => row.name === moduleName)).toBe(false) expect(rows.filter(row => row.name === moduleName && row.disabled !== true)).toHaveLength(1) } const group = composeEntries([...shipped, bundle(name).patches]).find(row => row.id === 'optional-forge-panel') expect(group?.isolate).toEqual({ worktrees: true }) + expect(rows.filter(row => row.name === '@deepseek-ai/dsh-experimental-worktree')).toHaveLength(2) }) - it('composes the forge panel beside Git Worktrees with one worktree service each', () => { - const composed = composeEntries([ - ...shipped, - bundle('@deepseek-ai/dsh-experimental-tool-worktree').patches, - bundle('@deepseek-ai/dsh-experimental-forge-bundle').patches, - ], (message) => { throw new Error(message) }) - const topLevelIds = composed.flatMap(entry => typeof entry.id === 'string' ? [entry.id] : []) - expect(topLevelIds).toHaveLength(new Set(topLevelIds).size) - expect(flattenRows(composed).filter(row => row.name === '@deepseek-ai/dsh-experimental-worktree')).toHaveLength(2) + it('keeps the existing tool and panel row overrides addressable in the unified bundle', () => { + const config = { directory: '.agents/custom-worktrees' } + const warnings: string[] = [] + const composed = composeEntries([...shipped, bundle('@deepseek-ai/dsh-experimental-forge-bundle').patches, [ + { id: 'worktree', config }, + { id: 'tool-worktree', disabled: true }, + { id: 'optional-forge-panel', disabled: true }, + ]], message => warnings.push(message)) + expect(warnings).toEqual([]) + expect(composed.find(row => row.id === 'worktree')?.config).toEqual(config) + expect(composed.find(row => row.id === 'tool-worktree')?.disabled).toBe(true) + expect(composed.find(row => row.id === 'optional-forge-panel')?.disabled).toBe(true) }) it('ships all five lightweight bundles without selecting them in a default template', () => { diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index f18901f6be..09dbf45585 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -74,7 +74,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/experimental/forge': { kind: 'none', reason: 'Providers and watchers exchange code host status without contributing model context.' }, 'packages/experimental/forge-github': { kind: 'none', reason: 'The provider reads pull request status for host-side watchers without contributing model context.' }, 'packages/experimental/client-ui-forge': { kind: 'indirect', reason: 'Switching a Session directory from the panel reaches the model through the working-directory notice; inspection and status stay on the host.' }, - 'packages/experimental/forge-bundle': { kind: 'none', reason: 'Composes the forge packages and panel without adding a model tool or prompt.' }, + 'packages/experimental/forge-bundle': { kind: 'indirect', reason: 'The worktree tool and panel own the model-visible schemas, results, and directory notices selected by the bundle.' }, 'packages/experimental/forge-session': { kind: 'none', reason: 'Bindings, discovery records, and polled status stay on the host; binding records never enter model requests.' }, 'packages/experimental/worktree': { kind: 'indirect', reason: 'The worktree tool renders creation results; the working-directory service owns model-visible directory context.' }, 'packages/experimental/client-ui-stats': { kind: 'none', reason: 'The panel reads Session events and renders them in the browser; it registers no tool, prompt section, or model input.' }, diff --git a/snapshots/web/creation-recommendations/session.v4.jsonl b/snapshots/web/creation-recommendations/session.v4.jsonl index b92ddb4ef8..f327415d58 100644 --- a/snapshots/web/creation-recommendations/session.v4.jsonl +++ b/snapshots/web/creation-recommendations/session.v4.jsonl @@ -16,7 +16,7 @@ {"type":"session/title","data":{"title":"I wish I had a","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_creation_skill","name":"skill","arguments":"{\"name\":\"personalized-pages\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"stream":[{"type":"chunk","time":1792674450282,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1792674450282,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_creation_skill","name":"skill","arguments":"{\"name\":\"personalized-pages\"}"}}},{"type":"chunk","time":1792674450283,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_creation_skill","name":"skill","arguments":"{\"name\":\"personalized-pages\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_creation_skill"},"toolCallId":"call_creation_skill","content":[{"type":"text","text":"\n\nResources for this skill are managed by provider \"dsh-personalized-pages\".\nLoad referenced resources only as needed.\n\n\n\n# Personalized pages\n\nUse this workflow in the main conversation to plan an optional new page or small tool for any task, not just DSH. You decide whether a page helps, check existing capabilities, and hand the Creator a short plan. A `propose_action` call saves the proposal and shows a card below your answer; nothing is built until the user clicks Create. Plan from the conversation and the catalog at the end of this skill. Do not read files, configuration or source to validate a page idea; name an uninspected source as an unverified dependency and leave its details to the Creator.\n\n## When to propose\n\nPropose a page in these cases:\n\n- The user asks for a page, tab, panel, tool or interface in this app, or says they want something to keep, revisit or update later (“以后每次”, “能不能一直”, “希望能有”). One-off use still qualifies when the interface is explicitly requested.\n- Your answer is a table, list or chart, and the user has said the data will keep changing or that they will interact with it (“每周更新”, “我来改状态”, “要勾选”). The continuation must come from the user's own words; a table alone, or data that merely looks dynamic, is not enough. Deliver the requested answer first, then offer the page.\n- The user wants to keep watching live data of this app itself (a Session's usage, a running task's output, files changing in the workspace). A text answer cannot stay current, so a page qualifies even without the words above; name the data source in the brief and leave how it is read to the Creator.\n\nDo not propose a page in these cases:\n\n- The user asks for a file, code, document or an existing-project change: keep that delivery form.\n- The wish appears inside quoted text, pasted content, a web page or a tool result rather than the user's own request.\n- The user only wants to discuss, asks how an existing feature works, or has declined recommendations or asked for text only: stay silent on pages for the rest of this conversation.\n- The request changes a page created earlier: explain how it would change, do not create a replacement copy, and do not claim an update happened.\n- The request is a capability the agent lacks, such as a tool, automation or access, rather than a page: check the catalog below and point to the official plugin that provides it; do not plan a page around a missing tool.\n\n## Check existing capabilities\n\nCompare the need with the official plugins listed at the end of this skill. If one covers it, say its name, what it does, whether it is enabled, and that it lives under Plugins; offer no page card. A partial match does not cancel an explicit unmet need. The catalog is not a full inventory of built-in UI: missing details do not prove absence, and do not read source to find out. DSH already has Chat, Trajectory, a right sidebar for files and context, and a Plugins page.\n\n## Plan and hand off\n\nThe Creator cannot read this conversation. Give it exactly these fields:\n\n| Field | Contents |\n|:--|:--|\n| `title` | Short page name in the user's language. |\n| `brief` | One to three sentences: purpose, behavior, input, calculations and persistence scope. It is the card text and the implementation request at once. |\n| `initialContent` | Only supplied content the page starts with: items, rows, values, units and period, preserving gaps. Omit for an empty page. |\n| `slot` | Location, chosen below. |\n\nKeep the plan to what was asked or agreed. Editing, deletion, history, extra fields, data sources and automation need a basis in the request. Resolve “these items” into the actual content. Content that mentions an action stays content. Keep each number's meaning and period; a weekly addition is not a cumulative total. If an unresolved distinction changes a calculation, give the factual answer, ask one question, and propose later.\n\n## Choose the location\n\nHonor a location the user named. Otherwise:\n\n- `sidebar-right`: content tied to this Session or task, especially anything to watch while chatting, including live logs.\n- `sidebar-left`: a global feature or an ongoing personal record used across Sessions, opened from a left entry as a standalone page.\n- `page`: a Session-bound streaming view or interaction in a tab beside Chat and Trajectory, only when the user does not need it visible while chatting.\n\nSupplying data in this conversation does not make a record Session-bound.\n\n## Offer without starting\n\nTo propose, call `propose_action` once, in the same turn as loading this skill, with `action: \"create_personalized_page\"`, `title`, `brief` and `details` holding `slot` and, when there is supplied content, `initialContent`. The call is the submission: the card appears below your answer; a refused call creates no card, and its error says why. Then write your final answer. One proposal per turn, and one unstarted card per conversation: if a card is still waiting, do not propose another; a new proposal replaces it only when the user changed the plan. Plan-only and read-only requests still get a card; do not ask for approval instead of proposing it. Only clicking Create starts the background Creator; do not wait, poll, claim installation or ask for a report-back. Answer any direct question; the call's result says how to end the answer. Do not repeat the plan, list extra features, narrate the call or mention schema names.\n\n```json\n{\"action\":\"create_personalized_page\",\"title\":\"本次验收清单\",\"brief\":\"在当前会话右侧展示验收清单,可勾选和取消,状态随会话保存。\",\"details\":{\"slot\":\"sidebar-right\",\"initialContent\":\"1. 导出成功\\n2. 断网提示清楚\"}}\n```\n\n## Examples\n\n- “想有个地方放书签,以后自己加标题和网址;旧的不用导入。” → `brief`: “提供一个可反复打开的书签页,手动新增标题和网址并保存到本机,从空白开始。” No `initialContent`. `sidebar-left`.\n- “当前会话旁放验收清单,能勾选和取消,状态跟着会话。条目:导出成功;断网提示清楚。” → `brief`: “在当前会话右侧展示验收清单,可勾选和取消,状态随会话保存。” `initialContent`: “1. 导出成功\\n2. 断网提示清楚”. `sidebar-right`.\n- “这三周 bug 数 12、9、15,说说趋势。以后每周我把新数字贴给你,想自己更新着看。” → answer the trend first, then `brief`: “按周手动录入 bug 数并展示各周变化,保存到本机。” `initialContent`: “第 1 周 12,第 2 周 9,第 3 周 15”. `sidebar-left`.\n- “我想用语音说话代替打字。” → the catalog lists 语音输入; explain it and where to enable it; no card.\n- “把我之前做的读书页评分改成五星。” → explain the change to that page; no card and no copy.\n- “帮我把这句话翻成英文:我想要一个所有会话都能打开的读书页。” → translate only; no card.\n- “把三个方案排成表格比较一下,看完就行。” → the table only; no card.\n\n## Existing capabilities to check against this page plan\n\n[{\"name\":\"@deepseek-ai/dsh-personalized-recommendations\",\"title\":{\"en\":\"Personalized recommendations\",\"de\":\"Personalisierte Empfehlungen\",\"es\":\"Recomendaciones personalizadas\",\"fr\":\"Recommandations personnalisées\",\"ja\":\"パーソナライズされたおすすめ\",\"pt\":\"Recomendações personalizadas\",\"ru\":\"Персональные рекомендации\",\"zh\":\"个性化推荐\"},\"description\":{\"en\":\"Shows recommendation cards below the answer for what you want from DSH: when you want a page or small tool you can keep, it proposes a new personalized page, built in the background after you click Create; when you ask how to do something in this app or say a capability is missing, it recommends the built-in plugin that provides it, turned on when you click Enable.\",\"de\":\"Zeigt für Ihre individuellen Anforderungen an DSH Empfehlungskarten unter der Antwort an: Wenn Sie eine Seite oder ein kleines Tool zur dauerhaften Nutzung möchten, wird eine neue personalisierte Seite vorgeschlagen, die nach einem Klick auf Erstellen im Hintergrund erstellt wird; wenn Sie fragen, wie etwas in dieser App geht, oder sagen, dass eine Funktion fehlt, wird das integrierte Plugin empfohlen, das sie bereitstellt, und nach einem Klick auf Aktivieren eingeschaltet.\",\"es\":\"Muestra tarjetas de recomendación debajo de la respuesta para tus necesidades personalizadas en DSH: cuando quieres una página o una pequeña herramienta para conservar, propone una nueva página personalizada, que se genera en segundo plano al hacer clic en Crear; cuando preguntas cómo hacer algo en esta aplicación o dices que falta una función, recomienda el complemento integrado que la ofrece, que se activa al hacer clic en Habilitar.\",\"fr\":\"Affiche des cartes de recommandation sous la réponse pour vos besoins personnalisés dans DSH : lorsque vous voulez une page ou un petit outil à conserver, propose une nouvelle page personnalisée, générée en arrière-plan après un clic sur Créer ; lorsque vous demandez comment faire quelque chose dans cette application ou signalez qu’une fonctionnalité manque, recommande le plugin intégré qui la fournit, activé lorsque vous cliquez sur Activer.\",\"ja\":\"DSH に対するあなた個人のニーズに合わせて、回答の下におすすめカードを表示します。長く使えるページや小さなツールが欲しいときは新しいパーソナルページを提案し、「作成」をクリックするとバックグラウンドで生成します。このアプリで何かをする方法を尋ねたときや機能が足りないと言ったときは、それを提供する内蔵プラグインを推薦し、「有効にする」をクリックすると有効になります。\",\"pt\":\"Mostra cartões de recomendação abaixo da resposta para suas necessidades personalizadas no DSH: quando você quer uma página ou uma pequena ferramenta para manter, propõe uma nova página personalizada, gerada em segundo plano depois que você clica em Criar; quando você pergunta como fazer algo neste aplicativo ou diz que falta um recurso, recomenda o plug-in integrado que o oferece, ativado quando você clica em Ativar.\",\"ru\":\"Показывает карточки рекомендаций под ответом с учетом ваших индивидуальных потребностей в DSH: когда вам нужна страница или небольшой инструмент для постоянного использования, предлагает новую персональную страницу, которая создается в фоне после нажатия «Создать»; когда вы спрашиваете, как что-то сделать в этом приложении, или говорите, что не хватает какой-то возможности, рекомендует встроенный плагин, который ее предоставляет, и включает его после нажатия «Включить».\",\"zh\":\"针对你对 DSH 的个性化需求,在回答下方展示推荐卡片:想要能长期使用的页面或小工具时,提议新增个性化页面,点击创建后在后台生成;问到本应用怎么做某件事或缺少某项能力时,推荐对应的内置插件,点击启用后开启。\"},\"enabled\":true},{\"name\":\"@deepseek-ai/dsh-experimental-agent-team-profile\",\"title\":{\"en\":\"Agent teams\",\"de\":\"Agententeams\",\"es\":\"Equipos de agentes\",\"fr\":\"Équipes d'agents\",\"ja\":\"エージェントチーム\",\"pt\":\"Equipes de agentes\",\"ru\":\"Команды агентов\",\"zh\":\"智能体团队\"},\"description\":{\"en\":\"Lets the agent you talk to split a job among named teammates, track it on a shared task board, and report back with their results.\",\"de\":\"Ermöglicht dem Agenten, mit dem Sie sprechen, eine Aufgabe auf benannte Teammitglieder aufzuteilen, sie auf einem gemeinsamen Aufgabenboard zu verfolgen und Ihnen mit deren Ergebnissen zu berichten.\",\"es\":\"Permite que el agente con el que habla reparta un trabajo entre compañeros con nombre, lo siga en un tablero de tareas compartido y le informe con sus resultados.\",\"fr\":\"Permet à l'agent avec lequel vous parlez de répartir un travail entre des coéquipiers nommés, de le suivre sur un tableau de tâches partagé et de vous rendre compte de leurs résultats.\",\"ja\":\"対話しているエージェントが、仕事を名前付きのチームメイトに分担させ、共有タスクボードで進捗を追跡し、チームメイトの結果をまとめて報告します。\",\"pt\":\"Permite que o agente com quem você conversa divida um trabalho entre colegas de equipe nomeados, acompanhe-o em um quadro de tarefas compartilhado e informe você com os resultados deles.\",\"ru\":\"Позволяет агенту, с которым вы общаетесь, распределить работу между именованными участниками команды, отслеживать её на общей доске задач и отчитываться перед вами об их результатах.\",\"zh\":\"让与你对话的智能体把工作分给有名字的队友,在共享任务看板上跟踪进度,并汇总队友的结果向你汇报。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-inspector-profile\",\"title\":{\"en\":\"Developer tools\",\"de\":\"Entwicklertools\",\"es\":\"Herramientas de desarrollador\",\"fr\":\"Outils de développement\",\"ja\":\"開発者ツール\",\"pt\":\"Ferramentas de desenvolvedor\",\"ru\":\"Инструменты разработчика\",\"zh\":\"开发者工具\"},\"description\":{\"en\":\"For troubleshooting: view what DSH recorded for a session in Session log, inspect per-turn and per-step statistics in the Statistics tab, and debug the DSH backend in Chrome DevTools.\",\"de\":\"Zur Fehlersuche: Sehen Sie in „Sitzungsprotokoll“, was DSH für eine Sitzung aufgezeichnet hat, prüfen Sie im Tab „Statistik“ Kontext, Dauer, Cache und Werkzeugkosten pro Runde und Schritt und debuggen Sie das DSH-Backend in Chrome DevTools.\",\"es\":\"Para solucionar problemas: vea en «Registro de sesión» lo que DSH registró de una sesión, consulte el contexto, los tiempos, la caché y el coste de herramientas por turno y paso en la pestaña «Estadísticas», y depure el backend de DSH en Chrome DevTools.\",\"fr\":\"Pour le dépannage : consultez dans « Journal de session » ce que DSH a enregistré pour une session, examinez le contexte, les durées, le cache et le coût des outils par tour et étape dans l’onglet « Statistiques », et déboguez le backend de DSH dans Chrome DevTools.\",\"ja\":\"トラブルシューティング用です。「セッションログ」で DSH がセッションについて記録した内容を確認し、「統計」タブでターン/ステップごとのコンテキスト、時間、キャッシュ、ツールコストを確認し、Chrome DevTools で DSH バックエンドをデバッグできます。\",\"pt\":\"Para solução de problemas: veja em «Registro da sessão» o que o DSH registrou de uma sessão, consulte contexto, tempos, cache e custo de ferramentas por turno e etapa na aba «Estatísticas» e depure o backend do DSH no Chrome DevTools.\",\"ru\":\"Для диагностики: просматривайте в «Журнале сеанса», что DSH записал для сеанса, смотрите контекст, время, кэш и стоимость инструментов по ходам и шагам на вкладке «Статистика» и отлаживайте бэкенд DSH в Chrome DevTools.\",\"zh\":\"用于排查问题:在「会话数据诊断」中查看 DSH 为会话记录的内容,在「统计」页签查看每轮/每步的上下文、耗时、缓存和工具成本,并在 Chrome DevTools 中调试 DSH 后端。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-voice-input-bundle\",\"title\":{\"en\":\"Voice input\",\"de\":\"Spracheingabe\",\"es\":\"Entrada de voz\",\"fr\":\"Saisie vocale\",\"ja\":\"音声入力\",\"pt\":\"Entrada de voz\",\"ru\":\"Голосовой ввод\",\"zh\":\"语音输入\"},\"description\":{\"en\":\"Transcribe recordings locally with SenseVoice; first use requires installing dependencies.\",\"de\":\"Transkribieren Sie Aufnahmen lokal mit SenseVoice; Für die erste Verwendung müssen Abhängigkeiten installiert werden.\",\"es\":\"Transcribe grabaciones localmente con SenseVoice; El primer uso requiere instalar dependencias.\",\"fr\":\"Transcrivez les enregistrements localement avec SenseVoice ; la première utilisation nécessite l'installation de dépendances.\",\"ja\":\"ローカルの SenseVoice で録音を文字に起こします。初回利用時には依存関係のインストールが必要です。\",\"pt\":\"Transcreva gravações localmente com SenseVoice; o primeiro uso requer a instalação de dependências.\",\"ru\":\"Транскрибируйте записи локально с помощью SenseVoice; при первом использовании требуется установка зависимостей.\",\"zh\":\"在本机使用 SenseVoice 转写录音,首次使用需安装依赖。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-auto-review\",\"title\":{\"en\":\"Auto authorization review\",\"de\":\"Automatische Autorisierungsüberprüfung\",\"es\":\"Revisión de autorización automática\",\"fr\":\"Examen des autorisations automatiques\",\"ja\":\"自動認可レビュー\",\"pt\":\"Revisão de autorização automática\",\"ru\":\"Проверка автоматической авторизации\",\"zh\":\"自动授权审查\"},\"description\":{\"en\":\"Adds an Auto review access mode: before each tool call, a model checks it against what you asked for, runs allowed calls at once, and holds denied calls for your approval.\",\"de\":\"Fügt den Zugriffsmodus „Auto review“ hinzu: Vor jedem Tool-Aufruf prüft ein Modell den Aufruf anhand Ihrer Anforderung; zugelassene Aufrufe laufen sofort, abgelehnte Aufrufe warten auf Ihre Genehmigung.\",\"es\":\"Añade el modo de acceso “Auto review”: antes de cada llamada a una herramienta, un modelo comprueba la llamada frente a lo que usted pidió; las llamadas permitidas se ejecutan de inmediato y las denegadas esperan su aprobación.\",\"fr\":\"Ajoute le mode d'accès « Auto review » : avant chaque appel d'outil, un modèle vérifie l'appel par rapport à ce que vous avez demandé ; les appels autorisés s'exécutent immédiatement et les appels refusés attendent votre approbation.\",\"ja\":\"「自動認可レビュー」アクセスモードを追加します。各ツール呼び出しの前に、モデルがその呼び出しをあなたの依頼内容と照らし合わせて確認し、許可された呼び出しは即座に実行され、拒否された呼び出しはあなたの承認を待ちます。\",\"pt\":\"Adiciona o modo de acesso “Auto review”: antes de cada chamada de ferramenta, um modelo verifica a chamada em relação ao que você pediu; as chamadas permitidas são executadas imediatamente e as negadas aguardam sua aprovação.\",\"ru\":\"Добавляет режим доступа «Auto review»: перед каждым вызовом инструмента модель сверяет вызов с вашей просьбой; разрешённые вызовы выполняются сразу, а отклонённые ждут вашего одобрения.\",\"zh\":\"提供“自动审查”访问模式:每次工具调用前,由模型对照你的要求检查,获准的调用立即运行,被拒绝的调用等你审批。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-cot-translation-bundle\",\"title\":{\"en\":\"Machine translation of reasoning\",\"de\":\"Maschinelle Übersetzung der Argumentation\",\"es\":\"Traducción automática de razonamiento\",\"fr\":\"Traduction automatique du raisonnement\",\"ja\":\"思考内容の機械翻訳\",\"pt\":\"Tradução automática de raciocínio\",\"ru\":\"Машинный перевод рассуждений\",\"zh\":\"思考过程机器翻译\"},\"description\":{\"en\":\"Translates expanded Think rows into your chosen language as the agent writes them, and View original switches back.\",\"de\":\"Übersetzt aufgeklappte „Denken Sie nach“-Zeilen, während der Agent sie schreibt, in die von Ihnen gewählte Sprache, und „Original ansehen“ wechselt zurück.\",\"es\":\"Traduce las filas “pensar” expandidas al idioma que elija mientras el agente las escribe, y “Ver original” vuelve al texto original.\",\"fr\":\"Traduit les lignes « Pensez » développées dans la langue de votre choix pendant que l'agent les écrit, et « Voir l'original » permet de revenir au texte d'origine.\",\"ja\":\"エージェントが書き出すのと同時に、展開された「思考」行を選択した言語に翻訳します。「原文を表示」で原文に戻せます。\",\"pt\":\"Traduz as linhas “Pense” expandidas para o idioma que você escolher enquanto o agente as escreve, e “Ver original” volta ao texto original.\",\"ru\":\"Переводит развёрнутые строки «Подумайте» на выбранный вами язык по мере того, как агент их пишет, а «Посмотреть оригинал» возвращает исходный текст.\",\"zh\":\"智能体输出时,把展开的“思考”行翻译成你选择的语言,点击“查看原文”可切换回原文。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-badge-skill-bundle\",\"title\":{\"en\":\"DSH badge skill\",\"de\":\"DSH-Abzeichen-Fertigkeit\",\"es\":\"Habilidad de insignia DSH\",\"fr\":\"Compétence du badge DSH\",\"ja\":\"DSH バッジスキル\",\"pt\":\"DSH habilidade de distintivo\",\"ru\":\"DSH значок навыка\",\"zh\":\"DSH 徽章技能\"},\"description\":{\"en\":\"Let agents add a “powered by dsh” badge to documents, pull requests, and other outputs.\",\"de\":\"Lassen Sie Agenten Dokumente, Pull-Requests und andere Ausgaben mit einem „powered by dsh“-Badge versehen.\",\"es\":\"Permita que los agentes agreguen una insignia \\\"con tecnología de dsh\\\" a documentos, solicitudes de extracción y otros resultados.\",\"fr\":\"Laissez les agents ajouter un badge « propulsé par dsh » aux documents, demandes d'extraction et autres sorties.\",\"ja\":\"エージェントが文書や PR などの成果物に「powered by dsh」バッジを追加できます。\",\"pt\":\"Permita que os agentes adicionem um selo “powered by dsh” a documentos, pull requests e outros resultados.\",\"ru\":\"Позвольте агентам добавлять значок «Powered by dsh» к документам, запросам на включение и другим результатам.\",\"zh\":\"让 Agent 为文档、PR 等产出添加「powered by dsh」徽章。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-ralph-bundle\",\"title\":{\"en\":\"Ralph loops\",\"de\":\"Ralph-Loops\",\"es\":\"Bucles de Ralph\",\"fr\":\"Boucles Ralph\",\"ja\":\"Ralph ループ\",\"pt\":\"Loops de Ralph\",\"ru\":\"Ральф петли\",\"zh\":\"Ralph 循环\"},\"description\":{\"en\":\"Ask for a Ralph loop with a goal, and the agent starts a fresh agent on it round after round until the goal is met, a blocker is reported, or the round limit is reached.\",\"de\":\"Bitten Sie um eine Ralph-Schleife mit einem Ziel, und der Agent startet Runde um Runde einen neuen Agenten darauf, bis das Ziel erreicht ist, ein Hindernis gemeldet wird oder das Rundenlimit erreicht ist.\",\"es\":\"Pida un bucle Ralph con un objetivo, y el agente inicia un agente nuevo sobre él ronda tras ronda hasta que se cumpla el objetivo, se informe de un bloqueo o se alcance el límite de rondas.\",\"fr\":\"Demandez une boucle Ralph avec un objectif, et l'agent lance un nouvel agent dessus, tour après tour, jusqu'à ce que l'objectif soit atteint, qu'un blocage soit signalé ou que la limite de tours soit atteinte.\",\"ja\":\"目標を指定して Ralph ループを依頼すると、エージェントは、目標が達成されるか、阻害要因が報告されるか、ラウンド数の上限に達するまで、ラウンドごとに新しいエージェントを起動してその目標に取り組ませます。\",\"pt\":\"Peça um loop Ralph com um objetivo, e o agente inicia um novo agente nele rodada após rodada até que o objetivo seja atingido, um bloqueio seja informado ou o limite de rodadas seja alcançado.\",\"ru\":\"Попросите запустить цикл Ralph с целью, и агент будет раунд за раундом запускать для неё нового агента, пока цель не будет достигнута, не будет сообщено о препятствии или не будет достигнут лимит раундов.\",\"zh\":\"要求用 Ralph 循环完成一个目标后,智能体会一轮接一轮地启动新的智能体处理它,直到目标达成、报告受阻或达到轮次上限。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-session-titles-bundle\",\"title\":{\"en\":\"Conversation-following titles\",\"de\":\"Konversationsfolgende Titel\",\"es\":\"Títulos que siguen la conversación\",\"fr\":\"Titres de suivi de conversation\",\"ja\":\"会話に追従するタイトル\",\"pt\":\"Títulos que acompanham conversas\",\"ru\":\"Заголовки, следующие за разговором\",\"zh\":\"随对话更新的标题\"},\"description\":{\"en\":\"Writes a session's title from all of your messages and updates it when the main task changes, instead of only from the first message.\",\"de\":\"Erstellt den Titel einer Sitzung aus all Ihren Nachrichten und aktualisiert ihn, wenn sich die Hauptaufgabe ändert, statt ihn nur aus der ersten Nachricht zu bilden.\",\"es\":\"Escribe el título de una sesión a partir de todos sus mensajes y lo actualiza cuando cambia la tarea principal, en lugar de basarse solo en el primer mensaje.\",\"fr\":\"Rédige le titre d'une session à partir de tous vos messages et le met à jour lorsque la tâche principale change, au lieu de se baser uniquement sur le premier message.\",\"ja\":\"最初のメッセージだけでなく、あなたのすべてのメッセージからセッションのタイトルを作成し、主なタスクが変わったときに更新します。\",\"pt\":\"Escreve o título de uma sessão a partir de todas as suas mensagens e o atualiza quando a tarefa principal muda, em vez de usar apenas a primeira mensagem.\",\"ru\":\"Составляет заголовок сеанса по всем вашим сообщениям и обновляет его при смене основной задачи, а не только по первому сообщению.\",\"zh\":\"根据你的所有消息生成会话标题,并在主要任务变化时更新,而不是只根据第一条消息生成。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-terminal-bundle\",\"title\":{\"en\":\"Persistent terminals\",\"de\":\"Persistente Terminals\",\"es\":\"Terminales persistentes\",\"fr\":\"Terminaux persistants\",\"ja\":\"永続ターミナル\",\"pt\":\"Terminais persistentes\",\"ru\":\"Постоянные терминалы\",\"zh\":\"持久终端\"},\"description\":{\"en\":\"Gives the agent terminals whose processes keep running in the background between tool calls, so it can start a dev server or REPL and come back later to read output or type input.\",\"de\":\"Stellt dem Agenten Terminals bereit, deren Prozesse zwischen Tool-Aufrufen im Hintergrund weiterlaufen, sodass er einen Entwicklungsserver oder eine REPL starten und später zurückkehren kann, um Ausgaben zu lesen oder Eingaben zu tippen.\",\"es\":\"Proporciona al agente terminales cuyos procesos siguen ejecutándose en segundo plano entre llamadas a herramientas, de modo que puede iniciar un servidor de desarrollo o un REPL y volver más tarde para leer la salida o escribir entradas.\",\"fr\":\"Fournit à l'agent des terminaux dont les processus continuent de s'exécuter en arrière-plan d'un appel d'outil à l'autre, afin qu'il puisse lancer un serveur de développement ou un REPL et revenir plus tard lire la sortie ou saisir des entrées.\",\"ja\":\"エージェントにターミナルを提供します。プロセスはツール呼び出し間もバックグラウンドで実行され続けるため、エージェントは開発サーバーや REPL を起動し、後から出力を読んだり入力したりできます。\",\"pt\":\"Fornece ao agente terminais cujos processos continuam em execução em segundo plano entre chamadas de ferramentas, para que ele possa iniciar um servidor de desenvolvimento ou um REPL e voltar depois para ler a saída ou digitar entradas.\",\"ru\":\"Предоставляет агенту терминалы, процессы которых продолжают работать в фоне между вызовами инструментов, поэтому агент может запустить сервер разработки или REPL и позже вернуться, чтобы прочитать вывод или ввести данные.\",\"zh\":\"为智能体提供终端,进程在多次工具调用之间持续在后台运行,因此它可以启动开发服务器或 REPL,稍后再读取输出或继续输入。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-liveshare\",\"title\":{\"en\":\"LiveShare Session Sharing\",\"de\":\"LiveShare-Sitzungsfreigabe\",\"es\":\"Compartir sesión de LiveShare\",\"fr\":\"Partage de session LiveShare\",\"ja\":\"LiveShare セッション共有\",\"pt\":\"Compartilhamento de sessão do LiveShare\",\"ru\":\"Совместный доступ к сеансу LiveShare\",\"zh\":\"LiveShare 会话分享\"},\"description\":{\"en\":\"Share the current Session with a colleague, who can drive it in a browser, edit files inside the granted workspace when the share allows writes, or branch their own Session from the completed history.\",\"de\":\"Teilt die aktuelle Sitzung mit einer Kollegin oder einem Kollegen, die oder der sie im Browser bedienen, bei erlaubten Schreibzugriffen Dateien im freigegebenen Arbeitsbereich bearbeiten oder aus der abgeschlossenen Historie eine eigene Sitzung abzweigen kann.\",\"es\":\"Comparte la sesión actual con un compañero, que puede dirigirla en un navegador, editar archivos dentro del espacio de trabajo concedido cuando el recurso compartido permite escritura, o crear su propia sesión a partir del historial completado.\",\"fr\":\"Partagez la session en cours avec un collègue, qui peut la piloter dans un navigateur, modifier des fichiers dans l'espace de travail accordé lorsque le partage autorise l'écriture, ou créer sa propre session à partir de l'historique terminé.\",\"ja\":\"現在のセッションを同僚と共有し、ブラウザから操作してもらう、共有が書き込みを許可する場合は付与されたワークスペース内のファイルを編集してもらう、または完了した履歴から自分のセッションを分岐してもらえます。\",\"pt\":\"Compartilhe a sessão atual com um colega, que pode conduzi-la no navegador, editar arquivos dentro do espaço de trabalho concedido quando o compartilhamento permite escrita, ou ramificar a própria sessão a partir do histórico concluído.\",\"ru\":\"Поделитесь текущим сеансом с коллегой: он сможет управлять им в браузере, редактировать файлы в предоставленном рабочем пространстве, если общий доступ разрешает запись, или ответвить собственный сеанс из завершённой истории.\",\"zh\":\"把当前会话分享给同事,对方可在浏览器中驱动该会话、在分享允许写入时修改授权工作区内的文件,或从已完成历史分支出自己的会话。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-session-search\",\"title\":{\"en\":\"Session search\",\"de\":\"Sitzungssuche\",\"es\":\"búsqueda de sesión\",\"fr\":\"Recherche de sessions\",\"ja\":\"セッション検索\",\"pt\":\"Pesquisa de sessão\",\"ru\":\"Поиск сеанса\",\"zh\":\"会话搜索\"},\"description\":{\"en\":\"Ask about earlier work, such as “How did we fix the pool leak last week?”, and the agent searches your earlier sessions in the same workspace to answer.\",\"de\":\"Fragen Sie nach früherer Arbeit, etwa „Wie haben wir letzte Woche das Pool-Leck behoben?“, und der Agent durchsucht Ihre früheren Sitzungen aus demselben Arbeitsverzeichnis, um zu antworten.\",\"es\":\"Pregunte por trabajo anterior, como “¿Cómo arreglamos la fuga del pool la semana pasada?”, y el agente busca en sus sesiones anteriores del mismo directorio de trabajo para responder.\",\"fr\":\"Posez une question sur un travail antérieur, par exemple « Comment avons-nous corrigé la fuite du pool la semaine dernière ? », et l'agent recherche dans vos sessions antérieures du même répertoire de travail pour répondre.\",\"ja\":\"「先週プールのリークをどう直した?」のように以前の作業について尋ねると、エージェントは同じ作業ディレクトリの過去のセッションを検索して回答します。\",\"pt\":\"Pergunte sobre trabalhos anteriores, como “Como corrigimos o vazamento do pool na semana passada?”, e o agente pesquisa suas sessões anteriores do mesmo diretório de trabalho para responder.\",\"ru\":\"Спросите о прошлой работе, например «Как мы исправили утечку в пуле на прошлой неделе?», и агент выполнит поиск по вашим прошлым сеансам из того же рабочего каталога, чтобы ответить.\",\"zh\":\"询问以往的工作,例如“上周我们是怎么修好连接池泄漏的?”,智能体会搜索同一工作区的历史会话来回答。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-tool-worktree\",\"title\":{\"en\":\"Git worktrees\",\"de\":\"Git-Arbeitsbäume\",\"es\":\"árboles de trabajo de git\",\"fr\":\"Arbres de travail Git\",\"ja\":\"Git ワークツリー\",\"pt\":\"Árvores de trabalho Git\",\"ru\":\"Рабочие деревья Git\",\"zh\":\"Git 工作树\"},\"description\":{\"en\":\"Ask for work on a new branch, and the agent creates a separate Git worktree and continues there, leaving your current checkout as it is.\",\"de\":\"Bitten Sie um Arbeit auf einem neuen Branch, und der Agent erstellt einen separaten Git-Worktree und arbeitet dort weiter, während Ihr aktueller Checkout unverändert bleibt.\",\"es\":\"Pida trabajar en una rama nueva, y el agente crea un worktree de Git independiente y continúa allí, dejando su checkout actual tal como está.\",\"fr\":\"Demandez à travailler sur une nouvelle branche, et l'agent crée un worktree Git distinct et y poursuit le travail, en laissant votre copie de travail actuelle telle quelle.\",\"ja\":\"新しいブランチでの作業を依頼すると、エージェントは別の Git ワークツリーを作成してそこで作業を続け、現在のチェックアウトはそのまま残します。\",\"pt\":\"Peça para trabalhar em um novo branch, e o agente cria um worktree do Git separado e continua lá, deixando seu checkout atual como está.\",\"ru\":\"Попросите поработать в новой ветке, и агент создаст отдельное рабочее дерево Git и продолжит работу там, оставив вашу текущую рабочую копию как есть.\",\"zh\":\"要求在新分支上工作时,智能体会创建单独的 Git 工作树并在其中继续,你当前的工作树保持原样。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-plugin-market-bundle\",\"title\":{\"en\":\"Custom plugin indexes\",\"de\":\"Benutzerdefinierte Plugin-Indizes\",\"es\":\"Índices de complementos personalizados\",\"fr\":\"Index de plugins personnalisés\",\"ja\":\"カスタムプラグインインデックス\",\"pt\":\"Índices de plug-ins personalizados\",\"ru\":\"Пользовательские индексы плагинов\",\"zh\":\"自定义插件索引\"},\"description\":{\"en\":\"Adds a Discover tab to Plugins, where you browse and install plugins from plugin indexes you add.\",\"de\":\"Fügt unter „Plugins“ den Tab „Entdecken“ hinzu, in dem Sie Plugins aus von Ihnen hinzugefügten Plugin-Indizes durchsuchen und installieren.\",\"es\":\"Añade la pestaña “Descubrir” a “Complementos”, donde puede explorar e instalar complementos de los índices de complementos que agregue.\",\"fr\":\"Ajoute à « Plugins » un onglet « Découvrez » où vous parcourez et installez des plugins provenant des index de plugins que vous ajoutez.\",\"ja\":\"「プラグイン」に「見つける」タブを追加し、追加したプラグインインデックスからプラグインを閲覧・インストールできるようにします。\",\"pt\":\"Adiciona a guia “Descubra” a “Plug-ins”, onde você navega e instala plug-ins dos índices de plug-ins que adiciona.\",\"ru\":\"Добавляет в «Плагины» вкладку «Откройте для себя», где можно просматривать и устанавливать плагины из добавленных вами индексов плагинов.\",\"zh\":\"在插件页添加“发现”标签页,从你添加的插件索引中浏览和安装插件。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-forge-bundle\",\"title\":{\"en\":\"Change requests & worktrees\",\"de\":\"Änderungsanfragen und Arbeitsbäume\",\"es\":\"Solicitudes de cambio y árboles de trabajo\",\"fr\":\"Demandes de modification et arbres de travail\",\"ja\":\"変更リクエストとワークツリー\",\"pt\":\"Solicitações de alteração e árvores de trabalho\",\"ru\":\"Запросы на изменение и рабочие деревья\",\"zh\":\"变更请求与工作树\"},\"description\":{\"en\":\"Track each Session's change requests with their checks and reviews, and start new Sessions in a Git worktree; uses the gh CLI for GitHub.\",\"de\":\"Verfolgen Sie die Änderungsanfragen jeder Sitzung mit ihren Prüfungen und Reviews und starten Sie neue Sitzungen in einem Git-Arbeitsbaum; nutzt die gh CLI für GitHub.\",\"es\":\"Siga las solicitudes de cambio de cada sesión con sus comprobaciones y revisiones, e inicie nuevas sesiones en un árbol de trabajo de Git; usa la CLI gh para GitHub.\",\"fr\":\"Suivez les demandes de modification de chaque session avec leurs vérifications et revues, et démarrez de nouvelles sessions dans un arbre de travail Git ; utilise la CLI gh pour GitHub.\",\"ja\":\"各セッションの変更リクエストとそのチェックやレビューを追跡し、新しいセッションを Git ワークツリーで開始できます。GitHub には gh CLI を使用します。\",\"pt\":\"Acompanhe as solicitações de alteração de cada sessão com suas verificações e revisões e inicie novas sessões em uma árvore de trabalho Git; usa a CLI gh para o GitHub.\",\"ru\":\"Отслеживайте запросы на изменение каждого сеанса вместе с их проверками и ревью и начинайте новые сеансы в рабочем дереве Git; для GitHub используется gh CLI.\",\"zh\":\"跟踪每个会话的变更请求及其检查与评审,新会话也可以在 Git 工作树中开始;GitHub 通过 gh 命令行访问。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-subagent-claude-code\",\"title\":{\"de\":\"Claude Code-Subagent\",\"en\":\"Claude Code subagent\",\"es\":\"Subagente Claude Code\",\"fr\":\"Sous-agent Claude Code\",\"ja\":\"Claude Code サブエージェント\",\"pt\":\"Claude Code subagente\",\"ru\":\"Субагент Claude Code\",\"zh\":\"Claude Code 子智能体\"},\"description\":{\"de\":\"Delegieren Sie eine Aufgabe an Claude Code, indem Sie dessen native Authentifizierung und Einstellungen verwenden.\",\"en\":\"Delegate one task to Claude Code using its native authentication and settings.\",\"es\":\"Delegue una tarea a Claude Code utilizando su autenticación y configuración nativas.\",\"fr\":\"Déléguez une tâche à Claude Code à l’aide de son authentification et de ses paramètres natifs.\",\"ja\":\"Claude Code のネイティブの認証と設定を使用して、単独のタスクを委派します。\",\"pt\":\"Delegue uma tarefa a Claude Code usando sua autenticação e configurações nativas.\",\"ru\":\"Делегируйте одну задачу Claude Code, используя его встроенную аутентификацию и настройки.\",\"zh\":\"使用 Claude Code 原生认证和设置委派单个任务。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-subagent-codex\",\"title\":{\"de\":\"Codex-Subagent\",\"en\":\"Codex subagent\",\"es\":\"Subagente Codex\",\"fr\":\"Sous-agent Codex\",\"ja\":\"Codex サブエージェント\",\"pt\":\"Codex subagente\",\"ru\":\"Субагент Codex\",\"zh\":\"Codex 子智能体\"},\"description\":{\"de\":\"Delegieren Sie eine Aufgabe an Codex, indem Sie dessen native Authentifizierung und Einstellungen verwenden.\",\"en\":\"Delegate one task to Codex using its native authentication and settings.\",\"es\":\"Delegue una tarea a Codex utilizando su autenticación y configuración nativas.\",\"fr\":\"Déléguez une tâche à Codex à l’aide de son authentification et de ses paramètres natifs.\",\"ja\":\"Codex のネイティブの認証と設定を使用して、単独のタスクを委派します。\",\"pt\":\"Delegue uma tarefa a Codex usando sua autenticação e configurações nativas.\",\"ru\":\"Делегируйте одну задачу Codex, используя его встроенную аутентификацию и настройки.\",\"zh\":\"使用 Codex 原生认证和设置委派单个任务。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-cua-bundle\",\"title\":{\"de\":\"Computernutzung\",\"en\":\"Computer use\",\"es\":\"Uso de la computadora\",\"fr\":\"Utilisation de l'ordinateur\",\"ja\":\"Computer Use\",\"pt\":\"Uso do computador\",\"ru\":\"Использование компьютера\",\"zh\":\"Computer Use\"},\"description\":{\"de\":\"Unterstützt derzeit macOS; Die Windows-Unterstützung befindet sich in der Entwicklung. Lädt die Computernutzung für bestehende und neue PTC-Sitzungen. Wechseln Sie in den PTC-Modus und erteilen Sie die Berechtigungen für Barrierefreiheit und Bildschirmaufzeichnung, um ihn zu verwenden.\",\"en\":\"Currently supports macOS; Windows support is in development. Loads computer use for existing and new PTC sessions. Switch to PTC mode and grant Accessibility and Screen Recording permissions to use it.\",\"es\":\"Actualmente es compatible con macOS; El soporte para Windows está en desarrollo. Carga el uso de la computadora para sesiones PTC nuevas y existentes. Cambie al modo PTC y otorgue permisos de Accesibilidad y Grabación de pantalla para usarlo.\",\"fr\":\"Prend actuellement en charge macOS ; La prise en charge de Windows est en cours de développement. Charge l'utilisation de l'ordinateur pour les sessions PTC existantes et nouvelles. Passez en mode PTC et accordez les autorisations d’accessibilité et d’enregistrement d’écran pour l’utiliser.\",\"ja\":\"現在は macOS のみ対応し、Windows 対応は開発中です。既存および新規の PTC セッションに Computer Use を読み込みます。PTC モードに切り替え、アクセシビリティと画面収録の権限を許可して使用してください。\",\"pt\":\"Atualmente suporta macOS; O suporte Windows está em desenvolvimento. Carrega o uso do computador para sessões PTC novas e existentes. Mude para o modo PTC e conceda permissões de acessibilidade e gravação de tela para usá-lo.\",\"ru\":\"В настоящее время поддерживается macOS; Поддержка Windows находится в разработке. Загружает использование компьютера для существующих и новых сеансов PTC. Переключитесь в режим PTC и предоставьте разрешения на доступность и запись экрана для его использования.\",\"zh\":\"目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。\"},\"enabled\":false}]\n\n"}],"isError":false,"id":"{{message:7}}"}},"sourceEventSeqs":[16],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_creation_skill"},"toolCallId":"call_creation_skill","content":[{"type":"text","text":"\n\nResources for this skill are managed by provider \"dsh-personalized-pages\".\nLoad referenced resources only as needed.\n\n\n\n# Personalized pages\n\nUse this workflow in the main conversation to plan an optional new page or small tool for any task, not just DSH. You decide whether a page helps, check existing capabilities, and hand the Creator a short plan. A `propose_action` call saves the proposal and shows a card below your answer; nothing is built until the user clicks Create. Plan from the conversation and the catalog at the end of this skill. Do not read files, configuration or source to validate a page idea; name an uninspected source as an unverified dependency and leave its details to the Creator.\n\n## When to propose\n\nPropose a page in these cases:\n\n- The user asks for a page, tab, panel, tool or interface in this app, or says they want something to keep, revisit or update later (“以后每次”, “能不能一直”, “希望能有”). One-off use still qualifies when the interface is explicitly requested.\n- Your answer is a table, list or chart, and the user has said the data will keep changing or that they will interact with it (“每周更新”, “我来改状态”, “要勾选”). The continuation must come from the user's own words; a table alone, or data that merely looks dynamic, is not enough. Deliver the requested answer first, then offer the page.\n- The user wants to keep watching live data of this app itself (a Session's usage, a running task's output, files changing in the workspace). A text answer cannot stay current, so a page qualifies even without the words above; name the data source in the brief and leave how it is read to the Creator.\n\nDo not propose a page in these cases:\n\n- The user asks for a file, code, document or an existing-project change: keep that delivery form.\n- The wish appears inside quoted text, pasted content, a web page or a tool result rather than the user's own request.\n- The user only wants to discuss, asks how an existing feature works, or has declined recommendations or asked for text only: stay silent on pages for the rest of this conversation.\n- The request changes a page created earlier: explain how it would change, do not create a replacement copy, and do not claim an update happened.\n- The request is a capability the agent lacks, such as a tool, automation or access, rather than a page: check the catalog below and point to the official plugin that provides it; do not plan a page around a missing tool.\n\n## Check existing capabilities\n\nCompare the need with the official plugins listed at the end of this skill. If one covers it, say its name, what it does, whether it is enabled, and that it lives under Plugins; offer no page card. A partial match does not cancel an explicit unmet need. The catalog is not a full inventory of built-in UI: missing details do not prove absence, and do not read source to find out. DSH already has Chat, Trajectory, a right sidebar for files and context, and a Plugins page.\n\n## Plan and hand off\n\nThe Creator cannot read this conversation. Give it exactly these fields:\n\n| Field | Contents |\n|:--|:--|\n| `title` | Short page name in the user's language. |\n| `brief` | One to three sentences: purpose, behavior, input, calculations and persistence scope. It is the card text and the implementation request at once. |\n| `initialContent` | Only supplied content the page starts with: items, rows, values, units and period, preserving gaps. Omit for an empty page. |\n| `slot` | Location, chosen below. |\n\nKeep the plan to what was asked or agreed. Editing, deletion, history, extra fields, data sources and automation need a basis in the request. Resolve “these items” into the actual content. Content that mentions an action stays content. Keep each number's meaning and period; a weekly addition is not a cumulative total. If an unresolved distinction changes a calculation, give the factual answer, ask one question, and propose later.\n\n## Choose the location\n\nHonor a location the user named. Otherwise:\n\n- `sidebar-right`: content tied to this Session or task, especially anything to watch while chatting, including live logs.\n- `sidebar-left`: a global feature or an ongoing personal record used across Sessions, opened from a left entry as a standalone page.\n- `page`: a Session-bound streaming view or interaction in a tab beside Chat and Trajectory, only when the user does not need it visible while chatting.\n\nSupplying data in this conversation does not make a record Session-bound.\n\n## Offer without starting\n\nTo propose, call `propose_action` once, in the same turn as loading this skill, with `action: \"create_personalized_page\"`, `title`, `brief` and `details` holding `slot` and, when there is supplied content, `initialContent`. The call is the submission: the card appears below your answer; a refused call creates no card, and its error says why. Then write your final answer. One proposal per turn, and one unstarted card per conversation: if a card is still waiting, do not propose another; a new proposal replaces it only when the user changed the plan. Plan-only and read-only requests still get a card; do not ask for approval instead of proposing it. Only clicking Create starts the background Creator; do not wait, poll, claim installation or ask for a report-back. Answer any direct question; the call's result says how to end the answer. Do not repeat the plan, list extra features, narrate the call or mention schema names.\n\n```json\n{\"action\":\"create_personalized_page\",\"title\":\"本次验收清单\",\"brief\":\"在当前会话右侧展示验收清单,可勾选和取消,状态随会话保存。\",\"details\":{\"slot\":\"sidebar-right\",\"initialContent\":\"1. 导出成功\\n2. 断网提示清楚\"}}\n```\n\n## Examples\n\n- “想有个地方放书签,以后自己加标题和网址;旧的不用导入。” → `brief`: “提供一个可反复打开的书签页,手动新增标题和网址并保存到本机,从空白开始。” No `initialContent`. `sidebar-left`.\n- “当前会话旁放验收清单,能勾选和取消,状态跟着会话。条目:导出成功;断网提示清楚。” → `brief`: “在当前会话右侧展示验收清单,可勾选和取消,状态随会话保存。” `initialContent`: “1. 导出成功\\n2. 断网提示清楚”. `sidebar-right`.\n- “这三周 bug 数 12、9、15,说说趋势。以后每周我把新数字贴给你,想自己更新着看。” → answer the trend first, then `brief`: “按周手动录入 bug 数并展示各周变化,保存到本机。” `initialContent`: “第 1 周 12,第 2 周 9,第 3 周 15”. `sidebar-left`.\n- “我想用语音说话代替打字。” → the catalog lists 语音输入; explain it and where to enable it; no card.\n- “把我之前做的读书页评分改成五星。” → explain the change to that page; no card and no copy.\n- “帮我把这句话翻成英文:我想要一个所有会话都能打开的读书页。” → translate only; no card.\n- “把三个方案排成表格比较一下,看完就行。” → the table only; no card.\n\n## Existing capabilities to check against this page plan\n\n[{\"name\":\"@deepseek-ai/dsh-personalized-recommendations\",\"title\":{\"en\":\"Personalized recommendations\",\"de\":\"Personalisierte Empfehlungen\",\"es\":\"Recomendaciones personalizadas\",\"fr\":\"Recommandations personnalisées\",\"ja\":\"パーソナライズされたおすすめ\",\"pt\":\"Recomendações personalizadas\",\"ru\":\"Персональные рекомендации\",\"zh\":\"个性化推荐\"},\"description\":{\"en\":\"Shows recommendation cards below the answer for what you want from DSH: when you want a page or small tool you can keep, it proposes a new personalized page, built in the background after you click Create; when you ask how to do something in this app or say a capability is missing, it recommends the built-in plugin that provides it, turned on when you click Enable.\",\"de\":\"Zeigt für Ihre individuellen Anforderungen an DSH Empfehlungskarten unter der Antwort an: Wenn Sie eine Seite oder ein kleines Tool zur dauerhaften Nutzung möchten, wird eine neue personalisierte Seite vorgeschlagen, die nach einem Klick auf Erstellen im Hintergrund erstellt wird; wenn Sie fragen, wie etwas in dieser App geht, oder sagen, dass eine Funktion fehlt, wird das integrierte Plugin empfohlen, das sie bereitstellt, und nach einem Klick auf Aktivieren eingeschaltet.\",\"es\":\"Muestra tarjetas de recomendación debajo de la respuesta para tus necesidades personalizadas en DSH: cuando quieres una página o una pequeña herramienta para conservar, propone una nueva página personalizada, que se genera en segundo plano al hacer clic en Crear; cuando preguntas cómo hacer algo en esta aplicación o dices que falta una función, recomienda el complemento integrado que la ofrece, que se activa al hacer clic en Habilitar.\",\"fr\":\"Affiche des cartes de recommandation sous la réponse pour vos besoins personnalisés dans DSH : lorsque vous voulez une page ou un petit outil à conserver, propose une nouvelle page personnalisée, générée en arrière-plan après un clic sur Créer ; lorsque vous demandez comment faire quelque chose dans cette application ou signalez qu’une fonctionnalité manque, recommande le plugin intégré qui la fournit, activé lorsque vous cliquez sur Activer.\",\"ja\":\"DSH に対するあなた個人のニーズに合わせて、回答の下におすすめカードを表示します。長く使えるページや小さなツールが欲しいときは新しいパーソナルページを提案し、「作成」をクリックするとバックグラウンドで生成します。このアプリで何かをする方法を尋ねたときや機能が足りないと言ったときは、それを提供する内蔵プラグインを推薦し、「有効にする」をクリックすると有効になります。\",\"pt\":\"Mostra cartões de recomendação abaixo da resposta para suas necessidades personalizadas no DSH: quando você quer uma página ou uma pequena ferramenta para manter, propõe uma nova página personalizada, gerada em segundo plano depois que você clica em Criar; quando você pergunta como fazer algo neste aplicativo ou diz que falta um recurso, recomenda o plug-in integrado que o oferece, ativado quando você clica em Ativar.\",\"ru\":\"Показывает карточки рекомендаций под ответом с учетом ваших индивидуальных потребностей в DSH: когда вам нужна страница или небольшой инструмент для постоянного использования, предлагает новую персональную страницу, которая создается в фоне после нажатия «Создать»; когда вы спрашиваете, как что-то сделать в этом приложении, или говорите, что не хватает какой-то возможности, рекомендует встроенный плагин, который ее предоставляет, и включает его после нажатия «Включить».\",\"zh\":\"针对你对 DSH 的个性化需求,在回答下方展示推荐卡片:想要能长期使用的页面或小工具时,提议新增个性化页面,点击创建后在后台生成;问到本应用怎么做某件事或缺少某项能力时,推荐对应的内置插件,点击启用后开启。\"},\"enabled\":true},{\"name\":\"@deepseek-ai/dsh-experimental-agent-team-profile\",\"title\":{\"en\":\"Agent teams\",\"de\":\"Agententeams\",\"es\":\"Equipos de agentes\",\"fr\":\"Équipes d'agents\",\"ja\":\"エージェントチーム\",\"pt\":\"Equipes de agentes\",\"ru\":\"Команды агентов\",\"zh\":\"智能体团队\"},\"description\":{\"en\":\"Lets the agent you talk to split a job among named teammates, track it on a shared task board, and report back with their results.\",\"de\":\"Ermöglicht dem Agenten, mit dem Sie sprechen, eine Aufgabe auf benannte Teammitglieder aufzuteilen, sie auf einem gemeinsamen Aufgabenboard zu verfolgen und Ihnen mit deren Ergebnissen zu berichten.\",\"es\":\"Permite que el agente con el que habla reparta un trabajo entre compañeros con nombre, lo siga en un tablero de tareas compartido y le informe con sus resultados.\",\"fr\":\"Permet à l'agent avec lequel vous parlez de répartir un travail entre des coéquipiers nommés, de le suivre sur un tableau de tâches partagé et de vous rendre compte de leurs résultats.\",\"ja\":\"対話しているエージェントが、仕事を名前付きのチームメイトに分担させ、共有タスクボードで進捗を追跡し、チームメイトの結果をまとめて報告します。\",\"pt\":\"Permite que o agente com quem você conversa divida um trabalho entre colegas de equipe nomeados, acompanhe-o em um quadro de tarefas compartilhado e informe você com os resultados deles.\",\"ru\":\"Позволяет агенту, с которым вы общаетесь, распределить работу между именованными участниками команды, отслеживать её на общей доске задач и отчитываться перед вами об их результатах.\",\"zh\":\"让与你对话的智能体把工作分给有名字的队友,在共享任务看板上跟踪进度,并汇总队友的结果向你汇报。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-inspector-profile\",\"title\":{\"en\":\"Developer tools\",\"de\":\"Entwicklertools\",\"es\":\"Herramientas de desarrollador\",\"fr\":\"Outils de développement\",\"ja\":\"開発者ツール\",\"pt\":\"Ferramentas de desenvolvedor\",\"ru\":\"Инструменты разработчика\",\"zh\":\"开发者工具\"},\"description\":{\"en\":\"For troubleshooting: view what DSH recorded for a session in Session log, inspect per-turn and per-step statistics in the Statistics tab, and debug the DSH backend in Chrome DevTools.\",\"de\":\"Zur Fehlersuche: Sehen Sie in „Sitzungsprotokoll“, was DSH für eine Sitzung aufgezeichnet hat, prüfen Sie im Tab „Statistik“ Kontext, Dauer, Cache und Werkzeugkosten pro Runde und Schritt und debuggen Sie das DSH-Backend in Chrome DevTools.\",\"es\":\"Para solucionar problemas: vea en «Registro de sesión» lo que DSH registró de una sesión, consulte el contexto, los tiempos, la caché y el coste de herramientas por turno y paso en la pestaña «Estadísticas», y depure el backend de DSH en Chrome DevTools.\",\"fr\":\"Pour le dépannage : consultez dans « Journal de session » ce que DSH a enregistré pour une session, examinez le contexte, les durées, le cache et le coût des outils par tour et étape dans l’onglet « Statistiques », et déboguez le backend de DSH dans Chrome DevTools.\",\"ja\":\"トラブルシューティング用です。「セッションログ」で DSH がセッションについて記録した内容を確認し、「統計」タブでターン/ステップごとのコンテキスト、時間、キャッシュ、ツールコストを確認し、Chrome DevTools で DSH バックエンドをデバッグできます。\",\"pt\":\"Para solução de problemas: veja em «Registro da sessão» o que o DSH registrou de uma sessão, consulte contexto, tempos, cache e custo de ferramentas por turno e etapa na aba «Estatísticas» e depure o backend do DSH no Chrome DevTools.\",\"ru\":\"Для диагностики: просматривайте в «Журнале сеанса», что DSH записал для сеанса, смотрите контекст, время, кэш и стоимость инструментов по ходам и шагам на вкладке «Статистика» и отлаживайте бэкенд DSH в Chrome DevTools.\",\"zh\":\"用于排查问题:在「会话数据诊断」中查看 DSH 为会话记录的内容,在「统计」页签查看每轮/每步的上下文、耗时、缓存和工具成本,并在 Chrome DevTools 中调试 DSH 后端。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-voice-input-bundle\",\"title\":{\"en\":\"Voice input\",\"de\":\"Spracheingabe\",\"es\":\"Entrada de voz\",\"fr\":\"Saisie vocale\",\"ja\":\"音声入力\",\"pt\":\"Entrada de voz\",\"ru\":\"Голосовой ввод\",\"zh\":\"语音输入\"},\"description\":{\"en\":\"Transcribe recordings locally with SenseVoice; first use requires installing dependencies.\",\"de\":\"Transkribieren Sie Aufnahmen lokal mit SenseVoice; Für die erste Verwendung müssen Abhängigkeiten installiert werden.\",\"es\":\"Transcribe grabaciones localmente con SenseVoice; El primer uso requiere instalar dependencias.\",\"fr\":\"Transcrivez les enregistrements localement avec SenseVoice ; la première utilisation nécessite l'installation de dépendances.\",\"ja\":\"ローカルの SenseVoice で録音を文字に起こします。初回利用時には依存関係のインストールが必要です。\",\"pt\":\"Transcreva gravações localmente com SenseVoice; o primeiro uso requer a instalação de dependências.\",\"ru\":\"Транскрибируйте записи локально с помощью SenseVoice; при первом использовании требуется установка зависимостей.\",\"zh\":\"在本机使用 SenseVoice 转写录音,首次使用需安装依赖。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-auto-review\",\"title\":{\"en\":\"Auto authorization review\",\"de\":\"Automatische Autorisierungsüberprüfung\",\"es\":\"Revisión de autorización automática\",\"fr\":\"Examen des autorisations automatiques\",\"ja\":\"自動認可レビュー\",\"pt\":\"Revisão de autorização automática\",\"ru\":\"Проверка автоматической авторизации\",\"zh\":\"自动授权审查\"},\"description\":{\"en\":\"Adds an Auto review access mode: before each tool call, a model checks it against what you asked for, runs allowed calls at once, and holds denied calls for your approval.\",\"de\":\"Fügt den Zugriffsmodus „Auto review“ hinzu: Vor jedem Tool-Aufruf prüft ein Modell den Aufruf anhand Ihrer Anforderung; zugelassene Aufrufe laufen sofort, abgelehnte Aufrufe warten auf Ihre Genehmigung.\",\"es\":\"Añade el modo de acceso “Auto review”: antes de cada llamada a una herramienta, un modelo comprueba la llamada frente a lo que usted pidió; las llamadas permitidas se ejecutan de inmediato y las denegadas esperan su aprobación.\",\"fr\":\"Ajoute le mode d'accès « Auto review » : avant chaque appel d'outil, un modèle vérifie l'appel par rapport à ce que vous avez demandé ; les appels autorisés s'exécutent immédiatement et les appels refusés attendent votre approbation.\",\"ja\":\"「自動認可レビュー」アクセスモードを追加します。各ツール呼び出しの前に、モデルがその呼び出しをあなたの依頼内容と照らし合わせて確認し、許可された呼び出しは即座に実行され、拒否された呼び出しはあなたの承認を待ちます。\",\"pt\":\"Adiciona o modo de acesso “Auto review”: antes de cada chamada de ferramenta, um modelo verifica a chamada em relação ao que você pediu; as chamadas permitidas são executadas imediatamente e as negadas aguardam sua aprovação.\",\"ru\":\"Добавляет режим доступа «Auto review»: перед каждым вызовом инструмента модель сверяет вызов с вашей просьбой; разрешённые вызовы выполняются сразу, а отклонённые ждут вашего одобрения.\",\"zh\":\"提供“自动审查”访问模式:每次工具调用前,由模型对照你的要求检查,获准的调用立即运行,被拒绝的调用等你审批。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-cot-translation-bundle\",\"title\":{\"en\":\"Machine translation of reasoning\",\"de\":\"Maschinelle Übersetzung der Argumentation\",\"es\":\"Traducción automática de razonamiento\",\"fr\":\"Traduction automatique du raisonnement\",\"ja\":\"思考内容の機械翻訳\",\"pt\":\"Tradução automática de raciocínio\",\"ru\":\"Машинный перевод рассуждений\",\"zh\":\"思考过程机器翻译\"},\"description\":{\"en\":\"Translates expanded Think rows into your chosen language as the agent writes them, and View original switches back.\",\"de\":\"Übersetzt aufgeklappte „Denken Sie nach“-Zeilen, während der Agent sie schreibt, in die von Ihnen gewählte Sprache, und „Original ansehen“ wechselt zurück.\",\"es\":\"Traduce las filas “pensar” expandidas al idioma que elija mientras el agente las escribe, y “Ver original” vuelve al texto original.\",\"fr\":\"Traduit les lignes « Pensez » développées dans la langue de votre choix pendant que l'agent les écrit, et « Voir l'original » permet de revenir au texte d'origine.\",\"ja\":\"エージェントが書き出すのと同時に、展開された「思考」行を選択した言語に翻訳します。「原文を表示」で原文に戻せます。\",\"pt\":\"Traduz as linhas “Pense” expandidas para o idioma que você escolher enquanto o agente as escreve, e “Ver original” volta ao texto original.\",\"ru\":\"Переводит развёрнутые строки «Подумайте» на выбранный вами язык по мере того, как агент их пишет, а «Посмотреть оригинал» возвращает исходный текст.\",\"zh\":\"智能体输出时,把展开的“思考”行翻译成你选择的语言,点击“查看原文”可切换回原文。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-badge-skill-bundle\",\"title\":{\"en\":\"DSH badge skill\",\"de\":\"DSH-Abzeichen-Fertigkeit\",\"es\":\"Habilidad de insignia DSH\",\"fr\":\"Compétence du badge DSH\",\"ja\":\"DSH バッジスキル\",\"pt\":\"DSH habilidade de distintivo\",\"ru\":\"DSH значок навыка\",\"zh\":\"DSH 徽章技能\"},\"description\":{\"en\":\"Let agents add a “powered by dsh” badge to documents, pull requests, and other outputs.\",\"de\":\"Lassen Sie Agenten Dokumente, Pull-Requests und andere Ausgaben mit einem „powered by dsh“-Badge versehen.\",\"es\":\"Permita que los agentes agreguen una insignia \\\"con tecnología de dsh\\\" a documentos, solicitudes de extracción y otros resultados.\",\"fr\":\"Laissez les agents ajouter un badge « propulsé par dsh » aux documents, demandes d'extraction et autres sorties.\",\"ja\":\"エージェントが文書や PR などの成果物に「powered by dsh」バッジを追加できます。\",\"pt\":\"Permita que os agentes adicionem um selo “powered by dsh” a documentos, pull requests e outros resultados.\",\"ru\":\"Позвольте агентам добавлять значок «Powered by dsh» к документам, запросам на включение и другим результатам.\",\"zh\":\"让 Agent 为文档、PR 等产出添加「powered by dsh」徽章。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-ralph-bundle\",\"title\":{\"en\":\"Ralph loops\",\"de\":\"Ralph-Loops\",\"es\":\"Bucles de Ralph\",\"fr\":\"Boucles Ralph\",\"ja\":\"Ralph ループ\",\"pt\":\"Loops de Ralph\",\"ru\":\"Ральф петли\",\"zh\":\"Ralph 循环\"},\"description\":{\"en\":\"Ask for a Ralph loop with a goal, and the agent starts a fresh agent on it round after round until the goal is met, a blocker is reported, or the round limit is reached.\",\"de\":\"Bitten Sie um eine Ralph-Schleife mit einem Ziel, und der Agent startet Runde um Runde einen neuen Agenten darauf, bis das Ziel erreicht ist, ein Hindernis gemeldet wird oder das Rundenlimit erreicht ist.\",\"es\":\"Pida un bucle Ralph con un objetivo, y el agente inicia un agente nuevo sobre él ronda tras ronda hasta que se cumpla el objetivo, se informe de un bloqueo o se alcance el límite de rondas.\",\"fr\":\"Demandez une boucle Ralph avec un objectif, et l'agent lance un nouvel agent dessus, tour après tour, jusqu'à ce que l'objectif soit atteint, qu'un blocage soit signalé ou que la limite de tours soit atteinte.\",\"ja\":\"目標を指定して Ralph ループを依頼すると、エージェントは、目標が達成されるか、阻害要因が報告されるか、ラウンド数の上限に達するまで、ラウンドごとに新しいエージェントを起動してその目標に取り組ませます。\",\"pt\":\"Peça um loop Ralph com um objetivo, e o agente inicia um novo agente nele rodada após rodada até que o objetivo seja atingido, um bloqueio seja informado ou o limite de rodadas seja alcançado.\",\"ru\":\"Попросите запустить цикл Ralph с целью, и агент будет раунд за раундом запускать для неё нового агента, пока цель не будет достигнута, не будет сообщено о препятствии или не будет достигнут лимит раундов.\",\"zh\":\"要求用 Ralph 循环完成一个目标后,智能体会一轮接一轮地启动新的智能体处理它,直到目标达成、报告受阻或达到轮次上限。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-session-titles-bundle\",\"title\":{\"en\":\"Conversation-following titles\",\"de\":\"Konversationsfolgende Titel\",\"es\":\"Títulos que siguen la conversación\",\"fr\":\"Titres de suivi de conversation\",\"ja\":\"会話に追従するタイトル\",\"pt\":\"Títulos que acompanham conversas\",\"ru\":\"Заголовки, следующие за разговором\",\"zh\":\"随对话更新的标题\"},\"description\":{\"en\":\"Writes a session's title from all of your messages and updates it when the main task changes, instead of only from the first message.\",\"de\":\"Erstellt den Titel einer Sitzung aus all Ihren Nachrichten und aktualisiert ihn, wenn sich die Hauptaufgabe ändert, statt ihn nur aus der ersten Nachricht zu bilden.\",\"es\":\"Escribe el título de una sesión a partir de todos sus mensajes y lo actualiza cuando cambia la tarea principal, en lugar de basarse solo en el primer mensaje.\",\"fr\":\"Rédige le titre d'une session à partir de tous vos messages et le met à jour lorsque la tâche principale change, au lieu de se baser uniquement sur le premier message.\",\"ja\":\"最初のメッセージだけでなく、あなたのすべてのメッセージからセッションのタイトルを作成し、主なタスクが変わったときに更新します。\",\"pt\":\"Escreve o título de uma sessão a partir de todas as suas mensagens e o atualiza quando a tarefa principal muda, em vez de usar apenas a primeira mensagem.\",\"ru\":\"Составляет заголовок сеанса по всем вашим сообщениям и обновляет его при смене основной задачи, а не только по первому сообщению.\",\"zh\":\"根据你的所有消息生成会话标题,并在主要任务变化时更新,而不是只根据第一条消息生成。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-terminal-bundle\",\"title\":{\"en\":\"Persistent terminals\",\"de\":\"Persistente Terminals\",\"es\":\"Terminales persistentes\",\"fr\":\"Terminaux persistants\",\"ja\":\"永続ターミナル\",\"pt\":\"Terminais persistentes\",\"ru\":\"Постоянные терминалы\",\"zh\":\"持久终端\"},\"description\":{\"en\":\"Gives the agent terminals whose processes keep running in the background between tool calls, so it can start a dev server or REPL and come back later to read output or type input.\",\"de\":\"Stellt dem Agenten Terminals bereit, deren Prozesse zwischen Tool-Aufrufen im Hintergrund weiterlaufen, sodass er einen Entwicklungsserver oder eine REPL starten und später zurückkehren kann, um Ausgaben zu lesen oder Eingaben zu tippen.\",\"es\":\"Proporciona al agente terminales cuyos procesos siguen ejecutándose en segundo plano entre llamadas a herramientas, de modo que puede iniciar un servidor de desarrollo o un REPL y volver más tarde para leer la salida o escribir entradas.\",\"fr\":\"Fournit à l'agent des terminaux dont les processus continuent de s'exécuter en arrière-plan d'un appel d'outil à l'autre, afin qu'il puisse lancer un serveur de développement ou un REPL et revenir plus tard lire la sortie ou saisir des entrées.\",\"ja\":\"エージェントにターミナルを提供します。プロセスはツール呼び出し間もバックグラウンドで実行され続けるため、エージェントは開発サーバーや REPL を起動し、後から出力を読んだり入力したりできます。\",\"pt\":\"Fornece ao agente terminais cujos processos continuam em execução em segundo plano entre chamadas de ferramentas, para que ele possa iniciar um servidor de desenvolvimento ou um REPL e voltar depois para ler a saída ou digitar entradas.\",\"ru\":\"Предоставляет агенту терминалы, процессы которых продолжают работать в фоне между вызовами инструментов, поэтому агент может запустить сервер разработки или REPL и позже вернуться, чтобы прочитать вывод или ввести данные.\",\"zh\":\"为智能体提供终端,进程在多次工具调用之间持续在后台运行,因此它可以启动开发服务器或 REPL,稍后再读取输出或继续输入。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-liveshare\",\"title\":{\"en\":\"LiveShare Session Sharing\",\"de\":\"LiveShare-Sitzungsfreigabe\",\"es\":\"Compartir sesión de LiveShare\",\"fr\":\"Partage de session LiveShare\",\"ja\":\"LiveShare セッション共有\",\"pt\":\"Compartilhamento de sessão do LiveShare\",\"ru\":\"Совместный доступ к сеансу LiveShare\",\"zh\":\"LiveShare 会话分享\"},\"description\":{\"en\":\"Share the current Session with a colleague, who can drive it in a browser, edit files inside the granted workspace when the share allows writes, or branch their own Session from the completed history.\",\"de\":\"Teilt die aktuelle Sitzung mit einer Kollegin oder einem Kollegen, die oder der sie im Browser bedienen, bei erlaubten Schreibzugriffen Dateien im freigegebenen Arbeitsbereich bearbeiten oder aus der abgeschlossenen Historie eine eigene Sitzung abzweigen kann.\",\"es\":\"Comparte la sesión actual con un compañero, que puede dirigirla en un navegador, editar archivos dentro del espacio de trabajo concedido cuando el recurso compartido permite escritura, o crear su propia sesión a partir del historial completado.\",\"fr\":\"Partagez la session en cours avec un collègue, qui peut la piloter dans un navigateur, modifier des fichiers dans l'espace de travail accordé lorsque le partage autorise l'écriture, ou créer sa propre session à partir de l'historique terminé.\",\"ja\":\"現在のセッションを同僚と共有し、ブラウザから操作してもらう、共有が書き込みを許可する場合は付与されたワークスペース内のファイルを編集してもらう、または完了した履歴から自分のセッションを分岐してもらえます。\",\"pt\":\"Compartilhe a sessão atual com um colega, que pode conduzi-la no navegador, editar arquivos dentro do espaço de trabalho concedido quando o compartilhamento permite escrita, ou ramificar a própria sessão a partir do histórico concluído.\",\"ru\":\"Поделитесь текущим сеансом с коллегой: он сможет управлять им в браузере, редактировать файлы в предоставленном рабочем пространстве, если общий доступ разрешает запись, или ответвить собственный сеанс из завершённой истории.\",\"zh\":\"把当前会话分享给同事,对方可在浏览器中驱动该会话、在分享允许写入时修改授权工作区内的文件,或从已完成历史分支出自己的会话。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-session-search\",\"title\":{\"en\":\"Session search\",\"de\":\"Sitzungssuche\",\"es\":\"búsqueda de sesión\",\"fr\":\"Recherche de sessions\",\"ja\":\"セッション検索\",\"pt\":\"Pesquisa de sessão\",\"ru\":\"Поиск сеанса\",\"zh\":\"会话搜索\"},\"description\":{\"en\":\"Ask about earlier work, such as “How did we fix the pool leak last week?”, and the agent searches your earlier sessions in the same workspace to answer.\",\"de\":\"Fragen Sie nach früherer Arbeit, etwa „Wie haben wir letzte Woche das Pool-Leck behoben?“, und der Agent durchsucht Ihre früheren Sitzungen aus demselben Arbeitsverzeichnis, um zu antworten.\",\"es\":\"Pregunte por trabajo anterior, como “¿Cómo arreglamos la fuga del pool la semana pasada?”, y el agente busca en sus sesiones anteriores del mismo directorio de trabajo para responder.\",\"fr\":\"Posez une question sur un travail antérieur, par exemple « Comment avons-nous corrigé la fuite du pool la semaine dernière ? », et l'agent recherche dans vos sessions antérieures du même répertoire de travail pour répondre.\",\"ja\":\"「先週プールのリークをどう直した?」のように以前の作業について尋ねると、エージェントは同じ作業ディレクトリの過去のセッションを検索して回答します。\",\"pt\":\"Pergunte sobre trabalhos anteriores, como “Como corrigimos o vazamento do pool na semana passada?”, e o agente pesquisa suas sessões anteriores do mesmo diretório de trabalho para responder.\",\"ru\":\"Спросите о прошлой работе, например «Как мы исправили утечку в пуле на прошлой неделе?», и агент выполнит поиск по вашим прошлым сеансам из того же рабочего каталога, чтобы ответить.\",\"zh\":\"询问以往的工作,例如“上周我们是怎么修好连接池泄漏的?”,智能体会搜索同一工作区的历史会话来回答。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-tool-worktree\",\"title\":{\"en\":\"Git worktrees\",\"de\":\"Git-Arbeitsbäume\",\"es\":\"árboles de trabajo de git\",\"fr\":\"Arbres de travail Git\",\"ja\":\"Git ワークツリー\",\"pt\":\"Árvores de trabalho Git\",\"ru\":\"Рабочие деревья Git\",\"zh\":\"Git 工作树\"},\"description\":{\"en\":\"Ask for work on a new branch, and the agent creates a separate Git worktree and continues there, leaving your current checkout as it is.\",\"de\":\"Bitten Sie um Arbeit auf einem neuen Branch, und der Agent erstellt einen separaten Git-Worktree und arbeitet dort weiter, während Ihr aktueller Checkout unverändert bleibt.\",\"es\":\"Pida trabajar en una rama nueva, y el agente crea un worktree de Git independiente y continúa allí, dejando su checkout actual tal como está.\",\"fr\":\"Demandez à travailler sur une nouvelle branche, et l'agent crée un worktree Git distinct et y poursuit le travail, en laissant votre copie de travail actuelle telle quelle.\",\"ja\":\"新しいブランチでの作業を依頼すると、エージェントは別の Git ワークツリーを作成してそこで作業を続け、現在のチェックアウトはそのまま残します。\",\"pt\":\"Peça para trabalhar em um novo branch, e o agente cria um worktree do Git separado e continua lá, deixando seu checkout atual como está.\",\"ru\":\"Попросите поработать в новой ветке, и агент создаст отдельное рабочее дерево Git и продолжит работу там, оставив вашу текущую рабочую копию как есть.\",\"zh\":\"要求在新分支上工作时,智能体会创建单独的 Git 工作树并在其中继续,你当前的工作树保持原样。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-plugin-market-bundle\",\"title\":{\"en\":\"Custom plugin indexes\",\"de\":\"Benutzerdefinierte Plugin-Indizes\",\"es\":\"Índices de complementos personalizados\",\"fr\":\"Index de plugins personnalisés\",\"ja\":\"カスタムプラグインインデックス\",\"pt\":\"Índices de plug-ins personalizados\",\"ru\":\"Пользовательские индексы плагинов\",\"zh\":\"自定义插件索引\"},\"description\":{\"en\":\"Adds a Discover tab to Plugins, where you browse and install plugins from plugin indexes you add.\",\"de\":\"Fügt unter „Plugins“ den Tab „Entdecken“ hinzu, in dem Sie Plugins aus von Ihnen hinzugefügten Plugin-Indizes durchsuchen und installieren.\",\"es\":\"Añade la pestaña “Descubrir” a “Complementos”, donde puede explorar e instalar complementos de los índices de complementos que agregue.\",\"fr\":\"Ajoute à « Plugins » un onglet « Découvrez » où vous parcourez et installez des plugins provenant des index de plugins que vous ajoutez.\",\"ja\":\"「プラグイン」に「見つける」タブを追加し、追加したプラグインインデックスからプラグインを閲覧・インストールできるようにします。\",\"pt\":\"Adiciona a guia “Descubra” a “Plug-ins”, onde você navega e instala plug-ins dos índices de plug-ins que adiciona.\",\"ru\":\"Добавляет в «Плагины» вкладку «Откройте для себя», где можно просматривать и устанавливать плагины из добавленных вами индексов плагинов.\",\"zh\":\"在插件页添加“发现”标签页,从你添加的插件索引中浏览和安装插件。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-forge-bundle\",\"title\":{\"en\":\"Change requests & worktrees\",\"de\":\"Änderungsanfragen und Arbeitsbäume\",\"es\":\"Solicitudes de cambio y árboles de trabajo\",\"fr\":\"Demandes de modification et arbres de travail\",\"ja\":\"変更リクエストとワークツリー\",\"pt\":\"Solicitações de alteração e árvores de trabalho\",\"ru\":\"Запросы на изменение и рабочие деревья\",\"zh\":\"变更请求与工作树\"},\"description\":{\"en\":\"Keep linked GitHub pull requests beside your conversation: follow checks and reviews, inspect diffs, and send feedback to the agent. Review local changes and use a separate worktree to isolate each task. GitHub features require the gh CLI installed and signed in on the host.\",\"de\":\"Verknüpfte GitHub-Pull-Requests neben der Unterhaltung verfolgen: Prüfungen und Reviews ansehen, Code vergleichen und Feedback an den Agenten senden. Lokale Änderungen prüfen und Aufgaben mit separaten Arbeitsbäumen isolieren. GitHub-Funktionen erfordern eine installierte gh-CLI und eine Anmeldung auf dem Host.\",\"es\":\"Sigue los pull requests de GitHub vinculados a la conversación: consulta comprobaciones y revisiones, compara código y envía comentarios al agente. Revisa cambios locales y aísla cada tarea en un árbol de trabajo separado. Las funciones de GitHub requieren instalar la CLI gh e iniciar sesión en el equipo anfitrión.\",\"fr\":\"Suivez les pull requests GitHub liées à votre conversation : consultez les vérifications et les revues, comparez le code et transmettez vos remarques à l’agent. Examinez les modifications locales et isolez chaque tâche dans un arbre de travail séparé. Les fonctions GitHub nécessitent la CLI gh installée et une connexion sur l’hôte.\",\"ja\":\"会話のサイドバーで関連付けられた GitHub PR をまとめて確認できます。チェックやレビューの状況、コードの差分を確認し、修正案をエージェントに渡せます。ローカルの変更確認や、独立したワークツリーによるタスクの分離にも対応します。GitHub 機能にはホストでの gh CLI のインストールとログインが必要です。\",\"pt\":\"Acompanhe os pull requests do GitHub vinculados à conversa: consulte verificações e revisões, compare código e envie comentários ao agente. Revise alterações locais e isole cada tarefa em uma árvore de trabalho separada. Os recursos do GitHub exigem a CLI gh instalada e uma sessão iniciada no host.\",\"ru\":\"Следите за связанными с беседой pull request GitHub: проверками, рецензиями и различиями в коде, а затем передавайте замечания агенту. Просматривайте локальные изменения и изолируйте задачи в отдельных рабочих деревьях. Для функций GitHub требуется установить CLI gh и войти в аккаунт на хосте.\",\"zh\":\"在会话侧栏集中跟踪关联的 GitHub PR:查看检查状态、评审意见与代码差异,并把修改建议交给智能体继续处理。也可查看本地改动,用独立工作树隔离任务。GitHub 功能需要本机安装 gh 并登录。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-subagent-claude-code\",\"title\":{\"de\":\"Claude Code-Subagent\",\"en\":\"Claude Code subagent\",\"es\":\"Subagente Claude Code\",\"fr\":\"Sous-agent Claude Code\",\"ja\":\"Claude Code サブエージェント\",\"pt\":\"Claude Code subagente\",\"ru\":\"Субагент Claude Code\",\"zh\":\"Claude Code 子智能体\"},\"description\":{\"de\":\"Delegieren Sie eine Aufgabe an Claude Code, indem Sie dessen native Authentifizierung und Einstellungen verwenden.\",\"en\":\"Delegate one task to Claude Code using its native authentication and settings.\",\"es\":\"Delegue una tarea a Claude Code utilizando su autenticación y configuración nativas.\",\"fr\":\"Déléguez une tâche à Claude Code à l’aide de son authentification et de ses paramètres natifs.\",\"ja\":\"Claude Code のネイティブの認証と設定を使用して、単独のタスクを委派します。\",\"pt\":\"Delegue uma tarefa a Claude Code usando sua autenticação e configurações nativas.\",\"ru\":\"Делегируйте одну задачу Claude Code, используя его встроенную аутентификацию и настройки.\",\"zh\":\"使用 Claude Code 原生认证和设置委派单个任务。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-subagent-codex\",\"title\":{\"de\":\"Codex-Subagent\",\"en\":\"Codex subagent\",\"es\":\"Subagente Codex\",\"fr\":\"Sous-agent Codex\",\"ja\":\"Codex サブエージェント\",\"pt\":\"Codex subagente\",\"ru\":\"Субагент Codex\",\"zh\":\"Codex 子智能体\"},\"description\":{\"de\":\"Delegieren Sie eine Aufgabe an Codex, indem Sie dessen native Authentifizierung und Einstellungen verwenden.\",\"en\":\"Delegate one task to Codex using its native authentication and settings.\",\"es\":\"Delegue una tarea a Codex utilizando su autenticación y configuración nativas.\",\"fr\":\"Déléguez une tâche à Codex à l’aide de son authentification et de ses paramètres natifs.\",\"ja\":\"Codex のネイティブの認証と設定を使用して、単独のタスクを委派します。\",\"pt\":\"Delegue uma tarefa a Codex usando sua autenticação e configurações nativas.\",\"ru\":\"Делегируйте одну задачу Codex, используя его встроенную аутентификацию и настройки.\",\"zh\":\"使用 Codex 原生认证和设置委派单个任务。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-cua-bundle\",\"title\":{\"de\":\"Computernutzung\",\"en\":\"Computer use\",\"es\":\"Uso de la computadora\",\"fr\":\"Utilisation de l'ordinateur\",\"ja\":\"Computer Use\",\"pt\":\"Uso do computador\",\"ru\":\"Использование компьютера\",\"zh\":\"Computer Use\"},\"description\":{\"de\":\"Unterstützt derzeit macOS; Die Windows-Unterstützung befindet sich in der Entwicklung. Lädt die Computernutzung für bestehende und neue PTC-Sitzungen. Wechseln Sie in den PTC-Modus und erteilen Sie die Berechtigungen für Barrierefreiheit und Bildschirmaufzeichnung, um ihn zu verwenden.\",\"en\":\"Currently supports macOS; Windows support is in development. Loads computer use for existing and new PTC sessions. Switch to PTC mode and grant Accessibility and Screen Recording permissions to use it.\",\"es\":\"Actualmente es compatible con macOS; El soporte para Windows está en desarrollo. Carga el uso de la computadora para sesiones PTC nuevas y existentes. Cambie al modo PTC y otorgue permisos de Accesibilidad y Grabación de pantalla para usarlo.\",\"fr\":\"Prend actuellement en charge macOS ; La prise en charge de Windows est en cours de développement. Charge l'utilisation de l'ordinateur pour les sessions PTC existantes et nouvelles. Passez en mode PTC et accordez les autorisations d’accessibilité et d’enregistrement d’écran pour l’utiliser.\",\"ja\":\"現在は macOS のみ対応し、Windows 対応は開発中です。既存および新規の PTC セッションに Computer Use を読み込みます。PTC モードに切り替え、アクセシビリティと画面収録の権限を許可して使用してください。\",\"pt\":\"Atualmente suporta macOS; O suporte Windows está em desenvolvimento. Carrega o uso do computador para sessões PTC novas e existentes. Mude para o modo PTC e conceda permissões de acessibilidade e gravação de tela para usá-lo.\",\"ru\":\"В настоящее время поддерживается macOS; Поддержка Windows находится в разработке. Загружает использование компьютера для существующих и новых сеансов PTC. Переключитесь в режим PTC и предоставьте разрешения на доступность и запись экрана для его использования.\",\"zh\":\"目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。\"},\"enabled\":false}]\n\n"}],"isError":false,"id":"{{message:7}}"}},"sourceEventSeqs":[16],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","ask_user_question","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","list_subagent_models","plugin_manager","present","propose_action","read","read_image","schedule_create","schedule_edit","schedule_list","schedule_view","send_message","skill","subagent","subagent_fork","todo_write","update_goal","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} From eb1713f856d99b3561a7669b82a4aec6f73521a7 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:07:09 +0800 Subject: [PATCH 30/32] fix(session-projection): keep released units in checkpoints (#7754) * fix(session-projection): keep released units in checkpoints Since the Cordis rc.11 teardown order unloads the Web layer before the base layer, Session Controller and harness-dev-context release their projection units before their live Sessions detach. The projection cache is still alive at that point and wrote a checkpoint holding only the units that were still registered, replacing the complete record. A blank Session then lost sessionListMetadata and listed as an untitled conversation after every graceful restart. A released unit now stays in the registry as a predecessor: it leaves event drives, snapshots, restores, and change notifications, while checkpoint() keeps each Session's last cell of it without advancing it. Registering the same key again replaces the predecessor and refolds the log. The projection cache keeps replacing whole records. * test(session-projection): assert frozen released units across unload tests Update the remaining unload tests that still expected a released unit to vanish from checkpoint(): user-approval, agent-loop inbox and scope lifecycle, time-context, and the agent registry now assert that the same Session keeps its pre-unload row, that a later event does not advance it, and that a Session created after the unload has no row. The token-meter and goal unload tests also append an event after the unload. Document that a retained handle read still advances a released cell that checkpoint() serves, and that restore() folds only live units, so its refreshed rows omit released units. * docs(session-projection): sync the ProjectionHandle type-equiv block * docs(session-projection): record frozen released units and their upgrade Add the upgrade guide for checkpoint() keeping a released unit's last row, update the registration-handles Agent Note with the frozen-cell decision and the retained predecessor, list both consequences under the registry README's known limitations, note in the projection cache README that a stored row can be frozen at its unit's release, and say that restoreFloor() ignores released predecessors. Update the agent-loop resource-lifecycle unload test, which read checkpoints through an alias, to the frozen-row semantics. * docs(upgrade-guide): mark the released-checkpoint snippet ignore-check * docs(session-projection): state the retained-handle path and narrow a rejected alternative * docs(upgrade-guide): re-record the released-checkpoint guide pairing --- ...-projection-registration-handles.i18n.yaml | 12 +- ...session-projection-registration-handles.md | 8 +- ...sion-projection-registration-handles.zh.md | 8 +- apps/web/tests/server-restart.e2e.ts | 58 ++++++++-- docs/subsystems/session-projection.i18n.yaml | 8 +- docs/subsystems/session-projection.md | 19 +++- docs/subsystems/session-projection.zh.md | 19 +++- .../guide.i18n.yaml | 16 +++ .../projection-released-checkpoint/guide.md | 30 +++++ .../guide.zh.md | 30 +++++ .../time-context/tests/time-context.spec.ts | 8 +- packages/core/agent-loop/tests/inbox.spec.ts | 4 +- .../tests/resource-lifecycle.spec.ts | 4 +- .../agent-loop/tests/scope-lifecycle.spec.ts | 8 +- packages/core/agent/tests/agent.spec.ts | 8 +- .../agent-team/tests/team.spec.ts | 4 +- .../tests/controller.host.spec.ts | 4 +- .../tests/loader-composition.spec.ts | 4 +- .../forge-session/tests/service.spec.ts | 7 +- .../extensions/tool-cordis/src/api-catalog.ts | 10 +- packages/goal/goal/tests/goal.spec.ts | 6 +- .../user-approval/tests/projection.spec.ts | 12 +- .../llm/token-meter/tests/token-meter.spec.ts | 8 +- .../session-projection-cache/README.i18n.yaml | 4 +- .../session-projection-cache/README.md | 2 +- .../session-projection-cache/README.zh.md | 2 +- .../tests/cache.spec.ts | 106 ++++++++++++++++++ .../session-projection/README.i18n.yaml | 12 +- packages/session/session-projection/README.md | 6 +- .../session/session-projection/README.zh.md | 6 +- .../session/session-projection/src/index.ts | 57 ++++++++-- .../tests/registration-reader.spec.ts | 3 +- .../session-projection/tests/registry.spec.ts | 65 ++++++++++- 33 files changed, 472 insertions(+), 86 deletions(-) create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/projection-released-checkpoint/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/projection-released-checkpoint/guide.md create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/projection-released-checkpoint/guide.zh.md diff --git a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.i18n.yaml index afcfe07ab3..979033451f 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.i18n.yaml @@ -9,11 +9,11 @@ en: 47ba548b869be190 zh: c2e5038a56fe741c /agent-note-asynchronous-projection-registration-and-retained-readers/decision: - en: be0d17bfbd1b59d4 - zh: 7a23bccdf95c21ff + en: a6f9ae8a53af5e4b + zh: 0aece791574ef060 /agent-note-asynchronous-projection-registration-and-retained-readers/alternatives-considered: - en: e7c66403fab991d2 - zh: 1580ec29116c7d42 + en: 12e0f5fc9638acc6 + zh: 24213550da27b70b /agent-note-asynchronous-projection-registration-and-retained-readers/consequences: - en: 31ce6f02dd3bf39d - zh: bcd29b2146f689b5 + en: f5c294608d87844f + zh: 24488e439a04d81a diff --git a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md index 813b1dffd1..9d19c4e94b 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md +++ b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md @@ -12,7 +12,7 @@ Host readers coupled to registry key lookup lose access when a plugin unloads, a `SessionProjectionRegistry.register` returns an awaitable registration for a typed handle. Domain owners await it during activation before exposing dependent operations. Function plugins retain their namespace exports and use asynchronous `apply`; services use their initialization hook. Each domain service keeps its definitions and handles internal and exposes methods returning business data. Consumers depend on that service rather than registering its definitions or receiving projection readers. This supersedes the optional first-access acquisition and disposer-return details in [the mandatory-reader decision](2026-08-19-session-projection-mandatory-seam.md), while retaining its mandatory-state and cropped-client-view rules. -A handle owns one reference to a shared definition and its per-Session cells. Its disposer releases global participation exactly once; the final release removes the unit from live drives, snapshots, and checkpoints. Its reader remains usable after release, folding unseen events with the original definition. A later registration at a different state version does not replace an older handle's definition. Registration checks owner liveness after its asynchronous handoff and rejects if the owner unloaded before receiving the handle. +A handle owns one reference to a shared definition and its per-Session cells. Its disposer releases global participation exactly once; the final release removes the unit from live drives and snapshots. Checkpoints keep each Session's last cell of the released unit until its key registers again: the registry no longer advances that cell, while a retained handle read still advances it forward, so a writer that runs after the owner unloads still stores the state already folded. Under the Cordis rc.11 teardown order, Web-layer plugins release their units before the Sessions they describe detach, and the final projection-cache write would otherwise drop those rows. Stored rows can therefore lag the log; the cache promises only possibly stale rows. Its reader remains usable after release, folding unseen events with the original definition. A later registration at a different state version does not replace an older handle's definition. Registration checks owner liveness after its asynchronous handoff and rejects if the owner unloaded before receiving the handle. AgentRegistry owns turn and step state and exposes `turnBoundaryOf(session)`. AgentLoop acquires its inbox handle once before publishing its factory, reads the last turn before creating an Agent scope, and passes an inbox callback plus that turn number into synchronous Agent construction. The callback retains its handle through cleanup after the loop fiber removes global registrations. This preserves the existing creation/setup sequence without per-Agent registration or an extra initialization state. @@ -20,6 +20,10 @@ Plugins compiled against the synchronous API keep running during migration. The ## Alternatives considered +**Detach owned Sessions before unregistering.** Other Web-layer plugins unload in earlier fibers under the rc.11 teardown order, and plugin reload or disable reaches the same timing, so ordering one owner cannot keep every row. + +**Checkpoint a released unit only while its cell is at the Session cursor.** A Session with a running turn at shutdown appends teardown events after its units are released, so its rows would be dropped again. + **Keep registry-level lookup and delay unregistering.** This couples unrelated plugin disposal order to every reader's final access. Retained readers make cleanup independent of that order. **Return a synchronous handle.** This preserves callers' immediate reads but commits the acquisition API to synchronous initialization. Awaited acquisition leaves that timing explicit while state reads remain synchronous after readiness. @@ -36,4 +40,4 @@ Plugins compiled against the synchronous API keep running during migration. The ## Consequences -Projection owners must migrate acquisition and retain handles; other consumers use the owning services. Released handles retain their definitions and weakly keyed cells for as long as the caller keeps them; they do not restore global wire participation. Registration-reader tests cover post-release folding, version replacement, unrelated-unit isolation, repeated disposal, concurrent owner disposal, and owner unload during acquisition. Agent lifecycle tests cover cleanup failures, scope-minting races, and background child shutdown. Compatibility tests and a real Loader subprocess verify the deprecated calls. +Projection owners must migrate acquisition and retain handles; other consumers use the owning services. Released handles retain their definitions and weakly keyed cells for as long as the caller keeps them; they do not restore global wire participation. The registry also retains a released predecessor, with its definition closures and weakly keyed cells, until the same key registers again, so a key that a reloaded plugin renames or stops registering keeps the old definition referenced for the process lifetime. Registration-reader tests cover post-release folding, version replacement, unrelated-unit isolation, repeated disposal, concurrent owner disposal, and owner unload during acquisition. Agent lifecycle tests cover cleanup failures, scope-minting races, and background child shutdown. Compatibility tests and a real Loader subprocess verify the deprecated calls. diff --git a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.zh.md b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.zh.md index a80721d82d..56879ebc99 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.zh.md @@ -12,7 +12,7 @@ Status: implemented `SessionProjectionRegistry.register` 返回可等待的注册结果,用于获取类型化句柄。领域所有者在激活期间等待它,再提供依赖操作。函数插件保留命名空间导出并使用异步 `apply`;服务使用初始化钩子。领域服务在内部持有定义和句柄,通过方法返回业务数据。消费方依赖该服务,无需注册其定义或接收投影读取句柄。这取代了[必需读取方决策](2026-08-19-session-projection-mandatory-seam.zh.md)中的可选首次访问获取方式与返回释放函数的细节,同时保留必需状态和裁剪 Client 视图的规则。 -句柄拥有共享定义及其逐 Session 单元的一次引用。其释放函数恰好一次地解除全局参与;最后一次释放会从实时推进、快照和检查点中移除单元。读取句柄在释放后仍可使用原定义折叠未读取事件。后续注册的其他状态版本不会替换旧句柄的定义。注册在异步交接后检查 owner 是否存活;如果 owner 在收到句柄前卸载,注册拒绝。 +句柄拥有共享定义及其逐 Session 单元的一次引用。其释放函数恰好一次地解除全局参与;最后一次释放会从实时推进和快照中移除单元。检查点仍保留每个 Session 中已释放单元的最后一个 cell,直到它的 key 再次注册:注册表不再推进这个 cell,但已保留的 handle 读取时仍会将其向前推进,因此 owner 卸载后才运行的写入方仍会存下已经折叠出的状态。按 Cordis rc.11 的卸载顺序,Web 层插件会在它们所描述的 Session detach 之前释放单元,否则 projection cache 的最终写入会丢掉这些行。因此存储的行可能落后于日志;cache 只承诺行可能过时。读取句柄在释放后仍可使用原定义折叠未读取事件。后续注册的其他状态版本不会替换旧句柄的定义。注册在异步交接后检查 owner 是否存活;如果 owner 在收到句柄前卸载,注册拒绝。 AgentRegistry 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)`。AgentLoop 在发布工厂前只获取一次 inbox 句柄,在创建 Agent 作用域前读取最后轮次,并将 inbox 回调与该轮次编号传入同步 Agent 构造过程。loop fiber 移除全局注册后,回调仍在清理期间保留其句柄。这保留既有创建与 setup 顺序,无需逐 Agent 注册或增加初始化状态。 @@ -20,6 +20,10 @@ AgentRegistry 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)` ## Alternatives considered +**卸载前先 detach 自己拥有的 Session。** 按 rc.11 的卸载顺序,其他 Web 层插件在更早卸载的 fiber 里,插件重载或禁用也会碰上同样的时机,调整单个 owner 的顺序保不住所有行。 + +**只在已释放单元的 cell 位于 Session 游标时才写入检查点。** 关停时正在运行一轮的会话会在单元释放后追加 teardown 事件,因此它的这些行会再次被丢掉。 + **保留注册表级查询并延迟注销。** 这使无关插件的释放顺序依赖每个读取方的最后一次访问。保留读取句柄使清理不再依赖该顺序。 **返回同步句柄。** 这保留消费者的立即读取,却将获取 API 固定为同步初始化。等待获取过程使时序显式,同时就绪后的状态读取仍同步。 @@ -36,4 +40,4 @@ AgentRegistry 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)` ## Consequences -投影所有者必须迁移获取方式并保留句柄;其他消费方使用所属服务。只要调用方持有已释放句柄,其定义和弱键单元就仍存在;句柄不会恢复全局 wire 参与。注册读取测试覆盖释放后折叠、版本替换、无关单元隔离、重复释放、并发 owner 释放以及获取期间 owner 卸载。Agent 生命周期测试覆盖清理失败、作用域创建竞态与后台子 Agent 停止。兼容测试与真实 Loader 子进程验证这些已弃用调用。 +投影所有者必须迁移获取方式并保留句柄;其他消费方使用所属服务。只要调用方持有已释放句柄,其定义和弱键单元就仍存在;句柄不会恢复全局 wire 参与。注册表自身也会保留已释放的前任及其定义闭包和弱键单元,直到同一 key 再次注册;因此重载后改名或不再注册的 key 会在进程生命周期内一直引用旧定义。注册读取测试覆盖释放后折叠、版本替换、无关单元隔离、重复释放、并发 owner 释放以及获取期间 owner 卸载。Agent 生命周期测试覆盖清理失败、作用域创建竞态与后台子 Agent 停止。兼容测试与真实 Loader 子进程验证这些已弃用调用。 diff --git a/apps/web/tests/server-restart.e2e.ts b/apps/web/tests/server-restart.e2e.ts index 736165c2dc..d5b3c180cb 100644 --- a/apps/web/tests/server-restart.e2e.ts +++ b/apps/web/tests/server-restart.e2e.ts @@ -84,6 +84,24 @@ class RestartableServer { logs(): string { return this.output.replace(/([?&]token=)[A-Za-z0-9_-]+/gu, '$1') } } +type HostRpc = (endpoint: string, args: object) => Promise + +/** Authenticate against one server process and return its Host RPC caller. */ +async function hostRpc(url: string): Promise { + const authenticated = await fetch(url, { redirect: 'manual' }) + const cookie = authenticated.headers.get('set-cookie')?.split(';', 1)[0] + if (cookie === undefined) throw new Error('Server did not issue an authentication cookie') + return async (endpoint: string, args: object): Promise => { + const response = await fetch(new URL('/api/' + endpoint, url), { + method: 'POST', headers: { 'content-type': 'application/json', cookie }, + body: JSON.stringify({ type: 'client-request', rpcId: randomUUID(), method: endpoint, payload: { args } }), + }) + const result = await response.json() as { result: { ok: boolean; value: T; error?: { message: string } } } + if (!result.result.ok) throw new Error(`${endpoint}: ${String(result.result.error?.message)}`) + return result.result.value + } +} + it.each([false, true])('keeps the same revision, Session and page across a server restart (delayed startup: %s)', async (holdStartup) => { const world = await mkdtemp(join(tmpdir(), 'dsh-server-restart-')) onTestFinished(() => rm(world, { recursive: true, force: true })) @@ -118,18 +136,7 @@ it.each([false, true])('keeps the same revision, Session and page across a serve const url = await server.start(0) const port = Number(new URL(url).port) - const authenticated = await fetch(url, { redirect: 'manual' }) - const cookie = authenticated.headers.get('set-cookie')?.split(';', 1)[0] - if (cookie === undefined) throw new Error('Server did not issue an authentication cookie') - const rpc = async (endpoint: string, args: object): Promise => { - const response = await fetch(new URL('/api/' + endpoint, url), { - method: 'POST', headers: { 'content-type': 'application/json', cookie }, - body: JSON.stringify({ type: 'client-request', rpcId: randomUUID(), method: endpoint, payload: { args } }), - }) - const result = await response.json() as { result: { ok: boolean; value: T; error?: { message: string } } } - if (!result.result.ok) throw new Error(`${endpoint}: ${String(result.result.error?.message)}`) - return result.result.value - } + const rpc = await hostRpc(url) await rpc('workspace/create', { request: { path: workspacePath } }) const scenarioId = randomUUID() @@ -234,3 +241,30 @@ it.each([false, true])('keeps the same revision, Session and page across a serve expect(streamErrors).toEqual([]) expect(navigations).toBe(0) }) + +it('keeps a live blank Session blank across a graceful server restart', async () => { + const world = await mkdtemp(join(tmpdir(), 'dsh-server-restart-')) + onTestFinished(() => rm(world, { recursive: true, force: true })) + // The scenario runs no model turn, so the model endpoint is never contacted. + const server = new RestartableServer(world, 'http://127.0.0.1:9') + onTestFinished(() => server.stop()) + const workspacePath = join(world, 'workspace') + await mkdir(workspacePath) + onTestFailed(async () => { + await writeFile(join(REPO_ROOT, '.artifacts', 'server-restart-blank-' + randomUUID() + '.json'), JSON.stringify({ server: server.logs() }, null, 2)) + }) + + const before = await hostRpc(await server.start(0)) + const { workspace } = await before<{ workspace: { id: string } }>('workspace/create', { request: { path: workspacePath } }) + const { sessionId } = await before<{ sessionId: string }>('session/create', { request: { workspaceId: workspace.id } }) + const listed = async (rpc: HostRpc) => (await rpc<{ + items: { sessionId: string; blank: boolean; agentAvailable: boolean }[] + }>('session/list', { _request: {} })).items.find(item => item.sessionId === sessionId) + expect(await listed(before)).toMatchObject({ blank: true, agentAvailable: true }) + + // SIGTERM unloads the Web layer, including the list projection, before its live Session detaches; + // stopping right after creation also precedes the throttled checkpoint of the Session's setup events. + await server.stop() + const after = await hostRpc(await server.start(0)) + expect(await listed(after)).toMatchObject({ blank: true, agentAvailable: false }) +}) diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index a1fe995e3b..5c37a0ee71 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md /session-projections: - en: b2789f5257a935c5 - zh: 12d430a9cd454592 + en: 1804bc8fc5f226e4 + zh: 2f31d0f014508d29 /session-projections/the-unit: en: d48814d3ab0e997f zh: d0337d8eba097a63 @@ -18,5 +18,5 @@ en: 6a93a4e1682fd6e6 zh: 28729129c3d8a3b1 /session-projections/the-registry-ctx-sessionprojections: - en: 67fe0f49dee90e25 - zh: c23de3a2803040f0 + en: 33e4aa5276d4c0fa + zh: 18019b4990734b82 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 6284a8fb95..512542d505 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -6,7 +6,7 @@ The session-projection seam — a [capability seam](../capability-seams.md) thro Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts) -A `ProjectionHandle` retains its synchronous reader after unregistration. `stateOf(session)` folds newly committed events with the original unit; it does not rejoin global snapshots, checkpoints, or notifications. Registration must still finish before the handle can be used. See the [registration lifecycle](../../packages/session/session-projection/README.md). +A `ProjectionHandle` retains its synchronous reader after unregistration. `stateOf(session)` folds newly committed events with the original unit; it does not rejoin global snapshots or notifications. Registration must still finish before the handle can be used. See the [registration lifecycle](../../packages/session/session-projection/README.md). ```ts type-equiv /** An effect-owned registration with a reader retained independently of registration. */ @@ -14,6 +14,8 @@ interface ProjectionHandle { /** * Read the current state, including after unregistering, by folding new * Session events with this handle's original unit. Do not mutate the live value. + * After the final release, a read also advances the released cell that + * `checkpoint(session)` serves, until the key registers again. * @param session - Session whose committed events are folded. * @returns the projection state at the Session's current cursor. */ @@ -230,7 +232,7 @@ type ProjectionKeySelection = ## The registry: `ctx.sessionProjections` -`SessionProjectionRegistry` ([signatures](#ctxsessionprojections--sessionprojectionregistry)) owns the drive: one `session/event` subscription, eager `apply` over every registered unit, and per-session per-unit watermark cells. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Domains await `register(definition)` during activation and retain its handle. Registration belongs to the calling fiber: its last release removes the key from global drives and snapshots, while retained handles remain readable. Same-key, same-version registrants share one unit; incompatible versions reject registration. +`SessionProjectionRegistry` ([signatures](#ctxsessionprojections--sessionprojectionregistry)) owns the drive: one `session/event` subscription, eager `apply` over every registered unit, and per-session per-unit watermark cells. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Domains await `register(definition)` during activation and retain its handle. Registration belongs to the calling fiber: its last release removes the key from global drives and snapshots, checkpoints keep each Session's last state of it until the key registers again, and retained handles remain readable. Same-key, same-version registrants share one unit; incompatible versions reject registration. @@ -331,7 +333,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Consumers declare `sessionProjections` as a required injection, await registration during activation, and retain its ready handle. Registrants sharing a key share one unit and are counted. The last release removes the unit from live drives, checkpoints, and client snapshots; retained handles still fold new events on demand using the original unit. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Consumers declare `sessionProjections` as a required injection, await registration during activation, and retain its ready handle. Registrants sharing a key share one unit and are counted. The last release removes the unit from live drives, restores, and client snapshots, while checkpoints keep each Session's last cell of it until the key registers again and the new unit refolds the log; retained handles still fold new events on demand using the original unit. ```ts cordis-catalog /** @@ -408,8 +410,13 @@ summaryKeys(): ReadonlySet> * mutable state, and a caller reaching the live reference could corrupt * every subsequent snapshot and frame through it (plain JSON by the unit * contract, so the clone is total). + * A released unit contributes the Session's last cell. The registry no + * longer advances that cell, while a retained handle read still advances it + * forward, so a writer running after a plugin unloads keeps that unit's + * latest state; a Session that has no cell of it gets no row. {@link restore} + * folds only live units, so its refreshed rows omit released units. * @param session - the session whose unit states are checkpointed. - * @returns one row per registered key. + * @returns one row per registered key, plus one per released key with a cell for this Session. */ checkpoint(session: Session): ProjectionCheckpoint @@ -426,7 +433,7 @@ checkpoint(session: Session): ProjectionCheckpoint * re-read. * @param checkpoint - persisted rows for one session (possibly stale or empty). * @returns the offset for the stored-log suffix read (`SessionHandle.read`), - * or `undefined` when no unit is registered (no read needed — + * or `undefined` when no live unit is registered (released predecessors do not count; no read needed — * {@link restore} would serve empty values regardless). */ restoreFloor(checkpoint: ProjectionCheckpoint): SessionLogOffset | undefined @@ -467,6 +474,8 @@ viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: ProjectionKeySelection, * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last * supplied event's seq, `baseSeq - 1` for an empty tail) plus the * refreshed checkpoint rows at that cut, ready for a durable write-back. + * The rows cover live units only: a released unit has no cell for a log + * folded without a live `Session`, so a write-back of them omits it. */ restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: SessionLogOffset, header: SessionHeader, inheritedEventCount: SessionLogOffset, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index b924cc78a0..d6b38f28d3 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -6,7 +6,7 @@ 源码:[`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts) -`ProjectionHandle` 在注销后仍保留同步 reader。`stateOf(session)` 用原有单元折叠新提交的事件,不会重新加入全局快照、检查点或通知。必须等待注册完成才能使用 handle。参见[注册生命周期](../../packages/session/session-projection/README.zh.md)。 +`ProjectionHandle` 在注销后仍保留同步 reader。`stateOf(session)` 用原有单元折叠新提交的事件,不会重新加入全局快照或通知。必须等待注册完成才能使用 handle。参见[注册生命周期](../../packages/session/session-projection/README.zh.md)。 ```ts type-equiv /** An effect-owned registration with a reader retained independently of registration. */ @@ -14,6 +14,8 @@ interface ProjectionHandle { /** * Read the current state, including after unregistering, by folding new * Session events with this handle's original unit. Do not mutate the live value. + * After the final release, a read also advances the released cell that + * `checkpoint(session)` serves, until the key registers again. * @param session - Session whose committed events are folded. * @returns the projection state at the Session's current cursor. */ @@ -230,7 +232,7 @@ type ProjectionKeySelection = ## 注册表:`ctx.sessionProjections` -`SessionProjectionRegistry`([签名](#ctxsessionprojections--sessionprojectionregistry))拥有驱动逻辑:一个 `session/event` 订阅、对所有已注册单元立即执行的 `apply`,以及按会话、按单元保存的水位线缓存。缓存单元惰性构建:若单元在事件产生后才注册,或会话早于注册表存在,第一次访问(事件或读取)会从 `init` 开始折叠内存日志。领域在激活期间等待 `register(definition)`,并保留返回的 handle(句柄)。注册归调用 fiber 所有:最后一次释放会从全局驱动和快照中移除该 key,已有句柄仍可读取。同 key、同版本的注册者共享一个单元;版本不兼容时注册失败。 +`SessionProjectionRegistry`([签名](#ctxsessionprojections--sessionprojectionregistry))拥有驱动逻辑:一个 `session/event` 订阅、对所有已注册单元立即执行的 `apply`,以及按会话、按单元保存的水位线缓存。缓存单元惰性构建:若单元在事件产生后才注册,或会话早于注册表存在,第一次访问(事件或读取)会从 `init` 开始折叠内存日志。领域在激活期间等待 `register(definition)`,并保留返回的 handle(句柄)。注册归调用 fiber 所有:最后一次释放会从全局驱动和快照中移除该 key,检查点仍保留每个会话中它的最后状态,直到该 key 再次注册,已有句柄仍可读取。同 key、同版本的注册者共享一个单元;版本不兼容时注册失败。 @@ -331,7 +333,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Consumers declare `sessionProjections` as a required injection, await registration during activation, and retain its ready handle. Registrants sharing a key share one unit and are counted. The last release removes the unit from live drives, checkpoints, and client snapshots; retained handles still fold new events on demand using the original unit. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Consumers declare `sessionProjections` as a required injection, await registration during activation, and retain its ready handle. Registrants sharing a key share one unit and are counted. The last release removes the unit from live drives, restores, and client snapshots, while checkpoints keep each Session's last cell of it until the key registers again and the new unit refolds the log; retained handles still fold new events on demand using the original unit. ```ts cordis-catalog /** @@ -408,8 +410,13 @@ summaryKeys(): ReadonlySet> * mutable state, and a caller reaching the live reference could corrupt * every subsequent snapshot and frame through it (plain JSON by the unit * contract, so the clone is total). + * A released unit contributes the Session's last cell. The registry no + * longer advances that cell, while a retained handle read still advances it + * forward, so a writer running after a plugin unloads keeps that unit's + * latest state; a Session that has no cell of it gets no row. {@link restore} + * folds only live units, so its refreshed rows omit released units. * @param session - the session whose unit states are checkpointed. - * @returns one row per registered key. + * @returns one row per registered key, plus one per released key with a cell for this Session. */ checkpoint(session: Session): ProjectionCheckpoint @@ -426,7 +433,7 @@ checkpoint(session: Session): ProjectionCheckpoint * re-read. * @param checkpoint - persisted rows for one session (possibly stale or empty). * @returns the offset for the stored-log suffix read (`SessionHandle.read`), - * or `undefined` when no unit is registered (no read needed — + * or `undefined` when no live unit is registered (released predecessors do not count; no read needed — * {@link restore} would serve empty values regardless). */ restoreFloor(checkpoint: ProjectionCheckpoint): SessionLogOffset | undefined @@ -467,6 +474,8 @@ viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: ProjectionKeySelection, * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last * supplied event's seq, `baseSeq - 1` for an empty tail) plus the * refreshed checkpoint rows at that cut, ready for a durable write-back. + * The rows cover live units only: a released unit has no cell for a log + * folded without a live `Session`, so a write-back of them omits it. */ restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: SessionLogOffset, header: SessionHeader, inheritedEventCount: SessionLogOffset, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/projection-released-checkpoint/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/projection-released-checkpoint/guide.i18n.yaml new file mode 100644 index 0000000000..b4d7a475e4 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/projection-released-checkpoint/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/projection-released-checkpoint/guide.md +/: + en: 738f2376c5a8ebe5 + zh: 078f2a08d8b6ad4b +/released-projection-units-stay-in-checkpoints: + en: 9355d9de711acc7d + zh: eb89da6b886a7556 +/released-projection-units-stay-in-checkpoints/change: + en: 5944b6c7b72e15e4 + zh: 051d68e2eb5151d7 +/released-projection-units-stay-in-checkpoints/migration: + en: 9f24ecf12748eb41 + zh: c07649e79a0174f9 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/projection-released-checkpoint/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/projection-released-checkpoint/guide.md new file mode 100644 index 0000000000..125c513005 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/projection-released-checkpoint/guide.md @@ -0,0 +1,30 @@ +--- +kind: upgrade-guide +description: "`ctx.sessionProjections.checkpoint(session)` keeps a released unit's last row instead of omitting it." +--- + +# Released projection units stay in checkpoints + +English | [中文](guide.zh.md) + +## Change + +Previously, when the last registrant of a projection key released it, through `handle.dispose()` or by unloading the owning fiber, the registry deleted the unit and `ctx.sessionProjections.checkpoint(session)` stopped returning its row. + +Now the released unit stays in the registry as a predecessor. It leaves event drives, `snapshot()`, `summaryKeys()`, `restoreFloor()`, `restore()`, `viewCheckpoint()`, and `hydrate()`, and Sessions created afterward get no cell for it. `checkpoint(session)` still returns the last row the unit folded for each existing Session, and the registry no longer advances that row. Registering the same key again replaces the predecessor and refolds the log; see the [registration lifecycle](../../../../packages/session/session-projection/README.md). + +This affects plugin code and tests that read `checkpoint()` after a projection's owner unloads, such as HMR tests that assert a released key is absent. `@deepseek-ai/dsh-session-projection-cache` stores these rows, so a graceful shutdown no longer drops the values that Web-layer plugins folded before their Sessions detach. + +## Migration + +1. Find reads of `sessionProjections.checkpoint(...)` that run after a projection's owner unloads, including calls through an alias of `ctx.sessionProjections`. +2. Replace an assertion that the released key is absent from an existing Session with one that its row keeps the pre-unload value: + + ```ts ignore-check + const row = ctx.sessionProjections.checkpoint(session).myKey + await owner.dispose() + expect(ctx.sessionProjections.checkpoint(session)).toHaveProperty('myKey', row) + ``` + +3. To show that the unit left live participation, assert that a Session created after the unload has no row, `expect(ctx.sessionProjections.checkpoint(ctx.sessions.create())).not.toHaveProperty('myKey')`, or that an event appended after the unload leaves the row unchanged. A client-visible key is also absent from `snapshot(session).values`. +4. Run the affected tests; they pass when no assertion expects a released key to disappear from an existing Session's checkpoint. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/projection-released-checkpoint/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/projection-released-checkpoint/guide.zh.md new file mode 100644 index 0000000000..7ec9b32ece --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/projection-released-checkpoint/guide.zh.md @@ -0,0 +1,30 @@ +--- +kind: upgrade-guide +description: "`ctx.sessionProjections.checkpoint(session)` 保留已释放单元的最后一行,而不再省略它。" +--- + +# 已释放的投影单元保留在检查点中 + +[English](guide.md) | 中文 + +## 变更 + +此前,投影 key 的最后一个注册方通过 `handle.dispose()` 或卸载所属 fiber 释放它时,注册表会删除该单元,`ctx.sessionProjections.checkpoint(session)` 也不再返回它的行。 + +现在,已释放的单元作为前任留在注册表中。它退出事件推进、`snapshot()`、`summaryKeys()`、`restoreFloor()`、`restore()`、`viewCheckpoint()` 和 `hydrate()`,此后新建的 Session 也不会为它建立 cell。`checkpoint(session)` 仍为每个已有 Session 返回该单元最后折叠出的行,注册表不再推进这一行。同一 key 再次注册时会替换前任并重新折叠日志;参见[注册生命周期](../../../../packages/session/session-projection/README.zh.md)。 + +这会影响在投影所属方卸载后读取 `checkpoint()` 的插件代码和测试,例如断言已释放 key 不存在的 HMR 测试。`@deepseek-ai/dsh-session-projection-cache` 会存储这些行,因此正常关停不再丢掉 Web 层插件在其 Session detach 之前已折叠出的值。 + +## 迁移 + +1. 找出在投影所属方卸载后执行的 `sessionProjections.checkpoint(...)` 读取,包括通过 `ctx.sessionProjections` 别名的调用。 +2. 把「已有 Session 中不存在已释放 key」的断言,改为断言它的行保持卸载前的值: + + ```ts ignore-check + const row = ctx.sessionProjections.checkpoint(session).myKey + await owner.dispose() + expect(ctx.sessionProjections.checkpoint(session)).toHaveProperty('myKey', row) + ``` + +3. 如需证明该单元已退出实时参与,可断言卸载后新建的 Session 没有这一行,即 `expect(ctx.sessionProjections.checkpoint(ctx.sessions.create())).not.toHaveProperty('myKey')`,或断言卸载后追加的事件不会改变这一行。客户端可见的 key 也不会出现在 `snapshot(session).values` 中。 +4. 运行受影响的测试;当没有断言要求已释放 key 从已有 Session 的检查点中消失时,测试即通过。 diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index bd4883a13e..ba07a2f1f6 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -455,7 +455,7 @@ describe('time-context projection fold edges', () => { expect(contextTexts(cold).at(-1)).toContain('mixed ["America/New_York","Asia/Shanghai"]') }) - it('rebuilds an old checkpoint from the full journal and removes the unit on unload', async () => { + it('rebuilds an old checkpoint from the full journal and freezes the unit on unload', async () => { const { ctx, fiber } = await mount() onTestFinished(() => ctx.fiber.dispose()) const session = Session.create(SessionId('browser-old-row')) @@ -474,8 +474,12 @@ describe('time-context projection fold edges', () => { expect(ctx.sessionProjections.checkpoint(cold).timeContext).toEqual({ ver: 3, seq: cold.seq - 1, val: rows.timeContext!.val, }) + const row = ctx.sessionProjections.checkpoint(cold).timeContext await fiber.dispose() - expect(ctx.sessionProjections.checkpoint(cold).timeContext).toBeUndefined() + openMessageTurn(cold, 2, 'UTC') + // The released unit keeps the hydrated row and folds no event after the unload. + expect(ctx.sessionProjections.checkpoint(cold)).toHaveProperty('timeContext', row) + expect(ctx.sessionProjections.checkpoint(Session.create(SessionId('browser-after-unload')))).not.toHaveProperty('timeContext') }) it('retains reported invalid zones without validating rejected or throttled requests', async () => { diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts index 5bfe365bd3..d8ae3b3ba5 100644 --- a/packages/core/agent-loop/tests/inbox.spec.ts +++ b/packages/core/agent-loop/tests/inbox.spec.ts @@ -107,9 +107,11 @@ describe('ReactLoopInbox', () => { 'next-turn': [pending], 'next-step': [], }) + const row = ctx.sessionProjections.checkpoint(session).inbox await projection.dispose() - expect(ctx.sessionProjections.checkpoint(session).inbox?.val).toBeUndefined() + expect(ctx.sessionProjections.checkpoint(session)).toHaveProperty('inbox', row) + expect(ctx.sessionProjections.checkpoint(ctx.sessions.create())).not.toHaveProperty('inbox') expect(first.nextTurn).toEqual([pending]) first.clear() expect(second.nextTurn).toEqual([]) diff --git a/packages/core/agent-loop/tests/resource-lifecycle.spec.ts b/packages/core/agent-loop/tests/resource-lifecycle.spec.ts index 991746f567..134a594461 100644 --- a/packages/core/agent-loop/tests/resource-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/resource-lifecycle.spec.ts @@ -71,7 +71,9 @@ it.each([0, 1, 2])('retains Agent resources until all work exits with %i failed expect(scopeDisposedBeforeWorkExit).toBe(false) expect(scopeDisposed).toBe(true) expect(abortHandled).toBe(1) - expect(projections.checkpoint(agent.session).inbox).toBeUndefined() + // The released inbox unit keeps this Session's last row; a Session created after the unload gets none. + expect(projections.checkpoint(agent.session)).toHaveProperty('inbox') + expect(projections.checkpoint(ctx.sessions.create())).not.toHaveProperty('inbox') expect(projections.checkpoint(agent.session).turnBoundary).toBeDefined() expect(ctx.agents.get(agent.id)).toBeUndefined() expect(ctx.sessions.get(agent.session.id)).toBeUndefined() diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index bb760489f0..8fabc08787 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -325,8 +325,14 @@ describe('agent scope lifecycle', () => { expect(ctx.sessionProjections.checkpoint(second.agent.session).inbox?.val).toBeDefined() await Promise.all([first.dispose(), second.dispose()]) + const row = ctx.sessionProjections.checkpoint(second.agent.session).inbox + const coldRow = ctx.sessionProjections.checkpoint(cold).inbox await loopFiber.dispose() - expect(ctx.sessionProjections.checkpoint(second.agent.session).inbox?.val).toBeUndefined() + cold.append('turn/start', { turn: 1 }) + // The released unit keeps each Session's last row and folds no event after the unload. + expect(ctx.sessionProjections.checkpoint(second.agent.session)).toHaveProperty('inbox', row) + expect(ctx.sessionProjections.checkpoint(cold)).toHaveProperty('inbox', coldRow) + expect(ctx.sessionProjections.checkpoint(ctx.sessions.create(SessionId('projection-after-unload')))).not.toHaveProperty('inbox') }) it('agent.ctx listeners hear only their own agent (scoped dispatch end to end)', async () => { diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 54c5111eef..c5274de109 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -61,9 +61,13 @@ describe('AgentRegistry', () => { }) session.append('turn/end', { turn: 3, reason: { kind: 'completed' } }) expect(ctx.agents.turnBoundaryOf(session)).toMatchObject({ openTurnStartSeq: null, lastTurn: 3 }) - expect(ctx.sessionProjections.checkpoint(session).turnBoundary).toBeDefined() + const row = ctx.sessionProjections.checkpoint(session).turnBoundary + expect(row).toBeDefined() await fiber.dispose() - expect(ctx.sessionProjections.checkpoint(session).turnBoundary).toBeUndefined() + session.append('turn/start', { turn: 4 }) + // The released unit keeps its last row and folds no event after the unload. + expect(ctx.sessionProjections.checkpoint(session)).toHaveProperty('turnBoundary', row) + expect(ctx.sessionProjections.checkpoint(Session.create(SessionId('turn-positions-after-unload')))).not.toHaveProperty('turnBoundary') }) it('contributes Agent lookup and scoped Context providers while Typert is live', async () => { diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index b9da455b78..0e22113c66 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -1164,11 +1164,13 @@ describe('Team direct messages and waiting', () => { readDuringShutdown = true if (fails) throw new Error('admitted operation failed during shutdown') })) + const row = ctx.sessionProjections.checkpoint(lead.session).agentTeam const disposal = teamFiber.dispose() try { await vi.waitFor(() => { expect(internal.lifecycle.disposed).toBe(true) - expect(ctx.sessionProjections.checkpoint(lead.session)).not.toHaveProperty('agentTeam') + expect(ctx.sessionProjections.checkpoint(lead.session)).toHaveProperty('agentTeam', row) + expect(ctx.sessionProjections.checkpoint(ctx.sessions.create())).not.toHaveProperty('agentTeam') }) expect(readDuringShutdown).toBe(false) } finally { diff --git a/packages/experimental/client-ui-forge/tests/controller.host.spec.ts b/packages/experimental/client-ui-forge/tests/controller.host.spec.ts index 1d9e14a3f4..e379ac3126 100644 --- a/packages/experimental/client-ui-forge/tests/controller.host.spec.ts +++ b/packages/experimental/client-ui-forge/tests/controller.host.spec.ts @@ -1671,11 +1671,13 @@ describe('forgePanel lifecycle', () => { const opened = await open(repo, {}, { worktrees: true }) await opened.ctx.forgePanel.requestWorktree(opened.session, null, signal) const proposed = prompt('go') + const row = opened.ctx.sessionProjections.checkpoint(opened.session).forgeWorktree await opened.panel.dispose() expect(opened.ctx.get('forgePanel')).toBeUndefined() - expect(opened.ctx.sessionProjections.checkpoint(opened.session)).not.toHaveProperty('forgeWorktree') + expect(opened.ctx.sessionProjections.checkpoint(opened.session)).toHaveProperty('forgeWorktree', row) + expect(opened.ctx.sessionProjections.checkpoint(opened.ctx.sessions.create())).not.toHaveProperty('forgeWorktree') sendPrompt(opened.agent, 'go') await expect(proposeStep(opened.ctx, opened.agent, { turn: 1, step: 1, messages: [proposed] })).resolves.toEqual({ kind: 'enter', messages: [proposed] }) expect(opened.ctx.workingDirectory.get(opened.session)).toBe(repo) diff --git a/packages/experimental/forge-session/tests/loader-composition.spec.ts b/packages/experimental/forge-session/tests/loader-composition.spec.ts index e8fa6af4c9..acf51fb418 100644 --- a/packages/experimental/forge-session/tests/loader-composition.spec.ts +++ b/packages/experimental/forge-session/tests/loader-composition.spec.ts @@ -370,7 +370,9 @@ suite('forge-session through a real cordis.yml Loader composition', () => { : null)).toEqual([['Change 7', 'pending'], ['Change 8', 'success'], ['Change 9', 'success']]) }) const entry = [...ctx.loader.entries()].find(candidate => candidate.options.name === 'cordis:@deepseek-ai/dsh-experimental-forge-session') + const row = ctx.sessionProjections.checkpoint(agent.session).forgeChangeRequests await entry?.fiber?.dispose() - expect(ctx.sessionProjections.checkpoint(agent.session)).not.toHaveProperty('forgeChangeRequests') + expect(ctx.sessionProjections.checkpoint(agent.session)).toHaveProperty('forgeChangeRequests', row) + expect(ctx.sessionProjections.checkpoint(ctx.sessions.create())).not.toHaveProperty('forgeChangeRequests') }, 60_000) }) diff --git a/packages/experimental/forge-session/tests/service.spec.ts b/packages/experimental/forge-session/tests/service.spec.ts index bc15b816ad..ee13cc3341 100644 --- a/packages/experimental/forge-session/tests/service.spec.ts +++ b/packages/experimental/forge-session/tests/service.spec.ts @@ -394,13 +394,16 @@ describe('forgeSession lifecycle', () => { it('unregisters its projection and stops discovery when the service fiber is disposed', async () => { const { ctx, provider, session, service } = await open() create(session, 'create', pullUrl(1)) - expect(ctx.sessionProjections.checkpoint(session).forgeChangeRequests?.val).toMatchObject({ bindings: [{ url: pullUrl(1) }] }) + const row = ctx.sessionProjections.checkpoint(session).forgeChangeRequests + expect(row?.val).toMatchObject({ bindings: [{ url: pullUrl(1) }] }) await service.dispose() push(session, 'push', 'feat') await settle() - expect(ctx.sessionProjections.checkpoint(session)).not.toHaveProperty('forgeChangeRequests') + // The released unit keeps its last row and folds no event after the unload. + expect(ctx.sessionProjections.checkpoint(session)).toHaveProperty('forgeChangeRequests', row) + expect(ctx.sessionProjections.checkpoint(ctx.sessions.create())).not.toHaveProperty('forgeChangeRequests') expect(provider.lookups).toEqual([]) }) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 8267448232..9ebb1f644f 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2952,7 +2952,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'sessionProjections', summary: '`ctx.sessionProjections`: the projection unit table and its drive.', - description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Consumers declare `sessionProjections` as a required injection, await registration during activation, and retain its ready handle. Registrants sharing a key share one unit and are counted. The last release removes the unit from live drives, checkpoints, and client snapshots; retained handles still fold new events on demand using the original unit.', + description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Consumers declare `sessionProjections` as a required injection, await registration during activation, and retain its ready handle. Registrants sharing a key share one unit and are counted. The last release removes the unit from live drives, restores, and client snapshots, while checkpoints keep each Session\'s last cell of it until the key registers again and the new unit refolds the log; retained handles still fold new events on demand using the original unit.', methods: [ { signature: 'register( definition: Omit, \'wire\'> & { wire: NonNullable[\'wire\']> }, ): Promise>', @@ -2992,15 +2992,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'checkpoint(session: Session): ProjectionCheckpoint', - description: 'State-level checkpoint of every persisted unit for one session, read from the watermark cache (missing cells fold lazily over the in-memory log). This is the write side of the persisted projection cache: the returned rows are the `(key → {ver, seq, val})` part of the durable `(sessionId, key, ver, seq, val)` rows. Every `val` is a DETACHED structured clone — never the live cell reference: the watermark cache is this registry\'s authoritative mutable state, and a caller reaching the live reference could corrupt every subsequent snapshot and frame through it (plain JSON by the unit contract, so the clone is total).', + description: 'State-level checkpoint of every persisted unit for one session, read from the watermark cache (missing cells fold lazily over the in-memory log). This is the write side of the persisted projection cache: the returned rows are the `(key → {ver, seq, val})` part of the durable `(sessionId, key, ver, seq, val)` rows. Every `val` is a DETACHED structured clone — never the live cell reference: the watermark cache is this registry\'s authoritative mutable state, and a caller reaching the live reference could corrupt every subsequent snapshot and frame through it (plain JSON by the unit contract, so the clone is total). A released unit contributes the Session\'s last cell. The registry no longer advances that cell, while a retained handle read still advances it forward, so a writer running after a plugin unloads keeps that unit\'s latest state; a Session that has no cell of it gets no row. restore folds only live units, so its refreshed rows omit released units.', parameters: [{ name: 'session', description: 'the session whose unit states are checkpointed.' }], - returns: 'one row per registered key.', + returns: 'one row per registered key, plus one per released key with a cell for this Session.', }, { signature: 'restoreFloor(checkpoint: ProjectionCheckpoint): SessionLogOffset | undefined', description: 'The stored seq a restore tail read over `checkpoint` must start at: one event BELOW the lowest usable watermark (a row is usable when its `ver` matches the live unit\'s `stateVersion`; an absent or mismatched row pulls the floor to `0` — that key must refold the full log). The one-below anchor is load-bearing: the tail then proves how far the stored log still extends, so restore can detect a log that shrank below a row\'s watermark (crash-repair truncation) instead of serving the stale row as current — an empty tail read from the anchor yields an end below every watermark and the restore rejects for a full re-read.', parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }], - returns: 'the offset for the stored-log suffix read (`SessionHandle.read`), or `undefined` when no unit is registered (no read needed — {@link restore} would serve empty values regardless).', + returns: 'the offset for the stored-log suffix read (`SessionHandle.read`), or `undefined` when no live unit is registered (released predecessors do not count; no read needed — {@link restore} would serve empty values regardless).', }, { signature: 'viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: ProjectionKeySelection, ): Partial', @@ -3012,7 +3012,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: SessionLogOffset, header: SessionHeader, inheritedEventCount: SessionLogOffset, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }', description: 'Cold read: fold every persisted unit over a stored log suffix, seeding each from its checkpoint row when usable — the one read recipe (cached state + forward tail replay + `view`) applied without a live `Session`. Call with the stored events at or past `restoreFloor(checkpoint)` (a `SessionHandle.read` slice) and that same floor as `baseSeq`; the floor\'s one-below anchor makes the supplied end honest, so a shrunk log is detected here. A row is usable iff its `ver` matches the live unit\'s `stateVersion`, it does not predate `baseSeq` (`seq >= baseSeq - 1`), and it does not claim events past the supplied end (`seq <= endSeq`); an unusable row is discarded and its key refolds from `init` — which is only sound over the full log, so a discarded row with `baseSeq > 0` throws (the caller re-reads from seq 0, e.g. after a crash-repair truncation shrank the log below a row\'s watermark).', parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }, { name: 'events', description: 'the stored events with `seq >= baseSeq`, in seq order.' }, { name: 'baseSeq', description: 'the seq `events` starts at (its first event\'s seq when non-empty).' }, { name: 'header', description: 'immutable metadata for the Session being restored.' }, { name: 'inheritedEventCount', description: 'exact fork-inherited prefix length supplied to unit initialization.' }], - returns: 'the snapshot cut at the supplied log end (`asOfSeq` is the last supplied event\'s seq, `baseSeq - 1` for an empty tail) plus the refreshed checkpoint rows at that cut, ready for a durable write-back.', + returns: 'the snapshot cut at the supplied log end (`asOfSeq` is the last supplied event\'s seq, `baseSeq - 1` for an empty tail) plus the refreshed checkpoint rows at that cut, ready for a durable write-back. The rows cover live units only: a released unit has no cell for a log folded without a live `Session`, so a write-back of them omits it.', }, { signature: 'hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: SessionLogOffset, ): ProjectionSnapshot', diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 363540ab3c..d4e1c0e680 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -287,10 +287,14 @@ describe('GoalService creation and replay', () => { const stub = stubAgent('goal-hmr') await ctx.agents.register(stub.agent) const goal = ctx.goals.create(stub.agent, { objective: 'survive service reload' }) + const row = ctx.sessionProjections.checkpoint(stub.session).goal await fiber.dispose() expect(ctx.get('goals')).toBeUndefined() - expect(ctx.sessionProjections.checkpoint(stub.session).goal?.val).toBeUndefined() + stub.session.append('turn/start', { turn: 1 }) + // The released unit keeps its last row and folds no event after the unload. + expect(ctx.sessionProjections.checkpoint(stub.session)).toHaveProperty('goal', row) + expect(ctx.sessionProjections.checkpoint(Session.create(SessionId('goal-after-unload')))).not.toHaveProperty('goal') expect(() => first.get(stub.agent)).toThrow('cannot get required service "agents" in inactive context') await ctx.plugin(GoalService) diff --git a/packages/interaction/user-approval/tests/projection.spec.ts b/packages/interaction/user-approval/tests/projection.spec.ts index d58110e0e0..7ed69a8b6c 100644 --- a/packages/interaction/user-approval/tests/projection.spec.ts +++ b/packages/interaction/user-approval/tests/projection.spec.ts @@ -123,9 +123,14 @@ describe('approval state', () => { expect(ctx.approval.overrideOf(session)).toBe('never') expect(read).not.toHaveBeenCalled() expect(snapshot).not.toHaveBeenCalled() + const row = ctx.sessionProjections.checkpoint(session)['approvalState'] await service.dispose() - expect(ctx.sessionProjections.checkpoint(session)).not.toHaveProperty('approvalState') + session.append('turn/start', { turn: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + // The released unit keeps its last row and folds no event after the unload. + expect(ctx.sessionProjections.checkpoint(session)).toHaveProperty('approvalState', row) + expect(ctx.sessionProjections.checkpoint(ctx.sessions.create())).not.toHaveProperty('approvalState') await ctx.plugin(ApprovalService) expect(ctx.approval.overrideOf(session)).toBe('never') expect(ctx.sessionProjections.checkpoint(session)['approvalState']?.val).toEqual({ policy: 'never', turnOpen: false }) @@ -214,10 +219,13 @@ describe('approval state', () => { await registration.entered const session = ctx.sessions.create() setApprovalPolicy(session, 'never') + const row = ctx.sessionProjections.checkpoint(session)['approvalState'] const unloading = fiber.dispose() registration.release() await unloading - expect(ctx.sessionProjections.checkpoint(session)).not.toHaveProperty('approvalState') + session.append('turn/start', { turn: 1 }) + expect(ctx.sessionProjections.checkpoint(session)).toHaveProperty('approvalState', row) + expect(ctx.sessionProjections.checkpoint(ctx.sessions.create())).not.toHaveProperty('approvalState') expect(ctx.get('approval')).toBeUndefined() const prompt = await ctx.systemPrompt.assemble({ agent: agentFor(session) }) expect(prompt.contexts).not.toContainEqual(expect.objectContaining({ name: 'approval:policy' })) diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index a81ee8828b..ee0dd22e66 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -137,11 +137,15 @@ describe('TokenMeter configuration and registration', () => { const fiber = await ctx.plugin(TokenMeter) expect(ctx.get('tokenMeter')).toBeInstanceOf(TokenMeter) const session = ctx.sessions.create() - expect(Object.keys(ctx.sessionProjections.checkpoint(session)).sort()) + const rows = ctx.sessionProjections.checkpoint(session) + expect(Object.keys(rows).sort()) .toEqual(['contextBreakdown', 'contextPressure', 'tokenMeasurement', 'tokenUsage']) await fiber.dispose() expect(ctx.get('tokenMeter')).toBeUndefined() - expect(ctx.sessionProjections.checkpoint(session)).toEqual({}) + session.append('turn/start', { turn: 1 }) + // The released units keep their last rows and fold no event after the unload. + expect(ctx.sessionProjections.checkpoint(session)).toEqual(rows) + expect(ctx.sessionProjections.checkpoint(ctx.sessions.create())).toEqual({}) await ctx.fiber.dispose() }) }) diff --git a/packages/session/session-projection-cache/README.i18n.yaml b/packages/session/session-projection-cache/README.i18n.yaml index 4d5408b806..49ecc7d893 100644 --- a/packages/session/session-projection-cache/README.i18n.yaml +++ b/packages/session/session-projection-cache/README.i18n.yaml @@ -27,8 +27,8 @@ en: bae62224d0008bc0 zh: 0daf92dab3314913 /deepseek-ai-dsh-session-projection-cache/use-this-package/reading-cached-values: - en: e8dc676fbfe97c5b - zh: 686837459289fd41 + en: 342e5c8be6ab3a37 + zh: ff09481eebf0b4cc /deepseek-ai-dsh-session-projection-cache/use-this-package/what-the-cache-guarantees: en: 6e4878de28ea113e zh: 6f940a2220585b5e diff --git a/packages/session/session-projection-cache/README.md b/packages/session/session-projection-cache/README.md index 8aa797b35a..242dedeb1b 100644 --- a/packages/session/session-projection-cache/README.md +++ b/packages/session/session-projection-cache/README.md @@ -58,7 +58,7 @@ Three mandatory points always write: session creation persists the seed-derived ### Reading cached values -`cachedSnapshot(meta, keys?)` is the read-only face: it synchronously serves client values from the storage domain's in-memory tables with zero I/O. It accepts a record whose lifecycle identity (`formatVersion`, `createdAt`, `cwd`, `isSeeded`) matches the header and serves its version- and schema-matching keys as one block whose `asOfSeq` is the lowest watermark among the served rows. That watermark is the stored record's own: a header witnesses no inherited cut and cannot vouch that the row sequence is comparable with the log a consumer later opens, so the Session list labels the block `cached` and the client lets every value the connected Session later produces supersede it. Within one format generation the cut is fixed at fork time and distinguishes no lifecycle the other fields do not, and a viewed value never seeds a fold, so seeded (forked) sessions are served exactly like unseeded ones. `cachedPredecessorTitle(meta)` is the narrower listing-only exception across a Session-format edge: a structurally admitted predecessor record whose lifecycle matches may expose only a current-version-compatible `title` row, because title text is invariant across the adjacent edges. All other predecessor rows remain unavailable. `coldSnapshot(meta, inheritedEventCount, events)` is a fold face: it accepts the exact cut with a complete ordered log, skips the checkpointed prefix while folding, and refreshes the record without reading persistence itself. +`cachedSnapshot(meta, keys?)` is the read-only face: it synchronously serves client values from the storage domain's in-memory tables with zero I/O. It accepts a record whose lifecycle identity (`formatVersion`, `createdAt`, `cwd`, `isSeeded`) matches the header and serves its version- and schema-matching keys as one block whose `asOfSeq` is the lowest watermark among the served rows. That watermark is the stored record's own: a header witnesses no inherited cut and cannot vouch that the row sequence is comparable with the log a consumer later opens, so the Session list labels the block `cached` and the client lets every value the connected Session later produces supersede it. A row can also be frozen where its projection unit was released before the write, so its watermark may trail the log by more than the last checkpoint interval. Within one format generation the cut is fixed at fork time and distinguishes no lifecycle the other fields do not, and a viewed value never seeds a fold, so seeded (forked) sessions are served exactly like unseeded ones. `cachedPredecessorTitle(meta)` is the narrower listing-only exception across a Session-format edge: a structurally admitted predecessor record whose lifecycle matches may expose only a current-version-compatible `title` row, because title text is invariant across the adjacent edges. All other predecessor rows remain unavailable. `coldSnapshot(meta, inheritedEventCount, events)` is a fold face: it accepts the exact cut with a complete ordered log, skips the checkpointed prefix while folding, and refreshes the record without reading persistence itself. ### What the cache guarantees diff --git a/packages/session/session-projection-cache/README.zh.md b/packages/session/session-projection-cache/README.zh.md index 46d2c13bab..f8fc53feee 100644 --- a/packages/session/session-projection-cache/README.zh.md +++ b/packages/session/session-projection-cache/README.zh.md @@ -58,7 +58,7 @@ kind: "package-reference" ### 读取缓存值 -`cachedSnapshot(meta, keys?)` 是只读面:以零 I/O 从存储域的内存表同步提供客户端值。它接受生命周期身份(`formatVersion`、`createdAt`、`cwd`、`isSeeded`)与 header 匹配的记录,把其中版本和 schema 均匹配的 key 作为一个 block 提供,其 `asOfSeq` 是所服务各行中最低的水位。这个水位是存储记录自己的:header 作证不了 inherited cut,也作证不了行序号与消费者稍后打开的日志可比,因此 Session list 把该 block 标为 `cached`,客户端让建连后的 Session 产出的任何值覆盖它。在同一格式代内,cut 在 fork 时写死,不能区分其他字段区分不了的生命周期,而只读视图也从不播种 fold,所以 seeded(fork 出来的)会话与 unseeded 会话被同样地提供。`cachedPredecessorTitle(meta)` 是跨 Session 格式 edge 的更窄列表专用例外:生命周期匹配且已通过结构准入的 predecessor record 只能公开与当前版本兼容的 `title` row,因为 title 文本在相邻 edge 之间保持不变。其他 predecessor row 仍不可用。`coldSnapshot(meta, inheritedEventCount, events)` 是 fold 面:接受精确切点与完整有序日志,在折叠时跳过已检查点化的前缀,并在自身不读取持久化层的情况下刷新记录。 +`cachedSnapshot(meta, keys?)` 是只读面:以零 I/O 从存储域的内存表同步提供客户端值。它接受生命周期身份(`formatVersion`、`createdAt`、`cwd`、`isSeeded`)与 header 匹配的记录,把其中版本和 schema 均匹配的 key 作为一个 block 提供,其 `asOfSeq` 是所服务各行中最低的水位。这个水位是存储记录自己的:header 作证不了 inherited cut,也作证不了行序号与消费者稍后打开的日志可比,因此 Session list 把该 block 标为 `cached`,客户端让建连后的 Session 产出的任何值覆盖它。某一行也可能冻结在写入前投影单元被释放的那一刻,因此它的水位落后于日志的程度可能超过最近一次检查点间隔。在同一格式代内,cut 在 fork 时写死,不能区分其他字段区分不了的生命周期,而只读视图也从不播种 fold,所以 seeded(fork 出来的)会话与 unseeded 会话被同样地提供。`cachedPredecessorTitle(meta)` 是跨 Session 格式 edge 的更窄列表专用例外:生命周期匹配且已通过结构准入的 predecessor record 只能公开与当前版本兼容的 `title` row,因为 title 文本在相邻 edge 之间保持不变。其他 predecessor row 仍不可用。`coldSnapshot(meta, inheritedEventCount, events)` 是 fold 面:接受精确切点与完整有序日志,在折叠时跳过已检查点化的前缀,并在自身不读取持久化层的情况下刷新记录。 ### 缓存保证什么 diff --git a/packages/session/session-projection-cache/tests/cache.spec.ts b/packages/session/session-projection-cache/tests/cache.spec.ts index 5f28fd8f82..a6078bf375 100644 --- a/packages/session/session-projection-cache/tests/cache.spec.ts +++ b/packages/session/session-projection-cache/tests/cache.spec.ts @@ -175,6 +175,23 @@ function whenWritten(ctx: Context, id: SessionId): Promise { }) } +/** Register the secondary unit on its own fiber, so a test can release it before a Session detaches. */ +function releasableSecondaryUnit(ctx: Context) { + return ctx.plugin(Object.assign(async (inner: Context) => { + await inner.sessionProjections.register(secondaryMarksUnit) + }, { inject: ['sessionProjections'] })) +} + +/** Create a Session owned by its own fiber; disposing `owner` detaches it. */ +async function ownedSession(ctx: Context, id: SessionId) { + let session: Session | undefined + const owner = await ctx.plugin(Object.assign((inner: Context) => { + session = inner.sessions.create(id) + }, { inject: ['sessions'] })) + if (session === undefined) throw new Error('session was not created') + return { session, owner } +} + /** The stored record for one session id (undefined = absent or unreadable). */ async function storedRecord(root: string, id: Session['id']): Promise { try { @@ -266,6 +283,95 @@ describe('SessionProjectionCache write policy', () => { expect((await storedRows(root, id))?.['cache-test/marks']?.val).toEqual({ marks: ['live'] }) }) + it('writes the last state of a unit released before the detach write', async () => { + const { ctx, root } = await harness() + const unitOwner = await releasableSecondaryUnit(ctx) + const id = SessionId('released-exact') + const created = whenWritten(ctx, id) + const { session, owner } = await ownedSession(ctx, id) + await created + mark(session, ['a']) + const ended = whenWritten(ctx, id) + const end = endTurn(session) + await ended + // A plugin unloading ahead of the Sessions it describes releases its unit first. + await unitOwner.dispose() + const detached = whenWritten(ctx, id) + await owner.dispose() + await detached + expect(await storedRows(root, id)).toEqual({ + 'cache-test/marks': { ver: 1, seq: end.seq, val: { marks: ['a'] } }, + 'cache-test/secondary-marks': { ver: 1, seq: end.seq, val: { marks: ['a'] } }, + }) + }) + + it('keeps a released unit at the event it reached while later events advance the live units', async () => { + const { ctx, root } = await harness() + const unitOwner = await releasableSecondaryUnit(ctx) + const id = SessionId('released-behind') + const created = whenWritten(ctx, id) + const { session, owner } = await ownedSession(ctx, id) + await created + mark(session, ['a']) + const ended = whenWritten(ctx, id) + const end = endTurn(session) + await ended + await unitOwner.dispose() + const later = mark(session, ['b']) + const detached = whenWritten(ctx, id) + await owner.dispose() + await detached + expect(await storedRows(root, id)).toEqual({ + 'cache-test/marks': { ver: 1, seq: later.seq, val: { marks: ['b'] } }, + 'cache-test/secondary-marks': { ver: 1, seq: end.seq, val: { marks: ['a'] } }, + }) + }) + + it('drops a stored row whose unit this process never registered', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-')) + roots.push(root) + const meta = headerOf(SessionId('carried'), 9) + await seedRecord(root, meta.id, { + 'cache-test/marks': { ver: 1, seq: -1, val: null }, + 'cache-test/secret': { ver: 1, seq: -1, val: 'written by another composition' }, + }, { + formatVersion: SESSION_FORMAT_VERSION, + createdAt: 9, + isSeeded: false, + inheritedEventCount: SessionLogOffset(0), + }) + const { ctx } = await harness({ root }) + const events: SessionEvent[] = [{ type: 'cache-test/mark', seq: SessionSeq(0), time: 0, data: { marks: ['cold'] } }] + const written = whenWritten(ctx, meta.id) + ctx.sessionProjectionCache.coldSnapshot(meta, SessionLogOffset(0), events) + await written + expect(await storedRows(root, meta.id)).toEqual({ + 'cache-test/marks': { ver: 1, seq: 0, val: { marks: ['cold'] } }, + }) + }) + + it('never keeps a stored row of another lifecycle under the same Session id', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-')) + roots.push(root) + const id = SessionId('recreated') + // Same id and cut as the Session created below, but another creation time. + await seedRecord(root, id, { + 'cache-test/secondary-marks': { ver: 1, seq: -1, val: { marks: ['foreign'] } }, + }, { + formatVersion: SESSION_FORMAT_VERSION, + createdAt: 1, + isSeeded: false, + inheritedEventCount: SessionLogOffset(0), + }) + const { ctx } = await harness({ root }) + const created = whenWritten(ctx, id) + ctx.sessions.create(id) + await created + expect(await storedRows(root, id)).toEqual({ + 'cache-test/marks': { ver: 1, seq: -1, val: null }, + }) + }) + it('flushes when the in-turn event count reaches the configured threshold', async () => { const { ctx, root } = await harness({ config: { writeEveryEvents: 3, writeIntervalMs: 60_000 } }) vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index 53d259092a..92bdd362b1 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/README.i18n.yaml @@ -24,8 +24,8 @@ en: 09758d1475fad86e zh: 473637cb34aebe72 /deepseek-ai-dsh-session-projection/use-this-package/register-and-read: - en: 84f39e64f252a4d3 - zh: e3f6dff8c5df9016 + en: 4ef214a05a5090b3 + zh: 2119a4566d9e33eb /deepseek-ai-dsh-session-projection/use-this-package/bind-several-states-to-one-session: en: dac4ea4d67ce057d zh: 1cf3e5c7af5a5203 @@ -33,8 +33,8 @@ en: bfbeab7e33fc7f8f zh: 74e0b871f1639f14 /deepseek-ai-dsh-session-projection/use-this-package/persisted-checkpoints: - en: 3bbc61a438aeabd0 - zh: bd1824f4da278764 + en: b8196b8cfcacefac + zh: 4dd8e04fca04a580 /deepseek-ai-dsh-session-projection/use-this-package/session-summaries: en: ee868b44413a126f zh: f98c66d8e4da9a72 @@ -60,8 +60,8 @@ en: 8a592dae9bc946ab zh: eca939995bb5f8e0 /deepseek-ai-dsh-session-projection/known-limitations-and-deferred-work: - en: dda14db5a0fbe2fa - zh: 9675b2a39e7aea8b + en: 2c26ef65c8425be6 + zh: 4a797644f492772a /deepseek-ai-dsh-session-projection/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index e17c14ad77..cc39a7ecc7 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -55,7 +55,7 @@ const definition = { ### Register and read -`await register(definition)` infers the host state type from `stateSchema`, installs the unit, and returns a handle with synchronous `stateOf(session)` reads and `dispose(): Promise`. Await `dispose()` to release the registration; repeated calls are safe. Registrants with the same key and `stateVersion` share cells; incompatible or invalid versions reject registration. Registration belongs to the calling fiber. The last unregister removes the unit from global event drives, snapshots, checkpoints, and change notifications. Existing handles remain readable: each retains its original unit and folds new Session events on demand, even if another version later registers the same key. Reading a handle does not initialize unrelated units or register it again. Carriers use `snapshot(session)` for a consistent client-view cut, `onChanged(listener)` for one call per changed unit, and `onEventChanged(listener)` for one call per committed event that lists every client-visible unit whose view it changed. +`await register(definition)` infers the host state type from `stateSchema`, installs the unit, and returns a handle with synchronous `stateOf(session)` reads and `dispose(): Promise`. Await `dispose()` to release the registration; repeated calls are safe. Registrants with the same key and `stateVersion` share cells; incompatible or invalid versions reject registration. Registration belongs to the calling fiber. The last unregister removes the unit from global event drives, snapshots, restores, and change notifications; `checkpoint(session)` keeps each Session's last cell of it, until the key registers again and the new unit refolds the log. The registry no longer advances that cell, but a retained handle read still advances it forward, and the checkpoint serves the advanced cell. Existing handles remain readable: each retains its original unit and folds new Session events on demand, even if another version later registers the same key. Reading a handle does not initialize unrelated units or register it again. Carriers use `snapshot(session)` for a consistent client-view cut, `onChanged(listener)` for one call per changed unit, and `onEventChanged(listener)` for one call per committed event that lists every client-visible unit whose view it changed. ```text const todoProjectionHandle = await ctx.sessionProjections.register(definition) @@ -97,7 +97,7 @@ Definitions install synchronously, and handle delivery remains asynchronous. Reg ### Persisted checkpoints -Every unit's state is checkpointed — client-visible and host-only alike — through `checkpoint(session)`, and the sibling [session-projection-cache](../session-projection-cache/README.md) persists those checkpoints so cold reads skip full log loads. Checkpoint watermarks use `SessionSeqCursor` (`-1` for an empty log), while replay starts use `SessionLogOffset`; `restoreFloor` and `restore` implement the read recipe without conflating an existing event with a log gap. +Every unit's state is checkpointed — client-visible and host-only alike — through `checkpoint(session)`, and the sibling [session-projection-cache](../session-projection-cache/README.md) persists those checkpoints so cold reads skip full log loads. Checkpoint watermarks use `SessionSeqCursor` (`-1` for an empty log), while replay starts use `SessionLogOffset`; `restoreFloor` and `restore` implement the read recipe without conflating an existing event with a log gap. `restore` folds only live units, so its refreshed rows omit released units, and a write-back of them drops their stored rows. ### Session summaries @@ -168,6 +168,8 @@ These limits define where the projection registry needs care at scale. They are - **Only a key-subset read omits keys; a tail page still carries every client-visible key and there is no lazy per-value request** — a summary omits exactly the keys whose `SessionProjectionMap` entry carries `SessionProjectionSummaryExcluded` and whose registration declares `wire.summary: false`. The opt-out is per key, not per value: no read defers one large value. - **The unit table is process-wide, so key presence is not a per-session capability signal** — a key registered by any agent preset appears in every session's snapshot; a client must read the value rather than treat an absent key as absence of the feature. - **Eager drive touches every unit per event** — cheap by construction (whole-value rule and state/view reference gates), but a hot path would justify per-unit event-type prefilters. +- **A released unit's checkpoint row stops advancing** — after the last registrant releases a key, `checkpoint(session)` keeps each Session's last cell until the key registers again. Outside graceful shutdown, events appended meanwhile leave the stored row behind the log, and cold listings serve that older value. +- **A released predecessor keeps its definition referenced** — the registry holds the released unit's definition closures until the same key registers again, so a key that a reloaded plugin renames or stops registering keeps the old module referenced for the process lifetime. Cells are keyed weakly by Session and retain no Session. - **Registry cells live in memory only** — a restart rebuilds by folding the log on first touch; compositions that mount `dsh-session-projection-cache` seed that fold from persisted rows instead. - **Synchronous unit discipline is only partially mechanical** — `wire.viewSchema.parse` rejects a Promise-returning view, but an `apply` that blocks or reads torn non-session state is a review concern. diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index addea20856..82edeb2891 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -55,7 +55,7 @@ const definition = { ### 注册与读取 -`await register(definition)` 从 `stateSchema` 推导宿主状态类型,安装单元,并返回提供同步 `stateOf(session)` 读取和 `dispose(): Promise` 的 handle。等待 `dispose()` 完成即可释放注册;重复调用是安全的。具有相同 key 和 `stateVersion` 的注册方共享 cell;版本不兼容或非法时注册会拒绝。注册归调用方 fiber 所有。最后一个注册方注销后,单元退出全局事件驱动、快照、检查点和变更通知。已有 handle 仍可读取:它保留原有单元,按需折叠新增的 Session 事件,即使同一 key 后来注册了其他版本也不受影响。读取 handle 不会初始化无关单元,也不会重新注册。载体用 `snapshot(session)` 读取一致的客户端视图切面,用 `onChanged(listener)` 按每个变化的单元接收一次调用,用 `onEventChanged(listener)` 按每个已提交事件接收一次调用,列出该事件改变了视图的全部客户端可见单元。 +`await register(definition)` 从 `stateSchema` 推导宿主状态类型,安装单元,并返回提供同步 `stateOf(session)` 读取和 `dispose(): Promise` 的 handle。等待 `dispose()` 完成即可释放注册;重复调用是安全的。具有相同 key 和 `stateVersion` 的注册方共享 cell;版本不兼容或非法时注册会拒绝。注册归调用方 fiber 所有。最后一个注册方注销后,单元退出全局事件驱动、快照、恢复读取和变更通知;`checkpoint(session)` 仍保留每个 Session 中该单元的最后一个 cell,直到同一 key 再次注册、由新单元重新折叠日志。注册表不再推进这个 cell,但已保留的 handle 读取时仍会把它向前推进,检查点输出推进后的 cell。已有 handle 仍可读取:它保留原有单元,按需折叠新增的 Session 事件,即使同一 key 后来注册了其他版本也不受影响。读取 handle 不会初始化无关单元,也不会重新注册。载体用 `snapshot(session)` 读取一致的客户端视图切面,用 `onChanged(listener)` 按每个变化的单元接收一次调用,用 `onEventChanged(listener)` 按每个已提交事件接收一次调用,列出该事件改变了视图的全部客户端可见单元。 ```text const todoProjectionHandle = await ctx.sessionProjections.register(definition) @@ -97,7 +97,7 @@ const todo = projections.todo() ### 持久检查点 -系统通过 `checkpoint(session)` 为每个单元的状态创建检查点,client-visible 与 host-only 一视同仁;同级包 [session-projection-cache](../session-projection-cache/README.zh.md) 持久化这些检查点,使冷读跳过全量日志加载。检查点水位使用 `SessionSeqCursor`(空日志为 `-1`),回放起点使用 `SessionLogOffset`;`restoreFloor` 与 `restore` 实现读取流程,且不会混淆已有事件与日志间隙。 +系统通过 `checkpoint(session)` 为每个单元的状态创建检查点,client-visible 与 host-only 一视同仁;同级包 [session-projection-cache](../session-projection-cache/README.zh.md) 持久化这些检查点,使冷读跳过全量日志加载。检查点水位使用 `SessionSeqCursor`(空日志为 `-1`),回放起点使用 `SessionLogOffset`;`restoreFloor` 与 `restore` 实现读取流程,且不会混淆已有事件与日志间隙。`restore` 只折叠在线单元,因此它刷新后的行不含已释放单元,用这些行回写会丢弃这些单元已存储的行。 ### Session 摘要 @@ -168,6 +168,8 @@ Session 摘要包含每个已注册客户端视图,但排除 `SessionProjectio - **只有带 key 子集的读取才省略 key;尾页仍携带每个客户端可见 key,也不存在惰性逐值请求**——摘要只省略那些 `SessionProjectionMap` 条目带有 `SessionProjectionSummaryExcluded` 且注册声明了 `wire.summary: false` 的 key。该 opt-out 按 key 生效,不按值生效:没有任何读取会延后加载单个大值。 - **单元表是进程级的,因此 key 是否存在不能当作逐会话的能力信号**——任何 agent preset 注册的 key 都会出现在每个会话的快照里;客户端必须读值,不能把 key 缺席当作功能缺席。 - **主动驱动逐事件触达每个单元**——按构造开销很低(全量值规则与 state/view 引用闸门),但若出现热点路径,可加按单元的事件类型预过滤。 +- **已释放单元的检查点行停止推进**——最后一个注册方释放 key 后,`checkpoint(session)` 保留每个 Session 的最后一个 cell,直到该 key 再次注册。在正常关停之外,这期间追加的事件会让存储的行落后于日志,冷列表读到的是这个较旧的值。 +- **已释放的前任会继续引用其定义**——注册表持有已释放单元的定义闭包,直到同一 key 再次注册;因此重载后改名或不再注册的 key 会在进程生命周期内一直引用旧模块。cell 以 Session 为弱键,不会保留 Session。 - **注册表 cell 只活在内存里**——重启后首次触达时靠折叠日志重建;挂载了 `dsh-session-projection-cache` 的组合改由持久行播种该折叠。 - **单元同步纪律只有部分可机械把关**——`wire.viewSchema.parse` 能拒绝返回 Promise 的 view,但阻塞的 `apply`、或读取撕裂的非会话状态的 `apply`,只能靠评审把关。 diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 500cae4233..a3e9bc0f3b 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -52,6 +52,8 @@ export interface ProjectionHandle { /** * Read the current state, including after unregistering, by folding new * Session events with this handle's original unit. Do not mutate the live value. + * After the final release, a read also advances the released cell that + * `checkpoint(session)` serves, until the key registers again. * @param session - Session whose committed events are folded. * @returns the projection state at the Session's current cursor. */ @@ -233,8 +235,10 @@ interface UnitCell { } /** - * One shared unit and its per-session cells, retained by acquired readers after - * the final registration leaves the live table. + * One shared unit and its per-session cells. After the final release the entry + * stays in the table as a released predecessor until its key registers again, + * keeping its definition closures referenced; acquired readers retain it beyond + * that replacement. * * `refs` exists because one unit definition already serves every session — the * cells are keyed by `Session` — while registrants are per-session: @@ -246,7 +250,11 @@ interface UnitCell { interface Registration { readonly def: ErasedDefinition readonly cells: WeakMap - /** Live registrants sharing this unit; the last one out removes the key. */ + /** + * Live registrants sharing this unit. `0` marks a released predecessor: only + * `checkpoint()` reads its cells through the registry, while a retained handle + * read can still advance them. + */ refs: number } @@ -266,8 +274,10 @@ function cursorBefore(offset: SessionLogOffset): SessionSeqCursor { * touch (event or read). Consumers declare `sessionProjections` as a required * injection, await registration during activation, and retain its ready handle. * Registrants sharing a key share one unit and are counted. The last release - * removes the unit from live drives, checkpoints, and client snapshots; retained - * handles still fold new events on demand using the original unit. + * removes the unit from live drives, restores, and client snapshots, while + * checkpoints keep each Session's last cell of it until the key registers + * again and the new unit refolds the log; retained handles still fold new + * events on demand using the original unit. */ export class SessionProjectionRegistry extends Service { private readonly registrations = new Map() @@ -287,6 +297,7 @@ export class SessionProjectionRegistry extends Service { ctx.on('session/created', (session: Session) => { if (session.seq !== 0) return for (const registration of this.registrations.values()) { + if (registration.refs === 0) continue if (registration.cells.has(session)) continue registration.cells.set(session, { state: registration.def.init(session.header, session.inheritedEventCount), @@ -395,7 +406,7 @@ export class SessionProjectionRegistry extends Service { const dispose = this.ctx.effect(() => { const key = erased.key const existing = this.registrations.get(key) - if (existing === undefined) { + if (existing === undefined || existing.refs === 0) { registration = { def: erased, cells: new WeakMap(), refs: 1 } this.registrations.set(key, registration) this.summaryKeySet = undefined @@ -412,7 +423,6 @@ export class SessionProjectionRegistry extends Service { return () => { registration.refs -= 1 if (registration.refs === 0) { - this.registrations.delete(key) this.summaryKeySet = undefined } } @@ -449,7 +459,7 @@ export class SessionProjectionRegistry extends Service { this.ctx.logger.warn(`sessionProjections.stateOf(session, ${JSON.stringify(key)}) is deprecated; read handle.stateOf(session) from an awaited registration, or use the owning domain service`) } const registration = this.registrations.get(key) - if (registration === undefined) return undefined + if (registration === undefined || registration.refs === 0) return undefined this.materializeCells(session) return this.cellFor(registration, session).state } @@ -508,6 +518,7 @@ export class SessionProjectionRegistry extends Service { const values: Record = {} const selected = selectedKeys(keys) for (const registration of this.registrations.values()) { + if (registration.refs === 0) continue if (registration.def.wire === undefined) continue if (selected !== undefined && !selected.has(registration.def.key)) continue const cell = this.cellFor(registration, session) @@ -534,6 +545,7 @@ export class SessionProjectionRegistry extends Service { let asOfSeq: SessionSeqCursor | undefined const selected = selectedKeys(keys) for (const registration of this.registrations.values()) { + if (registration.refs === 0) continue if (registration.def.wire === undefined) continue if (selected !== undefined && !selected.has(registration.def.key)) continue const cell = registration.cells.get(session) @@ -564,6 +576,7 @@ export class SessionProjectionRegistry extends Service { private deriveSummaryKeys(): ReadonlySet> { const keys = new Set>() for (const registration of this.registrations.values()) { + if (registration.refs === 0) continue if (registration.def.wire === undefined || !registration.def.wire.summary) continue keys.add(registration.def.key as Extract) } @@ -581,13 +594,21 @@ export class SessionProjectionRegistry extends Service { * mutable state, and a caller reaching the live reference could corrupt * every subsequent snapshot and frame through it (plain JSON by the unit * contract, so the clone is total). + * A released unit contributes the Session's last cell. The registry no + * longer advances that cell, while a retained handle read still advances it + * forward, so a writer running after a plugin unloads keeps that unit's + * latest state; a Session that has no cell of it gets no row. {@link restore} + * folds only live units, so its refreshed rows omit released units. * @param session - the session whose unit states are checkpointed. - * @returns one row per registered key. + * @returns one row per registered key, plus one per released key with a cell for this Session. */ checkpoint(session: Session): ProjectionCheckpoint { const rows: ProjectionCheckpoint = {} for (const registration of this.registrations.values()) { - const cell = this.cellFor(registration, session) + const cell = registration.refs === 0 + ? registration.cells.get(session) + : this.cellFor(registration, session) + if (cell === undefined) continue rows[registration.def.key] = { ver: registration.def.stateVersion, seq: cell.observedSeq, @@ -610,12 +631,13 @@ export class SessionProjectionRegistry extends Service { * re-read. * @param checkpoint - persisted rows for one session (possibly stale or empty). * @returns the offset for the stored-log suffix read (`SessionHandle.read`), - * or `undefined` when no unit is registered (no read needed — + * or `undefined` when no live unit is registered (released predecessors do not count; no read needed — * {@link restore} would serve empty values regardless). */ restoreFloor(checkpoint: ProjectionCheckpoint): SessionLogOffset | undefined { let floor: number | undefined for (const registration of this.registrations.values()) { + if (registration.refs === 0) continue const row = checkpoint[registration.def.key] const need = row !== undefined && row.ver === registration.def.stateVersion ? Math.max(row.seq + 1, 0) @@ -643,6 +665,7 @@ export class SessionProjectionRegistry extends Service { const values: Record = {} const selected = selectedKeys(keys) for (const registration of this.registrations.values()) { + if (registration.refs === 0) continue const def = registration.def if (def.wire === undefined) continue if (selected !== undefined && !selected.has(def.key)) continue @@ -682,6 +705,8 @@ export class SessionProjectionRegistry extends Service { * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last * supplied event's seq, `baseSeq - 1` for an empty tail) plus the * refreshed checkpoint rows at that cut, ready for a durable write-back. + * The rows cover live units only: a released unit has no cell for a log + * folded without a live `Session`, so a write-back of them omits it. */ restore( checkpoint: ProjectionCheckpoint, @@ -696,6 +721,7 @@ export class SessionProjectionRegistry extends Service { const values: Record = {} const refreshed: ProjectionCheckpoint = {} for (const registration of this.registrations.values()) { + if (registration.refs === 0) continue const def = registration.def const row = checkpoint[def.key] const usable = row !== undefined @@ -749,6 +775,7 @@ export class SessionProjectionRegistry extends Service { const endSeq: SessionSeqCursor = events.at(-1)?.seq ?? cursorBefore(baseSeq) let complete = true for (const registration of this.registrations.values()) { + if (registration.refs === 0) continue const current = registration.cells.get(session) if (current?.observedSeq !== endSeq) { complete = false @@ -758,6 +785,7 @@ export class SessionProjectionRegistry extends Service { if (complete) { const values: Record = {} for (const registration of this.registrations.values()) { + if (registration.refs === 0) continue if (registration.def.wire === undefined) continue const current = registration.cells.get(session) as UnitCell values[registration.def.key] = this.viewCell(registration, current) @@ -772,6 +800,7 @@ export class SessionProjectionRegistry extends Service { session.inheritedEventCount, ) for (const registration of this.registrations.values()) { + if (registration.refs === 0) continue const row = restored.checkpoint[registration.def.key] /* v8 ignore next -- restore() writes a row for every registration in the same synchronous pass. */ if (row === undefined) continue @@ -788,7 +817,10 @@ export class SessionProjectionRegistry extends Service { /** Materialize every registered unit cell at the Session's current cursor. */ private materializeCells(session: Session): void { - for (const registration of this.registrations.values()) this.cellFor(registration, session) + for (const registration of this.registrations.values()) { + if (registration.refs === 0) continue + this.cellFor(registration, session) + } } /** Fold one unit from init over `events`, producing a cell watermarked at the last folded event. */ @@ -857,6 +889,7 @@ export class SessionProjectionRegistry extends Service { const changes: { key: Extract; val: unknown }[] = [] try { for (const registration of this.registrations.values()) { + if (registration.refs === 0) continue let cell = registration.cells.get(session) if (cell !== undefined && cell.observedSeq >= event.seq) continue if (cell === undefined) { diff --git a/packages/session/session-projection/tests/registration-reader.spec.ts b/packages/session/session-projection/tests/registration-reader.spec.ts index 939a6f1a1d..8c10b46a94 100644 --- a/packages/session/session-projection/tests/registration-reader.spec.ts +++ b/packages/session/session-projection/tests/registration-reader.spec.ts @@ -154,7 +154,8 @@ describe('retained projection readers', () => { expect(apply).toHaveBeenCalledTimes(2) expect(changed).toHaveBeenCalledOnce() expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) - expect(ctx.sessionProjections.checkpoint(session)).toEqual({}) + // The checkpoint carries the released unit's cell as the retained reader last advanced it. + expect(ctx.sessionProjections.checkpoint(session)).toEqual({ 'test/reader': { ver: 1, seq: 1, val: 2 } }) const later = ctx.sessions.create() later.append('turn/start', { turn: 1 }) expect(handle.stateOf(later)).toBe(1) diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts index cd0f9d65c1..a1f8c7897f 100644 --- a/packages/session/session-projection/tests/registry.spec.ts +++ b/packages/session/session-projection/tests/registry.spec.ts @@ -574,7 +574,7 @@ describe('SessionProjectionRegistry drive', () => { await expect(Promise.resolve(ctx.sessionProjections.register({ ...marksUnit(), stateVersion: 1.5 }))).rejects.toThrow(/stateVersion/) }) - it('register() disposer removes the key (with its cells) and frees it for re-registration', async () => { + it('register() disposer releases the key from snapshots and frees it for re-registration', async () => { const { ctx, session } = await harness() const dispose = await ctx.sessionProjections.register(marksUnit()) mark(session, ['cached']) @@ -585,6 +585,69 @@ describe('SessionProjectionRegistry drive', () => { expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['cached'] }) }) + it('freezes a released unit: checkpoints keep its last cell while every live face skips it', async () => { + const { ctx, session } = await harness() + vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const owner = await ctx.plugin(Object.assign(async (inner: Context) => { + await inner.sessionProjections.register(marksUnit()) + }, { inject: ['sessionProjections'] })) + await ctx.sessionProjections.register(countUnit()) + const released = mark(session, ['released']) + expect(ctx.sessionProjections.summaryKeys().has('test/marks')).toBe(true) + await owner.dispose() + + const after = mark(session, ['after']) + const fresh = ctx.sessions.create() + expect(ctx.sessionProjections.summaryKeys().has('test/marks')).toBe(false) + expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) + // oxlint-disable-next-line typescript/no-deprecated -- The deprecated cached face also skips a released unit. + expect(ctx.sessionProjections.cachedSnapshot(session)).toBeUndefined() + // oxlint-disable-next-line typescript/no-deprecated -- The deprecated key read reports a released key as absent. + expect(ctx.sessionProjections.stateOf(session, 'test/marks')).toBeUndefined() + // oxlint-disable-next-line typescript/no-deprecated -- Its materializing read skips the released unit. + expect(ctx.sessionProjections.stateOf(session, 'test/count')).toBe(2) + // No drive, snapshot, or materializing read advanced the released cell. + const rows = ctx.sessionProjections.checkpoint(session) + expect(rows).toEqual({ + 'test/marks': { ver: 1, seq: released.seq, val: { marks: ['released'] } }, + 'test/count': { ver: 1, seq: after.seq, val: 2 }, + }) + // A Session created after the release never gets a cell for the unit. + expect(ctx.sessionProjections.checkpoint(fresh)).toEqual({ 'test/count': { ver: 1, seq: -1, val: 0 } }) + + // Restore paths consult only live units, so the frozen row neither lowers the floor nor gets viewed. + expect(ctx.sessionProjections.restoreFloor(rows)).toBe(SessionLogOffset(after.seq)) + expect(ctx.sessionProjections.viewCheckpoint(rows)).toEqual({}) + const events = [released, after] + expect(ctx.sessionProjections.restore(rows, events, SessionLogOffset(0), RESTORE_HEADER, SessionLogOffset(0))).toEqual({ + snapshot: { asOfSeq: after.seq, values: {} }, + checkpoint: { 'test/count': { ver: 1, seq: after.seq, val: 2 } }, + }) + const prepared = ctx.sessions.prepare(SessionId('projection-hydrate-released'), { seed: events }) + const hydrated = ctx.sessionProjections.hydrate(prepared, rows, events, SessionLogOffset(0)) + expect(hydrated).toEqual({ asOfSeq: after.seq, values: {} }) + expect(ctx.sessionProjections.hydrate(prepared, rows, events, SessionLogOffset(0))).toEqual(hydrated) + expect(ctx.sessionProjections.checkpoint(prepared)).not.toHaveProperty('test/marks') + }) + + it('re-registering a released key refolds the Session instead of continuing the frozen cell', async () => { + const { ctx, session } = await harness() + const owner = await ctx.plugin(Object.assign(async (inner: Context) => { + await inner.sessionProjections.register(marksUnit()) + }, { inject: ['sessionProjections'] })) + const released = mark(session, ['released']) + await owner.dispose() + const after = mark(session, ['after']) + expect(ctx.sessionProjections.checkpoint(session)['test/marks']) + .toEqual({ ver: 1, seq: released.seq, val: { marks: ['released'] } }) + + const replacement = await ctx.sessionProjections.register(marksUnit()) + expect(replacement.stateOf(session)).toEqual({ marks: ['after'] }) + expect(ctx.sessionProjections.checkpoint(session)['test/marks']) + .toEqual({ ver: 1, seq: after.seq, val: { marks: ['after'] } }) + expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['after'] }) + }) + it('removes registrations and change listeners when their owning fiber unloads (HMR safety)', async () => { const { ctx, session } = await harness() const notifications: string[] = [] From a3f6f94c1e1a68a38086274f7f75b6c9b370adac Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:13:05 +0800 Subject: [PATCH 31/32] test(snapshot): refresh fixtures for the 10-query web_search default --- .../sdk/command-attachments/tool-schemas.expected.json | 2 +- snapshots/sdk/plan-review/tool-schemas.expected.json | 10 +++++----- .../sdk/session-resume/tool-schemas.expected.json | 4 ++-- .../apply-patch-update/tool-schemas.expected.json | 2 +- .../model-switch-notice/tool-schemas.expected.json | 4 ++-- .../ptc-script-imports/system-prompt.expected.md | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/snapshots/sdk/command-attachments/tool-schemas.expected.json b/snapshots/sdk/command-attachments/tool-schemas.expected.json index f7ff60492a..615ca39829 100644 --- a/snapshots/sdk/command-attachments/tool-schemas.expected.json +++ b/snapshots/sdk/command-attachments/tool-schemas.expected.json @@ -811,7 +811,7 @@ "properties": { "queries": { "type": "array", - "description": "1–4 search queries; their results are merged.", + "description": "1–10 search queries; their results are merged.", "items": { "type": "string" } diff --git a/snapshots/sdk/plan-review/tool-schemas.expected.json b/snapshots/sdk/plan-review/tool-schemas.expected.json index 6a6f934534..5b28e58aba 100644 --- a/snapshots/sdk/plan-review/tool-schemas.expected.json +++ b/snapshots/sdk/plan-review/tool-schemas.expected.json @@ -795,7 +795,7 @@ "properties": { "queries": { "type": "array", - "description": "1–4 search queries; their results are merged.", + "description": "1–10 search queries; their results are merged.", "items": { "type": "string" } @@ -1744,7 +1744,7 @@ "properties": { "queries": { "type": "array", - "description": "1–4 search queries; their results are merged.", + "description": "1–10 search queries; their results are merged.", "items": { "type": "string" } @@ -2692,7 +2692,7 @@ "properties": { "queries": { "type": "array", - "description": "1–4 search queries; their results are merged.", + "description": "1–10 search queries; their results are merged.", "items": { "type": "string" } @@ -3640,7 +3640,7 @@ "properties": { "queries": { "type": "array", - "description": "1–4 search queries; their results are merged.", + "description": "1–10 search queries; their results are merged.", "items": { "type": "string" } @@ -4588,7 +4588,7 @@ "properties": { "queries": { "type": "array", - "description": "1–4 search queries; their results are merged.", + "description": "1–10 search queries; their results are merged.", "items": { "type": "string" } diff --git a/snapshots/sdk/session-resume/tool-schemas.expected.json b/snapshots/sdk/session-resume/tool-schemas.expected.json index c8a7e0853b..0ed765713c 100644 --- a/snapshots/sdk/session-resume/tool-schemas.expected.json +++ b/snapshots/sdk/session-resume/tool-schemas.expected.json @@ -511,7 +511,7 @@ "properties": { "queries": { "type": "array", - "description": "1–4 search queries; their results are merged.", + "description": "1–10 search queries; their results are merged.", "items": { "type": "string" } @@ -1160,7 +1160,7 @@ "properties": { "queries": { "type": "array", - "description": "1–4 search queries; their results are merged.", + "description": "1–10 search queries; their results are merged.", "items": { "type": "string" } diff --git a/snapshots/session/apply-patch-update/tool-schemas.expected.json b/snapshots/session/apply-patch-update/tool-schemas.expected.json index 507bba5622..cecfbd4616 100644 --- a/snapshots/session/apply-patch-update/tool-schemas.expected.json +++ b/snapshots/session/apply-patch-update/tool-schemas.expected.json @@ -370,7 +370,7 @@ "properties": { "queries": { "type": "array", - "description": "1–4 search queries; their results are merged.", + "description": "1–10 search queries; their results are merged.", "items": { "type": "string" } diff --git a/snapshots/session/model-switch-notice/tool-schemas.expected.json b/snapshots/session/model-switch-notice/tool-schemas.expected.json index c8a7e0853b..0ed765713c 100644 --- a/snapshots/session/model-switch-notice/tool-schemas.expected.json +++ b/snapshots/session/model-switch-notice/tool-schemas.expected.json @@ -511,7 +511,7 @@ "properties": { "queries": { "type": "array", - "description": "1–4 search queries; their results are merged.", + "description": "1–10 search queries; their results are merged.", "items": { "type": "string" } @@ -1160,7 +1160,7 @@ "properties": { "queries": { "type": "array", - "description": "1–4 search queries; their results are merged.", + "description": "1–10 search queries; their results are merged.", "items": { "type": "string" } diff --git a/snapshots/session/ptc-script-imports/system-prompt.expected.md b/snapshots/session/ptc-script-imports/system-prompt.expected.md index 9674457366..0de40ba3b5 100644 --- a/snapshots/session/ptc-script-imports/system-prompt.expected.md +++ b/snapshots/session/ptc-script-imports/system-prompt.expected.md @@ -230,7 +230,7 @@ interface ToolArgsMap { } & Record; /** Search the web for current information. Returns an optional summary answer and a list of source URLs. */ web_search: { - /** 1–4 search queries; their results are merged. */ + /** 1–10 search queries; their results are merged. */ queries: string[]; } & Record; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. A stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) end the whole script instead of producing `null`. The script has no filesystem, network, timer, or Node.js APIs; the agents do the work. */ From 06bf7ce024054e355eb9475fa0b24626f7511a8a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:33:44 +0800 Subject: [PATCH 32/32] =?UTF-8?q?Revert=20"fix(settings):=20show=20saved?= =?UTF-8?q?=20API=20keys=20and=20preserve=20environment=20detection?= =?UTF-8?q?=E2=80=A6"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 9bb49604b63bb5d50e879bcf63c9f5cb2afbd737. --- apps/web/tests/credential-editor.e2e.ts | 235 ------------------ .../detected-environment.expected.md | 25 -- .../missing-environment.expected.md | 23 -- .../credential-editor/saved-key.expected.md | 26 -- .../cards.expected.md | 4 +- .../default-models.expected.md | 4 +- .../models.expected.md | 4 +- .../fixtures/credential-editor.patch.yml | 5 - apps/web/tests/models-settings.e2e.ts | 9 +- .../tests/onboarding-deepseek-config.e2e.ts | 17 +- apps/web/tsconfig.json | 1 - docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 2 +- docs/capability-seams.zh.md | 2 +- docs/subsystems/credentials.i18n.yaml | 4 +- docs/subsystems/credentials.md | 22 +- docs/subsystems/credentials.zh.md | 22 +- .../credential-editor/guide.i18n.yaml | 16 -- .../v0.2.2-alpha.2/credential-editor/guide.md | 19 -- .../credential-editor/guide.zh.md | 19 -- docs/user/guide/providers.i18n.yaml | 8 +- docs/user/guide/providers.md | 8 +- docs/user/guide/providers.zh.md | 8 +- .../api/settings-controller/README.i18n.yaml | 8 +- packages/api/settings-controller/README.md | 4 +- packages/api/settings-controller/README.zh.md | 4 +- .../settings-controller/src/credentials.ts | 24 +- packages/api/settings-controller/src/types.ts | 6 +- .../tests/credentials-controller.host.spec.ts | 40 --- .../ui-settings-models/README.i18n.yaml | 12 +- packages/client/ui-settings-models/README.md | 10 +- .../client/ui-settings-models/README.zh.md | 10 +- .../src/client/ApiKeyInput.tsx | 4 +- .../src/client/ModelListEditor.tsx | 2 +- .../src/client/ModelsSection.tsx | 49 +--- .../src/client/ProviderEditor.tsx | 36 ++- .../ui-settings-models/src/client/apiKey.ts | 9 +- .../ui-settings-models/src/client/locales.ts | 8 + .../src/client/operations.ts | 15 -- .../tests/components.client.spec.tsx | 178 +------------ .../tests/model-list-catalog.client.spec.tsx | 2 +- .../tests/model-order.client.spec.tsx | 4 +- .../model-reasoning-efforts.client.spec.tsx | 2 +- .../tests/provider-form.client.spec.tsx | 1 - .../credentials/authorization/tests/memory.ts | 4 - .../credentials-local/README.i18n.yaml | 4 +- .../credentials/credentials-local/README.md | 2 +- .../credentials-local/README.zh.md | 2 +- .../credentials-local/src/index.ts | 4 - .../credentials-local/tests/local.spec.ts | 12 - .../credentials/credentials/README.i18n.yaml | 12 +- packages/credentials/credentials/README.md | 6 +- packages/credentials/credentials/README.zh.md | 6 +- packages/credentials/credentials/src/index.ts | 7 - .../credentials/credentials/tests/memory.ts | 5 - .../liveshare/src/endpoint-classification.ts | 1 - .../endpoint-classification.client.spec.ts | 1 - .../extensions/tool-cordis/src/api-catalog.ts | 17 +- scripts/gen-doc-graphs.ts | 2 +- tsconfig.host.json | 1 - 60 files changed, 148 insertions(+), 853 deletions(-) delete mode 100644 apps/web/tests/credential-editor.e2e.ts delete mode 100644 apps/web/tests/expected/credential-editor/detected-environment.expected.md delete mode 100644 apps/web/tests/expected/credential-editor/missing-environment.expected.md delete mode 100644 apps/web/tests/expected/credential-editor/saved-key.expected.md delete mode 100644 apps/web/tests/fixtures/credential-editor.patch.yml delete mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.i18n.yaml delete mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.md delete mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.zh.md diff --git a/apps/web/tests/credential-editor.e2e.ts b/apps/web/tests/credential-editor.e2e.ts deleted file mode 100644 index b9ee283aa4..0000000000 --- a/apps/web/tests/credential-editor.e2e.ts +++ /dev/null @@ -1,235 +0,0 @@ -/** Saved credentials and endpoints reach the assembled editor and provider requests. */ -import { once } from 'node:events' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { createServer } from 'node:http' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { chromium } from 'playwright' -import { expect, it, onTestFinished } from 'vitest' -import { BlockAssembler } from '@deepseek-ai/dsh-llm' -import { credentialRef } from '@deepseek-ai/dsh-credentials' -import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' -import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, webSnapshotMode } from './scaffold.ts' -import { openSettings, ZH_BROWSER_LOCALE } from './support.ts' - -it.each([false, true])('edits saved DeepSeek keys and restores environment detection (environment present: %s)', async (hasEnvironment) => { - const scaffold = await launchWebScaffold({ - deepSeekMissingCredential: true, - extraOverlayPath: fileURLToPath(new URL('./fixtures/credential-editor.patch.yml', import.meta.url)), - openInAppEnvironment: createLaunchEnvironmentSnapshot([{ - source: 'process', values: hasEnvironment ? { DEEPSEEK_API_KEY: 'fixture-environment-key' } : {}, - }]), - }) - try { - const browser = await chromium.launch() - try { - const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE }) - const ref = credentialRef('DEEPSEEK_API_KEY') - await scaffold.ctx.credentials.set(ref, 'fixture-saved-deepseek-key') - await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await openSettings(page, 'zh') - const dialog = page.getByRole('dialog', { name: '设置' }) - await dialog.getByRole('button', { name: '模型', exact: true }).click() - const edit = dialog.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)', exact: true }) - await edit.click() - const key = dialog.getByLabel('API 密钥', { exact: true }) - await expect.poll(() => key.inputValue()).toBe('fixture-saved-deepseek-key') - expect(await key.getAttribute('type')).toBe('password') - await compareOrRefreshGolden( - fileURLToPath(new URL('./expected/credential-editor/saved-key.expected.md', import.meta.url)), - await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode(), - ) - await dialog.getByRole('button', { name: '显示密钥', exact: true }).click() - expect(await key.getAttribute('type')).toBe('text') - await key.fill('fixture-cancelled-key') - await dialog.getByRole('button', { name: '取消', exact: true }).click() - expect(await scaffold.ctx.credentials.readStored(ref)).toBe('fixture-saved-deepseek-key') - await edit.click() - await expect.poll(() => key.inputValue()).toBe('fixture-saved-deepseek-key') - expect(await key.getAttribute('type')).toBe('password') - await key.fill('fixture-replacement-key') - await dialog.getByRole('button', { name: '保存', exact: true }).click() - await dialog.getByText('已保存 DeepSeek (deepseek-official)。', { exact: true }).waitFor() - expect(await scaffold.ctx.credentials.readStored(ref)).toBe('fixture-replacement-key') - await page.reload({ waitUntil: 'load' }) - await openSettings(page, 'zh') - await dialog.getByRole('button', { name: '模型', exact: true }).click() - await edit.click() - await expect.poll(() => key.inputValue()).toBe('fixture-replacement-key') - await key.fill('') - expect(await key.getAttribute('placeholder')).toBe('留空使用环境认证(DEEPSEEK_API_KEY)') - await dialog.getByText('环境凭据状态尚未确认', { exact: true }).waitFor() - expect(await scaffold.ctx.credentials.readStored(ref)).toBe('fixture-replacement-key') - await dialog.getByRole('button', { name: '保存', exact: true }).click() - await dialog.getByText('已保存 DeepSeek (deepseek-official)。', { exact: true }).waitFor() - expect(await scaffold.ctx.credentials.readStored(ref)).toBeUndefined() - // A missing credential opens the first-run settings card automatically; - // an environment credential keeps an ordinary row whose editor is closed. - if (hasEnvironment) await edit.click() - await expect.poll(() => key.inputValue()).toBe('') - expect(await key.getAttribute('placeholder')).toBe('留空使用环境认证(DEEPSEEK_API_KEY)') - const hint = hasEnvironment - ? '已检测到环境变量(DEEPSEEK_API_KEY)' - : '未检测到环境变量。请填写 API 密钥,或设置环境变量 DEEPSEEK_API_KEY。' - const detection = dialog.getByText(hint, { exact: true }) - await detection.waitFor() - const colors = await detection.evaluate((element, present) => { - const label = element as HTMLElement - const actual = getComputedStyle(label).color - const previous = label.style.color - label.style.color = present ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-warn-label)' - const expected = getComputedStyle(label).color - label.style.color = previous - return { actual, expected } - }, hasEnvironment) - expect(colors.actual).toBe(colors.expected) - expect(await dialog.getByRole('button', { name: '显示密钥', exact: true }).count()).toBe(0) - expect((await scaffold.ctx.credentials.resolve(ref))?.source).toBe(hasEnvironment ? 'env' : undefined) - await compareOrRefreshGolden( - fileURLToPath(new URL(`./expected/credential-editor/${hasEnvironment ? 'detected' : 'missing'}-environment.expected.md`, import.meta.url)), - await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode(), - ) - } finally { await browser.close() } - } finally { await scaffold.close() } -}) - -/** Local provider endpoint recording the destination path and authentication actually received. */ -async function connectionProbe(messages: boolean) { - const calls: { path: string; key: string | undefined }[] = [] - const server = createServer((request, response) => { - request.on('end', () => { - const key = messages ? request.headers['x-api-key'] : request.headers.authorization - calls.push({ path: request.url ?? '', key: typeof key === 'string' ? key : undefined }) - const events = messages ? [ - { type: 'message_start', message: { id: 'probe', type: 'message', role: 'assistant', model: 'credential-editor-model', content: [], usage: { input_tokens: 1, output_tokens: 0 } } }, - { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, - { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'connection-ok' } }, - { type: 'content_block_stop', index: 0 }, - { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 1 } }, - { type: 'message_stop' }, - ] : [ - { choices: [{ delta: { role: 'assistant', content: 'connection-ok' }, index: 0, finish_reason: null }] }, - { choices: [{ delta: {}, index: 0, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1 } }, - ] - response.writeHead(200, { 'content-type': 'text/event-stream' }) - response.end(events.map(event => `${'type' in event ? `event: ${event.type}\n` : ''}data: ${JSON.stringify(event)}\n\n`).join('') - + (messages ? '' : 'data: [DONE]\n\n')) - }) - request.resume() - }) - onTestFinished(async () => { - await new Promise((resolve) => { server.close(() => { resolve() }); server.closeAllConnections() }) - }) - server.listen(0, '127.0.0.1') - await once(server, 'listening') - const address = server.address() - if (address === null || typeof address === 'string') throw new Error('connection probe did not bind TCP') - return { url: `http://127.0.0.1:${String(address.port)}`, calls } -} - -it.each([ - { provider: 'deepseek-official', ref: 'DEEPSEEK_API_KEY', messages: true }, - { provider: 'openai', ref: 'OPENAI_API_KEY', messages: false }, - { provider: 'anthropic', ref: 'ANTHROPIC_API_KEY', messages: true }, - { provider: 'credential-fixture', ref: 'CREDENTIAL_FIXTURE_API_KEY', messages: false }, -])('uses the saved endpoint and key on the wire for $provider, then restores fallbacks', async ({ provider, ref, messages }) => { - const ambient = await connectionProbe(messages) - const saved = await connectionProbe(messages) - const root = await mkdtemp(join(tmpdir(), 'dsh-credential-routing-')) - onTestFinished(() => rm(root, { recursive: true, force: true })) - const overlay = join(root, 'connection.patch.yml') - const models = [{ id: 'credential-editor-model' }] - const baseSuffix = provider === 'anthropic' ? '' : '/v1' - const operation = provider === 'anthropic' ? '/v1/messages?beta=true' : messages ? '/messages' : '/chat/completions' - const ambientURL = `${ambient.url}/ambient${baseSuffix}` - await writeFile(overlay, JSON.stringify([ - { id: 'llm-deepseek', config: { models } }, - ...provider === 'deepseek-official' ? [] : [{ - id: 'llm-pi-ai', config: { providers: { [provider]: { - api: messages ? 'anthropic-messages' : 'openai-completions', - baseURL: ambientURL, models, - ...provider === 'credential-fixture' ? {} : { apiKeyEnv: ref }, - } } }, - }], - ])) - const scaffold = await launchWebScaffold({ - deepSeekMissingCredential: true, - extraOverlayPath: overlay, - openInAppEnvironment: createLaunchEnvironmentSnapshot([{ - source: 'process', values: { - [ref]: 'fixture-environment-key', - DEEPSEEK_BASE_URL: ambientURL, - OPENAI_BASE_URL: ambientURL, - ANTHROPIC_BASE_URL: ambientURL, - }, - }]), - }) - try { - const browser = await chromium.launch() - try { - const page = await browser.newPage({ locale: ZH_BROWSER_LOCALE }) - await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await openSettings(page, 'zh') - const dialog = page.getByRole('dialog', { name: '设置' }) - await dialog.getByRole('button', { name: '模型', exact: true }).click() - const edit = dialog.getByRole('button', { - name: `编辑 ${provider === 'deepseek-official' ? 'DeepSeek (deepseek-official)' : provider}`, exact: true, - }) - const key = dialog.getByLabel('API 密钥', { exact: true }) - const endpoint = dialog.getByLabel('API 地址', { exact: true }) - const savedURL = `${saved.url}/configured${baseSuffix}` - const generate = async (): Promise => { - const assembler = new BlockAssembler() - for await (const chunk of scaffold.ctx.llm.stream({ provider, model: 'credential-editor-model', messages: [] })) assembler.push(chunk) - expect(assembler.message({ provider, model: 'credential-editor-model' }).content).toContainEqual({ type: 'text', text: 'connection-ok' }) - } - await edit.click() - await key.fill('fixture-saved-key') - await dialog.getByText('自定义设置', { exact: true }).click() - await endpoint.fill(savedURL) - await dialog.getByRole('button', { name: '保存', exact: true }).click() - await expect.poll(() => key.count()).toBe(0) - await generate() - expect(saved.calls).toEqual([{ path: `/configured${baseSuffix}${operation}`, key: messages ? 'fixture-saved-key' : 'Bearer fixture-saved-key' }]) - expect(ambient.calls).toEqual([]) - await page.reload({ waitUntil: 'load' }) - await openSettings(page, 'zh') - await dialog.getByRole('button', { name: '模型', exact: true }).click() - await edit.click() - expect(await key.inputValue()).toBe('fixture-saved-key') - expect(await key.getAttribute('type')).toBe('password') - await dialog.getByText('自定义设置', { exact: true }).click() - expect(await endpoint.inputValue()).toBe(savedURL) - await generate() - expect(saved.calls).toHaveLength(2) - expect(saved.calls[1]).toEqual(saved.calls[0]) - expect(ambient.calls).toEqual([]) - await key.fill('') - await dialog.getByRole('button', { name: '保存', exact: true }).click() - await expect.poll(() => key.count()).toBe(0) - await generate() - expect(saved.calls).toHaveLength(3) - expect(saved.calls[2]).toEqual({ path: `/configured${baseSuffix}${operation}`, key: messages ? 'fixture-environment-key' : 'Bearer fixture-environment-key' }) - expect(ambient.calls).toEqual([]) - await edit.click() - await key.fill('fixture-saved-key') - await dialog.getByText('自定义设置', { exact: true }).click() - await endpoint.fill('') - await dialog.getByRole('button', { name: '保存', exact: true }).click() - await expect.poll(() => key.count()).toBe(0) - await generate() - expect(saved.calls).toHaveLength(3) - expect(ambient.calls).toEqual([{ path: `/ambient${baseSuffix}${operation}`, key: messages ? 'fixture-saved-key' : 'Bearer fixture-saved-key' }]) - await edit.click() - await key.fill('') - await dialog.getByRole('button', { name: '保存', exact: true }).click() - await expect.poll(() => key.count()).toBe(0) - await generate() - expect(saved.calls).toHaveLength(3) - expect(ambient.calls[1]).toEqual({ path: `/ambient${baseSuffix}${operation}`, key: messages ? 'fixture-environment-key' : 'Bearer fixture-environment-key' }) - expect(ambient.calls).toHaveLength(2) - expect(await scaffold.ctx.credentials.readStored(credentialRef(ref))).toBeUndefined() - } finally { await browser.close() } - } finally { await scaffold.close() } -}) diff --git a/apps/web/tests/expected/credential-editor/detected-environment.expected.md b/apps/web/tests/expected/credential-editor/detected-environment.expected.md deleted file mode 100644 index 829eeedd37..0000000000 --- a/apps/web/tests/expected/credential-editor/detected-environment.expected.md +++ /dev/null @@ -1,25 +0,0 @@ -- dialog "设置": - - text: 设置 - - navigation: - - button "通用设置" - - button "模型" - - button "内置插件" - - button "Agent 预设" - - button "打开配置文件" - - button "关闭" - - heading "模型" [level=2] - - paragraph: 填入各提供商的 API 密钥即可使用其模型。 - - list: - - listitem: - - text: DeepSeek - - img "API 密钥已配置" - - button "编辑 DeepSeek (deepseek-official)": 编辑 - - text: DeepSeek deepseek-official API 密钥 - - button "认证说明": "?" - - textbox "API 密钥": - - /placeholder: 留空使用环境认证(DEEPSEEK_API_KEY) - - paragraph: 已检测到环境变量(DEEPSEEK_API_KEY) - - group: 自定义设置 - - button "取消" - - button "保存" - - button "添加模型提供商" diff --git a/apps/web/tests/expected/credential-editor/missing-environment.expected.md b/apps/web/tests/expected/credential-editor/missing-environment.expected.md deleted file mode 100644 index 609ac50d0e..0000000000 --- a/apps/web/tests/expected/credential-editor/missing-environment.expected.md +++ /dev/null @@ -1,23 +0,0 @@ -- dialog "设置": - - text: 设置 - - navigation: - - button "通用设置" - - button "模型" - - button "内置插件" - - button "Agent 预设" - - button "打开配置文件" - - button "关闭" - - heading "模型" [level=2] - - paragraph: 填入各提供商的 API 密钥即可使用其模型。 - - status: 已保存 DeepSeek (deepseek-official)。 - - list: - - listitem: - - text: DeepSeek deepseek-official API 密钥 - - button "认证说明": "?" - - textbox "API 密钥": - - /placeholder: 留空使用环境认证(DEEPSEEK_API_KEY) - - paragraph: 未检测到环境变量。请填写 API 密钥,或设置环境变量 DEEPSEEK_API_KEY。 - - group: 自定义设置 - - button "取消" - - button "保存" - - button "添加模型提供商" diff --git a/apps/web/tests/expected/credential-editor/saved-key.expected.md b/apps/web/tests/expected/credential-editor/saved-key.expected.md deleted file mode 100644 index 2d4dde4639..0000000000 --- a/apps/web/tests/expected/credential-editor/saved-key.expected.md +++ /dev/null @@ -1,26 +0,0 @@ -- dialog "设置": - - text: 设置 - - navigation: - - button "通用设置" - - button "模型" - - button "内置插件" - - button "Agent 预设" - - button "打开配置文件" - - button "关闭" - - heading "模型" [level=2] - - paragraph: 填入各提供商的 API 密钥即可使用其模型。 - - list: - - listitem: - - text: DeepSeek - - img "API 密钥已配置" - - button "编辑 DeepSeek (deepseek-official)": 编辑 - - text: DeepSeek deepseek-official API 密钥 - - button "认证说明": "?" - - textbox "API 密钥": - - /placeholder: 留空使用环境认证(DEEPSEEK_API_KEY) - - text: fixture-saved-deepseek-key - - button "显示密钥" - - group: 自定义设置 - - button "取消" - - button "保存" - - button "添加模型提供商" diff --git a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md index d4fa1c31fb..08a0750772 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md @@ -17,9 +17,7 @@ - text: DeepSeek deepseek-official API 密钥 - button "认证说明": "?" - textbox "API 密钥": - - /placeholder: 留空使用环境认证(DEEPSEEK_API_KEY) - - text: sk-messages-onboarding - - button "显示密钥" + - /placeholder: 留空使用已保存凭据 - group: - text: 自定义设置 API 地址 - textbox "API 地址": diff --git a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md index 018ea8a13c..03900f109b 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md @@ -17,9 +17,7 @@ - text: DeepSeek deepseek-official API 密钥 - button "认证说明": "?" - textbox "API 密钥": - - /placeholder: 留空使用环境认证(DEEPSEEK_API_KEY) - - text: dsh_onboarding_fixture_key - - button "显示密钥" + - /placeholder: 留空使用已保存凭据 - group: - text: 自定义设置 API 地址 - textbox "API 地址": diff --git a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md index a5811b5d66..27ba737236 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -17,9 +17,7 @@ - text: DeepSeek deepseek-official API 密钥 - button "认证说明": "?" - textbox "API 密钥": - - /placeholder: 留空使用环境认证(DEEPSEEK_API_KEY) - - text: dsh_onboarding_fixture_key - - button "显示密钥" + - /placeholder: 留空使用已保存凭据 - group: - text: 自定义设置 API 地址 - textbox "API 地址": diff --git a/apps/web/tests/fixtures/credential-editor.patch.yml b/apps/web/tests/fixtures/credential-editor.patch.yml deleted file mode 100644 index 911f60db22..0000000000 --- a/apps/web/tests/fixtures/credential-editor.patch.yml +++ /dev/null @@ -1,5 +0,0 @@ -- id: llm-deepseek - config: - baseURL: http://127.0.0.1 - models: - - id: credential-editor-model diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 1b5a5dcbed..d36008959d 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -2,7 +2,7 @@ // wire — the one add card offers the dormant pi-ai catalog on its third-party // mode and the create form on its custom-API mode, a blank key saves a // reference-free profile for provider-native auth, and typing an API key later -// stores it under the derived reference (`MINIMAX_CN_API_KEY`) +// stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`) // while the settings document records only that reference. Each saved row // appears after route topology invalidation without presenting liveness as // provider status. The customized-settings fold writes its curated fields — @@ -154,7 +154,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await dialog.getByText('该 API 密钥格式错误,请检查。').waitFor({ timeout: 10_000 }) await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(false) - // An empty field leaves this new profile on native authentication. + // Clearing it restores submit: an empty field means "keep what is stored", + // never a refusal, or editing any other setting would demand the key. await key.fill('') await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(true) expect(await dialog.getByText('该 API 密钥格式错误,请检查。').count()).toBe(0) @@ -220,9 +221,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-customized')) const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click() - const savedKey = dialog.getByLabel('API 密钥', { exact: true }) - await expect.poll(() => savedKey.inputValue(), { timeout: 10_000 }).toBe('sk-e2e-minimax') - expect(await savedKey.getAttribute('type')).toBe('password') + await dialog.getByPlaceholder('留空使用已保存凭据', { exact: true }).waitFor({ timeout: 10_000 }) expect(await dialog.textContent()).not.toContain('API 密钥来源:') expect(await dialog.textContent()).not.toMatch(/oauth/i) await dialog.getByText('自定义设置').click() diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 06e451032d..8706a7a4de 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -2,6 +2,7 @@ // credential is absent, both ordered steps share the shipped modal chrome, // and the inline key write lands in an isolated harness home without a reload // or model call. +import { randomBytes } from 'node:crypto' import { assertModelInputLayout } from './model-input-layout.ts' import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' @@ -32,7 +33,6 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup let page: Page let tripwire: ReturnType const browserConsole: string[] = [] - const secret = 'dsh_onboarding_fixture_key' beforeAll(async () => { scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, welcomeNoticePending: true }) @@ -50,7 +50,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await scaffold?.close() }) - it('stores a key and loads it masked only in an explicitly opened editor', async () => { + it('stores a key write-only and observes configured state without restarting', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-config')) const welcome = page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title }) await welcome.waitFor({ timeout: 15_000 }) @@ -86,6 +86,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup const initial = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MISSING_EXPECTED, initial, MODE) + const secret = `dsh_onboarding_${randomBytes(12).toString('hex')}` await keyInput.fill(secret) await credentialStep.getByRole('button', { name: '保存并继续' }).click() await credentialStep.waitFor({ state: 'detached', timeout: 15_000 }) @@ -100,7 +101,8 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup const acknowledgedSettings = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8') expect(acknowledgedSettings).toContain(`${WELCOME_NOTICE_ACK_FIELD}: ${WELCOME_NOTICE_VERSION}`) - // Opening the ordinary Models editor loads the saved key masked. + // The ordinary Models surface reuses the refreshed join and exposes the + // configured write-only placeholder without a reload. await openSettings(page, 'zh') const settings = page.getByRole('dialog', { name: '设置' }) await settings.waitFor({ timeout: 10_000 }) @@ -111,10 +113,9 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup const configuredInput = settings.getByLabel('API 密钥', { exact: true }) await configuredInput.waitFor({ timeout: 10_000 }) await expect.poll( - () => configuredInput.inputValue(), + () => configuredInput.getAttribute('placeholder'), { timeout: 10_000 }, - ).toBe(secret) - expect(await configuredInput.getAttribute('type')).toBe('password') + ).toBe('留空使用已保存凭据') const secondReloadWarnings = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) @@ -254,9 +255,9 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await settings.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).check() await expect.poll( - () => settings.getByLabel('API 密钥', { exact: true }).inputValue(), + () => settings.getByLabel('API 密钥', { exact: true }).getAttribute('placeholder'), { timeout: 10_000 }, - ).toBe(secret) + ).toBe('留空使用已保存凭据') const modelEditor = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MODELS_EXPECTED, modelEditor, MODE) await settings.getByRole('button', { name: '保存', exact: true }).click() diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 938c0dae3c..9b3977d4ee 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -23,7 +23,6 @@ // this client-registered project must not also hold them (one program // cannot see both sides of the cordis Context merges). "exclude": [ - "tests/credential-editor.e2e.ts", "tests/sidebar-drawer.e2e.ts", "tests/creation-recommendations.e2e.ts", "tests/plugin-recommendations.e2e.ts", diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 4dc23c45e6..1083094774 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -6,5 +6,5 @@ en: 41d772192075e133 zh: 5ef3da69b4de1d8a /capability-seams-and-core-services: - en: 6215d15e0e273fa4 - zh: dec5c7779e0a42f5 + en: 3c972fccec17a890 + zh: 94c82847a3c8b83c diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 334e3757bc..e333a79f71 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -698,7 +698,7 @@ flowchart LR | `ctx.configEditor` | `core` | [`config-editor`](../packages/boot/config-editor) | - | [`settings`](../packages/settings/settings), [`agent-default-model`](../packages/core/agent-default-model) | - | Persists profile config patches under the application file lock and HMR queue, then reconciles Loader entries. | | `ctx.settings` | `core` | [`settings`](../packages/settings/settings) | - | [`api-settings-controller`](../packages/api/settings-controller) | - | Forms project volatile Config fields from active profile entries and delegate validated edits to config-editor. Plugins consume their own Config references. | | `ctx.subagentModelSelection` | `core` | [`tool-subagent`](../packages/subagent/tool-subagent) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Owns the default-off settings namespace that Agent-scoped delegation tools sample when composing a new top-level Session. | -| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 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 metadata, writes, and owner-only reads of stored keys. | +| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 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. | | `ctx.deepseekAccount` | `seam` | [`deepseek-account`](../packages/credentials/deepseek-account) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | [`api-account-controller`](../packages/api/account-controller), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The Host owns browser authorization and local credentials; UI consumers receive state without tokens. | | `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 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. | | `ctx.productAnalytics` | `service` | [`client-product-analytics`](../packages/client/product-analytics) | - | - | - | Accepts selected Desktop events, enriches available login identity, and observes live compaction under the live Host collection policy. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index d0f0bdffb0..cf3f2e67fc 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -700,7 +700,7 @@ flowchart LR | `ctx.configEditor` | `core` | [`config-editor`](../packages/boot/config-editor) | - | [`settings`](../packages/settings/settings), [`agent-default-model`](../packages/core/agent-default-model) | - | Persists profile config patches under the application file lock and HMR queue, then reconciles Loader entries. | | `ctx.settings` | `core` | [`settings`](../packages/settings/settings) | - | [`api-settings-controller`](../packages/api/settings-controller) | - | Forms project volatile Config fields from active profile entries and delegate validated edits to config-editor. Plugins consume their own Config references. | | `ctx.subagentModelSelection` | `core` | [`tool-subagent`](../packages/subagent/tool-subagent) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | - | 拥有默认关闭的设置命名空间;Agent 作用域的委派工具会在组合新顶层 Session 时读取它。 | -| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;settings controller 提供不含实际值的元数据和写入操作,并仅向所有者读取已保存密钥。 | +| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;settings controller 提供不含实际值的视图和只写存储。 | | `ctx.deepseekAccount` | `seam` | [`deepseek-account`](../packages/credentials/deepseek-account) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | [`api-account-controller`](../packages/api/account-controller), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | Host 负责浏览器授权和本地凭证;UI 使用方只接收不含 token 的状态。 | | `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | flow 由知道如何取得某份凭据的插件注册,并以其写入的记录为键;seam 拥有这段对话与"每个键同时只跑一次尝试"的生命周期,而非协议本身。 | | `ctx.productAnalytics` | `service` | [`client-product-analytics`](../packages/client/product-analytics) | - | - | - | 根据 Host 动态采集策略接收指定桌面事件、补充可用登录身份并监听实时压缩。 | diff --git a/docs/subsystems/credentials.i18n.yaml b/docs/subsystems/credentials.i18n.yaml index 06c51858d4..bad9827407 100644 --- a/docs/subsystems/credentials.i18n.yaml +++ b/docs/subsystems/credentials.i18n.yaml @@ -9,8 +9,8 @@ en: 0083032b9d19467d zh: 15d7a7520bac094e /user-credentials/resolution: - en: 0583aeab49d80a4b - zh: 1ffd60d7e79f67cf + en: baaab4bca76bea6b + zh: 54ccdc03ffd44f63 /user-credentials/description: en: f7b09588c0f34952 zh: 1b93d673c5bf6a80 diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md index b4ffca123b..2a084fb29f 100644 --- a/docs/subsystems/credentials.md +++ b/docs/subsystems/credentials.md @@ -20,8 +20,6 @@ type CredentialRef = Branded<'CredentialRef'> `resolve(ref)` returns the value with the provider-defined source layer that supplied it, or `undefined` while unconfigured. Consumers re-resolve at each operation and never cache across operations — that per-operation read is the hot-update mechanism. -`readStored(ref)` returns only the provider-managed value for editing, or `undefined` when none is stored. It never reads environment fallbacks. The Remote editor operation restricts this read to the owner. - ```ts type-equiv /** One resolved credential value and the source layer that supplied it. */ interface ResolvedCredential { @@ -154,13 +152,6 @@ A CredentialKey answers "what credential does this plugin hold for this id". Not */ abstract resolve(ref: CredentialRef): Promise -/** - * Read the provider-managed value without consulting environment fallbacks. - * @param ref - the reference to read. - * @returns the stored secret, or undefined when no non-empty value is stored. - */ -abstract readStored(ref: CredentialRef): Promise - /** * Describe one reference for configuration surfaces without exposing the * value. @@ -237,7 +228,7 @@ Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credent ### `ctx.credentialsController` — `CredentialsController` -Host service backing the generated `ctx.remote.credentials` namespace. It carries every wire obligation the credential seam itself does not: the batch fan-out bound, the field-by-field view projection, the reference-grammar guard, and the refusal mapping. Metadata reads exclude secrets; readStored returns only a provider-managed value for an explicitly opened editor. +Host service backing the generated `ctx.remote.credentials` namespace. It carries every wire obligation the credential seam itself does not: the batch fan-out bound, the field-by-field view projection, the reference-grammar guard, and the refusal mapping. Secret values cross in one direction only — no method here returns one. ```ts cordis-catalog /** @@ -252,15 +243,8 @@ Host service backing the generated `ctx.remote.credentials` namespace. It carrie @Remote async describe(refs: string[]): Promise> /** - * Read one stored value for a credential editor, excluding environment fallbacks. - * @param ref - reference name to read. - * @returns the stored secret, or undefined when the provider stores none. - * @throws RemoteError for a non-owner Remote caller, an invalid reference, or an absent provider. - */ -@Remote async readStored(ref: string): Promise - -/** - * Store one value from a configuration surface. + * Store one value from a configuration surface. The value crosses the wire in + * this direction only: no read path returns it. * @param ref - reference name to store under. * @param value - the non-empty secret value. * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md index d4784832ca..1e9221699a 100644 --- a/docs/subsystems/credentials.zh.md +++ b/docs/subsystems/credentials.zh.md @@ -20,8 +20,6 @@ type CredentialRef = Branded<'CredentialRef'> `resolve(ref)` 返回值及提供该值的来源层(由提供方定义);未配置期间返回 `undefined`。消费方在每个操作中重新解析,绝不跨操作缓存——这种按操作进行的读取正是热更新机制。 -`readStored(ref)` 仅返回用于编辑的提供方托管值;未存储时返回 `undefined`。它不读取环境后备值。编辑器的 Remote 操作仅允许所有者执行此读取。 - ```ts type-equiv /** One resolved credential value and the source layer that supplied it. */ interface ResolvedCredential { @@ -154,13 +152,6 @@ A CredentialKey answers "what credential does this plugin hold for this id". Not */ abstract resolve(ref: CredentialRef): Promise -/** - * Read the provider-managed value without consulting environment fallbacks. - * @param ref - the reference to read. - * @returns the stored secret, or undefined when no non-empty value is stored. - */ -abstract readStored(ref: CredentialRef): Promise - /** * Describe one reference for configuration surfaces without exposing the * value. @@ -237,7 +228,7 @@ Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credent ### `ctx.credentialsController` — `CredentialsController` -Host service backing the generated `ctx.remote.credentials` namespace. It carries every wire obligation the credential seam itself does not: the batch fan-out bound, the field-by-field view projection, the reference-grammar guard, and the refusal mapping. Metadata reads exclude secrets; readStored returns only a provider-managed value for an explicitly opened editor. +Host service backing the generated `ctx.remote.credentials` namespace. It carries every wire obligation the credential seam itself does not: the batch fan-out bound, the field-by-field view projection, the reference-grammar guard, and the refusal mapping. Secret values cross in one direction only — no method here returns one. ```ts cordis-catalog /** @@ -252,15 +243,8 @@ Host service backing the generated `ctx.remote.credentials` namespace. It carrie @Remote async describe(refs: string[]): Promise> /** - * Read one stored value for a credential editor, excluding environment fallbacks. - * @param ref - reference name to read. - * @returns the stored secret, or undefined when the provider stores none. - * @throws RemoteError for a non-owner Remote caller, an invalid reference, or an absent provider. - */ -@Remote async readStored(ref: string): Promise - -/** - * Store one value from a configuration surface. + * Store one value from a configuration surface. The value crosses the wire in + * this direction only: no read path returns it. * @param ref - reference name to store under. * @param value - the non-empty secret value. * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.i18n.yaml deleted file mode 100644 index 3acec7569f..0000000000 --- a/docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.i18n.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading -# section, a hash of its English and Chinese blocks outside code blocks and generated regions. -# After editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.md -/: - en: 2f1907963c732ea8 - zh: e25494c07150f6b9 -/saved-api-keys-in-provider-editors: - en: 63376a595a0e6dae - zh: 2709df13410087e2 -/saved-api-keys-in-provider-editors/change: - en: 463718dbe83f900d - zh: d2e55dfebf8a4b9e -/saved-api-keys-in-provider-editors/migration: - en: 19c94ae9626773b1 - zh: 1098744f47162c43 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.md deleted file mode 100644 index 8e721ad800..0000000000 --- a/docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -kind: upgrade-guide -description: "Saved API keys appear in masked editor fields, and clearing a saved key removes its override." ---- -# Saved API keys in provider editors - -English | [中文](guide.zh.md) - -## Change - -Provider editors show stored API keys as masked values instead of empty fields. Applying an empty field removes a previously loaded key and restores environment authentication. Cancel preserves the stored key. Environment values are never loaded into the editor. - -Credential providers implement `readStored(ref): Promise` to return only the managed value, without environment fallbacks. The owner-only `credentials.readStored` Remote method supplies this value to an opened editor; general metadata reads remain redacted. - -## Migration - -1. Keep the loaded value to retain a saved key, replace it to rotate the key, or clear it and apply to use environment authentication. -2. Implement `readStored` in custom `CredentialProvider` subclasses; return `undefined` when no non-empty value is stored. -3. Reopen the DeepSeek editor to confirm the saved key appears masked. After clearing and applying, confirm the field is empty and offers environment authentication. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.zh.md deleted file mode 100644 index 75efbcc4d7..0000000000 --- a/docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.zh.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -kind: upgrade-guide -description: "已保存的 API 密钥显示为编辑器中的掩码值,清空已保存密钥会删除其覆盖。" ---- -# 提供商编辑器中的已保存 API 密钥 - -[English](guide.md) | 中文 - -## 变更 - -提供商编辑器以掩码值显示已保存的 API 密钥,不再显示空字段。应用空字段会删除之前加载的密钥并恢复环境认证。「取消」保留已保存密钥。环境变量值从不加载到编辑器。 - -凭据提供方实现 `readStored(ref): Promise`,仅返回托管值,不使用环境后备。仅允许所有者调用的 `credentials.readStored` Remote 方法向已打开的编辑器提供此值;普通元数据读取仍保持脱敏。 - -## 迁移 - -1. 保留加载的值即可保留已保存密钥,替换它可轮换密钥,清空并应用则使用环境认证。 -2. 为自定义 `CredentialProvider` 子类实现 `readStored`;未存储非空值时返回 `undefined`。 -3. 重新打开 DeepSeek 编辑器,确认已保存密钥显示为掩码。清空并应用后,确认字段为空且提示使用环境认证。 diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 62801fa60d..b04aada43b 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.i18n.yaml @@ -6,11 +6,11 @@ en: b1631dcf25e805a1 zh: 8ded02e2ae87f00b /configure-models/configure-deepseek: - en: 5cf396eb7f22b0f6 - zh: d70688c6cd826f3a + en: 44852da64a6a39ec + zh: 4c280e428f0fec08 /configure-models/add-a-third-party-provider: - en: c8fd933006c3830c - zh: 386f8f7d302e98c3 + en: f6fb44a85dc5e4dd + zh: 2b86ea4492c32ed7 /configure-models/add-a-custom-model-api: en: 17867305a3e1b52a zh: 876fedf1f6f563e4 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 1ab90f440e..fe831bcaf9 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -10,7 +10,7 @@ Open **Settings → Models**. The DeepSeek card exposes one API-key field; enter ![The Models page: the DeepSeek card, with Add model provider below it](providers-models-page.png) -Opening a provider editor loads its saved key into a masked input. Environment values are never loaded into this input. The key is stored in `$DSH_HOME/.credentials.yaml`, while settings retain only its credential reference. Credential metadata remains redacted. +Keys are write-only. The page receives a redacted descriptor after saving, never the literal secret. The key is stored in `$DSH_HOME/.credentials.yaml`, while settings retain only its credential reference. With a key saved and the default endpoint, DeepSeek's models follow the list the DeepSeek API publishes. The list loads when the models are first needed, refreshes in the background about every ten minutes while they are in use, and falls back to the built-in models when it cannot load. Editing **Models** under **Customized settings** keeps your own list instead and stops following the published one; **Restore defaults** follows it again and reloads it. While the published list is in use, the card shows the built-in models, so a newly published model appears only in the model picker. The [API-key provider reference](../../../packages/llm/llm-deepseek-api-key/README.md) owns the listing, fallback, and gating rules. @@ -18,11 +18,11 @@ With a key saved and the default endpoint, DeepSeek's models follow the list the Choose **Add model provider**. The card opens on **Third-party model provider**: pick a provider dsh ships with — the list shows provider ids such as `anthropic`, `openai`, `moonshotai` for Kimi, or `zai` for GLM — enter its API key, and save. The installed catalog supplies the endpoint, protocol, and model list. -The authentication **?** explains saved-credential priority and environment fallbacks. With no saved key, leave the input blank to try the listed environment variables. A green hint identifies a detected environment variable. A yellow hint means no credential variable was detected; enter an API key or configure the listed variable. An unconfirmed hint means the status read failed or a saved key still covers the environment lookup. Clearing a saved key shows an unconfirmed hint until you save its removal and the page refreshes the environment check. Detection does not validate the credential or test the connection. Provider-managed credential files, including AWS profiles and Google application-default credentials, are outside this check. Providers without native key authentication, such as `openai-codex`, show no key field unless the profile explicitly names a credential reference. +The authentication **?** explains saved-credential priority and environment fallbacks. Leave the key blank to use a saved credential or try the listed environment variables. A green hint identifies a detected environment variable. A yellow hint means no credential variable was detected; enter an API key or configure the listed variable. An unconfirmed hint means the status read failed. Detection does not validate the credential or test the connection. Provider-managed credential files, including AWS profiles and Google application-default credentials, are outside this check. Providers without native key authentication, such as `openai-codex`, show no key field unless the profile explicitly names a credential reference. -A saved key appears masked, with no status text below the input. An empty input uses **Leave blank to use env as auth** and one detection hint. A nonempty input shows no authentication status text. Address configuration variables appear under **Base URL** and do not count as credentials. +A saved credential uses the placeholder **Leave blank to use saved credentials**, with no status text below the input. Environment fallback uses **Leave blank to use env as auth** and one detection hint while the input is empty. A nonempty input shows no authentication status text. Address configuration variables appear under **Base URL** and do not count as credentials. -A nonempty key input has an eye button. Select **Show secret** to check the loaded or newly entered key, and **Hide secret** to mask it again. Changing the input masks it again. Saving an unchanged key preserves its exact stored value. Clearing it and saving removes the saved key. Cancelling preserves the saved key. +A nonempty key input has an eye button. Select **Show secret** to check what you typed, and **Hide secret** to mask it again. Changing the input masks it again. Stored credentials remain write-only. ## Add a custom model API diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index ddb44f1814..bb518c42b9 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -10,7 +10,7 @@ ![模型页:DeepSeek 卡片,及其下方的添加模型提供商入口](providers-models-page.zh.png) -打开提供商编辑器时,已保存密钥会载入遮罩输入框。环境变量的值不会载入此输入框。密钥存储在 `$DSH_HOME/.credentials.yaml` 中,settings 只保留它的凭据引用。凭据元数据仍保持脱敏。 +密钥是只写的。保存后,页面只会收到脱敏描述符,永远不会收到明文密钥。密钥存储在 `$DSH_HOME/.credentials.yaml` 中,settings 只保留它的凭据引用。 保存了密钥且使用默认端点时,DeepSeek 的模型跟随 DeepSeek API 发布的列表。第一次需要模型时加载列表,使用期间约每十分钟在后台刷新一次,无法加载时退回内置模型。在**自定义设置**里编辑**模型目录**会改用你自己的列表,不再跟随发布的列表;点**恢复默认模型**会重新跟随并重新加载。使用发布的列表时,卡片上显示的是内置模型,因此新发布的模型只出现在模型选择器中。列表、兜底和门控规则见 [API key 提供方参考](../../../packages/llm/llm-deepseek-api-key/README.zh.md)。 @@ -18,11 +18,11 @@ 选择**添加模型提供商**。卡片默认打开在**第三方模型提供商**:选取 dsh 自带的提供商——列表显示的是提供商 id,例如 `anthropic`、`openai`、Kimi 对应的 `moonshotai`、GLM 对应的 `zai`——输入其 API 密钥并保存。已安装目录会提供端点、协议和模型列表。 -认证 **?** 说明已保存凭据的优先级和环境后备。没有已保存密钥时,输入留空会尝试读取列出的环境变量。绿色提示标明已检测到环境变量。黄色提示表示未检测到凭据变量;请输入 API 密钥或配置列出的变量。状态未确认表示读取失败,或已保存密钥仍覆盖环境读取。清空已保存密钥后,提示会保持未确认,直到保存删除操作并刷新环境检测。检测不验证凭据,也不测试连接。AWS profile 和 Google 应用默认凭据等提供商管理的凭据文件不在检查范围内。没有原生密钥认证的提供商(例如 `openai-codex`)不显示密钥字段,除非 profile 显式指定了凭据引用。 +认证 **?** 说明已保存凭据的优先级和环境后备。密钥留空时,使用已保存凭据,或尝试读取列出的环境变量。绿色提示标明已检测到环境变量。黄色提示表示未检测到凭据变量;请输入 API 密钥或配置列出的变量。状态未确认表示读取失败。检测不验证凭据,也不测试连接。AWS profile 和 Google 应用默认凭据等提供商管理的凭据文件不在检查范围内。没有原生密钥认证的提供商(例如 `openai-codex`)不显示密钥字段,除非 profile 显式指定了凭据引用。 -已保存密钥以遮罩显示,输入框下方不显示状态文字。输入留空时,显示**留空使用环境认证**占位文字和一条检测提示。输入非空时,不显示认证状态文字。地址配置变量显示在**基础 URL**下方,不计作凭据。 +已保存凭据使用**留空使用已保存凭据**占位文字,输入框下方不显示状态文字。环境后备使用**留空使用环境认证**占位文字,输入留空时显示一条检测提示。输入非空时,不显示认证状态文字。地址配置变量显示在**基础 URL**下方,不计作凭据。 -密钥输入非空时,输入框内显示眼睛按钮。选择**显示密钥**检查已载入或新输入的密钥,选择**隐藏密钥**重新遮住输入。修改输入后会重新遮住密钥。保存未修改的密钥会保留原值。清空并保存会删除已保存密钥。取消编辑会保留已保存密钥。 +密钥输入非空时,输入框内显示眼睛按钮。选择**显示密钥**检查当前输入,选择**隐藏密钥**重新遮住输入。修改输入后会重新遮住密钥。已保存凭据仍保持只写。 ## 添加自定义模型 API diff --git a/packages/api/settings-controller/README.i18n.yaml b/packages/api/settings-controller/README.i18n.yaml index 979d0867fb..4b3f0d24d3 100644 --- a/packages/api/settings-controller/README.i18n.yaml +++ b/packages/api/settings-controller/README.i18n.yaml @@ -9,14 +9,14 @@ en: 7fecb4d8800e8f97 zh: 3e2c3fdb29b73cd0 /settings-controller/summary: - en: 94a995854f855609 - zh: 7165eecdd85a5632 + en: e5068267079e62d0 + zh: 9e2d663a6ca02c58 /settings-controller/table-of-contents: en: 3a654e12f423946b zh: 4e3a19aaee58f8a7 /settings-controller/use-this-package: - en: d6fbd0324044be6b - zh: 5e86d8c95e51b421 + en: cf2f4de5bee8be55 + zh: b2630dcba4be5ae8 /settings-controller/configuration: en: e0297f144b649af7 zh: e70ed56edffd97f0 diff --git a/packages/api/settings-controller/README.md b/packages/api/settings-controller/README.md index 17357971ed..b38072546d 100644 --- a/packages/api/settings-controller/README.md +++ b/packages/api/settings-controller/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -`@deepseek-ai/dsh-api-settings-controller` exposes generated `ctx.remote.settings` and `ctx.remote.credentials` namespaces for browser configuration surfaces. It returns redacted settings and credential metadata and supports settings and credential writes. It reads stored keys for owner-opened editors. It also opens provider-owned settings or Agent preset locations on the Host desktop. When a provider is absent, the namespace remains registered and returns an actionable configuration error. +`@deepseek-ai/dsh-api-settings-controller` exposes generated `ctx.remote.settings` and `ctx.remote.credentials` namespaces for browser configuration surfaces. It returns redacted settings and credential metadata, supports settings and credential writes without returning secret values, and opens provider-owned settings or Agent preset locations on the Host desktop. When a provider is absent, the namespace remains registered and returns an actionable configuration error. ## Table of Contents @@ -25,7 +25,7 @@ English | [中文](README.zh.md) Mount this package as a Loader entry in a profile that serves browser configuration. The entry registers both namespaces independently of their providers so a missing provider produces a named configuration error at invocation. Its generated descriptors enter the strict Typert registry, while the settings and credential Definitions remain plain Cordis Services with no wire obligations of their own. -`describe(refs)` answers one map keyed by the requested names, so a settings page describing every reference its rows carry settles those rows together. It accepts at most 64 names per call, reports an invalid name or empty write value as `bad-request`, and copies each answer field by field — a provider returning more than `CredentialInfo` declares cannot widen what crosses. Valid `set(ref, value)` and `unset(ref)` calls report a provider refusal as `credential-rejected`, carrying the provider's message with only the reference in its details. `readStored(ref)` returns a provider-managed key only for the owner or trusted in-process code; it returns no environment fallback. A non-owner read returns `credential/rejected` before consulting the provider. Metadata reads remain secret-free. +`describe(refs)` answers one map keyed by the requested names, so a settings page describing every reference its rows carry settles those rows together. It accepts at most 64 names per call, reports an invalid name or empty write value as `bad-request`, and copies each answer field by field — a provider returning more than `CredentialInfo` declares cannot widen what crosses. Valid `set(ref, value)` and `unset(ref)` calls report a provider refusal as `credential-rejected`, carrying the provider's message with only the reference in its details. Secret values cross in this direction only: no method here returns one. `settings.describe()` returns deployment facts and every namespace under `redactSecrets: true`. `settings.update`, `settings.replace`, and `settings.mutate` expose the settings service's three write operations and return the namespace's new redacted view; stale writes use `settings-conflict` and other provider refusals use `settings-rejected`. diff --git a/packages/api/settings-controller/README.zh.md b/packages/api/settings-controller/README.zh.md index 2c4f4a2b29..3c9e876740 100644 --- a/packages/api/settings-controller/README.zh.md +++ b/packages/api/settings-controller/README.zh.md @@ -8,7 +8,7 @@ kind: "package-reference" ## 概述 -`@deepseek-ai/dsh-api-settings-controller` 为浏览器配置界面提供生成的 `ctx.remote.settings` 与 `ctx.remote.credentials` namespace。它返回脱敏的 settings 与凭据元数据,支持 settings 与凭据写入。它为所有者打开的编辑器读取已保存密钥。它还在 Host 桌面打开由提供方持有的 settings 或 Agent preset 位置。提供方缺失时,namespace 仍会注册,并返回可操作的配置错误。 +`@deepseek-ai/dsh-api-settings-controller` 为浏览器配置界面提供生成的 `ctx.remote.settings` 与 `ctx.remote.credentials` namespace。它返回脱敏的 settings 与凭据元数据,支持 settings 与凭据写入而不返回机密值,并在 Host 桌面打开由提供方持有的 settings 或 Agent preset 位置。提供方缺失时,namespace 仍会注册,并返回可操作的配置错误。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" 请把本包作为 Loader entry 挂载到提供浏览器配置的 profile 中。本 entry 不依赖提供方是否存在而注册两个 namespace,因此缺少提供方会在调用时产生具名配置错误。它生成的 descriptor 进入严格 Typert 注册表,而 settings 与凭据 Definition 仍是普通 Cordis 服务,自身不承担任何 wire 义务。 -`describe(refs)` 以请求的名字为键返回一份 map,因此设置页描述其各行携带的全部引用时,这些行会一起落定。单次调用最多接受 64 个名字,无效名字或空写入值报告为 `bad-request`,并逐字段复制每个答案——提供方返回超出 `CredentialInfo` 声明的内容也无法扩大跨越 wire 的字段。有效的 `set(ref, value)` 与 `unset(ref)` 调用把提供方拒绝报告为 `credential-rejected`,携带提供方的消息,details 中只有该引用。`readStored(ref)` 仅向所有者或可信进程内代码返回提供方管理的密钥,不返回环境后备值。非所有者读取会在访问提供方之前返回 `credential/rejected`。元数据读取仍不包含机密。 +`describe(refs)` 以请求的名字为键返回一份 map,因此设置页描述其各行携带的全部引用时,这些行会一起落定。单次调用最多接受 64 个名字,无效名字或空写入值报告为 `bad-request`,并逐字段复制每个答案——提供方返回超出 `CredentialInfo` 声明的内容也无法扩大跨越 wire 的字段。有效的 `set(ref, value)` 与 `unset(ref)` 调用把提供方拒绝报告为 `credential-rejected`,携带提供方的消息,details 中只有该引用。机密值只在这个方向跨越 wire:这里没有任何方法会返回它。 `settings.describe()` 返回部署信息,以及在 `redactSecrets: true` 下读取的所有 namespace。`settings.update`、`settings.replace` 与 `settings.mutate` 暴露 settings 服务的三种写入操作,并返回该 namespace 的新脱敏视图;陈旧写入使用 `settings-conflict`,其他提供方拒绝使用 `settings-rejected`。 diff --git a/packages/api/settings-controller/src/credentials.ts b/packages/api/settings-controller/src/credentials.ts index f9172f8f9e..3889669048 100644 --- a/packages/api/settings-controller/src/credentials.ts +++ b/packages/api/settings-controller/src/credentials.ts @@ -9,7 +9,7 @@ import { Context } from '@deepseek-ai/cordis' import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialProvider } from '@deepseek-ai/dsh-credentials' import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types' -import { principalOf, Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { z } from 'zod' /** @@ -61,8 +61,8 @@ declare module '@deepseek-ai/cordis' { * Host service backing the generated `ctx.remote.credentials` namespace. It * carries every wire obligation the credential seam itself does not: the batch * fan-out bound, the field-by-field view projection, the reference-grammar - * guard, and the refusal mapping. Metadata reads exclude secrets; readStored - * returns only a provider-managed value for an explicitly opened editor. + * guard, and the refusal mapping. Secret values cross in one direction only — + * no method here returns one. */ export class CredentialsController extends TypertRemoteService { /** @param ctx - Host context where a credential provider may be mounted. */ @@ -90,22 +90,8 @@ export class CredentialsController extends TypertRemoteService { } /** - * Read one stored value for a credential editor, excluding environment fallbacks. - * @param ref - reference name to read. - * @returns the stored secret, or undefined when the provider stores none. - * @throws RemoteError for a non-owner Remote caller, an invalid reference, or an absent provider. - */ - @Remote - async readStored(ref: string): Promise { - const request = parseRequest('credentials.readStored', unsetRequestSchema, { ref }) - if (principalOf(this.ctx.invocation) !== undefined) { - throw new RemoteError('credential/rejected', 'Only the owner can read stored credentials.', { ref: request.ref }) - } - return this.provider().readStored(credentialRef(request.ref)) - } - - /** - * Store one value from a configuration surface. + * Store one value from a configuration surface. The value crosses the wire in + * this direction only: no read path returns it. * @param ref - reference name to store under. * @param value - the non-empty secret value. * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. diff --git a/packages/api/settings-controller/src/types.ts b/packages/api/settings-controller/src/types.ts index d1dadc1e16..3d11c6ab0c 100644 --- a/packages/api/settings-controller/src/types.ts +++ b/packages/api/settings-controller/src/types.ts @@ -20,9 +20,9 @@ declare module '@deepseek-ai/dsh-typert-protocol' { */ 'settings/conflict': { readonly ns: string; readonly expected: number; readonly actual: number } /** - * A non-owner requested a stored secret, or the provider refused a valid - * credential write, for example because the stored document no longer - * parses. The details name only the reference, never the value. + * The provider refused a valid credential write, for example because the + * stored document no longer parses. The details name only the reference, + * never the value. */ 'credential/rejected': { readonly ref: string } } diff --git a/packages/api/settings-controller/tests/credentials-controller.host.spec.ts b/packages/api/settings-controller/tests/credentials-controller.host.spec.ts index 552baa7b5d..5eeb190385 100644 --- a/packages/api/settings-controller/tests/credentials-controller.host.spec.ts +++ b/packages/api/settings-controller/tests/credentials-controller.host.spec.ts @@ -2,17 +2,9 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types' import { remoteErrorOf, remoteMethods } from '@deepseek-ai/dsh-typert-protocol' -import type { PeerId, RemoteInvocation } from '@deepseek-ai/dsh-typert-protocol' import CredentialsController from '../src/credentials.ts' import { MemoryCredentials } from '../../../credentials/credentials/tests/memory.ts' -declare module '@deepseek-ai/dsh-typert-protocol' { - interface PrincipalMap { - /** Non-owner used to verify stored-key read denial. */ - 'credential-viewer': { readonly kind: 'credential-viewer' } - } -} - /** A store whose `describe` carries more than the view declares, as a foreign provider might. */ class LeakyCredentials extends MemoryCredentials { override describe(): Promise { @@ -54,7 +46,6 @@ describe('the credentials Remote namespace a configuration surface calls', () => expect(binding.namespace).toBe('credentials') expect(remoteMethods(controller)).toEqual([ { method: 'describe', invocation: { kind: 'direct' } }, - { method: 'readStored', invocation: { kind: 'direct' } }, { method: 'set', invocation: { kind: 'direct' } }, { method: 'unset', invocation: { kind: 'direct' } }, ]) @@ -65,7 +56,6 @@ describe('the credentials Remote namespace a configuration surface calls', () => await ctx.plugin(CredentialsController) for (const call of [ () => ctx.credentialsController.describe(['DEEPSEEK_API_KEY']), - () => ctx.credentialsController.readStored('DEEPSEEK_API_KEY'), () => ctx.credentialsController.set('DEEPSEEK_API_KEY', 'sk-live'), () => ctx.credentialsController.unset('DEEPSEEK_API_KEY'), ]) { @@ -92,7 +82,6 @@ describe('the credentials Remote namespace a configuration surface calls', () => const controller = await boot() for (const call of [ () => controller.describe(['DEEPSEEK_API_KEY', 'not a var']), - () => controller.readStored('not a var'), () => controller.set('not a var', 'sk-live'), () => controller.unset('not a var'), ]) { @@ -126,35 +115,6 @@ describe('the credentials Remote namespace a configuration surface calls', () => .toEqual({ DEEPSEEK_API_KEY: { configured: false, writable: true } }) }) - it('reads stored secrets only through the explicit editor read', async () => { - const controller = await boot({ DEEPSEEK_API_KEY: 'fixture-saved-key' }) - expect(await controller.readStored('DEEPSEEK_API_KEY')).toBe('fixture-saved-key') - expect(await controller.readStored('OPENAI_API_KEY')).toBeUndefined() - expect(JSON.stringify(await controller.describe(['DEEPSEEK_API_KEY']))).not.toContain('fixture-saved-key') - await controller.unset('DEEPSEEK_API_KEY') - expect(await controller.readStored('DEEPSEEK_API_KEY')).toBeUndefined() - }) - - it('denies non-owner stored-key reads before consulting the provider', async () => { - const ctx = new Context() - await ctx.plugin(MemoryCredentials, { DEEPSEEK_API_KEY: 'fixture-saved-key' }) - await ctx.plugin(CredentialsController) - const signal = new AbortController().signal - const invocation: RemoteInvocation = { - request: { namespace: 'credentials', method: 'readStored', args: { ref: 'DEEPSEEK_API_KEY' } }, - service: 'credentialsController', signal, - peer: { - id: 'credential-viewer' as PeerId, ctx, signal, principal: { kind: 'credential-viewer' }, - interactions: 'none', dispose: () => Promise.resolve(), - }, - uplink: async function* (): AsyncGenerator {}, - } - const caller = ctx.extend({ invocation }) - const failure = await caller.credentialsController.readStored('DEEPSEEK_API_KEY').catch((error: unknown) => error) - expect(remoteErrorOf(failure)).toMatchObject({ code: 'credential/rejected', details: { ref: 'DEEPSEEK_API_KEY' } }) - expect(JSON.stringify(remoteErrorOf(failure))).not.toContain('fixture-saved-key') - }) - it('reports a refused write as credential/rejected naming only the reference', async () => { const controller = await boot({}, RejectingCredentials) const failure = await controller.set('DEEPSEEK_API_KEY', 'sk-live').catch((error: unknown) => error) diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 3c28ce7528..02b3bd6729 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -9,8 +9,8 @@ en: 0db6fa836e1ff511 zh: 0e2968763e1fb7a5 /deepseek-ai-dsh-client-ui-settings-models/summary: - en: 2d39061747367414 - zh: 35edfca13c457deb + en: 51252c1e9fa6da0a + zh: 227b54d7898417fa /deepseek-ai-dsh-client-ui-settings-models/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb @@ -18,11 +18,11 @@ en: db5aaf7f702e1f0c zh: 409a78c14fb2b6e3 /deepseek-ai-dsh-client-ui-settings-models/use-this-package/api-keys: - en: d771da09da774692 - zh: 9566b3c0e95da8a6 + en: f4817eb2e3537ed9 + zh: e589c193fa85473f /deepseek-ai-dsh-client-ui-settings-models/use-this-package/editing-a-provider: - en: 23c8e60086660111 - zh: f277b69a202f207c + en: ce52da7e6065ccc4 + zh: c63690cb5de5ea85 /deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers: en: c0b04b2b11af60d3 zh: d195eec281e445cb diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 3e579642fe..8db24c5170 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -11,7 +11,7 @@ Desktop product events use the optional [product analytics service](../product-a ## Summary -`dsh-client-ui-settings-models` is the Models settings page of the dsh web client: users configure API keys (stored under the profile's credential reference), edit each provider's model list, and hand-declare custom pi-ai routes, with provider rows and one editor card at a time. The page joins the provider directory, the settings document, and the credential descriptions into one shared snapshot, so a row's state stays consistent across all three. It also walks first-run users through two ordered dialogs — a versioned preview notice and the conditional official-DeepSeek credential step. +`dsh-client-ui-settings-models` is the Models settings page of the dsh web client: users configure API keys (stored write-only under the profile's credential reference), edit each provider's model list, and hand-declare custom pi-ai routes, with provider rows and one editor card at a time. The page joins the provider directory, the settings document, and the credential descriptions into one shared snapshot, so a row's state stays consistent across all three. It also walks first-run users through two ordered dialogs — a versioned preview notice and the conditional official-DeepSeek credential step. ## Table of Contents @@ -41,15 +41,15 @@ Host configuration `credentialOnboarding` defaults to `true`. The Electron prelo ### API keys -The API key input shows a saved key masked and uses `autocomplete="new-password"` to avoid filling login passwords. Its eye button reveals the current value; editing or clearing the value masks it again. Environment values are never loaded into the input. +API-key inputs start empty and use `autocomplete="new-password"` to ask browsers not to autofill saved login passwords. A nonempty draft has the same eye control as masked conversation text: **Show secret** reveals the current input and **Hide secret** masks it. Changing or clearing the input masks it again. Stored credentials are never read into the input. -The primary field on an editor card is a single **API key** input — the page never asks for an environment-variable name. A typed key stores through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `cordis.patch.yml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and preserves provider-native authentication (for example the Bedrock credential chain or Vertex ADC). The field's placeholder and help tooltip use the profile's `apiKeyEnv` when it has one; otherwise they use the credential variables of the route's `nativeAuth`, excluding `baseURLEnv` address variables, in lookup order, as the configurable-provider directory reports them. These display hints omit references containing `oauth` (case-insensitively), and describe saved credentials followed by the displayed environment fallbacks. This presentation filter does not change the [credential resolution order](../../../docs/subsystems/credentials.md#resolution), the adapter's complete environment list, or an explicit reference. A filtered explicit reference still has a usable key input with the plain placeholder and no tooltip. A native route that has no key authentication omits the key field and authentication help entirely; an explicit reference still enables the field. A profile without a reference on a route without `nativeAuth`, such as a hand-declared route, shows the plain key placeholder and no tooltip. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing. A successful Apply emits a local accessible status message without echoing secret material. +The primary field on an editor card is a single **API key** input — the page never asks for an environment-variable name. A typed key stores write-only through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `cordis.patch.yml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and preserves provider-native authentication (for example the Bedrock credential chain or Vertex ADC). The field's placeholder and help tooltip use the profile's `apiKeyEnv` when it has one; otherwise they use the credential variables of the route's `nativeAuth`, excluding `baseURLEnv` address variables, in lookup order, as the configurable-provider directory reports them. These display hints omit references containing `oauth` (case-insensitively), and describe saved credentials followed by the displayed environment fallbacks. This presentation filter does not change the [credential resolution order](../../../docs/subsystems/credentials.md#resolution), the adapter's complete environment list, or an explicit reference. A filtered explicit reference still has a usable key input with the plain placeholder and no tooltip. A native route that has no key authentication omits the key field and authentication help entirely; an explicit reference still enables the field. A profile without a reference on a route without `nativeAuth`, such as a hand-declared route, shows the plain key placeholder and no tooltip. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing. A successful Apply emits a local accessible status message without echoing secret material. -Opening an existing provider editor reads its stored key through the owner-only `credentials.readStored` operation and shows it as a password input. The reveal button shows the loaded value. The key stays in that editor's local state; provider-directory and settings snapshots contain only redacted metadata. A failed read shows an error instead of an empty editor. Cancel discards edits, and Apply preserves an unchanged key, replaces an edited key, or removes a cleared key so environment authentication can apply. +The editor uses redacted `CredentialInfo` to choose the saved-credential placeholder or environment detection, without displaying a credential-source label. Refreshing the snapshot updates an open editor without a separate probe. The hint never receives a credential value or changes field writability. ### Editing a provider -An empty key field offers environment authentication; a saved key appears as a masked value. Redacted `credentials.describe` checks distinguish a detected environment reference, no detected variables, and an unconfirmed read. A missing-variable hint uses the warning color and names the first variable to configure. A detected environment reference uses green. Address configuration variables do not count as credentials. Hidden authentication references still participate in detection; a generic detected hint keeps their names hidden. Detection does not validate credentials, test a connection, or inspect provider-managed files such as AWS profiles and Google application-default credentials. A nonempty input shows no authentication status text. The page describes native variable names and named references in batches of at most 64. Editor reads return only provider-managed values, never environment values. +A blank key field offers a saved credential when one is detected, with no status text below the input; otherwise it offers environment authentication. Redacted `credentials.describe` checks distinguish a detected environment reference, no detected variables, and an unconfirmed read. A missing-variable hint uses the warning color and names the first variable to configure. A detected environment reference uses green. Address configuration variables do not count as credentials. Hidden authentication references still participate in detection; a generic detected hint keeps their names hidden. Detection does not validate credentials, test a connection, or inspect provider-managed files such as AWS profiles and Google application-default credentials. A nonempty input shows no authentication status text. The page reads native variable names as well as named references, in batches of at most 64, and never reads their values into the Client. An endpoint without a fixed catalog default shows the provider's `baseURLEnv` configuration names below the input. A profile endpoint remains the first placeholder choice. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index f96f6d9d2b..23763f168c 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -11,7 +11,7 @@ kind: "package-reference" ## 概述 -`dsh-client-ui-settings-models` 是 dsh Web 客户端的 Models 设置页面:用户可以配置 API 密钥(存入 profile 的凭据引用之下)、编辑每个提供商的模型列表,并手工声明自定义 pi-ai 路由;页面以提供商行展示,一次只展开一张编辑卡片。该页面把提供商目录、设置文档与凭据描述合并为一个共享快照,因此行的状态在三个方面始终一致。它还会带首次运行的用户走两个有序弹窗——版本化预览版说明,以及按条件显示的官方 DeepSeek 凭据步骤。 +`dsh-client-ui-settings-models` 是 dsh Web 客户端的 Models 设置页面:用户可以配置 API 密钥(以只写方式存入 profile 的凭据引用之下)、编辑每个提供商的模型列表,并手工声明自定义 pi-ai 路由;页面以提供商行展示,一次只展开一张编辑卡片。该页面把提供商目录、设置文档与凭据描述合并为一个共享快照,因此行的状态在三个方面始终一致。它还会带首次运行的用户走两个有序弹窗——版本化预览版说明,以及按条件显示的官方 DeepSeek 凭据步骤。 ## 目录 @@ -41,15 +41,15 @@ Host 配置 `credentialOnboarding` 默认为 `true`。Electron preload 标记会 ### API 密钥 -API 密钥输入框以掩码显示已保存的密钥,并通过 `autocomplete="new-password"` 避免填入登录密码。眼睛按钮显示当前值;修改或清空输入后重新遮住密钥。环境变量值从不加载到输入框。 +API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求浏览器不要自动填入已保存的登录密码。输入非空时,显示与会话密钥遮罩一致的眼睛按钮:**显示密钥**展示当前输入,**隐藏密钥**重新遮住输入。修改或清空输入后会重新遮住密钥。已保存凭据从不读入输入框。 -编辑卡片上的主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名。键入的密钥经 `credentials.set` 存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `cordis.patch.yml` 从不携带密钥值。为新的 pi-ai 提供商留空密钥会保存一个不带引用的 profile,从而保留提供商原生认证(例如 Bedrock 凭据链或 Vertex ADC)。profile 有 `apiKeyEnv` 时,字段的占位文字与帮助提示采用该引用;否则按查找顺序采用可配置提供商目录为该路由报告的 `nativeAuth` 凭据变量,并排除 `baseURLEnv` 地址变量。这些显示提示会忽略包含 `oauth` 的引用(不区分大小写),并说明优先使用已保存凭据,其后为显示的环境后备。这项显示过滤不改变[凭据解析顺序](../../../docs/subsystems/credentials.zh.md#resolution)、适配器的完整环境变量列表或显式引用。被过滤的显式引用仍有可用的密钥输入框,使用普通占位文字且不显示帮助提示。没有密钥认证的原生路由不显示密钥字段及认证帮助;显式引用仍会启用该字段。路由没有 `nativeAuth`(例如手动声明的路由)且 profile 没有引用时,显示普通的密钥占位文字,且不显示帮助提示。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。 +编辑卡片上的主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名。键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `cordis.patch.yml` 从不携带密钥值。为新的 pi-ai 提供商留空密钥会保存一个不带引用的 profile,从而保留提供商原生认证(例如 Bedrock 凭据链或 Vertex ADC)。profile 有 `apiKeyEnv` 时,字段的占位文字与帮助提示采用该引用;否则按查找顺序采用可配置提供商目录为该路由报告的 `nativeAuth` 凭据变量,并排除 `baseURLEnv` 地址变量。这些显示提示会忽略包含 `oauth` 的引用(不区分大小写),并说明优先使用已保存凭据,其后为显示的环境后备。这项显示过滤不改变[凭据解析顺序](../../../docs/subsystems/credentials.zh.md#resolution)、适配器的完整环境变量列表或显式引用。被过滤的显式引用仍有可用的密钥输入框,使用普通占位文字且不显示帮助提示。没有密钥认证的原生路由不显示密钥字段及认证帮助;显式引用仍会启用该字段。路由没有 `nativeAuth`(例如手动声明的路由)且 profile 没有引用时,显示普通的密钥占位文字,且不显示帮助提示。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。 -打开已有提供商的编辑器时,通过仅允许所有者调用的 `credentials.readStored` 操作读取已保存密钥,并以密码输入框显示。显示按钮可以展示已加载的值。密钥只保存在该编辑器的本地状态中;提供商目录与设置快照只包含脱敏元数据。读取失败时显示错误,不打开空白编辑器。「取消」丢弃编辑;「保存」保留未修改的密钥、替换已编辑的密钥,或删除已清空的密钥,从而允许使用环境认证。 +编辑器使用脱敏 `CredentialInfo` 选择已保存凭据的占位文字或环境检测,不显示凭据来源标签。快照刷新会更新打开的编辑器,无需单独探测。提示从不接收凭据值,也不改变字段的可写性。 ### 编辑提供商 -空密钥字段提示使用环境认证;已保存的密钥显示为掩码值。脱敏的 `credentials.describe` 检查区分已检测到环境引用、未检测到变量和读取状态未确认。缺少变量时使用黄色提示,并列出应配置的首个变量名。已检测到环境引用时使用绿色。地址配置变量不计作凭据。隐藏的认证引用仍参与检测;使用不含变量名的通用检测提示。检测不验证凭据、不测试连接,也不检查 AWS profile 或 Google 应用默认凭据等提供商管理的文件。输入非空时,不显示认证状态文字。页面按每批最多 64 项描述原生变量名和具名引用。编辑器读取只返回提供方管理的值,不返回环境变量值。 +密钥字段留空时,已检测到已保存凭据则提示使用该凭据,输入框下方不显示状态文字;否则提示使用环境认证。脱敏的 `credentials.describe` 检查区分已检测到环境引用、未检测到变量和读取状态未确认。缺少变量时使用黄色提示,并列出应配置的首个变量名。已检测到环境引用时使用绿色。地址配置变量不计作凭据。隐藏的认证引用仍参与检测;使用不含变量名的通用检测提示。检测不验证凭据、不测试连接,也不检查 AWS profile 或 Google 应用默认凭据等提供商管理的文件。输入非空时,不显示认证状态文字。页面读取原生变量名和具名引用,每批最多 64 项,不将变量值读入 Client。 目录未提供固定默认端点时,输入框下方显示提供方的 `baseURLEnv` 配置变量名。Profile 中的端点仍是占位符的首选。 diff --git a/packages/client/ui-settings-models/src/client/ApiKeyInput.tsx b/packages/client/ui-settings-models/src/client/ApiKeyInput.tsx index 3727000fc1..e983a40303 100644 --- a/packages/client/ui-settings-models/src/client/ApiKeyInput.tsx +++ b/packages/client/ui-settings-models/src/client/ApiKeyInput.tsx @@ -1,4 +1,4 @@ -/** Credential input with the same reveal behavior as masked conversation text. */ +/** Write-only credential input with the same reveal behavior as masked conversation text. */ import { useState } from 'react' import type { InputHTMLAttributes, ReactNode } from 'react' import type { en } from './locales.ts' @@ -10,7 +10,7 @@ const EYE_OFF_PATH = `${EYE_CONTOUR}M2.5 2.5L13.5 13.5` /** Input attributes and section copy for the current credential draft. */ interface ApiKeyInputProps extends Omit, 'value' | 'type' | 'className' | 'autoComplete'> { - /** Stored credential or the user's current edit; initially masked. */ + /** Unsaved input; stored credentials are never read into this field. */ readonly value: string /** Section copy, including the same reveal labels as SecretText. */ readonly t: (key: keyof typeof en) => string diff --git a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx index 37a2ae989f..05c2b3e1ab 100644 --- a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx @@ -63,7 +63,7 @@ export interface ProbeTarget { baseURL?: string /** Wire protocol the form names, when it names one. */ api?: string - /** Loaded or newly entered key currently shown in the form, when there is one. */ + /** Key typed into the form and not yet stored, when there is one. */ apiKey?: string } diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index adff556795..461ab37aad 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -86,8 +86,6 @@ export interface ProviderIdentity { /** One existing row or dormant directory entry addressed by an editor action. */ interface EditorTarget extends ProviderIdentity { - /** Stored secret retained only while this editor is open. */ - initialCredential?: string settingsNs: string settingsPath: readonly string[] /** Writable credential identified under this page's conventional reference. */ @@ -125,18 +123,16 @@ interface ProviderEditorRenderProps extends Pick< /** Render an editor for either the setup posture or an expanded provider row. */ function renderProviderEditor({ target, row, ...props }: ProviderEditorRenderProps): ReactNode { - const credential = row.apiKeyEnv === undefined ? row.derivedCredential : row.credential return ( @@ -258,10 +254,6 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS if (state.status === 'ready' && !hasReadySnapshot) setHasReadySnapshot(true) if (state.status === 'error' && hasReadySnapshot) setHasReadySnapshot(false) const [editing, setEditing] = useState(undefined) - const [credentialRead, setCredentialRead] = useState< - { provider: string; status: 'loading' } | { provider: string; status: 'failed'; message: string } - >() - const editorLoading = credentialRead?.status === 'loading' const [addOpen, setAddOpen] = useState(false) const [addMode, setAddMode] = useState('catalog') /** The modes shown since the add card opened; each keeps its panel mounted. */ @@ -277,26 +269,6 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS const [savedTarget, setSavedTarget] = useState(undefined) const [dismissedSetup, setDismissedSetup] = useState>(() => new Set()) - const openEditor = async (row: ProviderRow, target: EditorTarget): Promise => { - setSavedTarget(undefined) - setAddOpen(false) - setEditing(undefined) - setCredentialRead(undefined) - const info = row.apiKeyEnv === undefined ? row.derivedCredential : row.credential - if (info?.configured === false) { - setEditing(target) - return - } - setCredentialRead({ provider: target.provider, status: 'loading' }) - const loaded = await operations.readCredential(row.apiKeyEnv ?? deriveKeyRef(target.provider)) - if (loaded.kind === 'refused') { - setCredentialRead({ provider: target.provider, status: 'failed', message: loaded.message }) - return - } - setCredentialRead(undefined) - setEditing({ ...target, ...loaded.value === undefined ? {} : { initialCredential: loaded.value } }) - } - const announceSaved = (target: ProviderIdentity): void => { // Announced only once the refreshed directory is in the snapshot the // notice reads its name from: an apply can rename the route, and the @@ -537,12 +509,15 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS type="button" className={styles['secondaryButton']} aria-label={providerCopy(t('editProvider'), target)} - disabled={editorLoading || (!open && state.status !== 'ready')} - aria-busy={credentialRead?.provider === target.provider && editorLoading} + disabled={!open && state.status !== 'ready'} onClick={() => { if (!open && controller.store.getSnapshot().status !== 'ready') return - if (open) setEditing(undefined) - else void openEditor(row, target) + setSavedTarget(undefined) + // One card at a time: the add card closes with whatever + // it held, since closing either card would otherwise + // discard the other's draft. + setAddOpen(false) + setEditing(open ? undefined : target) }} > {t('edit')} @@ -554,7 +529,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS type="button" className={styles['dangerButton']} aria-label={providerCopy(t('removeProvider'), target)} - disabled={editorLoading || !state.writable || state.status !== 'ready'} + disabled={!state.writable || state.status !== 'ready'} onClick={() => { if (controller.store.getSnapshot().status !== 'ready') return setSavedTarget(undefined) @@ -569,8 +544,6 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS {error} - {credentialRead?.provider === target.provider && credentialRead.status === 'failed' - ?

{credentialRead.message}

: null} {renderSlot( 'settings.models.provider-card', { provider: row.entry, configured: row.configured, keyConfigured: keyConfiguredOf(row) }, @@ -578,7 +551,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS )} {open ? renderProviderEditor({ - target: { ...target, ...editing.initialCredential === undefined ? {} : { initialCredential: editing.initialCredential } }, + target, row, namespace, schema, @@ -725,7 +698,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS