Files
deepseek-harness/packages/preset/persona/tests/persona.spec.ts
Yichen Jiang 739042a804 feat(persona): make the agent persona a composable row
`dsh-system-prompt` owns the deployment persona as its own config and registers
that section unconditionally, so a process has exactly one. An agent preset
cannot mount the prompt registry itself, which means that without a row of its
own a preset could change an agent's tools but never its identity — and a
roster of presets that all sound the same is not worth having.

The row is scope-only by construction: mounted outside an agent scope it
collides with the registry's own `deployment:persona` registration and fails
loud. That is the correct shape rather than a gap, because the unscoped slot
already has an owner and this row exists to shadow it for one agent.

Two behaviours are pinned by test because both read the other way at a glance:
an empty persona still occupies the slot (shadowing the deployment persona away
entirely, then disappearing at render), and `assemble()` keeps section text
uninterpolated — `renderPrompt()` is the stage that resolves `{{…}}`.
2026-08-07 00:35:30 +08:00

89 lines
3.6 KiB
TypeScript

import { Context } from 'cordis'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import { createScope, type ScopeKey } from '@deepseek-ai/dsh-scope'
import { describe, expect, it } from 'vitest'
import * as Persona from '@deepseek-ai/dsh-persona'
import { PERSONA_SECTION } from '@deepseek-ai/dsh-persona'
async function harness(deploymentPersona: string): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt, { persona: deploymentPersona })
return ctx
}
/** The rendered text of the persona slot as one scope sees it. */
async function personaText(ctx: Context, scope?: ScopeKey): Promise<string | undefined> {
const assembly = await ctx.systemPrompt.assemble(scope === undefined ? {} : { scope })
return assembly.sections.find(section => section.name === PERSONA_SECTION)?.text
}
describe('the persona row', () => {
it('rejects an unscoped mount, which would collide with the registry default', async () => {
const ctx = await harness('deployment identity')
await expect(ctx.plugin(Persona, { text: 'composition identity' }))
.rejects.toThrow(/"deployment:persona" is already registered/)
})
it('shadows the deployment default for one scope only', async () => {
const ctx = await harness('deployment identity')
const key: ScopeKey = { agent: 'a1' }
const scope = createScope(ctx, key)
await scope.ctx.plugin(Persona, { text: 'preset identity' })
expect(await personaText(ctx, key)).toBe('preset identity')
expect(await personaText(ctx)).toBe('deployment identity')
})
it('gives two scopes independent personas', async () => {
const ctx = await harness('')
const first: ScopeKey = { agent: 'a1' }
const second: ScopeKey = { agent: 'a2' }
await createScope(ctx, first).ctx.plugin(Persona, { text: 'first identity' })
await createScope(ctx, second).ctx.plugin(Persona, { text: 'second identity' })
expect(await personaText(ctx, first)).toBe('first identity')
expect(await personaText(ctx, second)).toBe('second identity')
})
it('shadows the deployment persona away entirely when its text is empty', async () => {
const ctx = await harness('deployment identity')
const key: ScopeKey = { agent: 'a1' }
await createScope(ctx, key).ctx.plugin(Persona, { text: '' })
// The slot is still occupied, so the deployment persona is gone for this
// agent; an empty section is dropped when the prompt renders.
expect(await personaText(ctx, key)).toBe('')
expect(await personaText(ctx)).toBe('deployment identity')
})
it('restores the shadowed default when its fiber unloads', async () => {
const ctx = await harness('deployment identity')
const key: ScopeKey = { agent: 'a1' }
const scope = createScope(ctx, key)
const fiber = await scope.ctx.plugin(Persona, { text: 'preset identity' })
expect(await personaText(ctx, key)).toBe('preset identity')
await fiber.dispose()
expect(await personaText(ctx, key)).toBe('deployment identity')
})
it('interpolates prompt variables strictly, like any other section', async () => {
const ctx = await harness('')
const key: ScopeKey = { agent: 'a1' }
ctx.systemPrompt.variable('model', () => 'deepseek-v4-pro')
await createScope(ctx, key).ctx.plugin(Persona, { text: 'You run on {{model}}.' })
// `assemble()` keeps section text uninterpolated; `renderPrompt()` is the
// stage that resolves `{{…}}` against the assembly's variables.
expect(await personaText(ctx, key)).toBe('You run on {{model}}.')
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: key })))
.toContain('You run on deepseek-v4-pro.')
})
})