Files
msd-core/sdk/src/phase-prompt.test.ts
Lex Christopherson 377a6d2c6e fix(sdk): use installed agent/workflow prompts instead of stripped-down bundled copies
The SDK bundled its own agents and workflows at ~17% the size of the real
ones, missing critical instructions like file naming conventions, scope
reduction rules, discovery protocols, and TDD integration. This caused
the planner to create a single PLAN.md instead of properly named
per-plan files (01-01-PLAN.md, 01-02-PLAN.md), breaking wave-based
parallel execution.

- Invert load priority: installed GSD agents/workflows first, SDK
  bundled as last-resort fallback
- Replace @-reference stripping with resolution (read + inline content)
- Use full agent definitions instead of extracting only the <role> block
- Delete sdk/prompts/agents/ and sdk/prompts/workflows/ (13 files)
- Delete headless-prompts.test.ts (validated deleted files)
- Thread projectDir through sanitizePrompt for @-reference resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 14:08:05 -06:00

536 lines
20 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { PromptFactory, extractBlock, extractSteps, PHASE_WORKFLOW_MAP } from './phase-prompt.js';
import { PhaseType } from './types.js';
import type { ContextFiles, ParsedPlan, PlanFrontmatter } from './types.js';
// ─── Helpers ─────────────────────────────────────────────────────────────────
async function createTempDir(): Promise<string> {
return mkdtemp(join(tmpdir(), 'gsd-prompt-'));
}
function makeWorkflowContent(purpose: string, steps: string[]): string {
const stepBlocks = steps
.map((s, i) => `<step name="step_${i + 1}">\n${s}\n</step>`)
.join('\n\n');
return `<purpose>\n${purpose}\n</purpose>\n\n<process>\n${stepBlocks}\n</process>`;
}
function makeAgentDef(name: string, tools: string, role: string): string {
return `---\nname: ${name}\ntools: ${tools}\n---\n\n<role>\n${role}\n</role>`;
}
function makeParsedPlan(overrides?: Partial<ParsedPlan>): ParsedPlan {
return {
frontmatter: {
phase: 'execute',
plan: 'test-plan',
type: 'feature',
wave: 1,
depends_on: [],
files_modified: [],
autonomous: true,
requirements: [],
must_haves: { truths: [], artifacts: [], key_links: [] },
} as PlanFrontmatter,
objective: 'Test objective',
execution_context: [],
context_refs: [],
tasks: [],
raw: '',
...overrides,
};
}
// ─── extractBlock tests ──────────────────────────────────────────────────────
describe('extractBlock', () => {
it('extracts content from a simple block', () => {
const content = '<purpose>\nDo the thing.\n</purpose>';
expect(extractBlock(content, 'purpose')).toBe('Do the thing.');
});
it('extracts content from block with attributes', () => {
const content = '<step name="init" priority="first">\nLoad context.\n</step>';
expect(extractBlock(content, 'step')).toBe('Load context.');
});
it('returns empty string for missing block', () => {
const content = '<purpose>Something</purpose>';
expect(extractBlock(content, 'role')).toBe('');
});
it('extracts multiline content', () => {
const content = '<role>\nLine 1\nLine 2\nLine 3\n</role>';
expect(extractBlock(content, 'role')).toBe('Line 1\nLine 2\nLine 3');
});
});
describe('extractSteps', () => {
it('extracts multiple steps from process content', () => {
const process = `
<step name="init">Initialize</step>
<step name="execute">Run tasks</step>
<step name="verify">Check results</step>`;
const steps = extractSteps(process);
expect(steps).toHaveLength(3);
expect(steps[0]).toEqual({ name: 'init', content: 'Initialize' });
expect(steps[1]).toEqual({ name: 'execute', content: 'Run tasks' });
expect(steps[2]).toEqual({ name: 'verify', content: 'Check results' });
});
it('returns empty array for no steps', () => {
expect(extractSteps('no steps here')).toEqual([]);
});
it('handles steps with priority attributes', () => {
const process = '<step name="init" priority="first">\nDo first.\n</step>';
const steps = extractSteps(process);
expect(steps).toHaveLength(1);
expect(steps[0].name).toBe('init');
expect(steps[0].content).toBe('Do first.');
});
});
// ─── PromptFactory tests ─────────────────────────────────────────────────────
describe('PromptFactory', () => {
let tempDir: string;
let workflowsDir: string;
let agentsDir: string;
beforeEach(async () => {
tempDir = await createTempDir();
workflowsDir = join(tempDir, 'workflows');
agentsDir = join(tempDir, 'agents');
await mkdir(workflowsDir, { recursive: true });
await mkdir(agentsDir, { recursive: true });
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
function makeFactory(): PromptFactory {
// sdkPromptsDir points to a non-existent temp subdir so real sdk/prompts/ files
// don't interfere — tests control exactly which files exist on disk.
return new PromptFactory({
gsdInstallDir: tempDir,
agentsDir,
sdkPromptsDir: join(tempDir, 'sdk-prompts-does-not-exist'),
});
}
describe('buildPrompt', () => {
it('assembles research prompt with role + purpose + process + context', async () => {
await writeFile(
join(workflowsDir, 'research-phase.md'),
makeWorkflowContent('Research the phase.', ['Gather info', 'Analyze findings']),
);
await writeFile(
join(agentsDir, 'gsd-phase-researcher.md'),
makeAgentDef('gsd-phase-researcher', 'Read, Grep, Bash', 'You are a researcher.'),
);
const factory = makeFactory();
const contextFiles: ContextFiles = {
state: '# State\nproject: test',
roadmap: '# Roadmap\nphases listed',
};
const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles);
expect(prompt).toContain('## Agent Instructions');
expect(prompt).toContain('You are a researcher.');
expect(prompt).toContain('## Purpose');
expect(prompt).toContain('Research the phase.');
expect(prompt).toContain('## Process');
expect(prompt).toContain('Gather info');
expect(prompt).toContain('## Context');
expect(prompt).toContain('# State');
expect(prompt).toContain('# Roadmap');
// Cache-friendly ordering (#1614): stable prefix before variable context
const agentIdx = prompt.indexOf('## Agent Instructions');
const contextIdx = prompt.indexOf('## Context');
expect(agentIdx).toBeLessThan(contextIdx);
});
it('assembles plan prompt with all context files', async () => {
await writeFile(
join(workflowsDir, 'plan-phase.md'),
makeWorkflowContent('Plan the implementation.', ['Break down tasks']),
);
await writeFile(
join(agentsDir, 'gsd-planner.md'),
makeAgentDef('gsd-planner', 'Read, Write, Bash', 'You are a planner.'),
);
const factory = makeFactory();
const contextFiles: ContextFiles = {
state: '# State',
roadmap: '# Roadmap',
context: '# Context',
research: '# Research',
requirements: '# Requirements',
};
const prompt = await factory.buildPrompt(PhaseType.Plan, null, contextFiles);
expect(prompt).toContain('You are a planner.');
expect(prompt).toContain('Plan the implementation.');
expect(prompt).toContain('# State');
expect(prompt).toContain('# Research');
expect(prompt).toContain('# Requirements');
expect(prompt).toContain('You are a planner.');
});
it('delegates execute phase with plan to buildExecutorPrompt', async () => {
await writeFile(
join(agentsDir, 'gsd-executor.md'),
makeAgentDef('gsd-executor', 'Read, Write, Edit, Bash', 'You are an executor.'),
);
const factory = makeFactory();
const plan = makeParsedPlan({ objective: 'Build the auth system' });
const contextFiles: ContextFiles = { state: '# State' };
const prompt = await factory.buildPrompt(PhaseType.Execute, plan, contextFiles);
// buildExecutorPrompt produces structured output with ## Objective
expect(prompt).toContain('## Objective');
expect(prompt).toContain('Build the auth system');
expect(prompt).toContain('## Role');
expect(prompt).toContain('You are an executor.');
});
it('handles execute phase without plan (non-delegation path)', async () => {
await writeFile(
join(workflowsDir, 'execute-plan.md'),
makeWorkflowContent('Execute the plan.', ['Run tasks']),
);
await writeFile(
join(agentsDir, 'gsd-executor.md'),
makeAgentDef('gsd-executor', 'Read, Write, Edit, Bash', 'You are an executor.'),
);
const factory = makeFactory();
const contextFiles: ContextFiles = { state: '# State' };
const prompt = await factory.buildPrompt(PhaseType.Execute, null, contextFiles);
// Falls through to general assembly path
expect(prompt).toContain('## Agent Instructions');
expect(prompt).toContain('You are an executor.');
expect(prompt).toContain('## Purpose');
expect(prompt).toContain('Execute the plan.');
});
it('assembles verify prompt with phase instructions', async () => {
await writeFile(
join(workflowsDir, 'verify-phase.md'),
makeWorkflowContent('Verify phase goals.', ['Check artifacts', 'Run tests']),
);
await writeFile(
join(agentsDir, 'gsd-verifier.md'),
makeAgentDef('gsd-verifier', 'Read, Bash, Grep', 'You are a verifier.'),
);
const factory = makeFactory();
const contextFiles: ContextFiles = {
state: '# State',
roadmap: '# Roadmap',
requirements: '# Requirements',
};
const prompt = await factory.buildPrompt(PhaseType.Verify, null, contextFiles);
expect(prompt).toContain('You are a verifier.');
expect(prompt).toContain('Verify phase goals.');
expect(prompt).toContain('You are a verifier.');
});
it('assembles discuss prompt without agent role (no dedicated agent)', async () => {
await writeFile(
join(workflowsDir, 'discuss-phase.md'),
makeWorkflowContent('Discuss implementation decisions.', ['Identify areas']),
);
const factory = makeFactory();
const contextFiles: ContextFiles = { state: '# State' };
const prompt = await factory.buildPrompt(PhaseType.Discuss, null, contextFiles);
// Discuss has no agent, so no Agent Instructions section
expect(prompt).not.toContain('## Agent Instructions');
expect(prompt).toContain('## Purpose');
expect(prompt).toContain('Discuss implementation decisions.');
});
it('handles missing workflow file gracefully', async () => {
// No workflow files on disk
await writeFile(
join(agentsDir, 'gsd-phase-researcher.md'),
makeAgentDef('gsd-phase-researcher', 'Read, Bash', 'You are a researcher.'),
);
const factory = makeFactory();
const contextFiles: ContextFiles = { state: '# State' };
const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles);
// Should still produce a prompt with agent instructions and context
expect(prompt).toContain('## Agent Instructions');
expect(prompt).toContain('## Context');
expect(prompt).not.toContain('## Purpose');
});
it('handles missing agent def gracefully', async () => {
await writeFile(
join(workflowsDir, 'research-phase.md'),
makeWorkflowContent('Research the phase.', ['Gather info']),
);
// No agent file on disk
const factory = makeFactory();
const contextFiles: ContextFiles = { state: '# State' };
const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles);
expect(prompt).not.toContain('## Agent Instructions');
expect(prompt).toContain('## Purpose');
expect(prompt).toContain('Research the phase.');
});
it('omits empty context section when no files provided', async () => {
await writeFile(
join(workflowsDir, 'discuss-phase.md'),
makeWorkflowContent('Discuss things.', ['Talk']),
);
const factory = makeFactory();
const contextFiles: ContextFiles = {};
const prompt = await factory.buildPrompt(PhaseType.Discuss, null, contextFiles);
expect(prompt).not.toContain('## Context');
});
});
describe('loadWorkflowFile', () => {
it('loads existing workflow file', async () => {
await writeFile(
join(workflowsDir, 'research-phase.md'),
'workflow content',
);
const factory = makeFactory();
const content = await factory.loadWorkflowFile(PhaseType.Research);
expect(content).toBe('workflow content');
});
it('returns undefined for missing workflow file', async () => {
const factory = makeFactory();
const content = await factory.loadWorkflowFile(PhaseType.Research);
expect(content).toBeUndefined();
});
});
describe('loadAgentDef', () => {
it('loads agent def from agents dir', async () => {
await writeFile(
join(agentsDir, 'gsd-executor.md'),
'agent content',
);
const factory = makeFactory();
const content = await factory.loadAgentDef(PhaseType.Execute);
expect(content).toBe('agent content');
});
it('returns undefined for phases with no agent (discuss)', async () => {
const factory = makeFactory();
const content = await factory.loadAgentDef(PhaseType.Discuss);
expect(content).toBeUndefined();
});
it('falls back to project agents dir', async () => {
const projectAgentsDir = join(tempDir, 'project-agents');
await mkdir(projectAgentsDir, { recursive: true });
await writeFile(
join(projectAgentsDir, 'gsd-executor.md'),
'project agent content',
);
const factory = new PromptFactory({
gsdInstallDir: tempDir,
agentsDir,
projectAgentsDir,
sdkPromptsDir: join(tempDir, 'sdk-prompts-does-not-exist'),
});
const content = await factory.loadAgentDef(PhaseType.Execute);
expect(content).toBe('project agent content');
});
it('prefers user agents dir over project agents dir', async () => {
const projectAgentsDir = join(tempDir, 'project-agents');
await mkdir(projectAgentsDir, { recursive: true });
await writeFile(join(agentsDir, 'gsd-executor.md'), 'user agent');
await writeFile(join(projectAgentsDir, 'gsd-executor.md'), 'project agent');
const factory = new PromptFactory({
gsdInstallDir: tempDir,
agentsDir,
projectAgentsDir,
sdkPromptsDir: join(tempDir, 'sdk-prompts-does-not-exist'),
});
const content = await factory.loadAgentDef(PhaseType.Execute);
expect(content).toBe('user agent');
});
});
// ─── Headless prompt loading ─────────────────────────────────────────────
describe('headless prompt loading', () => {
it('loadWorkflowFile prefers installed GSD over sdkPromptsDir', async () => {
const sdkDir = join(tempDir, 'sdk-prompts');
await mkdir(join(sdkDir, 'workflows'), { recursive: true });
// Write both: installed GSD and SDK bundled version
await writeFile(join(workflowsDir, 'research-phase.md'), 'GSD-1 original');
await writeFile(join(sdkDir, 'workflows', 'research-phase.md'), 'SDK bundled version');
const factory = new PromptFactory({
gsdInstallDir: tempDir,
agentsDir,
sdkPromptsDir: sdkDir,
});
const content = await factory.loadWorkflowFile(PhaseType.Research);
expect(content).toBe('GSD-1 original');
});
it('loadWorkflowFile falls back to GSD-1 when sdkPromptsDir file missing', async () => {
const sdkDir = join(tempDir, 'sdk-prompts');
await mkdir(join(sdkDir, 'workflows'), { recursive: true });
// Only GSD-1 original exists, no SDK version
await writeFile(join(workflowsDir, 'research-phase.md'), 'GSD-1 original');
const factory = new PromptFactory({
gsdInstallDir: tempDir,
agentsDir,
sdkPromptsDir: sdkDir,
});
const content = await factory.loadWorkflowFile(PhaseType.Research);
expect(content).toBe('GSD-1 original');
});
it('loadAgentDef prefers installed agents over sdkPromptsDir', async () => {
const sdkDir = join(tempDir, 'sdk-prompts');
await mkdir(join(sdkDir, 'agents'), { recursive: true });
// Write both: installed agent and SDK bundled agent
await writeFile(join(agentsDir, 'gsd-executor.md'), 'user agent');
await writeFile(join(sdkDir, 'agents', 'gsd-executor.md'), 'SDK bundled agent');
const factory = new PromptFactory({
gsdInstallDir: tempDir,
agentsDir,
sdkPromptsDir: sdkDir,
});
const content = await factory.loadAgentDef(PhaseType.Execute);
expect(content).toBe('user agent');
});
it('loadAgentDef falls back to user agents when sdkPromptsDir file missing', async () => {
const sdkDir = join(tempDir, 'sdk-prompts');
await mkdir(join(sdkDir, 'agents'), { recursive: true });
// Only user agent exists, no SDK version
await writeFile(join(agentsDir, 'gsd-executor.md'), 'user agent');
const factory = new PromptFactory({
gsdInstallDir: tempDir,
agentsDir,
sdkPromptsDir: sdkDir,
});
const content = await factory.loadAgentDef(PhaseType.Execute);
expect(content).toBe('user agent');
});
it('buildPrompt sanitizes interactive patterns from output', async () => {
// Use separate lines so non-interactive content survives stripping
await writeFile(
join(workflowsDir, 'research-phase.md'),
makeWorkflowContent('Research the codebase thoroughly.', [
'Gather data from the project.\nAskUserQuestion("what?")\nAnalyze findings.',
'Run the analysis.\n/gsd:analyze --deep\nDocument results.',
]),
);
await writeFile(
join(agentsDir, 'gsd-phase-researcher.md'),
makeAgentDef('gsd-phase-researcher', 'Read, Bash', 'You are a researcher.\nSTOP and wait for user input.\nBe thorough.'),
);
const factory = makeFactory();
const contextFiles: ContextFiles = { state: '# State' };
const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles);
// Interactive patterns should be stripped by sanitizePrompt()
expect(prompt).not.toContain('AskUserQuestion');
expect(prompt).not.toContain('/gsd:');
expect(prompt).not.toMatch(/\bSTOP\s+and\s+wait/);
// Non-interactive content on separate lines should remain
expect(prompt).toContain('You are a researcher.');
expect(prompt).toContain('Be thorough.');
expect(prompt).toContain('Gather data from the project.');
expect(prompt).toContain('Analyze findings.');
});
it('buildPrompt with execute+plan sanitizes output from buildExecutorPrompt', async () => {
await writeFile(
join(agentsDir, 'gsd-executor.md'),
makeAgentDef('gsd-executor', 'Read, Write, Edit, Bash', 'You are an executor.\nSTOP and wait for user.\nExecute thoroughly.'),
);
const factory = makeFactory();
const plan = makeParsedPlan({ objective: 'Build the auth system' });
const contextFiles: ContextFiles = { state: '# State' };
const prompt = await factory.buildPrompt(PhaseType.Execute, plan, contextFiles);
// Objective should remain (no interactive pattern on that line)
expect(prompt).toContain('Build the auth system');
// The role's STOP directive should be stripped
expect(prompt).not.toMatch(/\bSTOP\s+and\s+wait/);
// Non-interactive role content should remain
expect(prompt).toContain('You are an executor.');
});
});
});
describe('PHASE_WORKFLOW_MAP', () => {
it('maps all phase types to workflow filenames', () => {
for (const phase of Object.values(PhaseType)) {
expect(PHASE_WORKFLOW_MAP[phase]).toBeDefined();
expect(PHASE_WORKFLOW_MAP[phase]).toMatch(/\.md$/);
}
});
it('execute phase maps to execute-plan.md (not execute-phase.md)', () => {
expect(PHASE_WORKFLOW_MAP[PhaseType.Execute]).toBe('execute-plan.md');
});
});