Files
deepseek-harness/packages/preset/agent-presets/tests/invariant.spec.ts
Yichen Jiang 1cfbafab6a 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

112 lines
4.9 KiB
TypeScript

import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { describe, expect, it } from 'vitest'
import AgentPresets, { livePresetMounts } from '@deepseek-ai/dsh-agent-presets'
import * as AgentPresetsInvariant from '@deepseek-ai/dsh-agent-presets/invariant'
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
const ROOTS = [
{ path: join(FIXTURES, 'system'), trust: 'system' as const },
{ path: join(FIXTURES, 'user'), trust: 'user' as const },
]
async function harness(): Promise<Context> {
const ctx = new Context()
ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS })
await ctx.plugin(InvariantService)
await ctx.plugin(AgentPresetsInvariant)
return ctx
}
describe('agent-presets invariants', () => {
it('keeps the standing composition alive across the agents that joined it', async () => {
const ctx = await harness()
const handle = await ctx.agents.create({
sessionId: SessionId('inv-live'),
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'),
})
expect(livePresetMounts().map(mount => mount.presetId)).toContain('standard')
// A standing mount survives its agents: the composition a session joined
// is shared, so one session ending must not strip it from the next.
await handle.dispose()
expect(livePresetMounts().map(mount => mount.presetId)).toContain('standard')
// A second agent reuses the same mount rather than adding one.
await ctx.agents.create({
sessionId: SessionId('inv-live-2'),
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'),
})
expect(livePresetMounts().filter(mount => mount.presetId === 'standard')).toHaveLength(1)
// Whole-tree teardown is the boundary that does reclaim it.
await ctx.fiber.dispose()
expect(livePresetMounts().map(mount => mount.presetId)).not.toContain('standard')
})
it('rejects a composition that publishes a process-global service after its audit', async () => {
const ctx = await harness()
await ctx.agents.create({
sessionId: SessionId('inv-late'),
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'late'),
})
const publishLate = (globalThis as { __PUBLISH_LATE__?: () => void }).__PUBLISH_LATE__
expect(publishLate).toBeTypeOf('function')
expect(() => { publishLate?.() }).toThrow(/published process-global service\(s\) \[fixtureLateSvc\]/)
})
it('stays quiet while every composition keeps its services out of the root realm', async () => {
const ctx = await harness()
await expect(ctx.agents.create({
sessionId: SessionId('inv-isolated'),
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'isolated'),
})).resolves.toBeDefined()
})
it('rejects an agent that addresses a model without joining any preset', async () => {
const ctx = await harness()
// The delegation shape: an agent composed outside the roster keeps a
// scope chain of length one, so every registry view it reads is the empty
// global layer. Publication alone stays legal — `recompose` binds exactly
// such an agent — so nothing fires until that empty world reaches a prompt.
const handle = await ctx.agents.create({ sessionId: SessionId('inv-unjoined') })
await expect(ctx.systemPrompt.assemble({ scope: handle.agent }))
.rejects.toThrow(/without joining any agent preset/)
})
it('admits a joined agent and a host assembly that names no scope', async () => {
const ctx = await harness()
const handle = await ctx.agents.create({
sessionId: SessionId('inv-joined'),
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'),
})
await expect(ctx.systemPrompt.assemble({ scope: handle.agent })).resolves.toBeDefined()
// A scopeless assembly belongs to no agent, so it cannot be an unjoined one.
await expect(ctx.systemPrompt.assemble({})).resolves.toBeDefined()
})
})