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.
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
|
||||
import { networkInterfaces } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dshHomePath } from '@deepseek-ai/dsh-paths'
|
||||
import type { Context } from 'cordis'
|
||||
import type { PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot'
|
||||
@@ -17,6 +18,12 @@ import { runProfile } from './profile-boot.ts'
|
||||
|
||||
const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
|
||||
|
||||
/** Shipped agent-preset root: beside this app's own config, in both source and built layouts. */
|
||||
const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url))
|
||||
|
||||
/** Harness-home directory holding locally authored agent presets. */
|
||||
const USER_PRESET_DIR = '.agent-presets'
|
||||
|
||||
/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation. */
|
||||
const ALL_INTERFACES_HOST = '0.0.0.0'
|
||||
|
||||
@@ -96,6 +103,16 @@ function deriveWebFlagPatches(
|
||||
// inserts the client-hmr row), never pass-throughs of composed values.
|
||||
put('web-runtime', 'mode', flags.dev ? 'development' : 'production')
|
||||
put('web-runtime', 'lanAddresses', lanAddresses)
|
||||
// The agent-preset roots are an assembly fact, like the values above: the
|
||||
// shipped set sits beside this app's config and the user's own under the
|
||||
// Harness home, and neither location is something a patch author chooses.
|
||||
// Only patched when the composed tree actually mounts the roster.
|
||||
if (rows.has('agent-presets')) {
|
||||
put('agent-presets', 'roots', [
|
||||
{ path: SHIPPED_PRESET_ROOT, trust: 'system' },
|
||||
{ path: dshHomePath(USER_PRESET_DIR), trust: 'user' },
|
||||
])
|
||||
}
|
||||
const patches = [...overrides.entries()].map(([id, bag]): PatchOptions => {
|
||||
const composed = rows.get(id)
|
||||
if (composed === undefined) throw new Error(`dsh: patch target row "${id}" not found in the web profile composition`)
|
||||
|
||||
173
apps/cli/tests/web-agent-presets.spec.ts
Normal file
173
apps/cli/tests/web-agent-presets.spec.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
import type {} from '@deepseek-ai/dsh-agent-presets'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
|
||||
const CONFIG_DIR = fileURLToPath(new URL('../config/', import.meta.url))
|
||||
const BASE_CONFIG = join(CONFIG_DIR, 'base.cordis.yml')
|
||||
const WEB_OVERLAY = join(CONFIG_DIR, 'web.cordis.yml')
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async function bootWeb(): Promise<Context> {
|
||||
const patches: PatchOptions[] = [
|
||||
...loadOverlayPatches('dsh-test', WEB_OVERLAY),
|
||||
// Host rows with side effects outside this process.
|
||||
{ id: 'webserver', disabled: true },
|
||||
{ id: 'telemetry-otel', disabled: true },
|
||||
{ id: 'modules', disabled: true },
|
||||
{ id: 'connection', disabled: true },
|
||||
{ id: 'api-gateway', disabled: true },
|
||||
{ id: 'directory-picker', disabled: true },
|
||||
// The roster AppCLIEntry would patch in; only the shipped root, so a
|
||||
// developer's own `~/.dsh/.preset` cannot change this test's outcome.
|
||||
{
|
||||
id: 'agent-presets',
|
||||
config: { default: 'standard', roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }] },
|
||||
},
|
||||
]
|
||||
return await boot('dsh-test', BASE_CONFIG, patches)
|
||||
}
|
||||
|
||||
const toolNames = (ctx: Context, agent?: Agent): string[] =>
|
||||
ctx.tools.schemas(agent).map(schema => schema.name).sort()
|
||||
|
||||
let ctx: Context
|
||||
beforeAll(async () => {
|
||||
ctx = await bootWeb()
|
||||
}, 120_000)
|
||||
|
||||
describe('the shipped Web composition', () => {
|
||||
it('leaves only the host UI tool in the global layer', () => {
|
||||
// `ask_user_question` is the host's own interaction surface, not an agent
|
||||
// capability, so it stays global. Every other tool now belongs to a
|
||||
// preset; a regression here means an agent-plane row came back to base.
|
||||
expect(toolNames(ctx)).toEqual(['ask_user_question'])
|
||||
})
|
||||
|
||||
it('supplies both shipped presets, and only those, from the system root', async () => {
|
||||
const listed = await ctx.agentPresets.list()
|
||||
|
||||
expect(listed.map(preset => preset.id).sort()).toEqual(['core-web', 'standard'])
|
||||
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',
|
||||
'get_goal', 'list_agents', 'ralph', 'read', 'send_message', 'skill',
|
||||
'str_replace_editor', 'subagent', 'subagent_fork', 'task_kill',
|
||||
'task_list', 'task_output', 'todo_write', 'update_goal', 'web_search',
|
||||
'workflow', 'write',
|
||||
])
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('composes exactly two tools from `core-web`', async () => {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-core-web'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'core-web').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
expect(toolNames(ctx, handle.agent)).toEqual(['ask_user_question', 'bash', 'str_replace_editor'])
|
||||
} 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'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'core-web').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
expect(toolNames(ctx, minimal.agent)).toEqual(['ask_user_question', 'bash', 'str_replace_editor'])
|
||||
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)
|
||||
expect(toolNames(ctx)).toEqual(['ask_user_question'])
|
||||
} finally {
|
||||
await full.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
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.
|
||||
const path = join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml')
|
||||
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()
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
expect(await readFile(path, 'utf8')).toBe(before)
|
||||
})
|
||||
|
||||
it('gives each session its own persona', async () => {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-persona'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'core-web').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
const assembly = await ctx.systemPrompt.assemble({ scope: handle.agent })
|
||||
expect(assembly.sections.find(section => section.name === 'deployment:persona')?.text)
|
||||
.toContain('You are a coding agent powered by')
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('a session keeps the preset it was created with', () => {
|
||||
it('refuses to adopt a live session under a different preset', async () => {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-locked'),
|
||||
meta: { agentPreset: 'core-web' },
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'core-web').then(() => undefined),
|
||||
})
|
||||
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
|
||||
// switch. Its history was produced under `core-web`'s two tools.
|
||||
expect(handle.agent.session.header.agentPreset).toBe('core-web')
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user