248 lines
18 KiB
TypeScript
248 lines
18 KiB
TypeScript
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join, relative } from 'node:path'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { Store } from '../src/app/store.ts'
|
|
import { Prompts, builtInBase, composePrompt, defaultPrompts, policySection, promptParts, type PromptConfig } from '../src/app/prompts.ts'
|
|
import { USER_ERRORS, UserError } from '../src/core/index.ts'
|
|
import { KV } from '../src/kv.ts'
|
|
import { SettingsSchema, policyVersion } from '../src/policy.ts'
|
|
import { guides, handoffPolicy, mainAdmissionPolicy, masterPrompt, sharedPrompt, workerPrompt } from '../src/app/context.ts'
|
|
import { promptFile } from '../src/app/skill-files.ts'
|
|
import { packagePath } from '../src/infra/paths.ts'
|
|
import type { Variable } from '../src/app/execution-environment.ts'
|
|
|
|
/** The execution environment outside any ticket, as the runtime passes it once setup has finished. */
|
|
const variables = (): Variable[] => [{ key: 'DSH_SUPERAGENT_GITHUB_REPOSITORY', value: 'acme/widgets', description: { zh: '仓库', en: 'The repository' }, owner: 'github' }]
|
|
const ENVIRONMENT_LINE = '- DSH_SUPERAGENT_GITHUB_REPOSITORY = "acme/widgets": The repository.'
|
|
const promptsOf = (store: Store, maxChars = 100000) => new Prompts(store, () => maxChars, variables, () => true)
|
|
function withStore(body: (store: Store, dir: string) => void): void {
|
|
const dir = mkdtempSync(join(tmpdir(), 'prompts-')); const store = new Store(join(dir, 'db'))
|
|
try { body(store, dir) } finally { store.close(); rmSync(dir, { recursive: true, force: true }) }
|
|
}
|
|
|
|
it('saves team sections and skills as versions that survive a restart, with history and rollback', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'prompt-config-'))
|
|
let store = new Store(join(dir, 'db'))
|
|
try {
|
|
let prompts = promptsOf(store)
|
|
expect(prompts.current().config).toEqual(defaultPrompts())
|
|
const config = { ...defaultPrompts(), scope: 'Finish what was asked.', delivery: '', skills: { release: 'Tag releases on Fridays.' } }
|
|
prompts.save(config, 0, 'panel')
|
|
expect(composePrompt(prompts.current().config, 'worker')).toContain('## Ticket scope\nFinish what was asked.')
|
|
// An empty team section leaves the prompt; the fixed sections stay.
|
|
expect(promptParts(config, 'worker').map(part => part.name)).not.toContain('delivery')
|
|
expect(store.get('settings-revision')).toBeUndefined()
|
|
store.close(); store = new Store(join(dir, 'db')); prompts = promptsOf(store)
|
|
expect(prompts.current()).toMatchObject({ revision: 1, config })
|
|
prompts.save(prompts.version(0).config, 1, 'tool')
|
|
expect(prompts.current()).toMatchObject({ revision: 2, previousRevision: 1, config: defaultPrompts() })
|
|
expect(prompts.history(0, 1)).toMatchObject({ revisions: [{ revision: 2 }], next_offset: 1 })
|
|
expect(prompts.history(2, 1).next_offset).toBeNull()
|
|
expect(prompts.history(1, 1).revisions[0]).toMatchObject({ revision: 1, changed: ['scope', 'delivery', 'skill/release'] })
|
|
expect(() => prompts.save(config, 1, 'panel')).toThrow('version changed')
|
|
} finally { store.close(); rmSync(dir, { recursive: true, force: true }) }
|
|
})
|
|
|
|
it('assembles every role from fixed sections around the team sections, whatever the configuration says', () => {
|
|
const config = { communication: 'Say little.', scope: '', delivery: 'Attach screenshots.', skills: {} }
|
|
for (const role of ['orchestrator', 'worker'] as const) {
|
|
const names = promptParts(config, role, 'env').map(part => part.name)
|
|
expect(names).toEqual(['runtime/shared', `runtime/${role}`, 'communication', 'delivery', 'runtime/subscriptions', 'runtime/replies', role === 'orchestrator' ? 'runtime/dispatch' : 'runtime/work', ...role === 'orchestrator' ? ['runtime/admission'] : [], 'runtime/handoff', 'runtime/environment'])
|
|
const prompt = composePrompt(config, role)
|
|
for (const fixed of [sharedPrompt, role === 'orchestrator' ? masterPrompt : workerPrompt, guides.subscriptions!, guides.replies!, handoffPolicy(role)]) expect(prompt).toContain(fixed)
|
|
expect(prompt.endsWith(handoffPolicy(role))).toBe(true)
|
|
}
|
|
expect(composePrompt(config, 'orchestrator')).toContain(mainAdmissionPolicy)
|
|
expect(composePrompt(config, 'worker')).not.toContain(mainAdmissionPolicy)
|
|
})
|
|
|
|
it('reads a prompt file whatever its line ends and blank lines, and names a missing or empty one', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'prompt-file-'))
|
|
// promptFile resolves paths relative to prompts/; use a relative path to reach the temp dir.
|
|
const name = (file: string) => relative(packagePath('prompts'), join(dir, file))
|
|
try {
|
|
writeFileSync(join(dir, 'crlf.md'), 'One.\r\nTwo. \r\n \t\r\n\r\nThree.\r\n')
|
|
writeFileSync(join(dir, 'blank.md'), ' \n\t\n')
|
|
expect(promptFile(name('crlf'))).toBe('One. Two.\nThree.')
|
|
expect(() => promptFile(name('blank'))).toThrow(`Empty packaged file: ${join(dir, 'blank.md')}`)
|
|
expect(() => promptFile(name('absent'))).toThrow(`Missing packaged file: ${join(dir, 'absent.md')}`)
|
|
} finally { rmSync(dir, { recursive: true, force: true }) }
|
|
})
|
|
|
|
it('leaves the UI before-and-after evidence to the delivery requirements, which a team can change', () => {
|
|
const config = { ...defaultPrompts(), delivery: 'Only capture the finished screen.' }
|
|
for (const role of ['orchestrator', 'worker'] as const) expect(composePrompt(config, role)).not.toContain('Before and After visual evidence')
|
|
})
|
|
|
|
it.each(['orchestrator', 'worker'] as const)('ends the %s prompt with the fixed handoff rules and the execution environment, and the main Session\'s with its Policy', role => {
|
|
withStore(store => {
|
|
const prompts = promptsOf(store)
|
|
const preview = prompts.preview(defaultPrompts())
|
|
expect(promptParts(defaultPrompts(), role).at(-1)).toEqual({ name: 'runtime/handoff', kind: 'fixed', key: `runtime/handoff/${role}`,
|
|
text: handoffPolicy(role) })
|
|
expect(preview[role]).toBe(prompts.compose(defaultPrompts(), role))
|
|
expect(preview[role]).toContain(`${handoffPolicy(role)}\n\nExecution environment`)
|
|
if (role === 'worker') expect(preview[role].endsWith(ENVIRONMENT_LINE)).toBe(true)
|
|
// Nothing follows the main Session's Policy section, so a Policy change leaves the rest of its prompt as it was.
|
|
else expect(preview[role].endsWith(`${ENVIRONMENT_LINE}\n\n${policySection(store.settings()).text}`)).toBe(true)
|
|
expect(preview.parts[role].at(-1)!.name).toBe(role === 'orchestrator' ? 'runtime/policy' : 'runtime/environment')
|
|
})
|
|
})
|
|
|
|
it('rejects unknown keys, built-in skill names and oversized configuration before persisting', () => {
|
|
withStore(store => {
|
|
const prompts = promptsOf(store)
|
|
expect(() => prompts.save({ ...defaultPrompts(), shared: 'x' }, 0, 'panel')).toThrow()
|
|
expect(() => prompts.save({ communication: '', scope: '' }, 0, 'panel')).toThrow()
|
|
for (const name of ['github', 'feishu']) expect(() => prompts.save({ ...defaultPrompts(), skills: { [name]: 'mine' } }, 0, 'panel')).toThrow('built-in skill')
|
|
expect(() => prompts.save({ ...defaultPrompts(), skills: { 'Bad Name': 'x' } }, 0, 'panel')).toThrow()
|
|
expect(() => promptsOf(store, 1000).save(defaultPrompts(), 0, 'panel')).toThrow('exceeds')
|
|
expect(prompts.current().revision).toBe(0)
|
|
})
|
|
})
|
|
|
|
it('lists a ticket\'s execution environment in its prompt, and says setup is incomplete before setup finished', () => {
|
|
withStore(store => {
|
|
const ticket: Variable[] = [{ key: 'DSH_SUPERAGENT_DIR', value: '/srv/work/fix-1', description: { zh: '', en: 'This ticket\'s directory' }, owner: 'executor' }]
|
|
expect(promptsOf(store).compose(defaultPrompts(), 'worker', ticket).endsWith('- DSH_SUPERAGENT_DIR = "/srv/work/fix-1": This ticket\'s directory.')).toBe(true)
|
|
expect(promptParts(defaultPrompts(), 'worker', 'env').at(-1)).toEqual({ name: 'runtime/environment', kind: 'generated', key: 'runtime/environment',
|
|
text: 'env' })
|
|
expect(new Prompts(store, () => 100000, () => undefined, () => false).compose(defaultPrompts(), 'worker')).toContain('dsh-superagent is waiting for setup')
|
|
})
|
|
})
|
|
|
|
it('registers the built-in Source skills, Feishu only with a Feishu bot, before the team skills', () => {
|
|
withStore(store => {
|
|
const config = { ...defaultPrompts(), skills: { release: 'Tag releases.' } }
|
|
const skills = promptsOf(store).skills(config)
|
|
expect(skills.map(([name]) => name)).toEqual(['feishu', 'github', 'release'])
|
|
expect(skills[1]![1]).toContain('## CLI usage')
|
|
expect(skills[2]![1]).toBe('Tag releases.')
|
|
expect(new Prompts(store, () => 100000, variables, () => false).skills(config).map(([name]) => name)).toEqual(['github', 'release'])
|
|
})
|
|
})
|
|
|
|
|
|
describe('overrides of built-in parts', () => {
|
|
const settings = () => SettingsSchema.parse({})
|
|
const config = (overrides: PromptConfig['overrides']) => ({ ...defaultPrompts(), overrides })
|
|
it('compose in place of the built-in texts, for the roles that read each, the handoff per role and the Source skills', () => {
|
|
withStore(store => {
|
|
const prompts = promptsOf(store)
|
|
const edited = prompts.validate(config({ 'runtime/shared': { text: 'Shared, the team way.' }, 'runtime/dispatch': { text: '## dispatch\nRoute well.' },
|
|
'runtime/handoff/worker': { text: 'Hand off as a worker.' }, 'skill/github': { text: 'GitHub, the team way.' } }))
|
|
const main = prompts.compose(edited, 'orchestrator'), worker = prompts.compose(edited, 'worker')
|
|
for (const prompt of [main, worker]) expect(prompt.startsWith('Shared, the team way.\n\n')).toBe(true)
|
|
expect(main).toContain('## dispatch\nRoute well.')
|
|
expect(main).not.toContain(guides.dispatch)
|
|
expect(worker).toContain('Hand off as a worker.')
|
|
expect(main).toContain(handoffPolicy('orchestrator'))
|
|
expect(prompts.skills(edited)).toContainEqual(['github', 'GitHub, the team way.'])
|
|
// The parts say what they are edited by.
|
|
expect(promptParts(edited, 'worker').find(part => part.name === 'runtime/handoff')).toMatchObject({ key: 'runtime/handoff/worker',
|
|
text: 'Hand off as a worker.' })
|
|
})
|
|
})
|
|
it('fill the templates of the generated parts with the data, which stays generated', () => {
|
|
withStore(store => {
|
|
const prompts = promptsOf(store)
|
|
const edited = prompts.validate(config({ 'runtime/environment': { text: 'Where you work:\n{variables}\nAsk nobody for more.' },
|
|
'runtime/policy': { text: '## Policy\nRules of {version} in {mode} mode:\n{rules}' },
|
|
'runtime/policy/none': { text: '## Policy\n{version}, {mode}: nothing to accept.' } }))
|
|
expect(prompts.compose(edited, 'worker').endsWith(`Where you work:\n${ENVIRONMENT_LINE}\nAsk nobody for more.`)).toBe(true)
|
|
const policy = policySection(settings(), edited)
|
|
expect(policy).toEqual({ key: 'runtime/policy', text: `## Policy\nRules of ${policyVersion(settings())} in ${settings().eval.mode} mode:\n`
|
|
+ settings().policy.rules.filter(rule => rule.enabled).map(rule => `- ${rule.name} (${rule.id}): ${rule.instruction}`).join('\n') })
|
|
const none = SettingsSchema.parse({ policy: { rules: [] } })
|
|
expect(policySection(none, edited)).toEqual({ key: 'runtime/policy/none', text: `## Policy\n${policyVersion(none)}, ${none.eval.mode}: nothing to accept.` })
|
|
// A rule's text that names a placeholder is not filled again.
|
|
const named = SettingsSchema.parse({ policy: { rules: [{ id: 'x', name: 'X', enabled: true, instruction: 'Keep {mode} and $& as written.' }] } })
|
|
expect(policySection(named, edited).text.endsWith('- X (x): Keep {mode} and $& as written.')).toBe(true)
|
|
})
|
|
})
|
|
it('refuses an unknown part and a template that drops a placeholder, and drops an override equal to the built-in text', () => {
|
|
withStore(store => {
|
|
const prompts = promptsOf(store)
|
|
expect(() => prompts.validate(config({ 'runtime/nothing': { text: 'x' } }))).toThrow(new UserError('unknownPromptPart', { part: 'runtime/nothing' }))
|
|
expect(() => prompts.validate(config({ 'runtime/policy': { text: '## Policy\n{version} {mode}' } })))
|
|
.toThrow(new UserError('promptPlaceholders', { part: 'runtime/policy', placeholders: '{rules}' }))
|
|
expect(() => prompts.validate(config({ 'runtime/environment': { text: 'Where you work.' } })))
|
|
.toThrow(new UserError('promptPlaceholders', { part: 'runtime/environment', placeholders: '{variables}' }))
|
|
// Back to the built-in text: the part follows the built-in text again, so a later upgrade updates it.
|
|
expect(prompts.validate(config({ 'runtime/shared': { text: sharedPrompt } }))).not.toHaveProperty('overrides')
|
|
// The whole configuration, overrides included, stays within the limit.
|
|
expect(() => promptsOf(store, 5000).validate(config({ 'runtime/shared': { text: 'x'.repeat(5000) } }))).toThrow(UserError)
|
|
})
|
|
})
|
|
it('records the built-in text an override was edited from, and lists the overrides whose built-in text changed since', () => {
|
|
withStore(store => {
|
|
const prompts = promptsOf(store)
|
|
// An override edited from today's built-in text has nothing to show.
|
|
const saved = prompts.save(config({ 'runtime/dispatch': { text: '## dispatch\nRoute well.' } }), 0, 'panel')
|
|
expect(saved.config.overrides!['runtime/dispatch']!.base).toBe(builtInBase(`## dispatch\n${guides.dispatch}`))
|
|
expect(prompts.defaultChanged(saved.config)).toEqual([])
|
|
// One edited from a built-in text an earlier version shipped, which this installation kept when it was saved.
|
|
const earlier = '## replies\nAn earlier built-in text.'
|
|
store.set(KV.prompts.builtIn(builtInBase(earlier)), earlier)
|
|
const upgraded = prompts.save(config({ ...saved.config.overrides, 'runtime/replies': { text: 'Our replies.', base: builtInBase(earlier) },
|
|
'runtime/work': { text: 'Our work.', base: 'unknown' } }), saved.revision, 'panel')
|
|
expect(prompts.defaultChanged(upgraded.config)).toEqual([{ part: 'runtime/replies', base: builtInBase(earlier), previous: earlier },
|
|
{ part: 'runtime/work', base: 'unknown' }])
|
|
// Kept as it was: an upgrade never replaces the team's text.
|
|
expect(prompts.compose(prompts.current().config, 'worker')).toContain('Our replies.')
|
|
})
|
|
})
|
|
it('reads a configuration saved before overrides existed unchanged', () => {
|
|
withStore(store => {
|
|
const prompts = promptsOf(store)
|
|
const saved = prompts.save({ ...defaultPrompts(), communication: 'Ours.' }, 0, 'panel')
|
|
expect(saved.config).toEqual({ ...defaultPrompts(), communication: 'Ours.' })
|
|
expect(prompts.compose(saved.config, 'orchestrator')).toBe(prompts.compose({ ...defaultPrompts(), communication: 'Ours.', overrides: {} }, 'orchestrator'))
|
|
})
|
|
})
|
|
it('drops an override of a part a later version no longer has, which the saved version holds, and lists it as removed', () => {
|
|
withStore(store => {
|
|
const prompts = promptsOf(store)
|
|
const config = { ...defaultPrompts(), overrides: { 'runtime/retired': { text: 'Old.', base: 'b' } } }
|
|
// Only a part the saved version holds goes quietly; one a client makes up is refused.
|
|
expect(() => prompts.validate(config)).toThrow(new UserError('unknownPromptPart', { part: 'runtime/retired' }))
|
|
store.set(KV.prompts.version(1), { revision: 1, previousRevision: 0, savedAt: '', source: 'panel', config }); store.set(KV.prompts.revision, 1)
|
|
expect(prompts.defaultChanged(config)).toEqual([{ part: 'runtime/retired', base: 'b', removed: true }])
|
|
expect(prompts.validate(config)).toEqual(defaultPrompts())
|
|
expect(USER_ERRORS.unknownPromptPart[0]).toBe('refused')
|
|
})
|
|
})
|
|
it('keeps the base of the saved override of a part when a save omits it, and shows a re-based override as a change', () => {
|
|
withStore(store => {
|
|
const prompts = promptsOf(store)
|
|
const first = prompts.save(config({ 'runtime/shared': { text: 'Ours.', base: 'earlier' } }), 0, 'panel')
|
|
const second = prompts.save(config({ 'runtime/shared': { text: 'Ours, edited.' } }), first.revision, 'tool')
|
|
expect(second.config.overrides!['runtime/shared']).toEqual({ text: 'Ours, edited.', base: 'earlier' })
|
|
// Keeping the team's text after an upgrade changes only the base, which the history names.
|
|
const rebased = prompts.save(config({ 'runtime/shared': { text: 'Ours, edited.', base: builtInBase(sharedPrompt) } }), second.revision, 'panel')
|
|
expect(prompts.history().revisions.find(version => version.revision === rebased.revision)!.changed).toEqual(['runtime/shared'])
|
|
})
|
|
})
|
|
it('refuses a part an object\'s prototype names, and empty text, and fills no placeholder a prototype names', () => {
|
|
withStore(store => {
|
|
const prompts = promptsOf(store)
|
|
for (const part of ['constructor', 'toString']) {
|
|
expect(() => prompts.validate(config(JSON.parse(`{"${part}": { "text": "x" }}`)))).toThrow(new UserError('unknownPromptPart', { part }))
|
|
}
|
|
// The parser drops a __proto__ key, which sets no prototype.
|
|
expect(prompts.validate(config(JSON.parse('{"__proto__": { "text": "x" }}')))).toEqual(defaultPrompts())
|
|
expect(() => prompts.validate(config({ 'runtime/shared': { text: ' \n ' } }))).toThrow(new UserError('promptPartEmpty', { part: 'runtime/shared' }))
|
|
const edited = prompts.validate(config({ 'runtime/environment': { text: '{constructor} {toString}\n{variables}' } }))
|
|
expect(prompts.compose(edited, 'worker').endsWith(`{constructor} {toString}\n${ENVIRONMENT_LINE}`)).toBe(true)
|
|
})
|
|
})
|
|
it('keeps the environment heading as it was, naming the prepare command', () => {
|
|
expect(composePrompt(defaultPrompts(), 'worker', 'x')).not.toContain('The prepare command')
|
|
withStore(store => {
|
|
expect(promptsOf(store).compose(defaultPrompts(), 'worker')).toContain('an empty value means that part is not configured here). '
|
|
+ `The prepare command of a ticket directory reads the same variables:\n${ENVIRONMENT_LINE}`)
|
|
})
|
|
})
|
|
})
|