- agent-usecases.ts を core/piece/ → agents/ へ移動 - schema-loader.ts を core/piece/ → infra/resources/ へ移動 - interactive-summary-types.ts を分離、shared/types/ ディレクトリを追加 - pieceExecution.ts を abortHandler / analyticsEmitter / iterationLimitHandler / outputFns / runMeta / sessionLogger に分割 - buildMergeFn を async → sync に変更(custom merge の file 戦略を削除) - cleanupOrphanedClone にパストラバーサル保護を追加 - review-fix / frontend-review-fix ピースの IT テストを追加
360 lines
11 KiB
TypeScript
360 lines
11 KiB
TypeScript
/**
|
|
* Tests for isPiecePath and loadPieceByIdentifier
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import {
|
|
isPiecePath,
|
|
loadPieceByIdentifier,
|
|
listPieces,
|
|
loadAllPieces,
|
|
loadAllPiecesWithSources,
|
|
} from '../infra/config/loaders/pieceLoader.js';
|
|
|
|
const SAMPLE_PIECE = `name: test-piece
|
|
description: Test piece
|
|
initial_movement: step1
|
|
max_movements: 1
|
|
|
|
movements:
|
|
- name: step1
|
|
persona: coder
|
|
instruction: "{task}"
|
|
`;
|
|
|
|
describe('isPiecePath', () => {
|
|
it('should return true for absolute paths', () => {
|
|
expect(isPiecePath('/path/to/piece.yaml')).toBe(true);
|
|
expect(isPiecePath('/piece')).toBe(true);
|
|
});
|
|
|
|
it('should return true for home directory paths', () => {
|
|
expect(isPiecePath('~/piece.yaml')).toBe(true);
|
|
expect(isPiecePath('~/.takt/pieces/custom.yaml')).toBe(true);
|
|
});
|
|
|
|
it('should return true for relative paths starting with ./', () => {
|
|
expect(isPiecePath('./piece.yaml')).toBe(true);
|
|
expect(isPiecePath('./subdir/piece.yaml')).toBe(true);
|
|
});
|
|
|
|
it('should return true for relative paths starting with ../', () => {
|
|
expect(isPiecePath('../piece.yaml')).toBe(true);
|
|
expect(isPiecePath('../subdir/piece.yaml')).toBe(true);
|
|
});
|
|
|
|
it('should return true for paths ending with .yaml', () => {
|
|
expect(isPiecePath('custom.yaml')).toBe(true);
|
|
expect(isPiecePath('my-piece.yaml')).toBe(true);
|
|
});
|
|
|
|
it('should return true for paths ending with .yml', () => {
|
|
expect(isPiecePath('custom.yml')).toBe(true);
|
|
expect(isPiecePath('my-piece.yml')).toBe(true);
|
|
});
|
|
|
|
it('should return false for plain piece names', () => {
|
|
expect(isPiecePath('default')).toBe(false);
|
|
expect(isPiecePath('simple')).toBe(false);
|
|
expect(isPiecePath('magi')).toBe(false);
|
|
expect(isPiecePath('my-custom-piece')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('loadPieceByIdentifier', () => {
|
|
let tempDir: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = mkdtempSync(join(tmpdir(), 'takt-test-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should load piece by name (builtin)', () => {
|
|
const piece = loadPieceByIdentifier('default', process.cwd());
|
|
expect(piece).not.toBeNull();
|
|
expect(piece!.name).toBe('default');
|
|
});
|
|
|
|
it('should load piece by absolute path', () => {
|
|
const filePath = join(tempDir, 'test.yaml');
|
|
writeFileSync(filePath, SAMPLE_PIECE);
|
|
|
|
const piece = loadPieceByIdentifier(filePath, tempDir);
|
|
expect(piece).not.toBeNull();
|
|
expect(piece!.name).toBe('test-piece');
|
|
});
|
|
|
|
it('should load piece by relative path', () => {
|
|
const filePath = join(tempDir, 'test.yaml');
|
|
writeFileSync(filePath, SAMPLE_PIECE);
|
|
|
|
const piece = loadPieceByIdentifier('./test.yaml', tempDir);
|
|
expect(piece).not.toBeNull();
|
|
expect(piece!.name).toBe('test-piece');
|
|
});
|
|
|
|
it('should load piece by filename with .yaml extension', () => {
|
|
const filePath = join(tempDir, 'test.yaml');
|
|
writeFileSync(filePath, SAMPLE_PIECE);
|
|
|
|
const piece = loadPieceByIdentifier('test.yaml', tempDir);
|
|
expect(piece).not.toBeNull();
|
|
expect(piece!.name).toBe('test-piece');
|
|
});
|
|
|
|
it('should return null for non-existent name', () => {
|
|
const piece = loadPieceByIdentifier('non-existent-piece-xyz', process.cwd());
|
|
expect(piece).toBeNull();
|
|
});
|
|
|
|
it('should return null for non-existent path', () => {
|
|
const piece = loadPieceByIdentifier('./non-existent.yaml', tempDir);
|
|
expect(piece).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('listPieces with project-local', () => {
|
|
let tempDir: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = mkdtempSync(join(tmpdir(), 'takt-test-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should include project-local pieces when cwd is provided', () => {
|
|
const projectPiecesDir = join(tempDir, '.takt', 'pieces');
|
|
mkdirSync(projectPiecesDir, { recursive: true });
|
|
writeFileSync(join(projectPiecesDir, 'project-custom.yaml'), SAMPLE_PIECE);
|
|
|
|
const pieces = listPieces(tempDir);
|
|
expect(pieces).toContain('project-custom');
|
|
});
|
|
|
|
it('should include builtin pieces regardless of cwd', () => {
|
|
const pieces = listPieces(tempDir);
|
|
expect(pieces).toContain('default');
|
|
});
|
|
|
|
});
|
|
|
|
describe('loadAllPieces with project-local', () => {
|
|
let tempDir: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = mkdtempSync(join(tmpdir(), 'takt-test-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should include project-local pieces when cwd is provided', () => {
|
|
const projectPiecesDir = join(tempDir, '.takt', 'pieces');
|
|
mkdirSync(projectPiecesDir, { recursive: true });
|
|
writeFileSync(join(projectPiecesDir, 'project-custom.yaml'), SAMPLE_PIECE);
|
|
|
|
const pieces = loadAllPieces(tempDir);
|
|
expect(pieces.has('project-custom')).toBe(true);
|
|
expect(pieces.get('project-custom')!.name).toBe('test-piece');
|
|
});
|
|
|
|
it('should have project-local override builtin when same name', () => {
|
|
const projectPiecesDir = join(tempDir, '.takt', 'pieces');
|
|
mkdirSync(projectPiecesDir, { recursive: true });
|
|
|
|
const overridePiece = `name: project-override
|
|
description: Project override
|
|
initial_movement: step1
|
|
max_movements: 1
|
|
|
|
movements:
|
|
- name: step1
|
|
persona: coder
|
|
instruction: "{task}"
|
|
`;
|
|
writeFileSync(join(projectPiecesDir, 'default.yaml'), overridePiece);
|
|
|
|
const pieces = loadAllPieces(tempDir);
|
|
expect(pieces.get('default')!.name).toBe('project-override');
|
|
});
|
|
|
|
});
|
|
|
|
describe('loadPieceByIdentifier with @scope ref (repertoire)', () => {
|
|
let tempDir: string;
|
|
let configDir: string;
|
|
const originalTaktConfigDir = process.env.TAKT_CONFIG_DIR;
|
|
|
|
beforeEach(() => {
|
|
tempDir = mkdtempSync(join(tmpdir(), 'takt-test-'));
|
|
configDir = mkdtempSync(join(tmpdir(), 'takt-config-'));
|
|
process.env.TAKT_CONFIG_DIR = configDir;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalTaktConfigDir !== undefined) {
|
|
process.env.TAKT_CONFIG_DIR = originalTaktConfigDir;
|
|
} else {
|
|
delete process.env.TAKT_CONFIG_DIR;
|
|
}
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
rmSync(configDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should load piece by @scope ref (repertoire)', () => {
|
|
// Given: repertoire package with a piece file
|
|
const piecesDir = join(configDir, 'repertoire', '@nrslib', 'takt-ensemble', 'pieces');
|
|
mkdirSync(piecesDir, { recursive: true });
|
|
writeFileSync(join(piecesDir, 'expert.yaml'), SAMPLE_PIECE);
|
|
|
|
// When: piece is loaded via @scope ref
|
|
const piece = loadPieceByIdentifier('@nrslib/takt-ensemble/expert', tempDir);
|
|
|
|
// Then: the piece is resolved correctly
|
|
expect(piece).not.toBeNull();
|
|
expect(piece!.name).toBe('test-piece');
|
|
});
|
|
|
|
it('should return null for non-existent @scope piece', () => {
|
|
// Given: repertoire dir exists but the requested piece does not
|
|
const piecesDir = join(configDir, 'repertoire', '@nrslib', 'takt-ensemble', 'pieces');
|
|
mkdirSync(piecesDir, { recursive: true });
|
|
|
|
// When: a non-existent piece is requested
|
|
const piece = loadPieceByIdentifier('@nrslib/takt-ensemble/no-such-piece', tempDir);
|
|
|
|
// Then: null is returned
|
|
expect(piece).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('loadAllPiecesWithSources with repertoire pieces', () => {
|
|
let tempDir: string;
|
|
let configDir: string;
|
|
const originalTaktConfigDir = process.env.TAKT_CONFIG_DIR;
|
|
|
|
beforeEach(() => {
|
|
tempDir = mkdtempSync(join(tmpdir(), 'takt-test-'));
|
|
configDir = mkdtempSync(join(tmpdir(), 'takt-config-'));
|
|
process.env.TAKT_CONFIG_DIR = configDir;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalTaktConfigDir !== undefined) {
|
|
process.env.TAKT_CONFIG_DIR = originalTaktConfigDir;
|
|
} else {
|
|
delete process.env.TAKT_CONFIG_DIR;
|
|
}
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
rmSync(configDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should include repertoire pieces with @scope qualified names', () => {
|
|
// Given: repertoire package with a piece file
|
|
const piecesDir = join(configDir, 'repertoire', '@nrslib', 'takt-ensemble', 'pieces');
|
|
mkdirSync(piecesDir, { recursive: true });
|
|
writeFileSync(join(piecesDir, 'expert.yaml'), SAMPLE_PIECE);
|
|
|
|
// When: all pieces are loaded
|
|
const pieces = loadAllPiecesWithSources(tempDir);
|
|
|
|
// Then: the repertoire piece is included with 'repertoire' source
|
|
expect(pieces.has('@nrslib/takt-ensemble/expert')).toBe(true);
|
|
expect(pieces.get('@nrslib/takt-ensemble/expert')!.source).toBe('repertoire');
|
|
});
|
|
|
|
it('should not throw when repertoire dir does not exist', () => {
|
|
// Given: no repertoire dir created (configDir/repertoire does not exist)
|
|
|
|
// When: all pieces are loaded
|
|
const pieces = loadAllPiecesWithSources(tempDir);
|
|
|
|
// Then: no @scope pieces are present and no error thrown
|
|
const repertoirePieces = Array.from(pieces.keys()).filter((k) => k.startsWith('@'));
|
|
expect(repertoirePieces).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('normalizeArpeggio: strategy coercion via loadPieceByIdentifier', () => {
|
|
let tempDir: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = mkdtempSync(join(tmpdir(), 'takt-arpeggio-coerce-'));
|
|
// Dummy files required by normalizeArpeggio (resolved relative to piece dir)
|
|
writeFileSync(join(tempDir, 'template.md'), '{line:1}');
|
|
writeFileSync(join(tempDir, 'data.csv'), 'col\nval');
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should preserve strategy:"custom" when loading arpeggio piece YAML', () => {
|
|
const pieceYaml = `name: arpeggio-coerce-test
|
|
initial_movement: process
|
|
max_movements: 5
|
|
movements:
|
|
- name: process
|
|
persona: coder
|
|
arpeggio:
|
|
source: csv
|
|
source_path: ./data.csv
|
|
template: ./template.md
|
|
merge:
|
|
strategy: custom
|
|
inline_js: 'return results.map(r => r.content).join(", ");'
|
|
rules:
|
|
- condition: All processed
|
|
next: COMPLETE
|
|
`;
|
|
const piecePath = join(tempDir, 'piece.yaml');
|
|
writeFileSync(piecePath, pieceYaml);
|
|
|
|
const config = loadPieceByIdentifier(piecePath, tempDir);
|
|
|
|
expect(config).not.toBeNull();
|
|
const movement = config!.movements[0]!;
|
|
expect(movement.arpeggio).toBeDefined();
|
|
expect(movement.arpeggio!.merge.strategy).toBe('custom');
|
|
expect(movement.arpeggio!.merge.inlineJs).toContain('map');
|
|
});
|
|
|
|
it('should preserve concat strategy and separator when loading arpeggio piece YAML', () => {
|
|
const pieceYaml = `name: arpeggio-concat-test
|
|
initial_movement: process
|
|
max_movements: 5
|
|
movements:
|
|
- name: process
|
|
persona: coder
|
|
arpeggio:
|
|
source: csv
|
|
source_path: ./data.csv
|
|
template: ./template.md
|
|
merge:
|
|
strategy: concat
|
|
separator: "\\n---\\n"
|
|
rules:
|
|
- condition: All processed
|
|
next: COMPLETE
|
|
`;
|
|
const piecePath = join(tempDir, 'piece.yaml');
|
|
writeFileSync(piecePath, pieceYaml);
|
|
|
|
const config = loadPieceByIdentifier(piecePath, tempDir);
|
|
|
|
expect(config).not.toBeNull();
|
|
const movement = config!.movements[0]!;
|
|
expect(movement.arpeggio!.merge.strategy).toBe('concat');
|
|
expect(movement.arpeggio!.merge.separator).toBe('\n---\n');
|
|
});
|
|
});
|