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>
350 lines
11 KiB
TypeScript
350 lines
11 KiB
TypeScript
/**
|
|
* Contract test: assembled prompts from PromptFactory.buildPrompt() and
|
|
* InitRunner.build*Prompt() must contain zero interactive patterns.
|
|
*
|
|
* Unlike headless-prompts.test.ts (which scans raw .md files on disk),
|
|
* these tests exercise the full assembly pipeline:
|
|
* file loading → role extraction → context injection → sanitizePrompt()
|
|
*
|
|
* If any assembly step reintroduces interactive patterns that sanitizePrompt()
|
|
* doesn't catch, these tests will fail.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
|
import { join, dirname } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { PromptFactory } from './phase-prompt.js';
|
|
import { InitRunner } from './init-runner.js';
|
|
import { PhaseType } from './types.js';
|
|
import type { ParsedPlan, ContextFiles, GSDEvent } from './types.js';
|
|
import type { GSDTools } from './gsd-tools.js';
|
|
import type { GSDEventStream } from './event-stream.js';
|
|
|
|
// ─── Paths ───────────────────────────────────────────────────────────────────
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const sdkPromptsDir = join(__dirname, '..', 'prompts');
|
|
|
|
// ─── Blocked patterns (aligned with headless-prompts.test.ts) ────────────────
|
|
|
|
const BLOCKED_PATTERNS: Array<[string, RegExp]> = [
|
|
['AskUserQuestion', /AskUserQuestion\s*\(/],
|
|
['SlashCommand', /SlashCommand\s*\(/],
|
|
['/gsd: command', /\/gsd:\S+/],
|
|
['@file: reference', /@file:\S+/],
|
|
['STOP + wait directive', /\bSTOP\b\s+(?:and\s+)?(?:wait|ask)/i],
|
|
['bare STOP directive', /^\s*STOP\s*[.!]?\s*$/m],
|
|
['wait for user', /\bwait\s+for\s+(?:the\s+)?user\b/i],
|
|
['ask the user', /\bask\s+the\s+user\b/i],
|
|
];
|
|
|
|
// ─── Minimal fixtures ────────────────────────────────────────────────────────
|
|
|
|
const MINIMAL_PLAN: ParsedPlan = {
|
|
frontmatter: {
|
|
phase: '01',
|
|
plan: 'test-plan',
|
|
type: 'feature',
|
|
wave: 1,
|
|
depends_on: [],
|
|
files_modified: ['src/index.ts'],
|
|
autonomous: true,
|
|
requirements: ['R001'],
|
|
must_haves: {
|
|
truths: ['It works'],
|
|
artifacts: [{ path: 'src/index.ts', provides: 'entry point' }],
|
|
key_links: [],
|
|
},
|
|
},
|
|
objective: 'Test objective for assembled prompt contract test',
|
|
execution_context: ['This is a test context line'],
|
|
context_refs: [],
|
|
tasks: [
|
|
{
|
|
type: 'create',
|
|
name: 'Create test file',
|
|
files: ['src/test.ts'],
|
|
read_first: [],
|
|
action: 'Create a test file',
|
|
verify: 'File exists',
|
|
acceptance_criteria: ['File created'],
|
|
done: 'src/test.ts exists',
|
|
},
|
|
],
|
|
raw: '# Test Plan\n\nMinimal plan for testing.',
|
|
};
|
|
|
|
const EMPTY_CONTEXT: ContextFiles = {};
|
|
|
|
// ─── Helper ──────────────────────────────────────────────────────────────────
|
|
|
|
function assertNoBlockedPatterns(output: string, label: string): void {
|
|
for (const [patternLabel, pattern] of BLOCKED_PATTERNS) {
|
|
const matches = output.match(new RegExp(pattern.source, pattern.flags + 'g'));
|
|
expect(
|
|
matches,
|
|
`Found ${patternLabel} in ${label}: ${matches?.join(', ')}`,
|
|
).toBeNull();
|
|
}
|
|
}
|
|
|
|
// ─── PromptFactory assembled output ──────────────────────────────────────────
|
|
|
|
describe('PromptFactory assembled output', () => {
|
|
let factory: PromptFactory;
|
|
|
|
beforeAll(() => {
|
|
factory = new PromptFactory({ sdkPromptsDir });
|
|
});
|
|
|
|
const phaseTypes = Object.values(PhaseType) as PhaseType[];
|
|
|
|
for (const phaseType of phaseTypes) {
|
|
describe(`${phaseType} phase`, () => {
|
|
let output: string;
|
|
|
|
beforeAll(async () => {
|
|
output = await factory.buildPrompt(phaseType, MINIMAL_PLAN, EMPTY_CONTEXT);
|
|
});
|
|
|
|
it('produces non-empty output', () => {
|
|
expect(output.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
for (const [label, pattern] of BLOCKED_PATTERNS) {
|
|
it(`contains no ${label}`, () => {
|
|
const matches = output.match(new RegExp(pattern.source, pattern.flags + 'g'));
|
|
expect(
|
|
matches,
|
|
`Found ${label} in ${phaseType} assembled prompt: ${matches?.join(', ')}`,
|
|
).toBeNull();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
it('includes role section for phases with agents', async () => {
|
|
// Research, Plan, Execute, Verify all have agents; Discuss does not
|
|
const researchOutput = await factory.buildPrompt(PhaseType.Research, null, EMPTY_CONTEXT);
|
|
expect(researchOutput).toContain('## Agent Instructions');
|
|
});
|
|
|
|
it('includes purpose section from workflow files', async () => {
|
|
const planOutput = await factory.buildPrompt(PhaseType.Plan, null, EMPTY_CONTEXT);
|
|
// Plan phase should have purpose from plan-phase.md
|
|
expect(planOutput).toContain('## Purpose');
|
|
});
|
|
|
|
it('includes context section when context files provided', async () => {
|
|
const contextFiles: ContextFiles = {
|
|
state: '# State\ncurrent_phase: 01',
|
|
roadmap: '# Roadmap\n## Phase 01',
|
|
};
|
|
const output = await factory.buildPrompt(PhaseType.Research, null, contextFiles);
|
|
expect(output).toContain('## Context');
|
|
expect(output).toContain('Project State');
|
|
});
|
|
});
|
|
|
|
// ─── InitRunner assembled output ─────────────────────────────────────────────
|
|
|
|
describe('InitRunner assembled output', () => {
|
|
let tmpDir: string;
|
|
let runner: InitRunner;
|
|
|
|
// Minimal stub tools and event stream — we only call build*Prompt(), not run()
|
|
const stubTools: GSDTools = {
|
|
initNewProject: async () => ({
|
|
researcher_model: 'test',
|
|
synthesizer_model: 'test',
|
|
roadmapper_model: 'test',
|
|
commit_docs: false,
|
|
project_exists: false,
|
|
has_codebase_map: false,
|
|
has_git: true,
|
|
}),
|
|
configSet: async () => {},
|
|
commit: async () => {},
|
|
} as unknown as GSDTools;
|
|
|
|
const stubEventStream: GSDEventStream = {
|
|
emitEvent: (_event: GSDEvent) => {},
|
|
} as unknown as GSDEventStream;
|
|
|
|
beforeAll(async () => {
|
|
// Create temp directory with .planning/ structure for InitRunner file reads
|
|
tmpDir = await mkdtemp(join(tmpdir(), 'assembled-prompts-'));
|
|
const planningDir = join(tmpDir, '.planning');
|
|
const researchDir = join(planningDir, 'research');
|
|
await mkdir(researchDir, { recursive: true });
|
|
|
|
// Write minimal stubs that InitRunner reads
|
|
await writeFile(
|
|
join(planningDir, 'PROJECT.md'),
|
|
'# Test Project\n\nA minimal test project for contract testing.\n',
|
|
);
|
|
await writeFile(
|
|
join(planningDir, 'config.json'),
|
|
JSON.stringify({ mode: 'yolo', parallelization: true }, null, 2),
|
|
);
|
|
await writeFile(
|
|
join(planningDir, 'REQUIREMENTS.md'),
|
|
'# Requirements\n\n## R001 — Test Requirement\n',
|
|
);
|
|
await writeFile(
|
|
join(researchDir, 'STACK.md'),
|
|
'# Stack Research\n\nTypeScript + Node.js\n',
|
|
);
|
|
await writeFile(
|
|
join(researchDir, 'FEATURES.md'),
|
|
'# Features Research\n\nCore features identified.\n',
|
|
);
|
|
await writeFile(
|
|
join(researchDir, 'ARCHITECTURE.md'),
|
|
'# Architecture Research\n\nModular architecture.\n',
|
|
);
|
|
await writeFile(
|
|
join(researchDir, 'PITFALLS.md'),
|
|
'# Pitfalls Research\n\nCommon pitfalls noted.\n',
|
|
);
|
|
await writeFile(
|
|
join(researchDir, 'SUMMARY.md'),
|
|
'# Research Summary\n\nAll research synthesized.\n',
|
|
);
|
|
|
|
runner = new InitRunner({
|
|
projectDir: tmpDir,
|
|
tools: stubTools,
|
|
eventStream: stubEventStream,
|
|
sdkPromptsDir,
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (tmpDir) {
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// Access private methods via (runner as any) — standard pattern for testing
|
|
// private methods in TypeScript without subclassing or mocking
|
|
|
|
describe('buildProjectPrompt', () => {
|
|
let output: string;
|
|
|
|
beforeAll(async () => {
|
|
output = await (runner as any).buildProjectPrompt('Build a CLI tool');
|
|
});
|
|
|
|
it('produces non-empty output', () => {
|
|
expect(output.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('contains project template content', () => {
|
|
expect(output).toContain('PROJECT.md');
|
|
});
|
|
|
|
it('contains user input', () => {
|
|
expect(output).toContain('Build a CLI tool');
|
|
});
|
|
|
|
it('contains zero blocked patterns', () => {
|
|
assertNoBlockedPatterns(output, 'buildProjectPrompt');
|
|
});
|
|
});
|
|
|
|
describe('buildResearchPrompt', () => {
|
|
const researchTypes = ['STACK', 'FEATURES', 'ARCHITECTURE', 'PITFALLS'] as const;
|
|
|
|
for (const researchType of researchTypes) {
|
|
describe(`${researchType} research`, () => {
|
|
let output: string;
|
|
|
|
beforeAll(async () => {
|
|
output = await (runner as any).buildResearchPrompt(researchType, 'Build a CLI tool');
|
|
});
|
|
|
|
it('produces non-empty output', () => {
|
|
expect(output.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('references the research type', () => {
|
|
expect(output).toContain(researchType);
|
|
});
|
|
|
|
it('contains zero blocked patterns', () => {
|
|
assertNoBlockedPatterns(output, `buildResearchPrompt(${researchType})`);
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('buildSynthesisPrompt', () => {
|
|
let output: string;
|
|
|
|
beforeAll(async () => {
|
|
output = await (runner as any).buildSynthesisPrompt();
|
|
});
|
|
|
|
it('produces non-empty output', () => {
|
|
expect(output.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('contains research content from temp files', () => {
|
|
// The synthesis prompt reads research files from disk — our stubs should appear
|
|
expect(output).toContain('Stack Research');
|
|
});
|
|
|
|
it('contains zero blocked patterns', () => {
|
|
assertNoBlockedPatterns(output, 'buildSynthesisPrompt');
|
|
});
|
|
});
|
|
|
|
describe('buildRequirementsPrompt', () => {
|
|
let output: string;
|
|
|
|
beforeAll(async () => {
|
|
output = await (runner as any).buildRequirementsPrompt();
|
|
});
|
|
|
|
it('produces non-empty output', () => {
|
|
expect(output.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('contains project context from temp files', () => {
|
|
expect(output).toContain('Test Project');
|
|
});
|
|
|
|
it('contains zero blocked patterns', () => {
|
|
assertNoBlockedPatterns(output, 'buildRequirementsPrompt');
|
|
});
|
|
});
|
|
|
|
describe('buildRoadmapPrompt', () => {
|
|
let output: string;
|
|
|
|
beforeAll(async () => {
|
|
output = await (runner as any).buildRoadmapPrompt();
|
|
});
|
|
|
|
it('produces non-empty output', () => {
|
|
expect(output.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('contains agent definition content', () => {
|
|
// Roadmap prompt loads gsd-roadmapper.md
|
|
expect(output).toContain('agent_definition');
|
|
});
|
|
|
|
it('contains project file content', () => {
|
|
expect(output).toContain('Test Project');
|
|
});
|
|
|
|
it('contains zero blocked patterns', () => {
|
|
assertNoBlockedPatterns(output, 'buildRoadmapPrompt');
|
|
});
|
|
});
|
|
});
|