Agents were bulk-reading all context task files (39 files) because filenames are opaque IDs and there was no way to find phase-relevant tasks without reading every file. Now writeInputFiles generates a context/index.json with tasksByPhase mapping phaseId to task metadata (file, id, name, status). Prompt updated to direct agents to read the index first.
370 lines
11 KiB
TypeScript
370 lines
11 KiB
TypeScript
/**
|
|
* File-Based Agent I/O Tests
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { randomUUID } from 'crypto';
|
|
import {
|
|
writeInputFiles,
|
|
readSummary,
|
|
readPhaseFiles,
|
|
readTaskFiles,
|
|
readDecisionFiles,
|
|
readPageFiles,
|
|
generateId,
|
|
} from './file-io.js';
|
|
import type { Initiative, Phase, Task } from '../db/schema.js';
|
|
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = join(tmpdir(), `cw-file-io-test-${randomUUID()}`);
|
|
mkdirSync(testDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('generateId', () => {
|
|
it('returns a non-empty string', () => {
|
|
const id = generateId();
|
|
expect(id).toBeTruthy();
|
|
expect(typeof id).toBe('string');
|
|
});
|
|
|
|
it('returns unique values', () => {
|
|
const ids = new Set(Array.from({ length: 100 }, () => generateId()));
|
|
expect(ids.size).toBe(100);
|
|
});
|
|
});
|
|
|
|
describe('writeInputFiles', () => {
|
|
it('writes initiative.md with frontmatter', () => {
|
|
const initiative: Initiative = {
|
|
id: 'init-1',
|
|
name: 'Test Initiative',
|
|
status: 'active',
|
|
mergeRequiresApproval: true,
|
|
branch: 'cw/test-initiative',
|
|
executionMode: 'review_per_phase',
|
|
createdAt: new Date('2026-01-01'),
|
|
updatedAt: new Date('2026-01-02'),
|
|
};
|
|
|
|
writeInputFiles({ agentWorkdir: testDir, initiative });
|
|
|
|
const filePath = join(testDir, '.cw', 'input', 'initiative.md');
|
|
expect(existsSync(filePath)).toBe(true);
|
|
});
|
|
|
|
it('writes phase.md with frontmatter', () => {
|
|
const phase = {
|
|
id: 'phase-1',
|
|
initiativeId: 'init-1',
|
|
number: 1,
|
|
name: 'Phase One',
|
|
content: 'First phase',
|
|
status: 'pending',
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
} as Phase;
|
|
|
|
writeInputFiles({ agentWorkdir: testDir, phase });
|
|
|
|
const filePath = join(testDir, '.cw', 'input', 'phase.md');
|
|
expect(existsSync(filePath)).toBe(true);
|
|
});
|
|
|
|
it('writes task.md with frontmatter', () => {
|
|
const task = {
|
|
id: 'task-1',
|
|
name: 'Test Task',
|
|
description: 'Do the thing',
|
|
category: 'execute',
|
|
type: 'auto',
|
|
priority: 'medium',
|
|
status: 'pending',
|
|
order: 1,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
} as Task;
|
|
|
|
writeInputFiles({ agentWorkdir: testDir, task });
|
|
|
|
const filePath = join(testDir, '.cw', 'input', 'task.md');
|
|
expect(existsSync(filePath)).toBe(true);
|
|
});
|
|
|
|
it('writes pages to pages/ subdirectory', () => {
|
|
writeInputFiles({
|
|
agentWorkdir: testDir,
|
|
pages: [
|
|
{ id: 'page-1', parentPageId: null, title: 'Root', content: null, sortOrder: 0 },
|
|
{ id: 'page-2', parentPageId: 'page-1', title: 'Child', content: null, sortOrder: 1 },
|
|
],
|
|
});
|
|
|
|
expect(existsSync(join(testDir, '.cw', 'input', 'pages', 'page-1.md'))).toBe(true);
|
|
expect(existsSync(join(testDir, '.cw', 'input', 'pages', 'page-2.md'))).toBe(true);
|
|
});
|
|
|
|
it('handles empty options without error', () => {
|
|
writeInputFiles({ agentWorkdir: testDir });
|
|
expect(existsSync(join(testDir, '.cw', 'input'))).toBe(true);
|
|
});
|
|
|
|
it('writes context/index.json grouping tasks by phaseId', () => {
|
|
writeInputFiles({
|
|
agentWorkdir: testDir,
|
|
tasks: [
|
|
{ id: 't1', name: 'Task A', phaseId: 'ph1', status: 'pending', category: 'execute', type: 'auto', priority: 'medium' } as Task,
|
|
{ id: 't2', name: 'Task B', phaseId: 'ph1', status: 'completed', category: 'execute', type: 'auto', priority: 'medium', summary: 'Done' } as Task,
|
|
{ id: 't3', name: 'Task C', phaseId: 'ph2', status: 'pending', category: 'research', type: 'auto', priority: 'high' } as Task,
|
|
],
|
|
});
|
|
|
|
const indexPath = join(testDir, '.cw', 'input', 'context', 'index.json');
|
|
expect(existsSync(indexPath)).toBe(true);
|
|
const index = JSON.parse(readFileSync(indexPath, 'utf-8'));
|
|
expect(index.tasksByPhase.ph1).toHaveLength(2);
|
|
expect(index.tasksByPhase.ph2).toHaveLength(1);
|
|
expect(index.tasksByPhase.ph1[0]).toEqual({
|
|
file: 'context/tasks/t1.md',
|
|
id: 't1',
|
|
name: 'Task A',
|
|
status: 'pending',
|
|
});
|
|
});
|
|
|
|
it('does not write context/index.json when no tasks', () => {
|
|
writeInputFiles({ agentWorkdir: testDir });
|
|
expect(existsSync(join(testDir, '.cw', 'input', 'context', 'index.json'))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('readSummary', () => {
|
|
it('reads SUMMARY.md with frontmatter', () => {
|
|
const outputDir = join(testDir, '.cw', 'output');
|
|
mkdirSync(outputDir, { recursive: true });
|
|
|
|
writeFileSync(
|
|
join(outputDir, 'SUMMARY.md'),
|
|
`---
|
|
files_modified:
|
|
- src/foo.ts
|
|
- src/bar.ts
|
|
---
|
|
Task completed successfully. Refactored the module.
|
|
`,
|
|
'utf-8',
|
|
);
|
|
|
|
const summary = readSummary(testDir);
|
|
expect(summary).not.toBeNull();
|
|
expect(summary!.body).toBe('Task completed successfully. Refactored the module.');
|
|
expect(summary!.filesModified).toEqual(['src/foo.ts', 'src/bar.ts']);
|
|
});
|
|
|
|
it('returns null when SUMMARY.md does not exist', () => {
|
|
const summary = readSummary(testDir);
|
|
expect(summary).toBeNull();
|
|
});
|
|
|
|
it('handles SUMMARY.md without frontmatter', () => {
|
|
const outputDir = join(testDir, '.cw', 'output');
|
|
mkdirSync(outputDir, { recursive: true });
|
|
writeFileSync(join(outputDir, 'SUMMARY.md'), 'Just plain text\n', 'utf-8');
|
|
|
|
const summary = readSummary(testDir);
|
|
expect(summary).not.toBeNull();
|
|
expect(summary!.body).toBe('Just plain text');
|
|
expect(summary!.filesModified).toBeUndefined();
|
|
});
|
|
|
|
it('handles empty files_modified', () => {
|
|
const outputDir = join(testDir, '.cw', 'output');
|
|
mkdirSync(outputDir, { recursive: true });
|
|
writeFileSync(
|
|
join(outputDir, 'SUMMARY.md'),
|
|
`---
|
|
files_modified: []
|
|
---
|
|
Done.
|
|
`,
|
|
'utf-8',
|
|
);
|
|
|
|
const summary = readSummary(testDir);
|
|
expect(summary!.filesModified).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('readPhaseFiles', () => {
|
|
it('reads phase files from phases/ directory', () => {
|
|
const phasesDir = join(testDir, '.cw', 'output', 'phases');
|
|
mkdirSync(phasesDir, { recursive: true });
|
|
|
|
writeFileSync(
|
|
join(phasesDir, 'abc123.md'),
|
|
`---
|
|
title: Database Schema
|
|
dependencies:
|
|
- xyz789
|
|
---
|
|
Create the user tables and auth schema.
|
|
`,
|
|
'utf-8',
|
|
);
|
|
|
|
const phases = readPhaseFiles(testDir);
|
|
expect(phases).toHaveLength(1);
|
|
expect(phases[0].id).toBe('abc123');
|
|
expect(phases[0].title).toBe('Database Schema');
|
|
expect(phases[0].dependencies).toEqual(['xyz789']);
|
|
expect(phases[0].body).toBe('Create the user tables and auth schema.');
|
|
});
|
|
|
|
it('returns empty array when directory does not exist', () => {
|
|
const phases = readPhaseFiles(testDir);
|
|
expect(phases).toEqual([]);
|
|
});
|
|
|
|
it('handles phases with no dependencies', () => {
|
|
const phasesDir = join(testDir, '.cw', 'output', 'phases');
|
|
mkdirSync(phasesDir, { recursive: true });
|
|
|
|
writeFileSync(
|
|
join(phasesDir, 'p1.md'),
|
|
`---
|
|
title: Foundation
|
|
---
|
|
Set up the base.
|
|
`,
|
|
'utf-8',
|
|
);
|
|
|
|
const phases = readPhaseFiles(testDir);
|
|
expect(phases[0].dependencies).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('readTaskFiles', () => {
|
|
it('reads task files from tasks/ directory', () => {
|
|
const tasksDir = join(testDir, '.cw', 'output', 'tasks');
|
|
mkdirSync(tasksDir, { recursive: true });
|
|
|
|
writeFileSync(
|
|
join(tasksDir, 'task-1.md'),
|
|
`---
|
|
title: Implement login
|
|
category: execute
|
|
type: auto
|
|
dependencies:
|
|
- task-0
|
|
---
|
|
Build the login form and submit handler.
|
|
`,
|
|
'utf-8',
|
|
);
|
|
|
|
const tasks = readTaskFiles(testDir);
|
|
expect(tasks).toHaveLength(1);
|
|
expect(tasks[0].id).toBe('task-1');
|
|
expect(tasks[0].title).toBe('Implement login');
|
|
expect(tasks[0].category).toBe('execute');
|
|
expect(tasks[0].type).toBe('auto');
|
|
expect(tasks[0].dependencies).toEqual(['task-0']);
|
|
expect(tasks[0].body).toBe('Build the login form and submit handler.');
|
|
});
|
|
|
|
it('defaults category and type when missing', () => {
|
|
const tasksDir = join(testDir, '.cw', 'output', 'tasks');
|
|
mkdirSync(tasksDir, { recursive: true });
|
|
writeFileSync(join(tasksDir, 't1.md'), `---\ntitle: Minimal\n---\nDo it.\n`, 'utf-8');
|
|
|
|
const tasks = readTaskFiles(testDir);
|
|
expect(tasks[0].category).toBe('execute');
|
|
expect(tasks[0].type).toBe('auto');
|
|
});
|
|
|
|
it('returns empty array when directory does not exist', () => {
|
|
expect(readTaskFiles(testDir)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('readDecisionFiles', () => {
|
|
it('reads decision files from decisions/ directory', () => {
|
|
const decisionsDir = join(testDir, '.cw', 'output', 'decisions');
|
|
mkdirSync(decisionsDir, { recursive: true });
|
|
|
|
writeFileSync(
|
|
join(decisionsDir, 'd1.md'),
|
|
`---
|
|
topic: Authentication
|
|
decision: Use JWT
|
|
reason: Stateless and scalable
|
|
---
|
|
Additional context about the decision.
|
|
`,
|
|
'utf-8',
|
|
);
|
|
|
|
const decisions = readDecisionFiles(testDir);
|
|
expect(decisions).toHaveLength(1);
|
|
expect(decisions[0].id).toBe('d1');
|
|
expect(decisions[0].topic).toBe('Authentication');
|
|
expect(decisions[0].decision).toBe('Use JWT');
|
|
expect(decisions[0].reason).toBe('Stateless and scalable');
|
|
expect(decisions[0].body).toBe('Additional context about the decision.');
|
|
});
|
|
|
|
it('returns empty array when directory does not exist', () => {
|
|
expect(readDecisionFiles(testDir)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('readPageFiles', () => {
|
|
it('reads page files from pages/ directory', () => {
|
|
const pagesDir = join(testDir, '.cw', 'output', 'pages');
|
|
mkdirSync(pagesDir, { recursive: true });
|
|
|
|
writeFileSync(
|
|
join(pagesDir, 'page-abc.md'),
|
|
`---
|
|
title: Architecture Overview
|
|
summary: Updated the overview section
|
|
---
|
|
# Architecture
|
|
|
|
New content for the page.
|
|
`,
|
|
'utf-8',
|
|
);
|
|
|
|
const pages = readPageFiles(testDir);
|
|
expect(pages).toHaveLength(1);
|
|
expect(pages[0].pageId).toBe('page-abc');
|
|
expect(pages[0].title).toBe('Architecture Overview');
|
|
expect(pages[0].summary).toBe('Updated the overview section');
|
|
expect(pages[0].body).toBe('# Architecture\n\nNew content for the page.');
|
|
});
|
|
|
|
it('returns empty array when directory does not exist', () => {
|
|
expect(readPageFiles(testDir)).toEqual([]);
|
|
});
|
|
|
|
it('ignores non-.md files', () => {
|
|
const pagesDir = join(testDir, '.cw', 'output', 'pages');
|
|
mkdirSync(pagesDir, { recursive: true });
|
|
writeFileSync(join(pagesDir, 'readme.txt'), 'not a page', 'utf-8');
|
|
writeFileSync(join(pagesDir, 'page1.md'), '---\ntitle: Page 1\n---\nContent.\n', 'utf-8');
|
|
|
|
const pages = readPageFiles(testDir);
|
|
expect(pages).toHaveLength(1);
|
|
});
|
|
});
|