takt/src/__tests__/stance-persona.test.ts
2026-02-07 09:06:49 +09:00

677 lines
19 KiB
TypeScript

/**
* Tests for stance and persona features.
*
* Covers:
* - persona/persona_name fields in piece YAML (with agent/agent_name backward compat)
* - Piece-level stances definition and resolution
* - Movement-level stance references
* - Stance injection in InstructionBuilder
* - File-based stance content loading via resolveContentPath
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { normalizePieceConfig } from '../infra/config/loaders/pieceParser.js';
import { InstructionBuilder } from '../core/piece/instruction/InstructionBuilder.js';
import type { InstructionContext } from '../core/piece/instruction/instruction-context.js';
// --- Test helpers ---
function createTestDir(): string {
return mkdtempSync(join(tmpdir(), 'takt-stance-'));
}
function makeContext(overrides: Partial<InstructionContext> = {}): InstructionContext {
return {
task: 'Test task',
iteration: 1,
maxIterations: 10,
movementIteration: 1,
cwd: '/tmp/test',
projectCwd: '/tmp/test',
userInputs: [],
language: 'ja',
...overrides,
};
}
// --- persona alias tests ---
describe('persona alias', () => {
let testDir: string;
beforeEach(() => {
testDir = createTestDir();
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
it('should treat persona as alias for agent', () => {
const raw = {
name: 'test-piece',
movements: [
{
name: 'step1',
persona: 'inline-prompt-text',
instruction: '{task}',
},
],
};
const config = normalizePieceConfig(raw, testDir);
expect(config.movements[0]!.persona).toBe('inline-prompt-text');
});
it('should prefer persona over agent when both specified', () => {
const raw = {
name: 'test-piece',
movements: [
{
name: 'step1',
persona: 'new-persona',
instruction: '{task}',
},
],
};
const config = normalizePieceConfig(raw, testDir);
expect(config.movements[0]!.persona).toBe('new-persona');
});
it('should have undefined persona when persona not specified', () => {
const raw = {
name: 'test-piece',
movements: [
{
name: 'step1',
instruction: '{task}',
},
],
};
const config = normalizePieceConfig(raw, testDir);
expect(config.movements[0]!.persona).toBeUndefined();
});
it('should treat persona_name as display name', () => {
const raw = {
name: 'test-piece',
movements: [
{
name: 'step1',
persona: 'some-prompt',
persona_name: 'My Persona',
instruction: '{task}',
},
],
};
const config = normalizePieceConfig(raw, testDir);
expect(config.movements[0]!.personaDisplayName).toBe('My Persona');
});
it('should use persona_name as display name', () => {
const raw = {
name: 'test-piece',
movements: [
{
name: 'step1',
persona: 'some-persona',
persona_name: 'New Name',
instruction: '{task}',
},
],
};
const config = normalizePieceConfig(raw, testDir);
expect(config.movements[0]!.personaDisplayName).toBe('New Name');
});
it('should resolve persona .md file path like agent', () => {
const agentFile = join(testDir, 'my-persona.md');
writeFileSync(agentFile, '# Test Persona\nYou are a test persona.');
const raw = {
name: 'test-piece',
movements: [
{
name: 'step1',
persona: './my-persona.md',
instruction: '{task}',
},
],
};
const config = normalizePieceConfig(raw, testDir);
expect(config.movements[0]!.persona).toBe('./my-persona.md');
expect(config.movements[0]!.personaPath).toBe(agentFile);
});
it('should work with persona in parallel sub-movements', () => {
const raw = {
name: 'test-piece',
movements: [
{
name: 'parallel-step',
parallel: [
{
name: 'sub1',
persona: 'sub-persona-1',
instruction: '{task}',
},
{
name: 'sub2',
persona: 'sub-persona-2',
persona_name: 'Sub Persona 2',
instruction: '{task}',
},
],
rules: [{ condition: 'all("done")', next: 'COMPLETE' }],
},
],
};
const config = normalizePieceConfig(raw, testDir);
const parallel = config.movements[0]!.parallel!;
expect(parallel[0]!.persona).toBe('sub-persona-1');
expect(parallel[1]!.persona).toBe('sub-persona-2');
expect(parallel[1]!.personaDisplayName).toBe('Sub Persona 2');
});
});
// --- stance tests ---
describe('stances', () => {
let testDir: string;
beforeEach(() => {
testDir = createTestDir();
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
it('should resolve piece-level stances from inline content', () => {
const raw = {
name: 'test-piece',
stances: {
coding: 'Always write clean code.',
review: 'Be thorough in reviews.',
},
movements: [
{
name: 'step1',
persona: 'coder',
stance: 'coding',
instruction: '{task}',
},
],
};
const config = normalizePieceConfig(raw, testDir);
expect(config.stances).toEqual({
coding: 'Always write clean code.',
review: 'Be thorough in reviews.',
});
expect(config.movements[0]!.stanceContents).toEqual(['Always write clean code.']);
});
it('should resolve stances from .md file paths', () => {
const stancesDir = join(testDir, 'stances');
mkdirSync(stancesDir, { recursive: true });
writeFileSync(join(stancesDir, 'coding.md'), '# Coding Stance\n\nWrite clean code.');
writeFileSync(join(stancesDir, 'review.md'), '# Review Stance\n\nBe thorough.');
const raw = {
name: 'test-piece',
stances: {
coding: './stances/coding.md',
review: './stances/review.md',
},
movements: [
{
name: 'step1',
persona: 'coder',
stance: 'coding',
instruction: '{task}',
},
],
};
const config = normalizePieceConfig(raw, testDir);
expect(config.stances!['coding']).toBe('# Coding Stance\n\nWrite clean code.');
expect(config.stances!['review']).toBe('# Review Stance\n\nBe thorough.');
expect(config.movements[0]!.stanceContents).toEqual(['# Coding Stance\n\nWrite clean code.']);
});
it('should support multiple stance references (array)', () => {
const raw = {
name: 'test-piece',
stances: {
coding: 'Clean code rules.',
testing: 'Test everything.',
},
movements: [
{
name: 'step1',
persona: 'coder',
stance: ['coding', 'testing'],
instruction: '{task}',
},
],
};
const config = normalizePieceConfig(raw, testDir);
expect(config.movements[0]!.stanceContents).toEqual([
'Clean code rules.',
'Test everything.',
]);
});
it('should leave stanceContents undefined when no stance specified', () => {
const raw = {
name: 'test-piece',
stances: {
coding: 'Clean code rules.',
},
movements: [
{
name: 'step1',
persona: 'coder',
instruction: '{task}',
},
],
};
const config = normalizePieceConfig(raw, testDir);
expect(config.movements[0]!.stanceContents).toBeUndefined();
});
it('should leave stanceContents undefined for unknown stance names', () => {
const raw = {
name: 'test-piece',
stances: {
coding: 'Clean code rules.',
},
movements: [
{
name: 'step1',
persona: 'coder',
stance: 'nonexistent',
instruction: '{task}',
},
],
};
const config = normalizePieceConfig(raw, testDir);
expect(config.movements[0]!.stanceContents).toBeUndefined();
});
it('should resolve stances in parallel sub-movements', () => {
const raw = {
name: 'test-piece',
stances: {
review: 'Be thorough.',
coding: 'Write clean code.',
},
movements: [
{
name: 'reviewers',
parallel: [
{
name: 'arch-review',
persona: 'reviewer',
stance: 'review',
instruction: '{task}',
},
{
name: 'code-fix',
persona: 'coder',
stance: ['coding', 'review'],
instruction: '{task}',
},
],
rules: [{ condition: 'all("done")', next: 'COMPLETE' }],
},
],
};
const config = normalizePieceConfig(raw, testDir);
const parallel = config.movements[0]!.parallel!;
expect(parallel[0]!.stanceContents).toEqual(['Be thorough.']);
expect(parallel[1]!.stanceContents).toEqual(['Write clean code.', 'Be thorough.']);
});
it('should leave config.stances undefined when no stances defined', () => {
const raw = {
name: 'test-piece',
movements: [
{
name: 'step1',
persona: 'coder',
instruction: '{task}',
},
],
};
const config = normalizePieceConfig(raw, testDir);
expect(config.stances).toBeUndefined();
});
});
// --- stance injection in InstructionBuilder ---
describe('InstructionBuilder stance injection', () => {
it('should inject stance content into instruction (JA)', () => {
const step = {
name: 'test-step',
personaDisplayName: 'coder',
instructionTemplate: 'Do the thing.',
passPreviousResponse: false,
stanceContents: ['# Coding Stance\n\nWrite clean code.'],
};
const ctx = makeContext({ language: 'ja' });
const builder = new InstructionBuilder(step, ctx);
const result = builder.build();
expect(result).toContain('## Stance');
expect(result).toContain('# Coding Stance');
expect(result).toContain('Write clean code.');
expect(result).toContain('Stance Reminder');
});
it('should inject stance content into instruction (EN)', () => {
const step = {
name: 'test-step',
personaDisplayName: 'coder',
instructionTemplate: 'Do the thing.',
passPreviousResponse: false,
stanceContents: ['# Coding Stance\n\nWrite clean code.'],
};
const ctx = makeContext({ language: 'en' });
const builder = new InstructionBuilder(step, ctx);
const result = builder.build();
expect(result).toContain('## Stance');
expect(result).toContain('Write clean code.');
expect(result).toContain('Stance Reminder');
});
it('should not inject stance section when no stanceContents', () => {
const step = {
name: 'test-step',
personaDisplayName: 'coder',
instructionTemplate: 'Do the thing.',
passPreviousResponse: false,
};
const ctx = makeContext({ language: 'ja' });
const builder = new InstructionBuilder(step, ctx);
const result = builder.build();
expect(result).not.toContain('## Stance');
expect(result).not.toContain('Stance Reminder');
});
it('should join multiple stances with separator', () => {
const step = {
name: 'test-step',
personaDisplayName: 'coder',
instructionTemplate: 'Do the thing.',
passPreviousResponse: false,
stanceContents: ['Stance A content.', 'Stance B content.'],
};
const ctx = makeContext({ language: 'en' });
const builder = new InstructionBuilder(step, ctx);
const result = builder.build();
expect(result).toContain('Stance A content.');
expect(result).toContain('Stance B content.');
expect(result).toContain('---');
});
it('should prefer context stanceContents over step stanceContents', () => {
const step = {
name: 'test-step',
personaDisplayName: 'coder',
instructionTemplate: 'Do the thing.',
passPreviousResponse: false,
stanceContents: ['Step stance.'],
};
const ctx = makeContext({
language: 'en',
stanceContents: ['Context stance.'],
});
const builder = new InstructionBuilder(step, ctx);
const result = builder.build();
expect(result).toContain('Context stance.');
expect(result).not.toContain('Step stance.');
});
});
// --- section reference tests ---
describe('section reference resolution', () => {
let testDir: string;
beforeEach(() => {
testDir = createTestDir();
// Create resource files
mkdirSync(join(testDir, 'personas'), { recursive: true });
mkdirSync(join(testDir, 'stances'), { recursive: true });
mkdirSync(join(testDir, 'instructions'), { recursive: true });
mkdirSync(join(testDir, 'report-formats'), { recursive: true });
writeFileSync(join(testDir, 'personas', 'coder.md'), '# Coder\nYou are a coder.');
writeFileSync(join(testDir, 'stances', 'coding.md'), '# Coding Stance\nWrite clean code.');
writeFileSync(join(testDir, 'stances', 'testing.md'), '# Testing Stance\nTest everything.');
writeFileSync(join(testDir, 'instructions', 'implement.md'), 'Implement the feature.');
writeFileSync(join(testDir, 'report-formats', 'plan.md'), '# Plan Report\n## Goal\n{goal}');
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
it('should resolve persona from personas section by name', () => {
const raw = {
name: 'test-piece',
personas: { coder: './personas/coder.md' },
movements: [{
name: 'impl',
persona: 'coder',
instruction: '{task}',
}],
};
const config = normalizePieceConfig(raw, testDir);
expect(config.movements[0]!.persona).toBe('./personas/coder.md');
expect(config.movements[0]!.personaPath).toBe(join(testDir, 'personas', 'coder.md'));
});
it('should resolve stance from stances section by name', () => {
const raw = {
name: 'test-piece',
stances: { coding: './stances/coding.md' },
movements: [{
name: 'impl',
persona: 'coder',
stance: 'coding',
instruction: '{task}',
}],
};
const config = normalizePieceConfig(raw, testDir);
expect(config.movements[0]!.stanceContents).toEqual(['# Coding Stance\nWrite clean code.']);
});
it('should resolve mixed stance array: [section-name, ./path]', () => {
const raw = {
name: 'test-piece',
stances: { coding: './stances/coding.md' },
movements: [{
name: 'impl',
persona: 'coder',
stance: ['coding', './stances/testing.md'],
instruction: '{task}',
}],
};
const config = normalizePieceConfig(raw, testDir);
expect(config.movements[0]!.stanceContents).toEqual([
'# Coding Stance\nWrite clean code.',
'# Testing Stance\nTest everything.',
]);
});
it('should resolve instruction from instructions section by name', () => {
const raw = {
name: 'test-piece',
instructions: { implement: './instructions/implement.md' },
movements: [{
name: 'impl',
persona: 'coder',
instruction: 'implement',
}],
};
const config = normalizePieceConfig(raw, testDir);
expect(config.movements[0]!.instructionTemplate).toBe('Implement the feature.');
});
it('should resolve report format from report_formats section by name', () => {
const raw = {
name: 'test-piece',
report_formats: { plan: './report-formats/plan.md' },
movements: [{
name: 'plan',
persona: 'planner',
instruction: '{task}',
report: {
name: '00-plan.md',
format: 'plan',
},
}],
};
const config = normalizePieceConfig(raw, testDir);
const report = config.movements[0]!.report as { name: string; format?: string };
expect(report.format).toBe('# Plan Report\n## Goal\n{goal}');
});
it('should treat unresolved name as inline value (no section match)', () => {
const raw = {
name: 'test-piece',
movements: [{
name: 'impl',
persona: 'nonexistent',
instruction: '{task}',
}],
};
const config = normalizePieceConfig(raw, testDir);
// No matching section key → treated as inline persona spec
expect(config.movements[0]!.persona).toBe('nonexistent');
});
it('should prefer instruction_template over instruction section reference', () => {
const raw = {
name: 'test-piece',
instructions: { implement: './instructions/implement.md' },
movements: [{
name: 'impl',
persona: 'coder',
instruction: 'implement',
instruction_template: 'Inline template takes priority.',
}],
};
const config = normalizePieceConfig(raw, testDir);
expect(config.movements[0]!.instructionTemplate).toBe('Inline template takes priority.');
});
it('should store resolved sections on PieceConfig', () => {
const raw = {
name: 'test-piece',
personas: { coder: './personas/coder.md' },
stances: { coding: './stances/coding.md' },
instructions: { implement: './instructions/implement.md' },
report_formats: { plan: './report-formats/plan.md' },
movements: [{
name: 'impl',
persona: 'coder',
instruction: '{task}',
}],
};
const config = normalizePieceConfig(raw, testDir);
expect(config.personas).toEqual({ coder: './personas/coder.md' });
expect(config.stances).toEqual({ coding: '# Coding Stance\nWrite clean code.' });
expect(config.instructions).toEqual({ implement: 'Implement the feature.' });
expect(config.reportFormats).toEqual({ plan: '# Plan Report\n## Goal\n{goal}' });
});
it('should work with section references in parallel sub-movements', () => {
const raw = {
name: 'test-piece',
personas: { coder: './personas/coder.md' },
stances: { coding: './stances/coding.md', testing: './stances/testing.md' },
instructions: { implement: './instructions/implement.md' },
movements: [{
name: 'parallel-step',
parallel: [
{
name: 'sub1',
persona: 'coder',
stance: 'coding',
instruction: 'implement',
},
{
name: 'sub2',
persona: 'coder',
stance: ['coding', 'testing'],
instruction: '{task}',
},
],
rules: [{ condition: 'all("done")', next: 'COMPLETE' }],
}],
};
const config = normalizePieceConfig(raw, testDir);
const parallel = config.movements[0]!.parallel!;
expect(parallel[0]!.persona).toBe('./personas/coder.md');
expect(parallel[0]!.stanceContents).toEqual(['# Coding Stance\nWrite clean code.']);
expect(parallel[0]!.instructionTemplate).toBe('Implement the feature.');
expect(parallel[1]!.stanceContents).toEqual([
'# Coding Stance\nWrite clean code.',
'# Testing Stance\nTest everything.',
]);
});
it('should resolve stance by plain name (primary mechanism)', () => {
const raw = {
name: 'test-piece',
stances: { coding: './stances/coding.md' },
movements: [{
name: 'impl',
persona: 'coder',
stance: 'coding',
instruction: '{task}',
}],
};
const config = normalizePieceConfig(raw, testDir);
expect(config.movements[0]!.stanceContents).toEqual(['# Coding Stance\nWrite clean code.']);
});
});