2026-08-09 22:48:47 +08:00
|
|
|
import { randomUUID } from 'node:crypto'
|
2026-08-12 19:28:31 +08:00
|
|
|
import { mkdir, mkdtemp, readFile, stat, symlink, writeFile } from 'node:fs/promises'
|
feat(agent-presets): make the default preset a user setting
`config.default` becomes the composition base of an `agent-presets` settings
namespace, so the user document layers over the deployment's engineering
default and a person can change which preset new sessions get without a
restart.
The value is read per resolution rather than snapshotted: a hot-reloaded
document takes effect on the next session created, and every running session
stays on the preset it was composed from — which is the same rule the
session-header guard enforces from the other side.
`resolve()` read `config.default` directly, which would have made the whole
setting inert; it now goes through `defaultId` like every other caller.
The write-protection test is rewritten against a temp profile root. It was
passing vacuously: the un-overridden Loader REWRITES the composition it read —
stamping `disabled: true` onto the self-disposing row — so the committed
fixture had been mutated by the very run that proved the bug, and every later
run compared against the damaged file and passed. Building the preset in a
temp directory makes the assertion immune to its own failure mode, and it now
fails with a visible `+ disabled: true` when the override is removed.
Review follow-ups on this layer. The exported schema is
`AgentPresetSettingsSchema`, symmetric with the `AgentPresetSettings`
interface it resolves and self-describing at an import site. The `session.create`
JSDoc promised "the deployment's default preset" for an omitted `agentPreset`,
which this layer makes false — it now names the effective default. The
constructor records why it does not use `installSettingsSection`: that helper
re-judges what a consumer DERIVED across attach and detach, and nothing here is
derived. The provider-unload test disposes the fiber `ctx.plugin()` handed back
instead of reaching into `ctx.reflect.store`, and the write-protection wait says
why slack is the right shape for an absence assertion.
The real composition covers the layering too. `apps/cli` boots the shipped
`cordis.yml`, stores `agent-presets.default`, and asserts an unnamed session
composes from it — the package suite proves the layering against a hand-built
context, this proves the roster and the settings provider are wired to each
other. That test also pins the settings row at a temp file: it defaulted to
`$DSH_HOME/settings.yaml`, so a developer's own stored default decided the
outcome of a file whose whole point is that only the shipped root does.
The Agent Note records the per-resolution read and its correspondence with the
session header, and the vacuous-test finding above.
2026-08-04 00:07:11 +08:00
|
|
|
import { tmpdir } from 'node:os'
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
import { fileURLToPath } from 'node:url'
|
fix(web): return every service a host row injects, and run the browser lane
The browser e2e lane had been failing wholesale since this stack moved the
agent plane into presets, and nothing caught it: 34 of 48 files. Two of the
causes are product defects, not test breakage.
`bashEnv` goes back to the host plane. `apps/cli/src/web.ts` injects it to
publish `DSH_WEB_URL`/`DSH_WEB_MODE`, so the earlier note that "nothing outside
the agent plane injects bashEnv" was simply wrong — behind a preset's `shell`
realm those variables reached no shell at all, and a `dsh web` agent could not
find the address of its own interface. This is the same criterion that returned
`subagents`: a host row that injects a service resolves before any session
exists and has no agent to key by, so the service is host-plane. `tool-bash`
consumes the host registry from inside the preset, which works because an
agent context chains to the host; only the reverse is invisible.
`tool-subagent-report` goes back with it. It is not a tool this agent calls: it
registers a continuable SETUP on the host `subagents` singleton, and that list
is not scope-aware. One copy per mounted preset meant every child was handed
`report` once per live session, so the second registration threw and a cold
subagent resume failed with `subagent-not-resumable` — a diagnostic three
layers removed from the cause.
The lane's own composition facts follow. Skill roots resolve inside a preset
now, a subtree include patches cannot reach, so the scaffold pins the roots'
documented environment fallback for its whole lifetime rather than for the boot
— presets mount per session. Without it the developer's real `~/.dsh/skills`
enters replay requests and goldens while CI sees none. The `apps/cli`
composition test pins `storage-json` for the same reason: unpinned it wrote,
and then read back, the developer's own `~/.dsh/storages/`.
Three tests now address through an agent what they used to read off the root
context, because that is where the thing lives: the tool catalog, the skill
registry, and the token meter. The seeded-history projection baseline asserts
the opposite of what it did — a detached session yields a preset-plane
projection only from a durable checkpoint written while it was live, and this
seed was written straight to persistence and never ran.
Goldens re-recorded for the hero's preset chip and the settings nav entry.
2026-08-06 19:14:13 +08:00
|
|
|
import { dirname, join } from 'node:path'
|
build(vendor): rescope the vendored Cordis packages into @deepseek-ai
Machine-produced by `pnpm run rescope-vendor --apply` plus the regeneration it
prints: `pnpm install` for the lockfile, `pnpm run gen-third-party-notices`,
`verify-translation-pairing --write` for the touched bilingual pairs,
`gen-doc-graphs`, and one typert snapshot whose ids embed character offsets.
`pnpm run rescope-vendor --check` verifies the result.
Renames nine vendored packages (cordis, cosmokit, schemastery and the six
@cordisjs plugins) and every reference that resolves them: manifest names and
dependency keys, module specifiers including declare-module merges, cordis.yml
plugin names, tsconfig paths, every Markdown fence, and `docs/` prose.
Directory names, upstream versions, and dependency ranges are unchanged, so
vendor/README.md still reads as an upstream snapshot; its manifest table gains
an upstream-name column so THIRD_PARTY_NOTICES keeps MIT attribution pointed
at each fork's origin.
The tutorial tier follows the rename end to end: its yaml fences named plugins
the Loader can no longer resolve, its `ts ignore-check` fences disagreed with
the compiled fences beside them, and its prose quoted both. The contracts that
told readers to keep upstream names — the root convention and the vendoring
cookbook's tree comment and manifest invariant — now say to rescope instead.
Two rules read `@deepseek-ai/` as "another workspace plugin": the client bundle
purity gate now names the vendored libraries a browser bundle inlines, and the
files where a bare `cordis` is an agent-preset id keep that product data.
2026-08-10 22:04:06 +08:00
|
|
|
import { Context } from '@deepseek-ai/cordis'
|
2026-08-15 04:24:03 +08:00
|
|
|
import { boot, healProfilesModuleFallback, loadOverlayPatches, loadProfile } from '@deepseek-ai/dsh-app-boot'
|
2026-08-06 20:52:26 +08:00
|
|
|
import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
|
|
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
build(vendor): rescope the vendored Cordis packages into @deepseek-ai
Machine-produced by `pnpm run rescope-vendor --apply` plus the regeneration it
prints: `pnpm install` for the lockfile, `pnpm run gen-third-party-notices`,
`verify-translation-pairing --write` for the touched bilingual pairs,
`gen-doc-graphs`, and one typert snapshot whose ids embed character offsets.
`pnpm run rescope-vendor --check` verifies the result.
Renames nine vendored packages (cordis, cosmokit, schemastery and the six
@cordisjs plugins) and every reference that resolves them: manifest names and
dependency keys, module specifiers including declare-module merges, cordis.yml
plugin names, tsconfig paths, every Markdown fence, and `docs/` prose.
Directory names, upstream versions, and dependency ranges are unchanged, so
vendor/README.md still reads as an upstream snapshot; its manifest table gains
an upstream-name column so THIRD_PARTY_NOTICES keeps MIT attribution pointed
at each fork's origin.
The tutorial tier follows the rename end to end: its yaml fences named plugins
the Loader can no longer resolve, its `ts ignore-check` fences disagreed with
the compiled fences beside them, and its prose quoted both. The contracts that
told readers to keep upstream names — the root convention and the vendoring
cookbook's tree comment and manifest invariant — now say to rescope instead.
Two rules read `@deepseek-ai/` as "another workspace plugin": the client bundle
purity gate now names the vendored libraries a browser bundle inlines, and the
files where a bare `cordis` is an agent-preset id keep that product data.
2026-08-10 22:04:06 +08:00
|
|
|
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
2026-08-12 19:28:31 +08:00
|
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
feat(agent-presets): make the default preset a user setting
`config.default` becomes the composition base of an `agent-presets` settings
namespace, so the user document layers over the deployment's engineering
default and a person can change which preset new sessions get without a
restart.
The value is read per resolution rather than snapshotted: a hot-reloaded
document takes effect on the next session created, and every running session
stays on the preset it was composed from — which is the same rule the
session-header guard enforces from the other side.
`resolve()` read `config.default` directly, which would have made the whole
setting inert; it now goes through `defaultId` like every other caller.
The write-protection test is rewritten against a temp profile root. It was
passing vacuously: the un-overridden Loader REWRITES the composition it read —
stamping `disabled: true` onto the self-disposing row — so the committed
fixture had been mutated by the very run that proved the bug, and every later
run compared against the damaged file and passed. Building the preset in a
temp directory makes the assertion immune to its own failure mode, and it now
fails with a visible `+ disabled: true` when the override is removed.
Review follow-ups on this layer. The exported schema is
`AgentPresetSettingsSchema`, symmetric with the `AgentPresetSettings`
interface it resolves and self-describing at an import site. The `session.create`
JSDoc promised "the deployment's default preset" for an omitted `agentPreset`,
which this layer makes false — it now names the effective default. The
constructor records why it does not use `installSettingsSection`: that helper
re-judges what a consumer DERIVED across attach and detach, and nothing here is
derived. The provider-unload test disposes the fiber `ctx.plugin()` handed back
instead of reaching into `ctx.reflect.store`, and the write-protection wait says
why slack is the right shape for an absence assertion.
The real composition covers the layering too. `apps/cli` boots the shipped
`cordis.yml`, stores `agent-presets.default`, and asserts an unnamed session
composes from it — the package suite proves the layering against a hand-built
context, this proves the roster and the settings provider are wired to each
other. That test also pins the settings row at a temp file: it defaulted to
`$DSH_HOME/settings.yaml`, so a developer's own stored default decided the
outcome of a file whose whole point is that only the shipped root does.
The Agent Note records the per-resolution read and its correspondence with the
session header, and the vacuous-test finding above.
2026-08-04 00:07:11 +08:00
|
|
|
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
2026-08-24 18:23:42 +08:00
|
|
|
import { SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-tool-subagent/model-selection-settings'
|
2026-08-25 06:08:14 +08:00
|
|
|
import { SETTINGS_NAMESPACE, SHIPPED_PRESET_ROOT } from '@deepseek-ai/dsh-agent-presets'
|
fix(subagent): compose children from their parent's preset
Tool and prompt-section visibility is inherited along dsh-scope's parent
chain, and an agent's scope key is minted with no parent. Per-session agent
presets moved every model-facing row onto the agent plane and made
AgentPresets.mount() the one thing that binds that link, from the api-proxy's
session create, resume, and fork paths. The two in-process subagent drivers
installed only the per-child persona and tool filter, so a child's scope chain
had length one and its registry view resolved the global layer alone — which
is empty wherever a preset roster is composed. One-shot children reached the
model with no tools, continuable ones with only the host-plane `report`, and
neither carried its parent's persona, workspace context, or skill catalog.
AgentPresets.composeFrom() joins one agent to the standing composition another
already runs on. It is a bind, not a mount: the child gets its parent's exact
generation, so a composition edited since the parent started cannot fork it
onto another one, and it is synchronous, which is what lets a child creation
window use it. applyChildComposition() now takes the parent and performs the
join first, making a child composed without it unrepresentable at the call
sites. childSessionMeta() records the joined id so a cold read rebuilds the
composition the child actually ran under.
The audit that followed found two api-proxy readers on the wrong authority:
presenterScopeFor() and the live-agent branch of assertPresetUnchanged() both
read header.agentPreset, which goes stale the moment a blank session switches
preset. A switched session's cold transcript resolved presenters in the older
composition's layer and silently degraded to generic cards, and the gateway
refused to adopt a live session under the preset it actually runs while
accepting the one it left. Both now resolve through resolveSessionPreset(),
matching the resume branch fifteen lines above. The owning architecture Agent
Note carried the stale claim that the header records what a session runs; it
is corrected to name the header/log pair and its three readers.
Fixes #2165
2026-08-10 17:46:34 +08:00
|
|
|
import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent'
|
2026-08-09 22:18:34 +08:00
|
|
|
import { CallId } from '@deepseek-ai/dsh-llm'
|
2026-08-13 00:36:22 +08:00
|
|
|
import type {} from '@deepseek-ai/dsh-compaction-basic'
|
2026-08-09 22:18:34 +08:00
|
|
|
import type {} from '@deepseek-ai/dsh-skill'
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
import type {} from '@deepseek-ai/dsh-tools'
|
fix(preset): keep the token meter host-plane and name unjoined agents
Moving the agent plane behind presets left two readers on the wrong side of
the host/agent line.
`dsh-token-meter` was disabled on the host and mounted inside each preset's
`compaction` realm, but its three projection units register into the
process-wide `sessionProjections` table. A unit registered from one preset
answers for every session, so whether a `minimal` session showed a context
meter depended on whether some other session had mounted `standard` since
boot, and a process that only ever ran `minimal` showed none. The meter takes
no configuration, keys every fold by Session, and registers no tool or prompt
section, so it returns to the host composition and leaves the presets'
`isolate` map; the realm and `compact-basic` stay, because what a preset
chooses is whether its agent compacts, not whether its tokens are counted.
Nothing named an agent that joined no preset. The join is a scope-parent link,
and without it the tools, prompt-section, and skill views resolve the empty
global layer: the agent publishes, the turn runs, and the model receives
nothing. `AgentPresets` now logs one warning per such agent while a roster is
configured, and the invariant companion fails outright — at
`system-prompt/assemble` rather than at publication, because an unjoined agent
is legal until it addresses a model and `recompose` binds exactly such an
agent. The warning stays advisory: a synchronous `agent/created` throw vetoes
publication, and the ACP bridge, SDK server, and headless bundle all create an
unjoined agent today.
Three limits are recorded rather than fixed: projection key presence is not a
per-session capability signal, a superseded standing generation is never
reclaimed, and a `cordis_mount` temporary plugin belongs to the composition
rather than the session that mounted it.
Fixes #2203
2026-08-10 22:36:06 +08:00
|
|
|
// Type-only: resolves `ctx.get('sessionProjections')` and `ctx.get('tokenMeter')`.
|
|
|
|
|
import type {} from '@deepseek-ai/dsh-session-projection'
|
|
|
|
|
import type {} from '@deepseek-ai/dsh-token-meter'
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
|
fix(cli): boot the composition test the way the profile boot now does
The shipped surface stopped being two yml files: `base.cordis.yml` and
`web.cordis.yml` are bundle patch layers now, applied over an empty preset
root. This test still opened the old paths, so it failed before asserting
anything. It composes the same two layers the profile boot composes, over the
same empty root, and heals the flat module fallback the way the boot does —
the root lives outside this workspace, so bare plugin names have no other way
to resolve.
The web bundle's runtime row is disabled beside the webserver: it injects
`httpServer`, so a disabled port leaves it pending forever. It owns dist
serving and the URL prompt line, neither of which decides an agent's
capabilities.
`apps/cli` declares the packages the shipped agent presets name again. The
bundle split emptied its plugin dependencies, and the flat fallback links only
the app's dependency closure — so a preset row naming `dsh-persona` resolved
to nothing, and every preset mount failed. Which packages the shipped
presets compose is not implied by any bundle: the presets live beside this
app's config, so this app is what has to declare them.
2026-08-06 21:20:56 +08:00
|
|
|
const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
|
|
|
|
|
/** The shipped Web surface: the dsh-base and dsh-web-app bundle patches over an empty preset root. */
|
|
|
|
|
const BASE_PATCH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml')
|
|
|
|
|
const WEB_PATCH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml')
|
2026-08-15 04:24:03 +08:00
|
|
|
const CODEX_PACKAGE_DIR = join(REPO_ROOT, 'packages/subagent/subagent-codex')
|
|
|
|
|
const CLAUDE_CODE_PACKAGE_DIR = join(REPO_ROOT, 'packages/subagent/subagent-claude-code')
|
fix(cli): boot the composition test the way the profile boot now does
The shipped surface stopped being two yml files: `base.cordis.yml` and
`web.cordis.yml` are bundle patch layers now, applied over an empty preset
root. This test still opened the old paths, so it failed before asserting
anything. It composes the same two layers the profile boot composes, over the
same empty root, and heals the flat module fallback the way the boot does —
the root lives outside this workspace, so bare plugin names have no other way
to resolve.
The web bundle's runtime row is disabled beside the webserver: it injects
`httpServer`, so a disabled port leaves it pending forever. It owns dist
serving and the URL prompt line, neither of which decides an agent's
capabilities.
`apps/cli` declares the packages the shipped agent presets name again. The
bundle split emptied its plugin dependencies, and the flat fallback links only
the app's dependency closure — so a preset row naming `dsh-persona` resolved
to nothing, and every preset mount failed. Which packages the shipped
presets compose is not implied by any bundle: the presets live beside this
app's config, so this app is what has to declare them.
2026-08-06 21:20:56 +08:00
|
|
|
/** The installation anchor whose dependency surface the preset module fallback mirrors. */
|
|
|
|
|
const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json')
|
2026-08-10 18:02:11 +08:00
|
|
|
const MINIMAL_PROMPT = 'You are a helpful software engineer assistant.'
|
|
|
|
|
const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell
|
|
|
|
|
* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped.
|
|
|
|
|
* You don't have access to the internet via this tool.
|
|
|
|
|
* You do have access to a mirror of common linux and python packages via apt and pip.
|
|
|
|
|
* State is persistent across command calls and discussions with the user.
|
|
|
|
|
* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.
|
|
|
|
|
* Please avoid commands that may produce a very large amount of output.
|
|
|
|
|
* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.`
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Boot the shipped Web composition, minus the rows that would bind a port,
|
|
|
|
|
* touch the network, or write outside the test. Everything that decides an
|
|
|
|
|
* agent's capabilities is the real thing, including both shipped presets.
|
|
|
|
|
*/
|
2026-08-12 19:28:31 +08:00
|
|
|
async function bootWeb(
|
|
|
|
|
settingsFile: string,
|
|
|
|
|
extra: PatchOptions[] = [],
|
|
|
|
|
profilePackages: readonly string[] = [],
|
2026-08-15 04:24:03 +08:00
|
|
|
profileBundles?: readonly string[],
|
2026-08-12 19:28:31 +08:00
|
|
|
): Promise<Context> {
|
fix(web): return every service a host row injects, and run the browser lane
The browser e2e lane had been failing wholesale since this stack moved the
agent plane into presets, and nothing caught it: 34 of 48 files. Two of the
causes are product defects, not test breakage.
`bashEnv` goes back to the host plane. `apps/cli/src/web.ts` injects it to
publish `DSH_WEB_URL`/`DSH_WEB_MODE`, so the earlier note that "nothing outside
the agent plane injects bashEnv" was simply wrong — behind a preset's `shell`
realm those variables reached no shell at all, and a `dsh web` agent could not
find the address of its own interface. This is the same criterion that returned
`subagents`: a host row that injects a service resolves before any session
exists and has no agent to key by, so the service is host-plane. `tool-bash`
consumes the host registry from inside the preset, which works because an
agent context chains to the host; only the reverse is invisible.
`tool-subagent-report` goes back with it. It is not a tool this agent calls: it
registers a continuable SETUP on the host `subagents` singleton, and that list
is not scope-aware. One copy per mounted preset meant every child was handed
`report` once per live session, so the second registration threw and a cold
subagent resume failed with `subagent-not-resumable` — a diagnostic three
layers removed from the cause.
The lane's own composition facts follow. Skill roots resolve inside a preset
now, a subtree include patches cannot reach, so the scaffold pins the roots'
documented environment fallback for its whole lifetime rather than for the boot
— presets mount per session. Without it the developer's real `~/.dsh/skills`
enters replay requests and goldens while CI sees none. The `apps/cli`
composition test pins `storage-json` for the same reason: unpinned it wrote,
and then read back, the developer's own `~/.dsh/storages/`.
Three tests now address through an agent what they used to read off the root
context, because that is where the thing lives: the tool catalog, the skill
registry, and the token meter. The seeded-history projection baseline asserts
the opposite of what it did — a detached session yields a preset-plane
projection only from a durable checkpoint written while it was live, and this
seed was written straight to persistence and never ran.
Goldens re-recorded for the hero's preset chip and the settings nav entry.
2026-08-06 19:14:13 +08:00
|
|
|
const storageRoot = join(dirname(settingsFile), 'storages')
|
2026-08-15 04:24:03 +08:00
|
|
|
const overrides: PatchOptions[] = [
|
feat(agent-presets): make the default preset a user setting
`config.default` becomes the composition base of an `agent-presets` settings
namespace, so the user document layers over the deployment's engineering
default and a person can change which preset new sessions get without a
restart.
The value is read per resolution rather than snapshotted: a hot-reloaded
document takes effect on the next session created, and every running session
stays on the preset it was composed from — which is the same rule the
session-header guard enforces from the other side.
`resolve()` read `config.default` directly, which would have made the whole
setting inert; it now goes through `defaultId` like every other caller.
The write-protection test is rewritten against a temp profile root. It was
passing vacuously: the un-overridden Loader REWRITES the composition it read —
stamping `disabled: true` onto the self-disposing row — so the committed
fixture had been mutated by the very run that proved the bug, and every later
run compared against the damaged file and passed. Building the preset in a
temp directory makes the assertion immune to its own failure mode, and it now
fails with a visible `+ disabled: true` when the override is removed.
Review follow-ups on this layer. The exported schema is
`AgentPresetSettingsSchema`, symmetric with the `AgentPresetSettings`
interface it resolves and self-describing at an import site. The `session.create`
JSDoc promised "the deployment's default preset" for an omitted `agentPreset`,
which this layer makes false — it now names the effective default. The
constructor records why it does not use `installSettingsSection`: that helper
re-judges what a consumer DERIVED across attach and detach, and nothing here is
derived. The provider-unload test disposes the fiber `ctx.plugin()` handed back
instead of reaching into `ctx.reflect.store`, and the write-protection wait says
why slack is the right shape for an absence assertion.
The real composition covers the layering too. `apps/cli` boots the shipped
`cordis.yml`, stores `agent-presets.default`, and asserts an unnamed session
composes from it — the package suite proves the layering against a hand-built
context, this proves the roster and the settings provider are wired to each
other. That test also pins the settings row at a temp file: it defaulted to
`$DSH_HOME/settings.yaml`, so a developer's own stored default decided the
outcome of a file whose whole point is that only the shipped root does.
The Agent Note records the per-resolution read and its correspondence with the
session header, and the vacuous-test finding above.
2026-08-04 00:07:11 +08:00
|
|
|
// The settings row defaults to `$DSH_HOME/settings.yaml`. Left alone it
|
|
|
|
|
// reads the developer's own document — and since the default preset is a
|
|
|
|
|
// setting, a stored `agent-presets.default` would decide this file's
|
2026-08-21 11:32:02 +08:00
|
|
|
// outcome. Point it at a temp file for the same reason the roster row
|
|
|
|
|
// below pins `includeUserRoot` off.
|
feat(agent-presets): make the default preset a user setting
`config.default` becomes the composition base of an `agent-presets` settings
namespace, so the user document layers over the deployment's engineering
default and a person can change which preset new sessions get without a
restart.
The value is read per resolution rather than snapshotted: a hot-reloaded
document takes effect on the next session created, and every running session
stays on the preset it was composed from — which is the same rule the
session-header guard enforces from the other side.
`resolve()` read `config.default` directly, which would have made the whole
setting inert; it now goes through `defaultId` like every other caller.
The write-protection test is rewritten against a temp profile root. It was
passing vacuously: the un-overridden Loader REWRITES the composition it read —
stamping `disabled: true` onto the self-disposing row — so the committed
fixture had been mutated by the very run that proved the bug, and every later
run compared against the damaged file and passed. Building the preset in a
temp directory makes the assertion immune to its own failure mode, and it now
fails with a visible `+ disabled: true` when the override is removed.
Review follow-ups on this layer. The exported schema is
`AgentPresetSettingsSchema`, symmetric with the `AgentPresetSettings`
interface it resolves and self-describing at an import site. The `session.create`
JSDoc promised "the deployment's default preset" for an omitted `agentPreset`,
which this layer makes false — it now names the effective default. The
constructor records why it does not use `installSettingsSection`: that helper
re-judges what a consumer DERIVED across attach and detach, and nothing here is
derived. The provider-unload test disposes the fiber `ctx.plugin()` handed back
instead of reaching into `ctx.reflect.store`, and the write-protection wait says
why slack is the right shape for an absence assertion.
The real composition covers the layering too. `apps/cli` boots the shipped
`cordis.yml`, stores `agent-presets.default`, and asserts an unnamed session
composes from it — the package suite proves the layering against a hand-built
context, this proves the roster and the settings provider are wired to each
other. That test also pins the settings row at a temp file: it defaulted to
`$DSH_HOME/settings.yaml`, so a developer's own stored default decided the
outcome of a file whose whole point is that only the shipped root does.
The Agent Note records the per-resolution read and its correspondence with the
session header, and the vacuous-test finding above.
2026-08-04 00:07:11 +08:00
|
|
|
{ id: 'settings', config: { path: settingsFile, watch: false } },
|
fix(web): return every service a host row injects, and run the browser lane
The browser e2e lane had been failing wholesale since this stack moved the
agent plane into presets, and nothing caught it: 34 of 48 files. Two of the
causes are product defects, not test breakage.
`bashEnv` goes back to the host plane. `apps/cli/src/web.ts` injects it to
publish `DSH_WEB_URL`/`DSH_WEB_MODE`, so the earlier note that "nothing outside
the agent plane injects bashEnv" was simply wrong — behind a preset's `shell`
realm those variables reached no shell at all, and a `dsh web` agent could not
find the address of its own interface. This is the same criterion that returned
`subagents`: a host row that injects a service resolves before any session
exists and has no agent to key by, so the service is host-plane. `tool-bash`
consumes the host registry from inside the preset, which works because an
agent context chains to the host; only the reverse is invisible.
`tool-subagent-report` goes back with it. It is not a tool this agent calls: it
registers a continuable SETUP on the host `subagents` singleton, and that list
is not scope-aware. One copy per mounted preset meant every child was handed
`report` once per live session, so the second registration threw and a cold
subagent resume failed with `subagent-not-resumable` — a diagnostic three
layers removed from the cause.
The lane's own composition facts follow. Skill roots resolve inside a preset
now, a subtree include patches cannot reach, so the scaffold pins the roots'
documented environment fallback for its whole lifetime rather than for the boot
— presets mount per session. Without it the developer's real `~/.dsh/skills`
enters replay requests and goldens while CI sees none. The `apps/cli`
composition test pins `storage-json` for the same reason: unpinned it wrote,
and then read back, the developer's own `~/.dsh/storages/`.
Three tests now address through an agent what they used to read off the root
context, because that is where the thing lives: the tool catalog, the skill
registry, and the token meter. The seeded-history projection baseline asserts
the opposite of what it did — a detached session yields a preset-plane
projection only from a durable checkpoint written while it was live, and this
seed was written straight to persistence and never ran.
Goldens re-recorded for the hero's preset chip and the settings nav entry.
2026-08-06 19:14:13 +08:00
|
|
|
// storage-json's root is anchored to the real $DSH_HOME. Unpinned, this
|
|
|
|
|
// file writes the developer's own `~/.dsh/storages/` — and then reads it
|
|
|
|
|
// back on the next run, so a stored document from any other build decides
|
|
|
|
|
// this test's boot. Same reason the settings row above is pinned.
|
|
|
|
|
{ id: 'storage-json', config: { root: storageRoot } },
|
2026-08-04 12:46:57 +08:00
|
|
|
// Host rows with side effects outside this process: a bound port, a served
|
|
|
|
|
// asset tree, a telemetry exporter. `api-gateway` and `directory-picker`
|
|
|
|
|
// stay ENABLED on purpose — the api-proxy is the host row that injects
|
|
|
|
|
// `subagents`, `workspace`, and the rest of the agent plane, so disabling
|
|
|
|
|
// it would hide exactly the breakage this file exists to catch: a service
|
|
|
|
|
// moved into the presets that a host row still waits for. The boot audit
|
|
|
|
|
// is that assertion.
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
{ id: 'webserver', disabled: true },
|
2026-08-13 00:36:22 +08:00
|
|
|
// The web bundle's runtime row injects `webServer`, so it cannot
|
fix(cli): boot the composition test the way the profile boot now does
The shipped surface stopped being two yml files: `base.cordis.yml` and
`web.cordis.yml` are bundle patch layers now, applied over an empty preset
root. This test still opened the old paths, so it failed before asserting
anything. It composes the same two layers the profile boot composes, over the
same empty root, and heals the flat module fallback the way the boot does —
the root lives outside this workspace, so bare plugin names have no other way
to resolve.
The web bundle's runtime row is disabled beside the webserver: it injects
`httpServer`, so a disabled port leaves it pending forever. It owns dist
serving and the URL prompt line, neither of which decides an agent's
capabilities.
`apps/cli` declares the packages the shipped agent presets name again. The
bundle split emptied its plugin dependencies, and the flat fallback links only
the app's dependency closure — so a preset row naming `dsh-persona` resolved
to nothing, and every preset mount failed. Which packages the shipped
presets compose is not implied by any bundle: the presets live beside this
app's config, so this app is what has to declare them.
2026-08-06 21:20:56 +08:00
|
|
|
// activate without the bound port disabled above. It owns dist serving
|
|
|
|
|
// and the URL prompt line — surface glue, not anything that decides an
|
|
|
|
|
// agent's capabilities, which is all this file asserts.
|
|
|
|
|
{ id: 'web-runtime', disabled: true },
|
2026-08-13 00:36:22 +08:00
|
|
|
{ id: 'session-telemetry-otel', disabled: true },
|
2026-08-09 22:18:34 +08:00
|
|
|
// A deployment-level skill on the host registry's GLOBAL layer — the same
|
|
|
|
|
// registration shape a repository plugin's skill root uses. The layered
|
|
|
|
|
// skills test below proves it reaches preset-composed agents.
|
|
|
|
|
{ id: 'skill-badge', disabled: false },
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
{ id: 'modules', disabled: true },
|
|
|
|
|
{ id: 'connection', disabled: true },
|
2026-08-11 17:32:36 +08:00
|
|
|
// The always-on reload chain waits for the browser roster and bound port
|
|
|
|
|
// disabled above.
|
|
|
|
|
{ id: 'client-hmr', disabled: true },
|
2026-08-04 12:46:57 +08:00
|
|
|
// The shipped `-auto` chooser resolves its interaction from a running
|
|
|
|
|
// host and so waits for the webserver disabled above; the browse variant
|
|
|
|
|
// supplies `directoryPicker` without one.
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
{ id: 'directory-picker', disabled: true },
|
2026-08-11 19:10:50 +08:00
|
|
|
{ insert: [
|
|
|
|
|
{ id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' },
|
2026-08-13 00:36:22 +08:00
|
|
|
{ id: 'ui-directory-picker-browse', name: '@deepseek-ai/dsh-client-ui-directory-picker-browse' },
|
2026-08-11 19:10:50 +08:00
|
|
|
] },
|
2026-08-20 22:57:58 +08:00
|
|
|
// Pin the roster away from the developer's machine: `includeUserRoot`
|
|
|
|
|
// false keeps `~/.dsh/.agent-presets` from changing a test's outcome.
|
feat(agent-presets): make the default preset a user setting
`config.default` becomes the composition base of an `agent-presets` settings
namespace, so the user document layers over the deployment's engineering
default and a person can change which preset new sessions get without a
restart.
The value is read per resolution rather than snapshotted: a hot-reloaded
document takes effect on the next session created, and every running session
stays on the preset it was composed from — which is the same rule the
session-header guard enforces from the other side.
`resolve()` read `config.default` directly, which would have made the whole
setting inert; it now goes through `defaultId` like every other caller.
The write-protection test is rewritten against a temp profile root. It was
passing vacuously: the un-overridden Loader REWRITES the composition it read —
stamping `disabled: true` onto the self-disposing row — so the committed
fixture had been mutated by the very run that proved the bug, and every later
run compared against the damaged file and passed. Building the preset in a
temp directory makes the assertion immune to its own failure mode, and it now
fails with a visible `+ disabled: true` when the override is removed.
Review follow-ups on this layer. The exported schema is
`AgentPresetSettingsSchema`, symmetric with the `AgentPresetSettings`
interface it resolves and self-describing at an import site. The `session.create`
JSDoc promised "the deployment's default preset" for an omitted `agentPreset`,
which this layer makes false — it now names the effective default. The
constructor records why it does not use `installSettingsSection`: that helper
re-judges what a consumer DERIVED across attach and detach, and nothing here is
derived. The provider-unload test disposes the fiber `ctx.plugin()` handed back
instead of reaching into `ctx.reflect.store`, and the write-protection wait says
why slack is the right shape for an absence assertion.
The real composition covers the layering too. `apps/cli` boots the shipped
`cordis.yml`, stores `agent-presets.default`, and asserts an unnamed session
composes from it — the package suite proves the layering against a hand-built
context, this proves the roster and the settings provider are wired to each
other. That test also pins the settings row at a temp file: it defaulted to
`$DSH_HOME/settings.yaml`, so a developer's own stored default decided the
outcome of a file whose whole point is that only the shipped root does.
The Agent Note records the per-resolution read and its correspondence with the
session header, and the vacuous-test finding above.
2026-08-04 00:07:11 +08:00
|
|
|
// `default` here is the COMPOSITION default — the base layer the settings
|
refactor(preset): bundle the shipped presets inside dsh-agent-presets
Review asked why the launcher special-cases one plugin's row. It no
longer does: the four shipped compositions move into the package
(presets/, in files), dsh-agent-presets resolves its own shipped root
and prepends it before configured roots (includeShippedRoot, default
true, opt-out for bare-machinery embedders), and the per-composition
derived patch, its spec, and the dump layer are deleted — profile-boot
and dump-config return to plain layer stacking. The always-load
guarantee now rides the schema default instead of patch ordering, so a
whole-config replacement keeps the shipped set and the squash, reload
freeze, and dump divergence stop being possible.
Gate globs, the web scaffold, and both preset browser lanes drop their
hand-fed shipped roots; the roster e2e keeps asserting configured roots
beside the shipped four against the built lib.
Fixes #2863.
2026-08-21 12:37:57 +08:00
|
|
|
// document overrides. No `roots` entry: the plugin bundles the shipped
|
|
|
|
|
// presets itself and prepends their root.
|
2026-08-20 22:57:58 +08:00
|
|
|
{ id: 'agent-presets', config: { default: 'standard', includeUserRoot: false } },
|
feat(web): author agent presets from a settings page
A composition is a file, but "edit it on the filesystem" is not a browser
affordance. The roster gains `read`/`write`/`remove` beside `select`, and
the browser gains a settings section over them: the presets as rows, one
composition open in a YAML editor at a time, and per-row default, duplicate,
and delete.
All four authoring methods are loopback-pinned. A composition names the
plugins a session runs, so reading one is reconnaissance, writing one is
arbitrary capability, and selecting one can move a session onto a preset
that edits the live runtime. `agentPreset.list` deliberately stays ordinary
and now reports `authorable`, so a surface knows whether creating is
possible at all rather than offering a button whose save always fails.
Authoring starts by duplicating: a shipped preset opens read-only because
the deployment's copy is what a broken local one is compared against. Ids
are contained before they become directory names, and the text is parsed
with the loader's own schema, so a save cannot leave a file no session
could load.
Fixes a defect the real-composition test found: a preset written under the
user's home could never mount, because the loader resolves a row against the
composition's own directory and Node's `node_modules` walk from there never
reaches the installed harness. The mount now records the host base and sends
bare specifiers there, leaving relative paths resolving from the preset.
Also closes the coverage the earlier surfaces in this stack shipped without —
the General row, the composer seat, and the plugin halves now have tests.
2026-08-04 12:23:40 +08:00
|
|
|
...extra,
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
]
|
fix(cli): boot the composition test the way the profile boot now does
The shipped surface stopped being two yml files: `base.cordis.yml` and
`web.cordis.yml` are bundle patch layers now, applied over an empty preset
root. This test still opened the old paths, so it failed before asserting
anything. It composes the same two layers the profile boot composes, over the
same empty root, and heals the flat module fallback the way the boot does —
the root lives outside this workspace, so bare plugin names have no other way
to resolve.
The web bundle's runtime row is disabled beside the webserver: it injects
`httpServer`, so a disabled port leaves it pending forever. It owns dist
serving and the URL prompt line, neither of which decides an agent's
capabilities.
`apps/cli` declares the packages the shipped agent presets name again. The
bundle split emptied its plugin dependencies, and the flat fallback links only
the app's dependency closure — so a preset row naming `dsh-persona` resolved
to nothing, and every preset mount failed. Which packages the shipped
presets compose is not implied by any bundle: the presets live beside this
app's config, so this app is what has to declare them.
2026-08-06 21:20:56 +08:00
|
|
|
// The surface is patch layers over an empty preset root, so the root sits
|
|
|
|
|
// outside this workspace and bare plugin names cannot resolve by Node's
|
|
|
|
|
// upward walk. The flat fallback the preset boot maintains is what makes
|
|
|
|
|
// them resolvable — the same mechanism, not a test-only shim.
|
|
|
|
|
const home = dirname(settingsFile)
|
2026-08-23 19:04:19 +08:00
|
|
|
await healProfilesModuleFallback(INSTALL_ANCHOR, home)
|
fix(cli): boot the composition test the way the profile boot now does
The shipped surface stopped being two yml files: `base.cordis.yml` and
`web.cordis.yml` are bundle patch layers now, applied over an empty preset
root. This test still opened the old paths, so it failed before asserting
anything. It composes the same two layers the profile boot composes, over the
same empty root, and heals the flat module fallback the way the boot does —
the root lives outside this workspace, so bare plugin names have no other way
to resolve.
The web bundle's runtime row is disabled beside the webserver: it injects
`httpServer`, so a disabled port leaves it pending forever. It owns dist
serving and the URL prompt line, neither of which decides an agent's
capabilities.
`apps/cli` declares the packages the shipped agent presets name again. The
bundle split emptied its plugin dependencies, and the flat fallback links only
the app's dependency closure — so a preset row naming `dsh-persona` resolved
to nothing, and every preset mount failed. Which packages the shipped
presets compose is not implied by any bundle: the presets live beside this
app's config, so this app is what has to declare them.
2026-08-06 21:20:56 +08:00
|
|
|
const profileDir = join(home, 'profiles', 'spec')
|
|
|
|
|
await mkdir(profileDir, { recursive: true })
|
2026-08-12 19:28:31 +08:00
|
|
|
// Product Bundles are installed into the Profile, not the dsh app. Model
|
|
|
|
|
// pnpm's package link for only the selected products; their own production
|
|
|
|
|
// dependencies resolve from the linked workspace packages, while shared
|
|
|
|
|
// peers still resolve through the installation fallback above.
|
|
|
|
|
for (const packageDir of profilePackages) {
|
|
|
|
|
const manifest = JSON.parse(await readFile(join(packageDir, 'package.json'), 'utf8')) as { name: string }
|
|
|
|
|
const link = join(profileDir, 'node_modules', manifest.name)
|
|
|
|
|
await mkdir(dirname(link), { recursive: true })
|
|
|
|
|
await symlink(packageDir, link, 'junction')
|
|
|
|
|
}
|
2026-08-15 04:24:03 +08:00
|
|
|
let bundlePatches: PatchOptions[] = [
|
|
|
|
|
...loadOverlayPatches('dsh-test', BASE_PATCH),
|
|
|
|
|
...loadOverlayPatches('dsh-test', WEB_PATCH),
|
|
|
|
|
]
|
|
|
|
|
if (profileBundles !== undefined) {
|
|
|
|
|
await writeFile(join(profileDir, 'package.json'), JSON.stringify({
|
|
|
|
|
private: true,
|
|
|
|
|
dependencies: Object.fromEntries(profileBundles.map(name => [name, 'workspace:*'])),
|
|
|
|
|
dsh: { profile: { bundles: profileBundles } },
|
|
|
|
|
}, null, 2) + '\n')
|
|
|
|
|
const profile = loadProfile('dsh-test', 'spec', INSTALL_ANCHOR, home, { userLayer: false })
|
|
|
|
|
bundlePatches = profile.layers.flatMap(layer => layer.patches)
|
|
|
|
|
}
|
fix(cli): boot the composition test the way the profile boot now does
The shipped surface stopped being two yml files: `base.cordis.yml` and
`web.cordis.yml` are bundle patch layers now, applied over an empty preset
root. This test still opened the old paths, so it failed before asserting
anything. It composes the same two layers the profile boot composes, over the
same empty root, and heals the flat module fallback the way the boot does —
the root lives outside this workspace, so bare plugin names have no other way
to resolve.
The web bundle's runtime row is disabled beside the webserver: it injects
`httpServer`, so a disabled port leaves it pending forever. It owns dist
serving and the URL prompt line, neither of which decides an agent's
capabilities.
`apps/cli` declares the packages the shipped agent presets name again. The
bundle split emptied its plugin dependencies, and the flat fallback links only
the app's dependency closure — so a preset row naming `dsh-persona` resolved
to nothing, and every preset mount failed. Which packages the shipped
presets compose is not implied by any bundle: the presets live beside this
app's config, so this app is what has to declare them.
2026-08-06 21:20:56 +08:00
|
|
|
const rootConfig = join(profileDir, 'cordis.yml')
|
|
|
|
|
await writeFile(rootConfig, '[]\n')
|
refactor(preset): bundle the shipped presets inside dsh-agent-presets
Review asked why the launcher special-cases one plugin's row. It no
longer does: the four shipped compositions move into the package
(presets/, in files), dsh-agent-presets resolves its own shipped root
and prepends it before configured roots (includeShippedRoot, default
true, opt-out for bare-machinery embedders), and the per-composition
derived patch, its spec, and the dump layer are deleted — profile-boot
and dump-config return to plain layer stacking. The always-load
guarantee now rides the schema default instead of patch ordering, so a
whole-config replacement keeps the shipped set and the squash, reload
freeze, and dump divergence stop being possible.
Gate globs, the web scaffold, and both preset browser lanes drop their
hand-fed shipped roots; the roster e2e keeps asserting configured roots
beside the shipped four against the built lib.
Fixes #2863.
2026-08-21 12:37:57 +08:00
|
|
|
return await boot('dsh-test', rootConfig, [...bundlePatches, ...overrides], (bootCtx) => {
|
2026-08-06 20:52:26 +08:00
|
|
|
provideCmdline(bootCtx, { args: [], exit: () => {} })
|
|
|
|
|
})
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const toolNames = (ctx: Context, agent?: Agent): string[] =>
|
|
|
|
|
ctx.tools.schemas(agent).map(schema => schema.name).sort()
|
|
|
|
|
|
2026-08-12 17:07:34 +08:00
|
|
|
function toolParameterNames(ctx: Context, agent: Agent, toolName: string): string[] {
|
|
|
|
|
const schema = ctx.tools.schemas(agent).find(tool => tool.name === toolName)
|
|
|
|
|
if (schema === undefined) throw new Error(`missing tool schema ${toolName}`)
|
|
|
|
|
const properties = schema.parameters.properties
|
|
|
|
|
if (typeof properties !== 'object' || properties === null || Array.isArray(properties)) {
|
|
|
|
|
throw new Error(`${toolName} has invalid parameter properties`)
|
|
|
|
|
}
|
|
|
|
|
return Object.keys(properties).sort()
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 12:45:05 +08:00
|
|
|
function enablePresetTool(composition: string, id: string): string {
|
|
|
|
|
const row = ` - id: ${id}\n`
|
|
|
|
|
const start = composition.indexOf(row)
|
|
|
|
|
if (start < 0) throw new Error(`missing preset row ${id}`)
|
|
|
|
|
const end = composition.indexOf('\n - id:', start + row.length)
|
|
|
|
|
const disabled = composition.indexOf(' disabled: true\n', start)
|
|
|
|
|
if (disabled < 0 || (end >= 0 && disabled > end)) {
|
|
|
|
|
throw new Error(`preset row ${id} is not disabled`)
|
|
|
|
|
}
|
|
|
|
|
return composition.slice(0, disabled) + composition.slice(disabled + ' disabled: true\n'.length)
|
|
|
|
|
}
|
|
|
|
|
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
let ctx: Context
|
|
|
|
|
beforeAll(async () => {
|
feat(agent-presets): make the default preset a user setting
`config.default` becomes the composition base of an `agent-presets` settings
namespace, so the user document layers over the deployment's engineering
default and a person can change which preset new sessions get without a
restart.
The value is read per resolution rather than snapshotted: a hot-reloaded
document takes effect on the next session created, and every running session
stays on the preset it was composed from — which is the same rule the
session-header guard enforces from the other side.
`resolve()` read `config.default` directly, which would have made the whole
setting inert; it now goes through `defaultId` like every other caller.
The write-protection test is rewritten against a temp profile root. It was
passing vacuously: the un-overridden Loader REWRITES the composition it read —
stamping `disabled: true` onto the self-disposing row — so the committed
fixture had been mutated by the very run that proved the bug, and every later
run compared against the damaged file and passed. Building the preset in a
temp directory makes the assertion immune to its own failure mode, and it now
fails with a visible `+ disabled: true` when the override is removed.
Review follow-ups on this layer. The exported schema is
`AgentPresetSettingsSchema`, symmetric with the `AgentPresetSettings`
interface it resolves and self-describing at an import site. The `session.create`
JSDoc promised "the deployment's default preset" for an omitted `agentPreset`,
which this layer makes false — it now names the effective default. The
constructor records why it does not use `installSettingsSection`: that helper
re-judges what a consumer DERIVED across attach and detach, and nothing here is
derived. The provider-unload test disposes the fiber `ctx.plugin()` handed back
instead of reaching into `ctx.reflect.store`, and the write-protection wait says
why slack is the right shape for an absence assertion.
The real composition covers the layering too. `apps/cli` boots the shipped
`cordis.yml`, stores `agent-presets.default`, and asserts an unnamed session
composes from it — the package suite proves the layering against a hand-built
context, this proves the roster and the settings provider are wired to each
other. That test also pins the settings row at a temp file: it defaulted to
`$DSH_HOME/settings.yaml`, so a developer's own stored default decided the
outcome of a file whose whole point is that only the shipped root does.
The Agent Note records the per-resolution read and its correspondence with the
session header, and the vacuous-test finding above.
2026-08-04 00:07:11 +08:00
|
|
|
const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-web-presets-')), 'settings.yaml')
|
|
|
|
|
await writeFile(settingsFile, '{}\n')
|
|
|
|
|
ctx = await bootWeb(settingsFile)
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
}, 120_000)
|
|
|
|
|
|
|
|
|
|
describe('the shipped Web composition', () => {
|
2026-08-04 13:10:21 +08:00
|
|
|
it('leaves the global tool layer empty', () => {
|
|
|
|
|
// Every model-facing tool belongs to a preset, `ask_user_question`
|
|
|
|
|
// included: a tool in the global layer reaches EVERY agent regardless of
|
|
|
|
|
// which preset composed it, so a two-tool benchmark surface would really
|
|
|
|
|
// present three. A regression here means an agent-plane row came back to
|
|
|
|
|
// the host composition.
|
|
|
|
|
expect(toolNames(ctx)).toEqual([])
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
})
|
|
|
|
|
|
fix(preset): keep the token meter host-plane and name unjoined agents
Moving the agent plane behind presets left two readers on the wrong side of
the host/agent line.
`dsh-token-meter` was disabled on the host and mounted inside each preset's
`compaction` realm, but its three projection units register into the
process-wide `sessionProjections` table. A unit registered from one preset
answers for every session, so whether a `minimal` session showed a context
meter depended on whether some other session had mounted `standard` since
boot, and a process that only ever ran `minimal` showed none. The meter takes
no configuration, keys every fold by Session, and registers no tool or prompt
section, so it returns to the host composition and leaves the presets'
`isolate` map; the realm and `compact-basic` stay, because what a preset
chooses is whether its agent compacts, not whether its tokens are counted.
Nothing named an agent that joined no preset. The join is a scope-parent link,
and without it the tools, prompt-section, and skill views resolve the empty
global layer: the agent publishes, the turn runs, and the model receives
nothing. `AgentPresets` now logs one warning per such agent while a roster is
configured, and the invariant companion fails outright — at
`system-prompt/assemble` rather than at publication, because an unjoined agent
is legal until it addresses a model and `recompose` binds exactly such an
agent. The warning stays advisory: a synchronous `agent/created` throw vetoes
publication, and the ACP bridge, SDK server, and headless bundle all create an
unjoined agent today.
Three limits are recorded rather than fixed: projection key presence is not a
per-session capability signal, a superseded standing generation is never
reclaimed, and a `cordis_mount` temporary plugin belongs to the composition
rather than the session that mounted it.
Fixes #2203
2026-08-10 22:36:06 +08:00
|
|
|
it('keeps the token meter and its context-meter projections on the host plane', async () => {
|
|
|
|
|
// Read before any preset in this file mounts, which is what makes this an
|
|
|
|
|
// ownership assertion rather than a mount-order coincidence: a preset-side
|
|
|
|
|
// meter sits behind an `isolate` realm and is invisible to `ctx.get`.
|
|
|
|
|
//
|
|
|
|
|
// The projection registry is process-wide rather than scope-layered, so a
|
|
|
|
|
// preset-side meter would also make the browser's context meter appear for
|
|
|
|
|
// a `minimal` session the moment some OTHER session mounted a preset that
|
|
|
|
|
// carries one, and vanish entirely in a process that only ever ran
|
|
|
|
|
// `minimal`. Host ownership is what makes the meter a per-session fact.
|
|
|
|
|
expect(ctx.get('tokenMeter')).toBeDefined()
|
|
|
|
|
const projections = ctx.get('sessionProjections')
|
|
|
|
|
if (projections === undefined) throw new Error('the Web composition must compose a projection registry')
|
|
|
|
|
const handle = await ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('preset-minimal-meter'),
|
|
|
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
|
|
|
|
|
})
|
|
|
|
|
try {
|
2026-08-11 10:45:15 +08:00
|
|
|
// A subset assertion: `tasks`, `goal`, and the rest register into the
|
|
|
|
|
// same process-wide table, and this is about the meter's three units.
|
|
|
|
|
expect(Object.keys(projections.snapshot(handle.agent.session).values))
|
fix(preset): keep the token meter host-plane and name unjoined agents
Moving the agent plane behind presets left two readers on the wrong side of
the host/agent line.
`dsh-token-meter` was disabled on the host and mounted inside each preset's
`compaction` realm, but its three projection units register into the
process-wide `sessionProjections` table. A unit registered from one preset
answers for every session, so whether a `minimal` session showed a context
meter depended on whether some other session had mounted `standard` since
boot, and a process that only ever ran `minimal` showed none. The meter takes
no configuration, keys every fold by Session, and registers no tool or prompt
section, so it returns to the host composition and leaves the presets'
`isolate` map; the realm and `compact-basic` stay, because what a preset
chooses is whether its agent compacts, not whether its tokens are counted.
Nothing named an agent that joined no preset. The join is a scope-parent link,
and without it the tools, prompt-section, and skill views resolve the empty
global layer: the agent publishes, the turn runs, and the model receives
nothing. `AgentPresets` now logs one warning per such agent while a roster is
configured, and the invariant companion fails outright — at
`system-prompt/assemble` rather than at publication, because an unjoined agent
is legal until it addresses a model and `recompose` binds exactly such an
agent. The warning stays advisory: a synchronous `agent/created` throw vetoes
publication, and the ACP bridge, SDK server, and headless bundle all create an
unjoined agent today.
Three limits are recorded rather than fixed: projection key presence is not a
per-session capability signal, a superseded standing generation is never
reclaimed, and a `cordis_mount` temporary plugin belongs to the composition
rather than the session that mounted it.
Fixes #2203
2026-08-10 22:36:06 +08:00
|
|
|
.toEqual(expect.arrayContaining(['contextBreakdown', 'contextPressure', 'tokenUsage']))
|
|
|
|
|
} finally {
|
|
|
|
|
await handle.dispose()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
it('supplies both shipped presets, and only those, from the system root', async () => {
|
|
|
|
|
const listed = await ctx.agentPresets.list()
|
|
|
|
|
|
feat(tools): let one agent choose its tool presentation, and ship `code`
Code Mode was a deployment-wide field on the host `tools` row: a
deployment ran every session that way or none. The obvious product
shape — 代码模式 beside 标准/极简/创造 in the preset picker — had
nothing to hang on.
The registry itself cannot move into a preset; the agent loop's
scheduler, the api-proxy's presenters, and every tool plugin are its
consumers. So split the registry from its projection: `presentAs(mode)`
writes one cell on the calling agent's scope layer, exactly as
`restrict()` does, and the three reads that decided presentation take
that scope's mode instead of the service's. The config `mode` becomes
the default agents shadow rather than a process-wide fact.
Two consequences are load-bearing. `run_code` now enters a view only
for scopes whose own mode presents it — a native agent must not find it
dispatchable because another agent in the process does — and the
reserved name holds whatever the configured mode, since any agent may
select a code mode later.
`dsh-agent-tool-mode` is the row a preset carries to declare this. A
code mode waits for the host's `codeRuntime` rather than assuming it,
so a runtime-less deployment fails the preset at mount, naming the
row, instead of at the session's first request.
The shipped `code` preset is `standard` plus that row, ordered second.
2026-08-05 20:31:52 +08:00
|
|
|
expect(listed.map(preset => preset.id).sort()).toEqual(['code', 'cordis', 'minimal', 'standard'])
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
expect(listed.every(preset => preset.trust === 'system')).toBe(true)
|
|
|
|
|
expect(ctx.agentPresets.defaultId).toBe('standard')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('composes the full agent from `standard`', async () => {
|
|
|
|
|
const handle = await ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('preset-standard'),
|
|
|
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
|
|
|
|
})
|
|
|
|
|
try {
|
|
|
|
|
// The EXACT catalog, not a spot-check: an omission is this design's
|
|
|
|
|
// quietest failure mode, because a row that registers into the wrong
|
|
|
|
|
// layer mounts cleanly and simply contributes nothing. `glob`/`grep` are
|
|
|
|
|
// 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([
|
|
|
|
|
'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode',
|
2026-08-13 00:36:22 +08:00
|
|
|
'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'ralph', 'read', 'read_image', 'send_message', 'skill',
|
|
|
|
|
'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'web_search',
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
'workflow', 'write',
|
|
|
|
|
])
|
2026-08-24 09:18:48 +08:00
|
|
|
expect(ctx.commands.find(handle.agent, 'goal')).toBeDefined()
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
} finally {
|
|
|
|
|
await handle.dispose()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-24 18:23:42 +08:00
|
|
|
it('applies the default-off subagent model-selection preference only to new sessions', async () => {
|
|
|
|
|
await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false })
|
|
|
|
|
const disabled = await ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('preset-model-selection-disabled'),
|
|
|
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
|
|
|
|
})
|
|
|
|
|
await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true })
|
|
|
|
|
const enabled = await ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('preset-model-selection-enabled'),
|
|
|
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
|
|
|
|
})
|
|
|
|
|
try {
|
|
|
|
|
expect(toolNames(ctx, disabled.agent)).not.toContain('list_subagent_models')
|
|
|
|
|
expect(toolParameterNames(ctx, disabled.agent, 'subagent')).not.toEqual(expect.arrayContaining([
|
|
|
|
|
'model', 'provider', 'reasoning_effort',
|
|
|
|
|
]))
|
|
|
|
|
expect(toolNames(ctx, enabled.agent)).toContain('list_subagent_models')
|
|
|
|
|
expect(toolParameterNames(ctx, enabled.agent, 'subagent')).toEqual(expect.arrayContaining([
|
|
|
|
|
'model', 'provider', 'reasoning_effort',
|
|
|
|
|
]))
|
|
|
|
|
expect(toolNames(ctx, disabled.agent)).not.toContain('list_subagent_models')
|
|
|
|
|
} finally {
|
|
|
|
|
await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false })
|
|
|
|
|
await enabled.dispose()
|
|
|
|
|
await disabled.dispose()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-10 18:02:11 +08:00
|
|
|
it('composes the exact RL prompt and two tools from `minimal`', async () => {
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
const handle = await ctx.agents.create({
|
2026-08-05 16:40:03 +08:00
|
|
|
sessionId: SessionId('preset-minimal'),
|
|
|
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
})
|
|
|
|
|
try {
|
2026-08-10 18:02:11 +08:00
|
|
|
const assembly = await ctx.systemPrompt.assemble({ scope: handle.agent })
|
|
|
|
|
expect(assembly.sections).toEqual([
|
|
|
|
|
{ name: 'deployment:persona', text: MINIMAL_PROMPT },
|
|
|
|
|
])
|
|
|
|
|
expect(assembly.tools.map(tool => tool.name)).toEqual(['bash', 'str_replace_editor'])
|
|
|
|
|
expect(assembly.tools.find(tool => tool.name === 'bash')?.description).toBe(MINIMAL_BASH_DESCRIPTION)
|
|
|
|
|
expect(JSON.stringify(assembly.tools.find(tool => tool.name === 'str_replace_editor')?.parameters))
|
|
|
|
|
.toContain('Absolute path')
|
2026-08-24 09:18:48 +08:00
|
|
|
expect(ctx.commands.find(handle.agent, 'goal')).toBeUndefined()
|
2026-08-13 00:36:22 +08:00
|
|
|
expect(ctx.agentPresets.serviceFor(handle.agent, 'compaction')).toBeUndefined()
|
|
|
|
|
expect(handle.agent.ctx.get('compaction')).toBeUndefined()
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
} finally {
|
|
|
|
|
await handle.dispose()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('keeps two differently composed sessions independent', async () => {
|
|
|
|
|
const full = await ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('preset-both-full'),
|
|
|
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
|
|
|
|
})
|
|
|
|
|
const minimal = await ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('preset-both-minimal'),
|
2026-08-05 16:40:03 +08:00
|
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
})
|
|
|
|
|
try {
|
2026-08-04 13:10:21 +08:00
|
|
|
expect(toolNames(ctx, minimal.agent)).toEqual(['bash', 'str_replace_editor'])
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10)
|
|
|
|
|
|
|
|
|
|
await minimal.dispose()
|
|
|
|
|
|
|
|
|
|
// Tearing the minimal session down leaves the full one whole.
|
|
|
|
|
expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10)
|
2026-08-04 13:10:21 +08:00
|
|
|
expect(toolNames(ctx)).toEqual([])
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
} finally {
|
|
|
|
|
await full.dispose()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
feat(agent-presets): ship a cordis agent that can author compositions
A third built-in preset: the standard coding agent plus the self-referential
Cordis toolset, a persona that explains the two-plane split, and a skill
teaching composition authoring. It exists so a person can ask an agent to
author another agent.
The skill ships INSIDE the preset directory rather than in the user's skill
root, and the root is derived from the preset's own `baseUrl` — the loader
evaluates `!!js` with `with (ctx)`, so a composition can locate itself. A
preset is the unit that gets copied and edited, so its documentation should
travel with it.
The skill leads with the rule that actually bites: a row publishing a service
may not sit loose in a preset, whether a row publishes one is not visible from
its name (`tool-bash` provides `bashEnv`), and a consumer left outside its
provider's isolate group resolves the host registry and then contributes
nothing — the quietest failure this design has.
Writing the test surfaced a consequence worth stating: an entry-local realm
makes the service invisible to the agent's own scope too, not just to the host.
Only rows inside that group resolve it, which is precisely what makes
`tool-skill` this agent's own rather than a shared one. The test asserts what
is actually observable from outside instead of reaching for the isolated
service.
TRUST: `cordis_mount` evaluates model-written JavaScript against the live
runtime, and a composition this agent writes becomes a preset other sessions
mount. Both the preset header and the toolset's own documentation say to treat
this as shell access. The tools stay opt-in per session — a test pins that they
are absent from every other preset.
2026-08-04 00:36:39 +08:00
|
|
|
it('composes the cordis agent with its own toolset', async () => {
|
|
|
|
|
const handle = await ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('preset-cordis'),
|
|
|
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'cordis').then(() => undefined),
|
|
|
|
|
})
|
|
|
|
|
try {
|
|
|
|
|
const tools = toolNames(ctx, handle.agent)
|
|
|
|
|
// The self-referential toolset is what distinguishes this preset.
|
2026-08-13 00:40:38 +08:00
|
|
|
expect(tools).toEqual(expect.arrayContaining([
|
|
|
|
|
'cordis_inspect_list', 'cordis_inspect_query', 'cordis_inspect_self',
|
|
|
|
|
'cordis_define', 'cordis_run', 'cordis_stop', 'cordis_undefine',
|
|
|
|
|
]))
|
feat(agent-presets): ship a cordis agent that can author compositions
A third built-in preset: the standard coding agent plus the self-referential
Cordis toolset, a persona that explains the two-plane split, and a skill
teaching composition authoring. It exists so a person can ask an agent to
author another agent.
The skill ships INSIDE the preset directory rather than in the user's skill
root, and the root is derived from the preset's own `baseUrl` — the loader
evaluates `!!js` with `with (ctx)`, so a composition can locate itself. A
preset is the unit that gets copied and edited, so its documentation should
travel with it.
The skill leads with the rule that actually bites: a row publishing a service
may not sit loose in a preset, whether a row publishes one is not visible from
its name (`tool-bash` provides `bashEnv`), and a consumer left outside its
provider's isolate group resolves the host registry and then contributes
nothing — the quietest failure this design has.
Writing the test surfaced a consequence worth stating: an entry-local realm
makes the service invisible to the agent's own scope too, not just to the host.
Only rows inside that group resolve it, which is precisely what makes
`tool-skill` this agent's own rather than a shared one. The test asserts what
is actually observable from outside instead of reaching for the isolated
service.
TRUST: `cordis_mount` evaluates model-written JavaScript against the live
runtime, and a composition this agent writes becomes a preset other sessions
mount. Both the preset header and the toolset's own documentation say to treat
this as shell access. The tools stay opt-in per session — a test pins that they
are absent from every other preset.
2026-08-04 00:36:39 +08:00
|
|
|
// And it keeps the standard agent's own tools rather than replacing them.
|
|
|
|
|
expect(tools).toEqual(expect.arrayContaining(['bash', 'read', 'edit', 'skill']))
|
2026-08-10 22:47:28 +08:00
|
|
|
expect(tools).not.toContain('str_replace_editor')
|
2026-08-24 09:18:48 +08:00
|
|
|
expect(ctx.commands.find(handle.agent, 'goal')).toBeDefined()
|
feat(agent-presets): ship a cordis agent that can author compositions
A third built-in preset: the standard coding agent plus the self-referential
Cordis toolset, a persona that explains the two-plane split, and a skill
teaching composition authoring. It exists so a person can ask an agent to
author another agent.
The skill ships INSIDE the preset directory rather than in the user's skill
root, and the root is derived from the preset's own `baseUrl` — the loader
evaluates `!!js` with `with (ctx)`, so a composition can locate itself. A
preset is the unit that gets copied and edited, so its documentation should
travel with it.
The skill leads with the rule that actually bites: a row publishing a service
may not sit loose in a preset, whether a row publishes one is not visible from
its name (`tool-bash` provides `bashEnv`), and a consumer left outside its
provider's isolate group resolves the host registry and then contributes
nothing — the quietest failure this design has.
Writing the test surfaced a consequence worth stating: an entry-local realm
makes the service invisible to the agent's own scope too, not just to the host.
Only rows inside that group resolve it, which is precisely what makes
`tool-skill` this agent's own rather than a shared one. The test asserts what
is actually observable from outside instead of reaching for the isolated
service.
TRUST: `cordis_mount` evaluates model-written JavaScript against the live
runtime, and a composition this agent writes becomes a preset other sessions
mount. Both the preset header and the toolset's own documentation say to treat
this as shell access. The tools stay opt-in per session — a test pins that they
are absent from every other preset.
2026-08-04 00:36:39 +08:00
|
|
|
|
2026-08-09 22:48:47 +08:00
|
|
|
// The preset's own authoring skill registers into ITS layer of the host
|
|
|
|
|
// registry: the cordis agent's view carries it, the global view does not.
|
|
|
|
|
const scoped = (await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name)
|
|
|
|
|
expect(scoped).toContain('editing-cordis-compositions')
|
|
|
|
|
expect((await ctx.skills.list()).map(skill => skill.name)).not.toContain('editing-cordis-compositions')
|
feat(agent-presets): ship a cordis agent that can author compositions
A third built-in preset: the standard coding agent plus the self-referential
Cordis toolset, a persona that explains the two-plane split, and a skill
teaching composition authoring. It exists so a person can ask an agent to
author another agent.
The skill ships INSIDE the preset directory rather than in the user's skill
root, and the root is derived from the preset's own `baseUrl` — the loader
evaluates `!!js` with `with (ctx)`, so a composition can locate itself. A
preset is the unit that gets copied and edited, so its documentation should
travel with it.
The skill leads with the rule that actually bites: a row publishing a service
may not sit loose in a preset, whether a row publishes one is not visible from
its name (`tool-bash` provides `bashEnv`), and a consumer left outside its
provider's isolate group resolves the host registry and then contributes
nothing — the quietest failure this design has.
Writing the test surfaced a consequence worth stating: an entry-local realm
makes the service invisible to the agent's own scope too, not just to the host.
Only rows inside that group resolve it, which is precisely what makes
`tool-skill` this agent's own rather than a shared one. The test asserts what
is actually observable from outside instead of reaching for the isolated
service.
TRUST: `cordis_mount` evaluates model-written JavaScript against the live
runtime, and a composition this agent writes becomes a preset other sessions
mount. Both the preset header and the toolset's own documentation say to treat
this as shell access. The tools stay opt-in per session — a test pins that they
are absent from every other preset.
2026-08-04 00:36:39 +08:00
|
|
|
} finally {
|
|
|
|
|
await handle.dispose()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
feat(tools): let one agent choose its tool presentation, and ship `code`
Code Mode was a deployment-wide field on the host `tools` row: a
deployment ran every session that way or none. The obvious product
shape — 代码模式 beside 标准/极简/创造 in the preset picker — had
nothing to hang on.
The registry itself cannot move into a preset; the agent loop's
scheduler, the api-proxy's presenters, and every tool plugin are its
consumers. So split the registry from its projection: `presentAs(mode)`
writes one cell on the calling agent's scope layer, exactly as
`restrict()` does, and the three reads that decided presentation take
that scope's mode instead of the service's. The config `mode` becomes
the default agents shadow rather than a process-wide fact.
Two consequences are load-bearing. `run_code` now enters a view only
for scopes whose own mode presents it — a native agent must not find it
dispatchable because another agent in the process does — and the
reserved name holds whatever the configured mode, since any agent may
select a code mode later.
`dsh-agent-tool-mode` is the row a preset carries to declare this. A
code mode waits for the host's `codeRuntime` rather than assuming it,
so a runtime-less deployment fails the preset at mount, naming the
row, instead of at the session's first request.
The shipped `code` preset is `standard` plus that row, ordered second.
2026-08-05 20:31:52 +08:00
|
|
|
it('presents `code` as Code Mode without disturbing a native session beside it', async () => {
|
|
|
|
|
const coded = await ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('preset-code'),
|
|
|
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'code').then(() => undefined),
|
|
|
|
|
})
|
|
|
|
|
const native = await ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('preset-code-native'),
|
|
|
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
|
|
|
|
})
|
|
|
|
|
try {
|
|
|
|
|
// One tool reaches the MODEL: the transport. The registry's catalog for
|
|
|
|
|
// this agent is unchanged — a code mode collapses the presentation, not
|
|
|
|
|
// the capabilities — so the assembly is what carries the claim.
|
|
|
|
|
const assembly = await ctx.systemPrompt.assemble({ scope: coded.agent })
|
|
|
|
|
expect(assembly.tools.map(tool => tool.name)).toEqual(['run_code'])
|
2026-08-10 22:47:28 +08:00
|
|
|
expect(toolNames(ctx, coded.agent)).not.toContain('str_replace_editor')
|
2026-08-24 09:18:48 +08:00
|
|
|
expect(ctx.commands.find(coded.agent, 'goal')).toBeDefined()
|
feat(tools): let one agent choose its tool presentation, and ship `code`
Code Mode was a deployment-wide field on the host `tools` row: a
deployment ran every session that way or none. The obvious product
shape — 代码模式 beside 标准/极简/创造 in the preset picker — had
nothing to hang on.
The registry itself cannot move into a preset; the agent loop's
scheduler, the api-proxy's presenters, and every tool plugin are its
consumers. So split the registry from its projection: `presentAs(mode)`
writes one cell on the calling agent's scope layer, exactly as
`restrict()` does, and the three reads that decided presentation take
that scope's mode instead of the service's. The config `mode` becomes
the default agents shadow rather than a process-wide fact.
Two consequences are load-bearing. `run_code` now enters a view only
for scopes whose own mode presents it — a native agent must not find it
dispatchable because another agent in the process does — and the
reserved name holds whatever the configured mode, since any agent may
select a code mode later.
`dsh-agent-tool-mode` is the row a preset carries to declare this. A
code mode waits for the host's `codeRuntime` rather than assuming it,
so a runtime-less deployment fails the preset at mount, naming the
row, instead of at the session's first request.
The shipped `code` preset is `standard` plus that row, ordered second.
2026-08-05 20:31:52 +08:00
|
|
|
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? ''
|
2026-08-10 22:47:28 +08:00
|
|
|
expect(sdk).not.toContain('str_replace_editor')
|
feat(tools): let one agent choose its tool presentation, and ship `code`
Code Mode was a deployment-wide field on the host `tools` row: a
deployment ran every session that way or none. The obvious product
shape — 代码模式 beside 标准/极简/创造 in the preset picker — had
nothing to hang on.
The registry itself cannot move into a preset; the agent loop's
scheduler, the api-proxy's presenters, and every tool plugin are its
consumers. So split the registry from its projection: `presentAs(mode)`
writes one cell on the calling agent's scope layer, exactly as
`restrict()` does, and the three reads that decided presentation take
that scope's mode instead of the service's. The config `mode` becomes
the default agents shadow rather than a process-wide fact.
Two consequences are load-bearing. `run_code` now enters a view only
for scopes whose own mode presents it — a native agent must not find it
dispatchable because another agent in the process does — and the
reserved name holds whatever the configured mode, since any agent may
select a code mode later.
`dsh-agent-tool-mode` is the row a preset carries to declare this. A
code mode waits for the host's `codeRuntime` rather than assuming it,
so a runtime-less deployment fails the preset at mount, naming the
row, instead of at the session's first request.
The shipped `code` preset is `standard` plus that row, ordered second.
2026-08-05 20:31:52 +08:00
|
|
|
expect(sdk).toContain('web_search')
|
|
|
|
|
|
|
|
|
|
// The presentation is this agent's alone: the deployment default is
|
|
|
|
|
// native, and the session composed from `standard` still sees it.
|
|
|
|
|
const nativeAssembly = await ctx.systemPrompt.assemble({ scope: native.agent })
|
|
|
|
|
expect(nativeAssembly.tools.map(tool => tool.name)).toContain('bash')
|
|
|
|
|
expect(nativeAssembly.tools.map(tool => tool.name)).not.toContain('run_code')
|
|
|
|
|
expect(nativeAssembly.sections.some(section => section.name === 'tools:sdk')).toBe(false)
|
|
|
|
|
} finally {
|
|
|
|
|
await native.dispose()
|
|
|
|
|
await coded.dispose()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
feat(agent-presets): ship a cordis agent that can author compositions
A third built-in preset: the standard coding agent plus the self-referential
Cordis toolset, a persona that explains the two-plane split, and a skill
teaching composition authoring. It exists so a person can ask an agent to
author another agent.
The skill ships INSIDE the preset directory rather than in the user's skill
root, and the root is derived from the preset's own `baseUrl` — the loader
evaluates `!!js` with `with (ctx)`, so a composition can locate itself. A
preset is the unit that gets copied and edited, so its documentation should
travel with it.
The skill leads with the rule that actually bites: a row publishing a service
may not sit loose in a preset, whether a row publishes one is not visible from
its name (`tool-bash` provides `bashEnv`), and a consumer left outside its
provider's isolate group resolves the host registry and then contributes
nothing — the quietest failure this design has.
Writing the test surfaced a consequence worth stating: an entry-local realm
makes the service invisible to the agent's own scope too, not just to the host.
Only rows inside that group resolve it, which is precisely what makes
`tool-skill` this agent's own rather than a shared one. The test asserts what
is actually observable from outside instead of reaching for the isolated
service.
TRUST: `cordis_mount` evaluates model-written JavaScript against the live
runtime, and a composition this agent writes becomes a preset other sessions
mount. Both the preset header and the toolset's own documentation say to treat
this as shell access. The tools stay opt-in per session — a test pins that they
are absent from every other preset.
2026-08-04 00:36:39 +08:00
|
|
|
it('keeps the self-referential toolset out of every other preset', async () => {
|
|
|
|
|
const handle = await ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('preset-no-cordis'),
|
|
|
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
|
|
|
|
})
|
|
|
|
|
try {
|
|
|
|
|
// Editing the live runtime is opt-in per session, not ambient.
|
2026-08-13 00:40:38 +08:00
|
|
|
expect(toolNames(ctx, handle.agent)).not.toContain('cordis_define')
|
feat(agent-presets): ship a cordis agent that can author compositions
A third built-in preset: the standard coding agent plus the self-referential
Cordis toolset, a persona that explains the two-plane split, and a skill
teaching composition authoring. It exists so a person can ask an agent to
author another agent.
The skill ships INSIDE the preset directory rather than in the user's skill
root, and the root is derived from the preset's own `baseUrl` — the loader
evaluates `!!js` with `with (ctx)`, so a composition can locate itself. A
preset is the unit that gets copied and edited, so its documentation should
travel with it.
The skill leads with the rule that actually bites: a row publishing a service
may not sit loose in a preset, whether a row publishes one is not visible from
its name (`tool-bash` provides `bashEnv`), and a consumer left outside its
provider's isolate group resolves the host registry and then contributes
nothing — the quietest failure this design has.
Writing the test surfaced a consequence worth stating: an entry-local realm
makes the service invisible to the agent's own scope too, not just to the host.
Only rows inside that group resolve it, which is precisely what makes
`tool-skill` this agent's own rather than a shared one. The test asserts what
is actually observable from outside instead of reaching for the isolated
service.
TRUST: `cordis_mount` evaluates model-written JavaScript against the live
runtime, and a composition this agent writes becomes a preset other sessions
mount. Both the preset header and the toolset's own documentation say to treat
this as shell access. The tools stay opt-in per session — a test pins that they
are absent from every other preset.
2026-08-04 00:36:39 +08:00
|
|
|
} finally {
|
|
|
|
|
await handle.dispose()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('ships the composition-authoring skill inside the preset directory', async () => {
|
|
|
|
|
// The preset's skill root is derived from its own `baseUrl`, so the skill
|
|
|
|
|
// travels with the directory wherever the preset is installed.
|
|
|
|
|
const skill = join(
|
refactor(preset): bundle the shipped presets inside dsh-agent-presets
Review asked why the launcher special-cases one plugin's row. It no
longer does: the four shipped compositions move into the package
(presets/, in files), dsh-agent-presets resolves its own shipped root
and prepends it before configured roots (includeShippedRoot, default
true, opt-out for bare-machinery embedders), and the per-composition
derived patch, its spec, and the dump layer are deleted — profile-boot
and dump-config return to plain layer stacking. The always-load
guarantee now rides the schema default instead of patch ordering, so a
whole-config replacement keeps the shipped set and the squash, reload
freeze, and dump divergence stop being possible.
Gate globs, the web scaffold, and both preset browser lanes drop their
hand-fed shipped roots; the roster e2e keeps asserting configured roots
beside the shipped four against the built lib.
Fixes #2863.
2026-08-21 12:37:57 +08:00
|
|
|
SHIPPED_PRESET_ROOT, 'cordis', 'skills', 'editing-cordis-compositions', 'SKILL.md',
|
feat(agent-presets): ship a cordis agent that can author compositions
A third built-in preset: the standard coding agent plus the self-referential
Cordis toolset, a persona that explains the two-plane split, and a skill
teaching composition authoring. It exists so a person can ask an agent to
author another agent.
The skill ships INSIDE the preset directory rather than in the user's skill
root, and the root is derived from the preset's own `baseUrl` — the loader
evaluates `!!js` with `with (ctx)`, so a composition can locate itself. A
preset is the unit that gets copied and edited, so its documentation should
travel with it.
The skill leads with the rule that actually bites: a row publishing a service
may not sit loose in a preset, whether a row publishes one is not visible from
its name (`tool-bash` provides `bashEnv`), and a consumer left outside its
provider's isolate group resolves the host registry and then contributes
nothing — the quietest failure this design has.
Writing the test surfaced a consequence worth stating: an entry-local realm
makes the service invisible to the agent's own scope too, not just to the host.
Only rows inside that group resolve it, which is precisely what makes
`tool-skill` this agent's own rather than a shared one. The test asserts what
is actually observable from outside instead of reaching for the isolated
service.
TRUST: `cordis_mount` evaluates model-written JavaScript against the live
runtime, and a composition this agent writes becomes a preset other sessions
mount. Both the preset header and the toolset's own documentation say to treat
this as shell access. The tools stay opt-in per session — a test pins that they
are absent from every other preset.
2026-08-04 00:36:39 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expect((await readFile(skill, 'utf8')).startsWith('---\nname: editing-cordis-compositions')).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-09 22:18:34 +08:00
|
|
|
it('merges the global skill layer into a preset agent\'s catalog, keeping local discovery preset-side', async () => {
|
|
|
|
|
const proj = await mkdtemp(join(tmpdir(), 'dsh-preset-skill-proj-'))
|
|
|
|
|
await mkdir(join(proj, '.dsh', 'skills', 'project-proof'), { recursive: true })
|
|
|
|
|
await writeFile(join(proj, '.dsh', 'skills', 'project-proof', 'SKILL.md'), [
|
|
|
|
|
'---',
|
|
|
|
|
'name: project-proof',
|
|
|
|
|
'description: Proves the preset layer discovers project skills beside global ones.',
|
|
|
|
|
'---',
|
|
|
|
|
'',
|
|
|
|
|
'Project proof body.',
|
|
|
|
|
'',
|
|
|
|
|
].join('\n'))
|
|
|
|
|
|
|
|
|
|
const handle = await ctx.agents.create({
|
2026-08-09 22:48:47 +08:00
|
|
|
// Unique per run: the composition persists into the ambient DSH home,
|
|
|
|
|
// and a fixed id would collide with a log an earlier run left there.
|
|
|
|
|
sessionId: SessionId(`preset-skills-standard-${randomUUID()}`),
|
2026-08-09 22:18:34 +08:00
|
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
|
|
|
|
})
|
|
|
|
|
try {
|
|
|
|
|
// The host (global) view carries the deployment-level provider alone:
|
2026-08-13 00:36:22 +08:00
|
|
|
// local discovery moved behind the presets with `skill-filesystem`.
|
2026-08-09 22:18:34 +08:00
|
|
|
expect((await ctx.skills.list({ cwd: proj })).map(skill => skill.name)).toEqual(['dsh-badge'])
|
|
|
|
|
|
|
|
|
|
// The standard agent's view merges the global layer with its preset's
|
|
|
|
|
// own local discovery over the session cwd.
|
|
|
|
|
const scoped = (await ctx.skills.list({ cwd: proj, scope: handle.agent })).map(skill => skill.name)
|
|
|
|
|
expect(scoped).toContain('dsh-badge')
|
|
|
|
|
expect(scoped).toContain('project-proof')
|
|
|
|
|
|
|
|
|
|
// The preset's own loader tool resolves the global-layer skill.
|
|
|
|
|
const loaded = await ctx.tools.execute({
|
|
|
|
|
callId: CallId('preset-skills-load'),
|
|
|
|
|
name: 'skill',
|
|
|
|
|
arguments: { name: 'dsh-badge' },
|
|
|
|
|
signal: new AbortController().signal,
|
|
|
|
|
agent: handle.agent,
|
|
|
|
|
})
|
|
|
|
|
expect(loaded.isError).toBe(false)
|
|
|
|
|
expect(JSON.stringify(loaded.content)).toContain('powered by dsh')
|
|
|
|
|
} finally {
|
|
|
|
|
await handle.dispose()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-09 23:02:37 +08:00
|
|
|
it('shows a minimal agent the global layer but no loader tool', async () => {
|
2026-08-09 22:18:34 +08:00
|
|
|
const handle = await ctx.agents.create({
|
2026-08-09 23:02:37 +08:00
|
|
|
sessionId: SessionId(`preset-skills-minimal-${randomUUID()}`),
|
|
|
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
|
2026-08-09 22:18:34 +08:00
|
|
|
})
|
|
|
|
|
try {
|
|
|
|
|
// Layer visibility is the registry's; whether an agent can USE skills
|
2026-08-09 23:02:37 +08:00
|
|
|
// stays the preset's choice — minimal mounts no `tool-skill`, so its
|
2026-08-09 22:18:34 +08:00
|
|
|
// tool table has no loader even though the global layer is readable.
|
|
|
|
|
expect((await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name)).toContain('dsh-badge')
|
|
|
|
|
expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
|
|
|
|
|
} finally {
|
|
|
|
|
await handle.dispose()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
it('never rewrites the preset file it composed from', async () => {
|
|
|
|
|
// The Loader persists a tree whose plugin self-disposed, and tearing an
|
|
|
|
|
// agent down disposes its whole subtree. Inherited, that rewrote the
|
|
|
|
|
// shipped composition — truncating it to `[]` the first time a session
|
|
|
|
|
// ended — so `PresetTree` refuses to write at all.
|
refactor(preset): bundle the shipped presets inside dsh-agent-presets
Review asked why the launcher special-cases one plugin's row. It no
longer does: the four shipped compositions move into the package
(presets/, in files), dsh-agent-presets resolves its own shipped root
and prepends it before configured roots (includeShippedRoot, default
true, opt-out for bare-machinery embedders), and the per-composition
derived patch, its spec, and the dump layer are deleted — profile-boot
and dump-config return to plain layer stacking. The always-load
guarantee now rides the schema default instead of patch ordering, so a
whole-config replacement keeps the shipped set and the squash, reload
freeze, and dump divergence stop being possible.
Gate globs, the web scaffold, and both preset browser lanes drop their
hand-fed shipped roots; the roster e2e keeps asserting configured roots
beside the shipped four against the built lib.
Fixes #2863.
2026-08-21 12:37:57 +08:00
|
|
|
const path = join(SHIPPED_PRESET_ROOT, 'standard', 'agent.cordis.yml')
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
const before = await readFile(path, 'utf8')
|
|
|
|
|
|
|
|
|
|
const handle = await ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('preset-readonly'),
|
|
|
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
|
|
|
|
})
|
|
|
|
|
await handle.dispose()
|
2026-08-06 13:43:32 +08:00
|
|
|
// Slack, not a race the number has to win. The write is driven by the
|
|
|
|
|
// Loader's fiber-unload listener, which fires as the subtree's fibers
|
|
|
|
|
// settle rather than when `dispose()` resolves, and the Loader exposes no
|
|
|
|
|
// flush to await. A regression writes synchronously inside that listener,
|
|
|
|
|
// so any wait past settlement fails; a longer one only slows the test.
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
await new Promise(resolve => setTimeout(resolve, 50))
|
|
|
|
|
|
|
|
|
|
expect(await readFile(path, 'utf8')).toBe(before)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-15 03:25:43 +08:00
|
|
|
describe('product Bundle and user-preset intersection', () => {
|
|
|
|
|
const presetIds = ['products-none', 'products-codex', 'products-claude', 'products-both'] as const
|
|
|
|
|
type Product = 'codex' | 'claude-code'
|
2026-08-13 16:41:25 +08:00
|
|
|
type PresetId = typeof presetIds[number]
|
2026-08-10 12:45:05 +08:00
|
|
|
|
2026-08-15 03:25:43 +08:00
|
|
|
async function bootProducts(installed: readonly Product[]): Promise<Context> {
|
2026-08-10 12:45:05 +08:00
|
|
|
const root = await mkdtemp(join(tmpdir(), 'dsh-product-presets-'))
|
|
|
|
|
const userRoot = join(root, 'presets')
|
|
|
|
|
const settingsFile = join(root, 'settings.yaml')
|
refactor(preset): bundle the shipped presets inside dsh-agent-presets
Review asked why the launcher special-cases one plugin's row. It no
longer does: the four shipped compositions move into the package
(presets/, in files), dsh-agent-presets resolves its own shipped root
and prepends it before configured roots (includeShippedRoot, default
true, opt-out for bare-machinery embedders), and the per-composition
derived patch, its spec, and the dump layer are deleted — profile-boot
and dump-config return to plain layer stacking. The always-load
guarantee now rides the schema default instead of patch ordering, so a
whole-config replacement keeps the shipped set and the squash, reload
freeze, and dump divergence stop being possible.
Gate globs, the web scaffold, and both preset browser lanes drop their
hand-fed shipped roots; the roster e2e keeps asserting configured roots
beside the shipped four against the built lib.
Fixes #2863.
2026-08-21 12:37:57 +08:00
|
|
|
const standard = await readFile(join(SHIPPED_PRESET_ROOT, 'standard', 'agent.cordis.yml'), 'utf8')
|
2026-08-10 12:45:05 +08:00
|
|
|
await writeFile(settingsFile, '{}\n')
|
2026-08-12 19:28:31 +08:00
|
|
|
for (const id of presetIds) {
|
2026-08-10 12:45:05 +08:00
|
|
|
let composition = standard
|
2026-08-15 03:25:43 +08:00
|
|
|
if (id === 'products-codex' || id === 'products-both') {
|
|
|
|
|
composition = enablePresetTool(composition, 'tool-subagent-codex')
|
|
|
|
|
}
|
|
|
|
|
if (id === 'products-claude' || id === 'products-both') {
|
2026-08-10 12:45:05 +08:00
|
|
|
composition = enablePresetTool(composition, 'tool-subagent-claude-code')
|
|
|
|
|
}
|
|
|
|
|
const directory = join(userRoot, id)
|
|
|
|
|
await mkdir(directory, { recursive: true })
|
|
|
|
|
await writeFile(join(directory, 'agent.cordis.yml'), composition)
|
|
|
|
|
}
|
2026-08-15 04:24:03 +08:00
|
|
|
const packageDir = (product: Product): string => (
|
|
|
|
|
product === 'codex' ? CODEX_PACKAGE_DIR : CLAUDE_CODE_PACKAGE_DIR
|
|
|
|
|
)
|
|
|
|
|
const packageName = (product: Product): string => (
|
|
|
|
|
product === 'codex'
|
|
|
|
|
? '@deepseek-ai/dsh-subagent-codex'
|
|
|
|
|
: '@deepseek-ai/dsh-subagent-claude-code'
|
2026-08-15 03:25:43 +08:00
|
|
|
)
|
2026-08-12 19:28:31 +08:00
|
|
|
return await bootWeb(settingsFile, [
|
|
|
|
|
{
|
|
|
|
|
id: 'agent-presets',
|
|
|
|
|
config: {
|
|
|
|
|
default: 'standard',
|
refactor(preset): bundle the shipped presets inside dsh-agent-presets
Review asked why the launcher special-cases one plugin's row. It no
longer does: the four shipped compositions move into the package
(presets/, in files), dsh-agent-presets resolves its own shipped root
and prepends it before configured roots (includeShippedRoot, default
true, opt-out for bare-machinery embedders), and the per-composition
derived patch, its spec, and the dump layer are deleted — profile-boot
and dump-config return to plain layer stacking. The always-load
guarantee now rides the schema default instead of patch ordering, so a
whole-config replacement keeps the shipped set and the squash, reload
freeze, and dump divergence stop being possible.
Gate globs, the web scaffold, and both preset browser lanes drop their
hand-fed shipped roots; the roster e2e keeps asserting configured roots
beside the shipped four against the built lib.
Fixes #2863.
2026-08-21 12:37:57 +08:00
|
|
|
// The shipped root is the plugin's own, prepended before this.
|
2026-08-20 22:57:58 +08:00
|
|
|
roots: [{ path: userRoot, trust: 'user' }],
|
2026-08-12 19:28:31 +08:00
|
|
|
includeUserRoot: false,
|
|
|
|
|
},
|
2026-08-10 12:45:05 +08:00
|
|
|
},
|
2026-08-15 04:24:03 +08:00
|
|
|
], installed.map(packageDir), [
|
|
|
|
|
'@deepseek-ai/dsh-base',
|
|
|
|
|
'@deepseek-ai/dsh-web-app',
|
|
|
|
|
...installed.map(packageName),
|
|
|
|
|
])
|
2026-08-12 19:28:31 +08:00
|
|
|
}
|
2026-08-10 12:45:05 +08:00
|
|
|
|
2026-08-15 03:25:43 +08:00
|
|
|
it('composes the intersection of installed Bundles and enabled preset rows', async () => {
|
|
|
|
|
const enabledByPreset: Record<PresetId, Product[]> = {
|
|
|
|
|
'products-none': [],
|
|
|
|
|
'products-codex': ['codex'],
|
|
|
|
|
'products-claude': ['claude-code'],
|
|
|
|
|
'products-both': ['codex', 'claude-code'],
|
|
|
|
|
}
|
|
|
|
|
const scenarios: Array<{ installed: Product[]; presets: readonly PresetId[] }> = [
|
|
|
|
|
{ installed: [], presets: ['products-both'] },
|
|
|
|
|
{ installed: ['codex'], presets: ['products-both'] },
|
|
|
|
|
{ installed: ['claude-code'], presets: ['products-both'] },
|
|
|
|
|
{ installed: ['codex', 'claude-code'], presets: presetIds },
|
2026-08-12 19:28:31 +08:00
|
|
|
]
|
|
|
|
|
|
2026-08-13 16:41:25 +08:00
|
|
|
for (const { installed, presets } of scenarios) {
|
2026-08-12 19:28:31 +08:00
|
|
|
const productCtx = await bootProducts(installed)
|
|
|
|
|
const spawn = vi.spyOn(productCtx.subprocess, 'spawn')
|
2026-08-10 12:45:05 +08:00
|
|
|
try {
|
2026-08-12 19:28:31 +08:00
|
|
|
expect(productCtx.subagents.list()
|
|
|
|
|
.filter(name => name === 'codex' || name === 'claude-code')
|
|
|
|
|
.sort())
|
2026-08-15 03:25:43 +08:00
|
|
|
.toEqual([...installed].sort())
|
2026-08-13 16:41:25 +08:00
|
|
|
for (const id of presets) {
|
2026-08-12 19:28:31 +08:00
|
|
|
const handle = await productCtx.agents.create({
|
2026-08-15 03:25:43 +08:00
|
|
|
sessionId: SessionId(`preset-${id}-${installed.join('-') || 'none'}-${randomUUID()}`),
|
2026-08-12 19:28:31 +08:00
|
|
|
setup: agentCtx => productCtx.agentPresets.mount(agentCtx, id).then(() => undefined),
|
|
|
|
|
})
|
|
|
|
|
try {
|
2026-08-15 03:25:43 +08:00
|
|
|
const productTools = enabledByPreset[id]
|
|
|
|
|
.filter(product => installed.includes(product))
|
|
|
|
|
.map(product => product === 'codex' ? 'subagent_codex' : 'subagent_claude_code')
|
|
|
|
|
.sort()
|
2026-08-14 16:18:47 +08:00
|
|
|
const tools = toolNames(productCtx, handle.agent)
|
|
|
|
|
expect(tools.filter(name => name === 'subagent_codex' || name === 'subagent_claude_code'))
|
|
|
|
|
.toEqual(productTools)
|
|
|
|
|
expect(tools).toEqual(expect.arrayContaining(['job_kill', 'job_list', 'job_output']))
|
|
|
|
|
for (const productTool of productTools) {
|
|
|
|
|
expect(toolParameterNames(productCtx, handle.agent, productTool)).toEqual([
|
|
|
|
|
'description', 'prompt', 'run_in_background',
|
|
|
|
|
])
|
|
|
|
|
}
|
2026-08-12 19:28:31 +08:00
|
|
|
} finally {
|
|
|
|
|
await handle.dispose()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
expect(spawn).not.toHaveBeenCalled()
|
2026-08-10 12:45:05 +08:00
|
|
|
} finally {
|
2026-08-12 19:28:31 +08:00
|
|
|
spawn.mockRestore()
|
|
|
|
|
await productCtx.fiber.dispose()
|
2026-08-10 12:45:05 +08:00
|
|
|
}
|
|
|
|
|
}
|
2026-08-12 19:28:31 +08:00
|
|
|
}, 120_000)
|
2026-08-10 12:45:05 +08:00
|
|
|
|
|
|
|
|
it('applies a product-row edit only to later sessions on the preset', async () => {
|
2026-08-15 03:25:43 +08:00
|
|
|
const productCtx = await bootProducts(['codex'])
|
2026-08-10 12:45:05 +08:00
|
|
|
const preset = await productCtx.agentPresets.resolve('products-none')
|
|
|
|
|
const original = await readFile(preset.path, 'utf8')
|
|
|
|
|
const existing = await productCtx.agents.create({
|
|
|
|
|
sessionId: SessionId('preset-product-generation-existing'),
|
|
|
|
|
setup: agentCtx => productCtx.agentPresets.mount(agentCtx, 'products-none').then(() => undefined),
|
|
|
|
|
})
|
|
|
|
|
try {
|
2026-08-15 03:25:43 +08:00
|
|
|
expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_codex')
|
|
|
|
|
await writeFile(preset.path, enablePresetTool(original, 'tool-subagent-codex'))
|
2026-08-10 12:45:05 +08:00
|
|
|
|
|
|
|
|
const later = await productCtx.agents.create({
|
|
|
|
|
sessionId: SessionId('preset-product-generation-later'),
|
|
|
|
|
setup: agentCtx => productCtx.agentPresets.mount(agentCtx, 'products-none').then(() => undefined),
|
|
|
|
|
})
|
|
|
|
|
try {
|
2026-08-15 03:25:43 +08:00
|
|
|
expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_codex')
|
|
|
|
|
expect(toolNames(productCtx, later.agent)).toContain('subagent_codex')
|
2026-08-10 12:45:05 +08:00
|
|
|
} finally {
|
|
|
|
|
await later.dispose()
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
await existing.dispose()
|
|
|
|
|
await writeFile(preset.path, original)
|
2026-08-12 19:28:31 +08:00
|
|
|
await productCtx.fiber.dispose()
|
2026-08-10 12:45:05 +08:00
|
|
|
}
|
2026-08-12 19:28:31 +08:00
|
|
|
}, 120_000)
|
2026-08-10 12:45:05 +08:00
|
|
|
})
|
|
|
|
|
|
fix(web): record which preset a session actually runs
The creation header names the preset a session STARTED with and is frozen,
which is correct — it is a creation fact. Switching is legal only while a
session is blank, and that looked like enough: no history exists yet.
It is not, because the switch's effect outlives the blank window. The user
switches, then sends the first message; every turn from there runs under the
new composition while the header still names the old one. The session is
then locked around a misrecorded preset, and resume reads the header to
rebuild it — composing one preset's tools over a history another produced,
which is exactly the replay the blank-only lock exists to prevent, reached
by another route. A picker showed `standard` for a session running
`core-web`.
A switch is now an `agent-preset/selected` event appended after the swap
commits, and `resolveSessionPreset()` (last selection, else the header) is
what every reconstruction reads: the summary, resume, the conflict guard,
and the fork introduced one layer down.
2026-08-05 15:46:44 +08:00
|
|
|
describe('a switch survives the session', () => {
|
|
|
|
|
it('records the choice so the log states what the agent runs', async () => {
|
|
|
|
|
const handle = await ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('preset-switch-logged'),
|
|
|
|
|
meta: { agentPreset: 'standard' },
|
|
|
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
|
|
|
|
})
|
|
|
|
|
try {
|
|
|
|
|
// The api-proxy's select does exactly this pair while the session is blank.
|
2026-08-24 09:18:48 +08:00
|
|
|
expect(ctx.commands.find(handle.agent, 'goal')).toBeDefined()
|
2026-08-05 16:40:03 +08:00
|
|
|
await ctx.agentPresets.recompose(handle.agent.ctx, 'minimal')
|
|
|
|
|
handle.agent.session.append('agent-preset/selected', { agentPreset: 'minimal' })
|
2026-08-24 09:18:48 +08:00
|
|
|
expect(ctx.commands.find(handle.agent, 'goal')).toBeUndefined()
|
fix(web): record which preset a session actually runs
The creation header names the preset a session STARTED with and is frozen,
which is correct — it is a creation fact. Switching is legal only while a
session is blank, and that looked like enough: no history exists yet.
It is not, because the switch's effect outlives the blank window. The user
switches, then sends the first message; every turn from there runs under the
new composition while the header still names the old one. The session is
then locked around a misrecorded preset, and resume reads the header to
rebuild it — composing one preset's tools over a history another produced,
which is exactly the replay the blank-only lock exists to prevent, reached
by another route. A picker showed `standard` for a session running
`core-web`.
A switch is now an `agent-preset/selected` event appended after the swap
commits, and `resolveSessionPreset()` (last selection, else the header) is
what every reconstruction reads: the summary, resume, the conflict guard,
and the fork introduced one layer down.
2026-08-05 15:46:44 +08:00
|
|
|
|
|
|
|
|
// The header keeps the creation fact; the log carries what it runs.
|
|
|
|
|
expect(handle.agent.session.header.agentPreset).toBe('standard')
|
2026-08-25 06:08:14 +08:00
|
|
|
expect(ctx.sessionProjections.stateOf(handle.agent.session, 'agentPreset')).toBe('minimal')
|
fix(web): record which preset a session actually runs
The creation header names the preset a session STARTED with and is frozen,
which is correct — it is a creation fact. Switching is legal only while a
session is blank, and that looked like enough: no history exists yet.
It is not, because the switch's effect outlives the blank window. The user
switches, then sends the first message; every turn from there runs under the
new composition while the header still names the old one. The session is
then locked around a misrecorded preset, and resume reads the header to
rebuild it — composing one preset's tools over a history another produced,
which is exactly the replay the blank-only lock exists to prevent, reached
by another route. A picker showed `standard` for a session running
`core-web`.
A switch is now an `agent-preset/selected` event appended after the swap
commits, and `resolveSessionPreset()` (last selection, else the header) is
what every reconstruction reads: the summary, resume, the conflict guard,
and the fork introduced one layer down.
2026-08-05 15:46:44 +08:00
|
|
|
} finally {
|
|
|
|
|
await handle.dispose()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-05 15:40:32 +08:00
|
|
|
describe('a forked session', () => {
|
|
|
|
|
it('inherits the composition its seeded history was produced under', async () => {
|
|
|
|
|
const parent = await ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('preset-fork-parent'),
|
2026-08-05 16:40:03 +08:00
|
|
|
meta: { agentPreset: 'minimal' },
|
|
|
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
|
2026-08-05 15:40:32 +08:00
|
|
|
})
|
2026-08-25 06:08:14 +08:00
|
|
|
const inherited = ctx.sessionProjections.stateOf(parent.agent.session, 'agentPreset') ?? undefined
|
2026-08-05 15:40:32 +08:00
|
|
|
const child = await ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('preset-fork-child'),
|
|
|
|
|
meta: {
|
|
|
|
|
parentSession: SessionId('preset-fork-parent'),
|
|
|
|
|
seedLength: 0,
|
|
|
|
|
...inherited === undefined ? {} : { agentPreset: inherited },
|
|
|
|
|
},
|
|
|
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, inherited).then(() => undefined),
|
|
|
|
|
})
|
|
|
|
|
try {
|
|
|
|
|
// Composing nothing would leave the child empty: this layer moved every
|
|
|
|
|
// model-facing row out of the host plane, so there is nothing to inherit
|
|
|
|
|
// for free any more.
|
|
|
|
|
expect(toolNames(ctx, child.agent)).toEqual(toolNames(ctx, parent.agent))
|
|
|
|
|
expect(toolNames(ctx, child.agent).length).toBeGreaterThan(0)
|
|
|
|
|
} finally {
|
|
|
|
|
await child.dispose()
|
|
|
|
|
await parent.dispose()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
fix(subagent): compose children from their parent's preset
Tool and prompt-section visibility is inherited along dsh-scope's parent
chain, and an agent's scope key is minted with no parent. Per-session agent
presets moved every model-facing row onto the agent plane and made
AgentPresets.mount() the one thing that binds that link, from the api-proxy's
session create, resume, and fork paths. The two in-process subagent drivers
installed only the per-child persona and tool filter, so a child's scope chain
had length one and its registry view resolved the global layer alone — which
is empty wherever a preset roster is composed. One-shot children reached the
model with no tools, continuable ones with only the host-plane `report`, and
neither carried its parent's persona, workspace context, or skill catalog.
AgentPresets.composeFrom() joins one agent to the standing composition another
already runs on. It is a bind, not a mount: the child gets its parent's exact
generation, so a composition edited since the parent started cannot fork it
onto another one, and it is synchronous, which is what lets a child creation
window use it. applyChildComposition() now takes the parent and performs the
join first, making a child composed without it unrepresentable at the call
sites. childSessionMeta() records the joined id so a cold read rebuilds the
composition the child actually ran under.
The audit that followed found two api-proxy readers on the wrong authority:
presenterScopeFor() and the live-agent branch of assertPresetUnchanged() both
read header.agentPreset, which goes stale the moment a blank session switches
preset. A switched session's cold transcript resolved presenters in the older
composition's layer and silently degraded to generic cards, and the gateway
refused to adopt a live session under the preset it actually runs while
accepting the one it left. Both now resolve through resolveSessionPreset(),
matching the resume branch fifteen lines above. The owning architecture Agent
Note carried the stale claim that the header records what a session runs; it
is corrected to name the header/log pair and its three readers.
Fixes #2165
2026-08-10 17:46:34 +08:00
|
|
|
describe('a delegated child', () => {
|
|
|
|
|
it('runs on the composition its parent runs on', async () => {
|
|
|
|
|
const parent = await ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('preset-child-parent'),
|
|
|
|
|
meta: { agentPreset: 'standard' },
|
|
|
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
|
|
|
|
})
|
|
|
|
|
// Exactly what an in-process subagent driver's creation window does.
|
|
|
|
|
const child = await parent.agent.ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('preset-child'),
|
|
|
|
|
meta: childSessionMeta(parent.agent, 1, 0),
|
|
|
|
|
setup: (agentCtx) => {
|
|
|
|
|
applyChildComposition(agentCtx, parent.agent, {})
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
try {
|
|
|
|
|
expect(toolNames(ctx, child.agent)).toEqual(toolNames(ctx, parent.agent))
|
|
|
|
|
// The shipped `standard` preset is the whole coding agent; an empty
|
|
|
|
|
// child here is the defect, and equality alone would not catch it.
|
|
|
|
|
expect(toolNames(ctx, child.agent)).toContain('bash')
|
|
|
|
|
expect(child.agent.session.header.agentPreset).toBe('standard')
|
|
|
|
|
} finally {
|
|
|
|
|
await child.dispose()
|
|
|
|
|
await parent.dispose()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('follows a parent that switched preset while blank', async () => {
|
|
|
|
|
const parent = await ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('preset-child-switch-parent'),
|
|
|
|
|
meta: { agentPreset: 'standard' },
|
|
|
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
|
|
|
|
})
|
|
|
|
|
await ctx.agentPresets.recompose(parent.agent.ctx, 'minimal')
|
|
|
|
|
const child = await parent.agent.ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('preset-child-switch'),
|
|
|
|
|
meta: childSessionMeta(parent.agent, 1, 0),
|
|
|
|
|
setup: (agentCtx) => {
|
|
|
|
|
applyChildComposition(agentCtx, parent.agent, {})
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
try {
|
|
|
|
|
// The live scope chain is the authority, not the parent's creation
|
|
|
|
|
// header — which still names `standard`.
|
|
|
|
|
expect(toolNames(ctx, child.agent)).toEqual(toolNames(ctx, parent.agent))
|
|
|
|
|
expect(child.agent.session.header.agentPreset).toBe('minimal')
|
|
|
|
|
} finally {
|
|
|
|
|
await child.dispose()
|
|
|
|
|
await parent.dispose()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-11 21:18:37 +08:00
|
|
|
describe('a launcher that configures no writable root', () => {
|
|
|
|
|
// The claim this default exists for, asserted through the real shipped
|
|
|
|
|
// bundles rather than a hand-built context: `apps/cli` patches in only the
|
|
|
|
|
// system root, and a person's own presets are found anyway because the
|
|
|
|
|
// roster derives `<dshHome>/.agent-presets` itself. `$DSH_HOME` is pointed
|
|
|
|
|
// at a temp home BEFORE boot — the derived root is resolved when the plugin
|
|
|
|
|
// is constructed, and an unpinned run would read the developer's own.
|
|
|
|
|
let derivedCtx: Context
|
|
|
|
|
let previousHome: string | undefined
|
|
|
|
|
|
|
|
|
|
beforeAll(async () => {
|
|
|
|
|
const home = await mkdtemp(join(tmpdir(), 'dsh-preset-derived-'))
|
|
|
|
|
previousHome = process.env.DSH_HOME
|
|
|
|
|
process.env.DSH_HOME = home
|
|
|
|
|
await mkdir(join(home, '.agent-presets', 'derived-mine'), { recursive: true })
|
|
|
|
|
await writeFile(
|
|
|
|
|
join(home, '.agent-presets', 'derived-mine', 'agent.cordis.yml'),
|
|
|
|
|
'- id: tool-todo\n name: \'@deepseek-ai/dsh-tool-todo\'\n config:\n allowParallelInProgress: true\n',
|
|
|
|
|
)
|
|
|
|
|
const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-preset-derived-settings-')), 'settings.yaml')
|
|
|
|
|
await writeFile(settingsFile, '{}\n')
|
refactor(preset): bundle the shipped presets inside dsh-agent-presets
Review asked why the launcher special-cases one plugin's row. It no
longer does: the four shipped compositions move into the package
(presets/, in files), dsh-agent-presets resolves its own shipped root
and prepends it before configured roots (includeShippedRoot, default
true, opt-out for bare-machinery embedders), and the per-composition
derived patch, its spec, and the dump layer are deleted — profile-boot
and dump-config return to plain layer stacking. The always-load
guarantee now rides the schema default instead of patch ordering, so a
whole-config replacement keeps the shipped set and the squash, reload
freeze, and dump divergence stop being possible.
Gate globs, the web scaffold, and both preset browser lanes drop their
hand-fed shipped roots; the roster e2e keeps asserting configured roots
beside the shipped four against the built lib.
Fixes #2863.
2026-08-21 12:37:57 +08:00
|
|
|
// No configured roots: the shipped one is the plugin's own, and the
|
2026-08-11 21:18:37 +08:00
|
|
|
// writable one is the roster's own default rather than this patch's job.
|
|
|
|
|
derivedCtx = await bootWeb(settingsFile, [{
|
|
|
|
|
id: 'agent-presets',
|
2026-08-20 22:57:58 +08:00
|
|
|
config: { default: 'standard', includeUserRoot: true },
|
2026-08-11 21:18:37 +08:00
|
|
|
}])
|
|
|
|
|
}, 120_000)
|
|
|
|
|
|
|
|
|
|
afterAll(async () => {
|
|
|
|
|
if (previousHome === undefined) delete process.env.DSH_HOME
|
|
|
|
|
else process.env.DSH_HOME = previousHome
|
|
|
|
|
await derivedCtx.fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('discovers and mounts a preset the person authored under the harness home', async () => {
|
|
|
|
|
const listed = await derivedCtx.agentPresets.list()
|
|
|
|
|
|
|
|
|
|
const mine = listed.find(preset => preset.id === 'derived-mine')
|
|
|
|
|
expect(mine).toMatchObject({ trust: 'user' })
|
|
|
|
|
// Omitted rather than undefined: a healthy row carries no `broken` key.
|
|
|
|
|
expect(mine?.broken).toBeUndefined()
|
|
|
|
|
expect(derivedCtx.agentPresets.authorable).toBe(true)
|
|
|
|
|
|
|
|
|
|
const handle = await derivedCtx.agents.create({
|
|
|
|
|
sessionId: SessionId('preset-derived-root'),
|
|
|
|
|
setup: agentCtx => derivedCtx.agentPresets.mount(agentCtx, 'derived-mine').then(() => undefined),
|
|
|
|
|
})
|
|
|
|
|
try {
|
|
|
|
|
expect(toolNames(derivedCtx, handle.agent)).toContain('todo_write')
|
|
|
|
|
} finally {
|
|
|
|
|
await handle.dispose()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
feat(web): author agent presets from a settings page
A composition is a file, but "edit it on the filesystem" is not a browser
affordance. The roster gains `read`/`write`/`remove` beside `select`, and
the browser gains a settings section over them: the presets as rows, one
composition open in a YAML editor at a time, and per-row default, duplicate,
and delete.
All four authoring methods are loopback-pinned. A composition names the
plugins a session runs, so reading one is reconnaissance, writing one is
arbitrary capability, and selecting one can move a session onto a preset
that edits the live runtime. `agentPreset.list` deliberately stays ordinary
and now reports `authorable`, so a surface knows whether creating is
possible at all rather than offering a button whose save always fails.
Authoring starts by duplicating: a shipped preset opens read-only because
the deployment's copy is what a broken local one is compared against. Ids
are contained before they become directory names, and the text is parsed
with the loader's own schema, so a save cannot leave a file no session
could load.
Fixes a defect the real-composition test found: a preset written under the
user's home could never mount, because the loader resolves a row against the
composition's own directory and Node's `node_modules` walk from there never
reaches the installed harness. The mount now records the host base and sends
bare specifiers there, leaving relative paths resolving from the preset.
Also closes the coverage the earlier surfaces in this stack shipped without —
the General row, the composer seat, and the plugin halves now have tests.
2026-08-04 12:23:40 +08:00
|
|
|
describe('authoring a preset on the shipped composition', () => {
|
|
|
|
|
let authorCtx: Context
|
|
|
|
|
let userRoot: string
|
|
|
|
|
|
|
|
|
|
beforeAll(async () => {
|
fix(cli): boot the composition test the way the profile boot now does
The shipped surface stopped being two yml files: `base.cordis.yml` and
`web.cordis.yml` are bundle patch layers now, applied over an empty preset
root. This test still opened the old paths, so it failed before asserting
anything. It composes the same two layers the profile boot composes, over the
same empty root, and heals the flat module fallback the way the boot does —
the root lives outside this workspace, so bare plugin names have no other way
to resolve.
The web bundle's runtime row is disabled beside the webserver: it injects
`httpServer`, so a disabled port leaves it pending forever. It owns dist
serving and the URL prompt line, neither of which decides an agent's
capabilities.
`apps/cli` declares the packages the shipped agent presets name again. The
bundle split emptied its plugin dependencies, and the flat fallback links only
the app's dependency closure — so a preset row naming `dsh-persona` resolved
to nothing, and every preset mount failed. Which packages the shipped
presets compose is not implied by any bundle: the presets live beside this
app's config, so this app is what has to declare them.
2026-08-06 21:20:56 +08:00
|
|
|
userRoot = join(await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-')), 'profiles')
|
feat(web): author agent presets from a settings page
A composition is a file, but "edit it on the filesystem" is not a browser
affordance. The roster gains `read`/`write`/`remove` beside `select`, and
the browser gains a settings section over them: the presets as rows, one
composition open in a YAML editor at a time, and per-row default, duplicate,
and delete.
All four authoring methods are loopback-pinned. A composition names the
plugins a session runs, so reading one is reconnaissance, writing one is
arbitrary capability, and selecting one can move a session onto a preset
that edits the live runtime. `agentPreset.list` deliberately stays ordinary
and now reports `authorable`, so a surface knows whether creating is
possible at all rather than offering a button whose save always fails.
Authoring starts by duplicating: a shipped preset opens read-only because
the deployment's copy is what a broken local one is compared against. Ids
are contained before they become directory names, and the text is parsed
with the loader's own schema, so a save cannot leave a file no session
could load.
Fixes a defect the real-composition test found: a preset written under the
user's home could never mount, because the loader resolves a row against the
composition's own directory and Node's `node_modules` walk from there never
reaches the installed harness. The mount now records the host base and sends
bare specifiers there, leaving relative paths resolving from the preset.
Also closes the coverage the earlier surfaces in this stack shipped without —
the General row, the composer seat, and the plugin halves now have tests.
2026-08-04 12:23:40 +08:00
|
|
|
const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-settings-')), 'settings.yaml')
|
|
|
|
|
await writeFile(settingsFile, '{}\n')
|
|
|
|
|
authorCtx = await bootWeb(settingsFile, [{
|
|
|
|
|
id: 'agent-presets',
|
|
|
|
|
config: {
|
|
|
|
|
default: 'standard',
|
2026-08-20 22:57:58 +08:00
|
|
|
// The root does not exist yet: a deployment whose user has authored
|
refactor(preset): bundle the shipped presets inside dsh-agent-presets
Review asked why the launcher special-cases one plugin's row. It no
longer does: the four shipped compositions move into the package
(presets/, in files), dsh-agent-presets resolves its own shipped root
and prepends it before configured roots (includeShippedRoot, default
true, opt-out for bare-machinery embedders), and the per-composition
derived patch, its spec, and the dump layer are deleted — profile-boot
and dump-config return to plain layer stacking. The always-load
guarantee now rides the schema default instead of patch ordering, so a
whole-config replacement keeps the shipped set and the squash, reload
freeze, and dump divergence stop being possible.
Gate globs, the web scaffold, and both preset browser lanes drop their
hand-fed shipped roots; the roster e2e keeps asserting configured roots
beside the shipped four against the built lib.
Fixes #2863.
2026-08-21 12:37:57 +08:00
|
|
|
// nothing is the normal first-run state. The shipped root is the
|
|
|
|
|
// plugin's own, prepended before this.
|
2026-08-20 22:57:58 +08:00
|
|
|
roots: [{ path: userRoot, trust: 'user' }],
|
2026-08-11 20:20:16 +08:00
|
|
|
includeUserRoot: false,
|
feat(web): author agent presets from a settings page
A composition is a file, but "edit it on the filesystem" is not a browser
affordance. The roster gains `read`/`write`/`remove` beside `select`, and
the browser gains a settings section over them: the presets as rows, one
composition open in a YAML editor at a time, and per-row default, duplicate,
and delete.
All four authoring methods are loopback-pinned. A composition names the
plugins a session runs, so reading one is reconnaissance, writing one is
arbitrary capability, and selecting one can move a session onto a preset
that edits the live runtime. `agentPreset.list` deliberately stays ordinary
and now reports `authorable`, so a surface knows whether creating is
possible at all rather than offering a button whose save always fails.
Authoring starts by duplicating: a shipped preset opens read-only because
the deployment's copy is what a broken local one is compared against. Ids
are contained before they become directory names, and the text is parsed
with the loader's own schema, so a save cannot leave a file no session
could load.
Fixes a defect the real-composition test found: a preset written under the
user's home could never mount, because the loader resolves a row against the
composition's own directory and Node's `node_modules` walk from there never
reaches the installed harness. The mount now records the host base and sends
bare specifiers there, leaving relative paths resolving from the preset.
Also closes the coverage the earlier surfaces in this stack shipped without —
the General row, the composer seat, and the plugin halves now have tests.
2026-08-04 12:23:40 +08:00
|
|
|
},
|
|
|
|
|
}])
|
|
|
|
|
})
|
|
|
|
|
|
refactor(agent-presets,web): copy-only preset authoring with a path to the files
The web YAML editor is gone. agentPreset.write (arbitrary composition
text) became agentPreset.copy { from, agentPreset, name? }: a host-side
whole-directory copy of ids the host resolves itself — symlinks
dereferenced, modes re-tightened to owner-only with owner-execute kept,
metadata rewritten to keep the source's description but never its name or
roster order. No composition text or path crosses the wire in either
authoring direction, and the entryListSchema/!!js concern dissolves with
assertComposition itself.
The settings section becomes: a read-only viewer over shipped
compositions, a copy dialog (id + optional display name) as the only
create entry, delete for custom rows, and a location action leading into
the preset's own files — agentPreset.openDocument { agentPreset } resolves
the directory host-side and opens it natively, or answers
{ opened: false, path } for the row to show as text where the deployment
has no desktop. agentPreset.list reports hasDocument beside authorable;
the gateway's nativeOpen config pins the capability where
canOpenNativePath platform detection would mislead. The privileged set is
now read/copy/openDocument/remove.
With files as the only composition editor, standing mounts grew
stamp-keyed generations: ensureStanding compares the composition file's
mtime+size and starts the next generation for later sessions, while every
joined session keeps the generation it runs on.
New keyless web lane (agent-preset-authoring, overlay pins
nativeOpen: false so goldens render one branch on every platform) drives
view/copy/reveal/delete end to end; the real-composition CLI e2e switches
to copy semantics.
2026-08-08 22:35:26 +08:00
|
|
|
it('refuses to copy over or delete a shipped preset', async () => {
|
|
|
|
|
await expect(authorCtx.agentPresets.copy('minimal', 'standard')).rejects.toThrow(/already exists/)
|
feat(web): author agent presets from a settings page
A composition is a file, but "edit it on the filesystem" is not a browser
affordance. The roster gains `read`/`write`/`remove` beside `select`, and
the browser gains a settings section over them: the presets as rows, one
composition open in a YAML editor at a time, and per-row default, duplicate,
and delete.
All four authoring methods are loopback-pinned. A composition names the
plugins a session runs, so reading one is reconnaissance, writing one is
arbitrary capability, and selecting one can move a session onto a preset
that edits the live runtime. `agentPreset.list` deliberately stays ordinary
and now reports `authorable`, so a surface knows whether creating is
possible at all rather than offering a button whose save always fails.
Authoring starts by duplicating: a shipped preset opens read-only because
the deployment's copy is what a broken local one is compared against. Ids
are contained before they become directory names, and the text is parsed
with the loader's own schema, so a save cannot leave a file no session
could load.
Fixes a defect the real-composition test found: a preset written under the
user's home could never mount, because the loader resolves a row against the
composition's own directory and Node's `node_modules` walk from there never
reaches the installed harness. The mount now records the host base and sends
bare specifiers there, leaving relative paths resolving from the preset.
Also closes the coverage the earlier surfaces in this stack shipped without —
the General row, the composer seat, and the plugin halves now have tests.
2026-08-04 12:23:40 +08:00
|
|
|
await expect(authorCtx.agentPresets.remove('standard')).rejects.toThrow(/ships with the deployment/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it.each(['../escape', 'a/b', '/abs', 'Upper'])('refuses the uncontainable id %j', async (id) => {
|
|
|
|
|
// The id becomes a directory name under the user root, so containment is
|
|
|
|
|
// checked on the id rather than on the joined path afterwards.
|
refactor(agent-presets,web): copy-only preset authoring with a path to the files
The web YAML editor is gone. agentPreset.write (arbitrary composition
text) became agentPreset.copy { from, agentPreset, name? }: a host-side
whole-directory copy of ids the host resolves itself — symlinks
dereferenced, modes re-tightened to owner-only with owner-execute kept,
metadata rewritten to keep the source's description but never its name or
roster order. No composition text or path crosses the wire in either
authoring direction, and the entryListSchema/!!js concern dissolves with
assertComposition itself.
The settings section becomes: a read-only viewer over shipped
compositions, a copy dialog (id + optional display name) as the only
create entry, delete for custom rows, and a location action leading into
the preset's own files — agentPreset.openDocument { agentPreset } resolves
the directory host-side and opens it natively, or answers
{ opened: false, path } for the row to show as text where the deployment
has no desktop. agentPreset.list reports hasDocument beside authorable;
the gateway's nativeOpen config pins the capability where
canOpenNativePath platform detection would mislead. The privileged set is
now read/copy/openDocument/remove.
With files as the only composition editor, standing mounts grew
stamp-keyed generations: ensureStanding compares the composition file's
mtime+size and starts the next generation for later sessions, while every
joined session keeps the generation it runs on.
New keyless web lane (agent-preset-authoring, overlay pins
nativeOpen: false so goldens render one branch on every platform) drives
view/copy/reveal/delete end to end; the real-composition CLI e2e switches
to copy semantics.
2026-08-08 22:35:26 +08:00
|
|
|
await expect(authorCtx.agentPresets.copy('minimal', id)).rejects.toThrow()
|
feat(web): author agent presets from a settings page
A composition is a file, but "edit it on the filesystem" is not a browser
affordance. The roster gains `read`/`write`/`remove` beside `select`, and
the browser gains a settings section over them: the presets as rows, one
composition open in a YAML editor at a time, and per-row default, duplicate,
and delete.
All four authoring methods are loopback-pinned. A composition names the
plugins a session runs, so reading one is reconnaissance, writing one is
arbitrary capability, and selecting one can move a session onto a preset
that edits the live runtime. `agentPreset.list` deliberately stays ordinary
and now reports `authorable`, so a surface knows whether creating is
possible at all rather than offering a button whose save always fails.
Authoring starts by duplicating: a shipped preset opens read-only because
the deployment's copy is what a broken local one is compared against. Ids
are contained before they become directory names, and the text is parsed
with the loader's own schema, so a save cannot leave a file no session
could load.
Fixes a defect the real-composition test found: a preset written under the
user's home could never mount, because the loader resolves a row against the
composition's own directory and Node's `node_modules` walk from there never
reaches the installed harness. The mount now records the host base and sends
bare specifiers there, leaving relative paths resolving from the preset.
Also closes the coverage the earlier surfaces in this stack shipped without —
the General row, the composer seat, and the plugin halves now have tests.
2026-08-04 12:23:40 +08:00
|
|
|
})
|
|
|
|
|
|
refactor(agent-presets,web): copy-only preset authoring with a path to the files
The web YAML editor is gone. agentPreset.write (arbitrary composition
text) became agentPreset.copy { from, agentPreset, name? }: a host-side
whole-directory copy of ids the host resolves itself — symlinks
dereferenced, modes re-tightened to owner-only with owner-execute kept,
metadata rewritten to keep the source's description but never its name or
roster order. No composition text or path crosses the wire in either
authoring direction, and the entryListSchema/!!js concern dissolves with
assertComposition itself.
The settings section becomes: a read-only viewer over shipped
compositions, a copy dialog (id + optional display name) as the only
create entry, delete for custom rows, and a location action leading into
the preset's own files — agentPreset.openDocument { agentPreset } resolves
the directory host-side and opens it natively, or answers
{ opened: false, path } for the row to show as text where the deployment
has no desktop. agentPreset.list reports hasDocument beside authorable;
the gateway's nativeOpen config pins the capability where
canOpenNativePath platform detection would mislead. The privileged set is
now read/copy/openDocument/remove.
With files as the only composition editor, standing mounts grew
stamp-keyed generations: ensureStanding compares the composition file's
mtime+size and starts the next generation for later sessions, while every
joined session keeps the generation it runs on.
New keyless web lane (agent-preset-authoring, overlay pins
nativeOpen: false so goldens render one branch on every platform) drives
view/copy/reveal/delete end to end; the real-composition CLI e2e switches
to copy semantics.
2026-08-08 22:35:26 +08:00
|
|
|
it('copies a shipped preset a session then really composes from', async () => {
|
|
|
|
|
await authorCtx.agentPresets.copy('minimal', 'my-agent', '我的模式')
|
feat(web): author agent presets from a settings page
A composition is a file, but "edit it on the filesystem" is not a browser
affordance. The roster gains `read`/`write`/`remove` beside `select`, and
the browser gains a settings section over them: the presets as rows, one
composition open in a YAML editor at a time, and per-row default, duplicate,
and delete.
All four authoring methods are loopback-pinned. A composition names the
plugins a session runs, so reading one is reconnaissance, writing one is
arbitrary capability, and selecting one can move a session onto a preset
that edits the live runtime. `agentPreset.list` deliberately stays ordinary
and now reports `authorable`, so a surface knows whether creating is
possible at all rather than offering a button whose save always fails.
Authoring starts by duplicating: a shipped preset opens read-only because
the deployment's copy is what a broken local one is compared against. Ids
are contained before they become directory names, and the text is parsed
with the loader's own schema, so a save cannot leave a file no session
could load.
Fixes a defect the real-composition test found: a preset written under the
user's home could never mount, because the loader resolves a row against the
composition's own directory and Node's `node_modules` walk from there never
reaches the installed harness. The mount now records the host base and sends
bare specifiers there, leaving relative paths resolving from the preset.
Also closes the coverage the earlier surfaces in this stack shipped without —
the General row, the composer seat, and the plugin halves now have tests.
2026-08-04 12:23:40 +08:00
|
|
|
|
refactor(agent-presets,web): copy-only preset authoring with a path to the files
The web YAML editor is gone. agentPreset.write (arbitrary composition
text) became agentPreset.copy { from, agentPreset, name? }: a host-side
whole-directory copy of ids the host resolves itself — symlinks
dereferenced, modes re-tightened to owner-only with owner-execute kept,
metadata rewritten to keep the source's description but never its name or
roster order. No composition text or path crosses the wire in either
authoring direction, and the entryListSchema/!!js concern dissolves with
assertComposition itself.
The settings section becomes: a read-only viewer over shipped
compositions, a copy dialog (id + optional display name) as the only
create entry, delete for custom rows, and a location action leading into
the preset's own files — agentPreset.openDocument { agentPreset } resolves
the directory host-side and opens it natively, or answers
{ opened: false, path } for the row to show as text where the deployment
has no desktop. agentPreset.list reports hasDocument beside authorable;
the gateway's nativeOpen config pins the capability where
canOpenNativePath platform detection would mislead. The privileged set is
now read/copy/openDocument/remove.
With files as the only composition editor, standing mounts grew
stamp-keyed generations: ensureStanding compares the composition file's
mtime+size and starts the next generation for later sessions, while every
joined session keeps the generation it runs on.
New keyless web lane (agent-preset-authoring, overlay pins
nativeOpen: false so goldens render one branch on every platform) drives
view/copy/reveal/delete end to end; the real-composition CLI e2e switches
to copy semantics.
2026-08-08 22:35:26 +08:00
|
|
|
// Round-trips through the roster as a `user` row carrying the given name
|
|
|
|
|
// and the source's description, over the source's own composition text.
|
feat(web): author agent presets from a settings page
A composition is a file, but "edit it on the filesystem" is not a browser
affordance. The roster gains `read`/`write`/`remove` beside `select`, and
the browser gains a settings section over them: the presets as rows, one
composition open in a YAML editor at a time, and per-row default, duplicate,
and delete.
All four authoring methods are loopback-pinned. A composition names the
plugins a session runs, so reading one is reconnaissance, writing one is
arbitrary capability, and selecting one can move a session onto a preset
that edits the live runtime. `agentPreset.list` deliberately stays ordinary
and now reports `authorable`, so a surface knows whether creating is
possible at all rather than offering a button whose save always fails.
Authoring starts by duplicating: a shipped preset opens read-only because
the deployment's copy is what a broken local one is compared against. Ids
are contained before they become directory names, and the text is parsed
with the loader's own schema, so a save cannot leave a file no session
could load.
Fixes a defect the real-composition test found: a preset written under the
user's home could never mount, because the loader resolves a row against the
composition's own directory and Node's `node_modules` walk from there never
reaches the installed harness. The mount now records the host base and sends
bare specifiers there, leaving relative paths resolving from the preset.
Also closes the coverage the earlier surfaces in this stack shipped without —
the General row, the composer seat, and the plugin halves now have tests.
2026-08-04 12:23:40 +08:00
|
|
|
const preset = await authorCtx.agentPresets.resolve('my-agent')
|
refactor(agent-presets,web): copy-only preset authoring with a path to the files
The web YAML editor is gone. agentPreset.write (arbitrary composition
text) became agentPreset.copy { from, agentPreset, name? }: a host-side
whole-directory copy of ids the host resolves itself — symlinks
dereferenced, modes re-tightened to owner-only with owner-execute kept,
metadata rewritten to keep the source's description but never its name or
roster order. No composition text or path crosses the wire in either
authoring direction, and the entryListSchema/!!js concern dissolves with
assertComposition itself.
The settings section becomes: a read-only viewer over shipped
compositions, a copy dialog (id + optional display name) as the only
create entry, delete for custom rows, and a location action leading into
the preset's own files — agentPreset.openDocument { agentPreset } resolves
the directory host-side and opens it natively, or answers
{ opened: false, path } for the row to show as text where the deployment
has no desktop. agentPreset.list reports hasDocument beside authorable;
the gateway's nativeOpen config pins the capability where
canOpenNativePath platform detection would mislead. The privileged set is
now read/copy/openDocument/remove.
With files as the only composition editor, standing mounts grew
stamp-keyed generations: ensureStanding compares the composition file's
mtime+size and starts the next generation for later sessions, while every
joined session keeps the generation it runs on.
New keyless web lane (agent-preset-authoring, overlay pins
nativeOpen: false so goldens render one branch on every platform) drives
view/copy/reveal/delete end to end; the real-composition CLI e2e switches
to copy semantics.
2026-08-08 22:35:26 +08:00
|
|
|
const source = await authorCtx.agentPresets.resolve('minimal')
|
feat(web): author agent presets from a settings page
A composition is a file, but "edit it on the filesystem" is not a browser
affordance. The roster gains `read`/`write`/`remove` beside `select`, and
the browser gains a settings section over them: the presets as rows, one
composition open in a YAML editor at a time, and per-row default, duplicate,
and delete.
All four authoring methods are loopback-pinned. A composition names the
plugins a session runs, so reading one is reconnaissance, writing one is
arbitrary capability, and selecting one can move a session onto a preset
that edits the live runtime. `agentPreset.list` deliberately stays ordinary
and now reports `authorable`, so a surface knows whether creating is
possible at all rather than offering a button whose save always fails.
Authoring starts by duplicating: a shipped preset opens read-only because
the deployment's copy is what a broken local one is compared against. Ids
are contained before they become directory names, and the text is parsed
with the loader's own schema, so a save cannot leave a file no session
could load.
Fixes a defect the real-composition test found: a preset written under the
user's home could never mount, because the loader resolves a row against the
composition's own directory and Node's `node_modules` walk from there never
reaches the installed harness. The mount now records the host base and sends
bare specifiers there, leaving relative paths resolving from the preset.
Also closes the coverage the earlier surfaces in this stack shipped without —
the General row, the composer seat, and the plugin halves now have tests.
2026-08-04 12:23:40 +08:00
|
|
|
expect(preset.trust).toBe('user')
|
refactor(agent-presets,web): copy-only preset authoring with a path to the files
The web YAML editor is gone. agentPreset.write (arbitrary composition
text) became agentPreset.copy { from, agentPreset, name? }: a host-side
whole-directory copy of ids the host resolves itself — symlinks
dereferenced, modes re-tightened to owner-only with owner-execute kept,
metadata rewritten to keep the source's description but never its name or
roster order. No composition text or path crosses the wire in either
authoring direction, and the entryListSchema/!!js concern dissolves with
assertComposition itself.
The settings section becomes: a read-only viewer over shipped
compositions, a copy dialog (id + optional display name) as the only
create entry, delete for custom rows, and a location action leading into
the preset's own files — agentPreset.openDocument { agentPreset } resolves
the directory host-side and opens it natively, or answers
{ opened: false, path } for the row to show as text where the deployment
has no desktop. agentPreset.list reports hasDocument beside authorable;
the gateway's nativeOpen config pins the capability where
canOpenNativePath platform detection would mislead. The privileged set is
now read/copy/openDocument/remove.
With files as the only composition editor, standing mounts grew
stamp-keyed generations: ensureStanding compares the composition file's
mtime+size and starts the next generation for later sessions, while every
joined session keeps the generation it runs on.
New keyless web lane (agent-preset-authoring, overlay pins
nativeOpen: false so goldens render one branch on every platform) drives
view/copy/reveal/delete end to end; the real-composition CLI e2e switches
to copy semantics.
2026-08-08 22:35:26 +08:00
|
|
|
expect(preset.name).toBe('我的模式')
|
|
|
|
|
expect(preset.description).toBe(source.description)
|
|
|
|
|
expect(await authorCtx.agentPresets.read('my-agent')).toBe(await authorCtx.agentPresets.read('minimal'))
|
feat(web): author agent presets from a settings page
A composition is a file, but "edit it on the filesystem" is not a browser
affordance. The roster gains `read`/`write`/`remove` beside `select`, and
the browser gains a settings section over them: the presets as rows, one
composition open in a YAML editor at a time, and per-row default, duplicate,
and delete.
All four authoring methods are loopback-pinned. A composition names the
plugins a session runs, so reading one is reconnaissance, writing one is
arbitrary capability, and selecting one can move a session onto a preset
that edits the live runtime. `agentPreset.list` deliberately stays ordinary
and now reports `authorable`, so a surface knows whether creating is
possible at all rather than offering a button whose save always fails.
Authoring starts by duplicating: a shipped preset opens read-only because
the deployment's copy is what a broken local one is compared against. Ids
are contained before they become directory names, and the text is parsed
with the loader's own schema, so a save cannot leave a file no session
could load.
Fixes a defect the real-composition test found: a preset written under the
user's home could never mount, because the loader resolves a row against the
composition's own directory and Node's `node_modules` walk from there never
reaches the installed harness. The mount now records the host base and sends
bare specifiers there, leaving relative paths resolving from the preset.
Also closes the coverage the earlier surfaces in this stack shipped without —
the General row, the composer seat, and the plugin halves now have tests.
2026-08-04 12:23:40 +08:00
|
|
|
// Owner-only, in an owner-only directory: a composition is executable
|
|
|
|
|
// configuration on a machine that may have other users.
|
|
|
|
|
expect((await stat(preset.path)).mode & 0o777).toBe(0o600)
|
|
|
|
|
const handle = await authorCtx.agents.create({
|
|
|
|
|
sessionId: SessionId('preset-authored'),
|
|
|
|
|
setup: agentCtx => authorCtx.agentPresets.mount(agentCtx, 'my-agent').then(() => undefined),
|
|
|
|
|
})
|
|
|
|
|
try {
|
refactor(agent-presets,web): copy-only preset authoring with a path to the files
The web YAML editor is gone. agentPreset.write (arbitrary composition
text) became agentPreset.copy { from, agentPreset, name? }: a host-side
whole-directory copy of ids the host resolves itself — symlinks
dereferenced, modes re-tightened to owner-only with owner-execute kept,
metadata rewritten to keep the source's description but never its name or
roster order. No composition text or path crosses the wire in either
authoring direction, and the entryListSchema/!!js concern dissolves with
assertComposition itself.
The settings section becomes: a read-only viewer over shipped
compositions, a copy dialog (id + optional display name) as the only
create entry, delete for custom rows, and a location action leading into
the preset's own files — agentPreset.openDocument { agentPreset } resolves
the directory host-side and opens it natively, or answers
{ opened: false, path } for the row to show as text where the deployment
has no desktop. agentPreset.list reports hasDocument beside authorable;
the gateway's nativeOpen config pins the capability where
canOpenNativePath platform detection would mislead. The privileged set is
now read/copy/openDocument/remove.
With files as the only composition editor, standing mounts grew
stamp-keyed generations: ensureStanding compares the composition file's
mtime+size and starts the next generation for later sessions, while every
joined session keeps the generation it runs on.
New keyless web lane (agent-preset-authoring, overlay pins
nativeOpen: false so goldens render one branch on every platform) drives
view/copy/reveal/delete end to end; the real-composition CLI e2e switches
to copy semantics.
2026-08-08 22:35:26 +08:00
|
|
|
// The same tools the shipped `minimal` composes, from a directory copied
|
feat(web): author agent presets from a settings page
A composition is a file, but "edit it on the filesystem" is not a browser
affordance. The roster gains `read`/`write`/`remove` beside `select`, and
the browser gains a settings section over them: the presets as rows, one
composition open in a YAML editor at a time, and per-row default, duplicate,
and delete.
All four authoring methods are loopback-pinned. A composition names the
plugins a session runs, so reading one is reconnaissance, writing one is
arbitrary capability, and selecting one can move a session onto a preset
that edits the live runtime. `agentPreset.list` deliberately stays ordinary
and now reports `authorable`, so a surface knows whether creating is
possible at all rather than offering a button whose save always fails.
Authoring starts by duplicating: a shipped preset opens read-only because
the deployment's copy is what a broken local one is compared against. Ids
are contained before they become directory names, and the text is parsed
with the loader's own schema, so a save cannot leave a file no session
could load.
Fixes a defect the real-composition test found: a preset written under the
user's home could never mount, because the loader resolves a row against the
composition's own directory and Node's `node_modules` walk from there never
reaches the installed harness. The mount now records the host base and sends
bare specifiers there, leaving relative paths resolving from the preset.
Also closes the coverage the earlier surfaces in this stack shipped without —
the General row, the composer seat, and the plugin halves now have tests.
2026-08-04 12:23:40 +08:00
|
|
|
// through the service into a root outside the installed harness.
|
2026-08-04 13:10:21 +08:00
|
|
|
expect(toolNames(authorCtx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
|
feat(web): author agent presets from a settings page
A composition is a file, but "edit it on the filesystem" is not a browser
affordance. The roster gains `read`/`write`/`remove` beside `select`, and
the browser gains a settings section over them: the presets as rows, one
composition open in a YAML editor at a time, and per-row default, duplicate,
and delete.
All four authoring methods are loopback-pinned. A composition names the
plugins a session runs, so reading one is reconnaissance, writing one is
arbitrary capability, and selecting one can move a session onto a preset
that edits the live runtime. `agentPreset.list` deliberately stays ordinary
and now reports `authorable`, so a surface knows whether creating is
possible at all rather than offering a button whose save always fails.
Authoring starts by duplicating: a shipped preset opens read-only because
the deployment's copy is what a broken local one is compared against. Ids
are contained before they become directory names, and the text is parsed
with the loader's own schema, so a save cannot leave a file no session
could load.
Fixes a defect the real-composition test found: a preset written under the
user's home could never mount, because the loader resolves a row against the
composition's own directory and Node's `node_modules` walk from there never
reaches the installed harness. The mount now records the host base and sends
bare specifiers there, leaving relative paths resolving from the preset.
Also closes the coverage the earlier surfaces in this stack shipped without —
the General row, the composer seat, and the plugin halves now have tests.
2026-08-04 12:23:40 +08:00
|
|
|
} finally {
|
|
|
|
|
await handle.dispose()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
refactor(agent-presets,web): copy-only preset authoring with a path to the files
The web YAML editor is gone. agentPreset.write (arbitrary composition
text) became agentPreset.copy { from, agentPreset, name? }: a host-side
whole-directory copy of ids the host resolves itself — symlinks
dereferenced, modes re-tightened to owner-only with owner-execute kept,
metadata rewritten to keep the source's description but never its name or
roster order. No composition text or path crosses the wire in either
authoring direction, and the entryListSchema/!!js concern dissolves with
assertComposition itself.
The settings section becomes: a read-only viewer over shipped
compositions, a copy dialog (id + optional display name) as the only
create entry, delete for custom rows, and a location action leading into
the preset's own files — agentPreset.openDocument { agentPreset } resolves
the directory host-side and opens it natively, or answers
{ opened: false, path } for the row to show as text where the deployment
has no desktop. agentPreset.list reports hasDocument beside authorable;
the gateway's nativeOpen config pins the capability where
canOpenNativePath platform detection would mislead. The privileged set is
now read/copy/openDocument/remove.
With files as the only composition editor, standing mounts grew
stamp-keyed generations: ensureStanding compares the composition file's
mtime+size and starts the next generation for later sessions, while every
joined session keeps the generation it runs on.
New keyless web lane (agent-preset-authoring, overlay pins
nativeOpen: false so goldens render one branch on every platform) drives
view/copy/reveal/delete end to end; the real-composition CLI e2e switches
to copy semantics.
2026-08-08 22:35:26 +08:00
|
|
|
it('deletes what it copied', async () => {
|
|
|
|
|
await authorCtx.agentPresets.copy('minimal', 'doomed')
|
feat(web): author agent presets from a settings page
A composition is a file, but "edit it on the filesystem" is not a browser
affordance. The roster gains `read`/`write`/`remove` beside `select`, and
the browser gains a settings section over them: the presets as rows, one
composition open in a YAML editor at a time, and per-row default, duplicate,
and delete.
All four authoring methods are loopback-pinned. A composition names the
plugins a session runs, so reading one is reconnaissance, writing one is
arbitrary capability, and selecting one can move a session onto a preset
that edits the live runtime. `agentPreset.list` deliberately stays ordinary
and now reports `authorable`, so a surface knows whether creating is
possible at all rather than offering a button whose save always fails.
Authoring starts by duplicating: a shipped preset opens read-only because
the deployment's copy is what a broken local one is compared against. Ids
are contained before they become directory names, and the text is parsed
with the loader's own schema, so a save cannot leave a file no session
could load.
Fixes a defect the real-composition test found: a preset written under the
user's home could never mount, because the loader resolves a row against the
composition's own directory and Node's `node_modules` walk from there never
reaches the installed harness. The mount now records the host base and sends
bare specifiers there, leaving relative paths resolving from the preset.
Also closes the coverage the earlier surfaces in this stack shipped without —
the General row, the composer seat, and the plugin halves now have tests.
2026-08-04 12:23:40 +08:00
|
|
|
|
|
|
|
|
await authorCtx.agentPresets.remove('doomed')
|
|
|
|
|
|
|
|
|
|
expect((await authorCtx.agentPresets.list()).map(preset => preset.id)).not.toContain('doomed')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
feat(agent-presets): make the default preset a user setting
`config.default` becomes the composition base of an `agent-presets` settings
namespace, so the user document layers over the deployment's engineering
default and a person can change which preset new sessions get without a
restart.
The value is read per resolution rather than snapshotted: a hot-reloaded
document takes effect on the next session created, and every running session
stays on the preset it was composed from — which is the same rule the
session-header guard enforces from the other side.
`resolve()` read `config.default` directly, which would have made the whole
setting inert; it now goes through `defaultId` like every other caller.
The write-protection test is rewritten against a temp profile root. It was
passing vacuously: the un-overridden Loader REWRITES the composition it read —
stamping `disabled: true` onto the self-disposing row — so the committed
fixture had been mutated by the very run that proved the bug, and every later
run compared against the damaged file and passed. Building the preset in a
temp directory makes the assertion immune to its own failure mode, and it now
fails with a visible `+ disabled: true` when the override is removed.
Review follow-ups on this layer. The exported schema is
`AgentPresetSettingsSchema`, symmetric with the `AgentPresetSettings`
interface it resolves and self-describing at an import site. The `session.create`
JSDoc promised "the deployment's default preset" for an omitted `agentPreset`,
which this layer makes false — it now names the effective default. The
constructor records why it does not use `installSettingsSection`: that helper
re-judges what a consumer DERIVED across attach and detach, and nothing here is
derived. The provider-unload test disposes the fiber `ctx.plugin()` handed back
instead of reaching into `ctx.reflect.store`, and the write-protection wait says
why slack is the right shape for an absence assertion.
The real composition covers the layering too. `apps/cli` boots the shipped
`cordis.yml`, stores `agent-presets.default`, and asserts an unnamed session
composes from it — the package suite proves the layering against a hand-built
context, this proves the roster and the settings provider are wired to each
other. That test also pins the settings row at a temp file: it defaulted to
`$DSH_HOME/settings.yaml`, so a developer's own stored default decided the
outcome of a file whose whole point is that only the shipped root does.
The Agent Note records the per-resolution read and its correspondence with the
session header, and the vacuous-test finding above.
2026-08-04 00:07:11 +08:00
|
|
|
/**
|
|
|
|
|
* Which preset an unnamed session gets is a user setting layered over the
|
|
|
|
|
* composition's own default. The package suite proves the layering against a
|
|
|
|
|
* hand-built context; this proves it through the shipped `cordis.yml` — that
|
|
|
|
|
* the roster and the settings provider are actually wired to each other, and
|
|
|
|
|
* that the id the setting names is the one a session composes from.
|
|
|
|
|
*/
|
|
|
|
|
describe('the default preset as a user setting', () => {
|
|
|
|
|
it('composes an unnamed session from the stored default, not the composed one', async () => {
|
|
|
|
|
expect(ctx.agentPresets.defaultId).toBe('standard')
|
|
|
|
|
|
2026-08-05 16:40:03 +08:00
|
|
|
await ctx.settings.update(settingsNamespace(SETTINGS_NAMESPACE), { default: 'minimal' })
|
feat(agent-presets): make the default preset a user setting
`config.default` becomes the composition base of an `agent-presets` settings
namespace, so the user document layers over the deployment's engineering
default and a person can change which preset new sessions get without a
restart.
The value is read per resolution rather than snapshotted: a hot-reloaded
document takes effect on the next session created, and every running session
stays on the preset it was composed from — which is the same rule the
session-header guard enforces from the other side.
`resolve()` read `config.default` directly, which would have made the whole
setting inert; it now goes through `defaultId` like every other caller.
The write-protection test is rewritten against a temp profile root. It was
passing vacuously: the un-overridden Loader REWRITES the composition it read —
stamping `disabled: true` onto the self-disposing row — so the committed
fixture had been mutated by the very run that proved the bug, and every later
run compared against the damaged file and passed. Building the preset in a
temp directory makes the assertion immune to its own failure mode, and it now
fails with a visible `+ disabled: true` when the override is removed.
Review follow-ups on this layer. The exported schema is
`AgentPresetSettingsSchema`, symmetric with the `AgentPresetSettings`
interface it resolves and self-describing at an import site. The `session.create`
JSDoc promised "the deployment's default preset" for an omitted `agentPreset`,
which this layer makes false — it now names the effective default. The
constructor records why it does not use `installSettingsSection`: that helper
re-judges what a consumer DERIVED across attach and detach, and nothing here is
derived. The provider-unload test disposes the fiber `ctx.plugin()` handed back
instead of reaching into `ctx.reflect.store`, and the write-protection wait says
why slack is the right shape for an absence assertion.
The real composition covers the layering too. `apps/cli` boots the shipped
`cordis.yml`, stores `agent-presets.default`, and asserts an unnamed session
composes from it — the package suite proves the layering against a hand-built
context, this proves the roster and the settings provider are wired to each
other. That test also pins the settings row at a temp file: it defaulted to
`$DSH_HOME/settings.yaml`, so a developer's own stored default decided the
outcome of a file whose whole point is that only the shipped root does.
The Agent Note records the per-resolution read and its correspondence with the
session header, and the vacuous-test finding above.
2026-08-04 00:07:11 +08:00
|
|
|
try {
|
2026-08-05 16:40:03 +08:00
|
|
|
expect(ctx.agentPresets.defaultId).toBe('minimal')
|
feat(agent-presets): make the default preset a user setting
`config.default` becomes the composition base of an `agent-presets` settings
namespace, so the user document layers over the deployment's engineering
default and a person can change which preset new sessions get without a
restart.
The value is read per resolution rather than snapshotted: a hot-reloaded
document takes effect on the next session created, and every running session
stays on the preset it was composed from — which is the same rule the
session-header guard enforces from the other side.
`resolve()` read `config.default` directly, which would have made the whole
setting inert; it now goes through `defaultId` like every other caller.
The write-protection test is rewritten against a temp profile root. It was
passing vacuously: the un-overridden Loader REWRITES the composition it read —
stamping `disabled: true` onto the self-disposing row — so the committed
fixture had been mutated by the very run that proved the bug, and every later
run compared against the damaged file and passed. Building the preset in a
temp directory makes the assertion immune to its own failure mode, and it now
fails with a visible `+ disabled: true` when the override is removed.
Review follow-ups on this layer. The exported schema is
`AgentPresetSettingsSchema`, symmetric with the `AgentPresetSettings`
interface it resolves and self-describing at an import site. The `session.create`
JSDoc promised "the deployment's default preset" for an omitted `agentPreset`,
which this layer makes false — it now names the effective default. The
constructor records why it does not use `installSettingsSection`: that helper
re-judges what a consumer DERIVED across attach and detach, and nothing here is
derived. The provider-unload test disposes the fiber `ctx.plugin()` handed back
instead of reaching into `ctx.reflect.store`, and the write-protection wait says
why slack is the right shape for an absence assertion.
The real composition covers the layering too. `apps/cli` boots the shipped
`cordis.yml`, stores `agent-presets.default`, and asserts an unnamed session
composes from it — the package suite proves the layering against a hand-built
context, this proves the roster and the settings provider are wired to each
other. That test also pins the settings row at a temp file: it defaulted to
`$DSH_HOME/settings.yaml`, so a developer's own stored default decided the
outcome of a file whose whole point is that only the shipped root does.
The Agent Note records the per-resolution read and its correspondence with the
session header, and the vacuous-test finding above.
2026-08-04 00:07:11 +08:00
|
|
|
|
|
|
|
|
const handle = await ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('preset-user-default'),
|
|
|
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined),
|
|
|
|
|
})
|
|
|
|
|
try {
|
|
|
|
|
// `mount()` with no id resolves the effective default. Two tools, not
|
|
|
|
|
// `standard`'s catalog: the setting decided the composition.
|
2026-08-04 13:10:21 +08:00
|
|
|
expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
|
feat(agent-presets): make the default preset a user setting
`config.default` becomes the composition base of an `agent-presets` settings
namespace, so the user document layers over the deployment's engineering
default and a person can change which preset new sessions get without a
restart.
The value is read per resolution rather than snapshotted: a hot-reloaded
document takes effect on the next session created, and every running session
stays on the preset it was composed from — which is the same rule the
session-header guard enforces from the other side.
`resolve()` read `config.default` directly, which would have made the whole
setting inert; it now goes through `defaultId` like every other caller.
The write-protection test is rewritten against a temp profile root. It was
passing vacuously: the un-overridden Loader REWRITES the composition it read —
stamping `disabled: true` onto the self-disposing row — so the committed
fixture had been mutated by the very run that proved the bug, and every later
run compared against the damaged file and passed. Building the preset in a
temp directory makes the assertion immune to its own failure mode, and it now
fails with a visible `+ disabled: true` when the override is removed.
Review follow-ups on this layer. The exported schema is
`AgentPresetSettingsSchema`, symmetric with the `AgentPresetSettings`
interface it resolves and self-describing at an import site. The `session.create`
JSDoc promised "the deployment's default preset" for an omitted `agentPreset`,
which this layer makes false — it now names the effective default. The
constructor records why it does not use `installSettingsSection`: that helper
re-judges what a consumer DERIVED across attach and detach, and nothing here is
derived. The provider-unload test disposes the fiber `ctx.plugin()` handed back
instead of reaching into `ctx.reflect.store`, and the write-protection wait says
why slack is the right shape for an absence assertion.
The real composition covers the layering too. `apps/cli` boots the shipped
`cordis.yml`, stores `agent-presets.default`, and asserts an unnamed session
composes from it — the package suite proves the layering against a hand-built
context, this proves the roster and the settings provider are wired to each
other. That test also pins the settings row at a temp file: it defaulted to
`$DSH_HOME/settings.yaml`, so a developer's own stored default decided the
outcome of a file whose whole point is that only the shipped root does.
The Agent Note records the per-resolution read and its correspondence with the
session header, and the vacuous-test finding above.
2026-08-04 00:07:11 +08:00
|
|
|
} finally {
|
|
|
|
|
await handle.dispose()
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
// The context is shared with the rest of the file. `replace({})` drops
|
|
|
|
|
// the user section wholesale so the field re-inherits the composition
|
|
|
|
|
// base; `update` merges, and would leave the override standing.
|
|
|
|
|
await ctx.settings.replace(settingsNamespace(SETTINGS_NAMESPACE), {})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
expect(ctx.agentPresets.defaultId).toBe('standard')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
describe('a session keeps the preset it was created with', () => {
|
2026-08-04 12:46:57 +08:00
|
|
|
it('refuses to adopt a live session under a different preset', async () => {
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
const handle = await ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('preset-locked'),
|
2026-08-05 16:40:03 +08:00
|
|
|
meta: { agentPreset: 'minimal' },
|
|
|
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
})
|
|
|
|
|
try {
|
|
|
|
|
// The api-proxy guard reads exactly this: the header records what the
|
|
|
|
|
// session runs, so naming anything else is a caller error rather than a
|
2026-08-05 16:40:03 +08:00
|
|
|
// switch. Its history was produced under `minimal`'s two tools.
|
|
|
|
|
expect(handle.agent.session.header.agentPreset).toBe('minimal')
|
feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-03 23:44:09 +08:00
|
|
|
} finally {
|
|
|
|
|
await handle.dispose()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-08-20 22:57:58 +08:00
|
|
|
|
|
|
|
|
describe('a composition that configures its own preset roots', () => {
|
|
|
|
|
let rootsCtx: Context
|
|
|
|
|
let teamRoot: string
|
|
|
|
|
|
|
|
|
|
beforeAll(async () => {
|
|
|
|
|
const home = await mkdtemp(join(tmpdir(), 'dsh-preset-roots-'))
|
|
|
|
|
const settingsFile = join(home, 'settings.yaml')
|
|
|
|
|
await writeFile(settingsFile, '{}\n')
|
|
|
|
|
// A workspace-shared root beside the deployment: one preset of its own,
|
|
|
|
|
// plus a directory that claims a shipped id.
|
|
|
|
|
teamRoot = join(home, 'team-presets')
|
refactor(preset): bundle the shipped presets inside dsh-agent-presets
Review asked why the launcher special-cases one plugin's row. It no
longer does: the four shipped compositions move into the package
(presets/, in files), dsh-agent-presets resolves its own shipped root
and prepends it before configured roots (includeShippedRoot, default
true, opt-out for bare-machinery embedders), and the per-composition
derived patch, its spec, and the dump layer are deleted — profile-boot
and dump-config return to plain layer stacking. The always-load
guarantee now rides the schema default instead of patch ordering, so a
whole-config replacement keeps the shipped set and the squash, reload
freeze, and dump divergence stop being possible.
Gate globs, the web scaffold, and both preset browser lanes drop their
hand-fed shipped roots; the roster e2e keeps asserting configured roots
beside the shipped four against the built lib.
Fixes #2863.
2026-08-21 12:37:57 +08:00
|
|
|
const minimalComposition = await readFile(join(SHIPPED_PRESET_ROOT, 'minimal', 'agent.cordis.yml'), 'utf8')
|
2026-08-20 22:57:58 +08:00
|
|
|
for (const id of ['team-spec', 'minimal']) {
|
|
|
|
|
await mkdir(join(teamRoot, id), { recursive: true })
|
|
|
|
|
await writeFile(join(teamRoot, id, 'agent.cordis.yml'), minimalComposition)
|
|
|
|
|
}
|
|
|
|
|
// The user layer of the reported regression: a profile's cordis.patch.yml
|
refactor(preset): bundle the shipped presets inside dsh-agent-presets
Review asked why the launcher special-cases one plugin's row. It no
longer does: the four shipped compositions move into the package
(presets/, in files), dsh-agent-presets resolves its own shipped root
and prepends it before configured roots (includeShippedRoot, default
true, opt-out for bare-machinery embedders), and the per-composition
derived patch, its spec, and the dump layer are deleted — profile-boot
and dump-config return to plain layer stacking. The always-load
guarantee now rides the schema default instead of patch ordering, so a
whole-config replacement keeps the shipped set and the squash, reload
freeze, and dump divergence stop being possible.
Gate globs, the web scaffold, and both preset browser lanes drop their
hand-fed shipped roots; the roster e2e keeps asserting configured roots
beside the shipped four against the built lib.
Fixes #2863.
2026-08-21 12:37:57 +08:00
|
|
|
// configuring a shared preset root. The plugin must EXTEND it with its
|
|
|
|
|
// own shipped root, never lose it.
|
2026-08-20 22:57:58 +08:00
|
|
|
rootsCtx = await bootWeb(settingsFile, [{
|
|
|
|
|
id: 'agent-presets',
|
|
|
|
|
config: {
|
|
|
|
|
default: 'standard',
|
|
|
|
|
roots: [{ path: teamRoot, trust: 'user' }],
|
|
|
|
|
includeUserRoot: false,
|
|
|
|
|
},
|
|
|
|
|
}])
|
|
|
|
|
}, 120_000)
|
|
|
|
|
|
|
|
|
|
afterAll(async () => {
|
|
|
|
|
await rootsCtx.fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('keeps configured roots alongside the always-prepended shipped root', async () => {
|
|
|
|
|
expect(rootsCtx.agentPresets.roots.map(root => root.path)).toEqual([
|
refactor(preset): bundle the shipped presets inside dsh-agent-presets
Review asked why the launcher special-cases one plugin's row. It no
longer does: the four shipped compositions move into the package
(presets/, in files), dsh-agent-presets resolves its own shipped root
and prepends it before configured roots (includeShippedRoot, default
true, opt-out for bare-machinery embedders), and the per-composition
derived patch, its spec, and the dump layer are deleted — profile-boot
and dump-config return to plain layer stacking. The always-load
guarantee now rides the schema default instead of patch ordering, so a
whole-config replacement keeps the shipped set and the squash, reload
freeze, and dump divergence stop being possible.
Gate globs, the web scaffold, and both preset browser lanes drop their
hand-fed shipped roots; the roster e2e keeps asserting configured roots
beside the shipped four against the built lib.
Fixes #2863.
2026-08-21 12:37:57 +08:00
|
|
|
SHIPPED_PRESET_ROOT,
|
2026-08-20 22:57:58 +08:00
|
|
|
teamRoot,
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
const listed = await rootsCtx.agentPresets.list()
|
|
|
|
|
expect(listed.map(preset => preset.id).sort()).toEqual(['code', 'cordis', 'minimal', 'standard', 'team-spec'])
|
|
|
|
|
expect(listed.every(preset => preset.broken === undefined)).toBe(true)
|
|
|
|
|
// The shipped root comes first: a configured directory claiming a shipped
|
|
|
|
|
// id is shadowed, never the other way around.
|
|
|
|
|
expect(listed.find(preset => preset.id === 'minimal')?.trust).toBe('system')
|
|
|
|
|
expect(listed.find(preset => preset.id === 'team-spec')?.trust).toBe('user')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('composes an agent from a configured-root preset', async () => {
|
|
|
|
|
const handle = await rootsCtx.agents.create({
|
|
|
|
|
sessionId: SessionId('preset-team-spec'),
|
|
|
|
|
setup: agentCtx => rootsCtx.agentPresets.mount(agentCtx, 'team-spec').then(() => undefined),
|
|
|
|
|
})
|
|
|
|
|
try {
|
|
|
|
|
expect(toolNames(rootsCtx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
|
|
|
|
|
} finally {
|
|
|
|
|
await handle.dispose()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|