Integrates main branch changes (headquarters dashboard, task retry count, agent prompt persistence, remote sync improvements) with the initiative's errand agent feature. Both features coexist in the merged result. Key resolutions: - Schema: take main's errands table (nullable projectId, no conflictFiles, with errandsRelations); migrate to 0035_faulty_human_fly - Router: keep both errandProcedures and headquartersProcedures - Errand prompt: take main's simpler version (no question-asking flow) - Manager: take main's status check (running|idle only, no waiting_for_input) - Tests: update to match removed conflictFiles field and undefined vs null
485 lines
15 KiB
TypeScript
485 lines
15 KiB
TypeScript
/**
|
|
* File-Based Agent I/O Tests
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, afterAll } 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,
|
|
writeErrandManifest,
|
|
} from './file-io.js';
|
|
import { buildErrandPrompt } from './prompts/index.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', async () => {
|
|
const initiative: Initiative = {
|
|
id: 'init-1',
|
|
name: 'Test Initiative',
|
|
status: 'active',
|
|
branch: 'cw/test-initiative',
|
|
executionMode: 'review_per_phase',
|
|
createdAt: new Date('2026-01-01'),
|
|
updatedAt: new Date('2026-01-02'),
|
|
};
|
|
|
|
await writeInputFiles({ agentWorkdir: testDir, initiative });
|
|
|
|
const filePath = join(testDir, '.cw', 'input', 'initiative.md');
|
|
expect(existsSync(filePath)).toBe(true);
|
|
});
|
|
|
|
it('writes phase.md with frontmatter', async () => {
|
|
const phase = {
|
|
id: 'phase-1',
|
|
initiativeId: 'init-1',
|
|
number: 1,
|
|
name: 'Phase One',
|
|
content: 'First phase',
|
|
status: 'pending',
|
|
mergeBase: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
} as Phase;
|
|
|
|
await writeInputFiles({ agentWorkdir: testDir, phase });
|
|
|
|
const filePath = join(testDir, '.cw', 'input', 'phase.md');
|
|
expect(existsSync(filePath)).toBe(true);
|
|
});
|
|
|
|
it('writes task.md with frontmatter', async () => {
|
|
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;
|
|
|
|
await writeInputFiles({ agentWorkdir: testDir, task });
|
|
|
|
const filePath = join(testDir, '.cw', 'input', 'task.md');
|
|
expect(existsSync(filePath)).toBe(true);
|
|
});
|
|
|
|
it('writes pages to pages/ subdirectory', async () => {
|
|
await 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', async () => {
|
|
await writeInputFiles({ agentWorkdir: testDir });
|
|
expect(existsSync(join(testDir, '.cw', 'input'))).toBe(true);
|
|
});
|
|
|
|
it('writes context/index.json grouping tasks by phaseId', async () => {
|
|
await 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', async () => {
|
|
await writeInputFiles({ agentWorkdir: testDir });
|
|
expect(existsSync(join(testDir, '.cw', 'input', 'context', 'index.json'))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('readSummary', () => {
|
|
it('reads SUMMARY.md with frontmatter', async () => {
|
|
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 = await 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', async () => {
|
|
const summary = await readSummary(testDir);
|
|
expect(summary).toBeNull();
|
|
});
|
|
|
|
it('handles SUMMARY.md without frontmatter', async () => {
|
|
const outputDir = join(testDir, '.cw', 'output');
|
|
mkdirSync(outputDir, { recursive: true });
|
|
writeFileSync(join(outputDir, 'SUMMARY.md'), 'Just plain text\n', 'utf-8');
|
|
|
|
const summary = await readSummary(testDir);
|
|
expect(summary).not.toBeNull();
|
|
expect(summary!.body).toBe('Just plain text');
|
|
expect(summary!.filesModified).toBeUndefined();
|
|
});
|
|
|
|
it('handles empty files_modified', async () => {
|
|
const outputDir = join(testDir, '.cw', 'output');
|
|
mkdirSync(outputDir, { recursive: true });
|
|
writeFileSync(
|
|
join(outputDir, 'SUMMARY.md'),
|
|
`---
|
|
files_modified: []
|
|
---
|
|
Done.
|
|
`,
|
|
'utf-8',
|
|
);
|
|
|
|
const summary = await readSummary(testDir);
|
|
expect(summary!.filesModified).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('readPhaseFiles', () => {
|
|
it('reads phase files from phases/ directory', async () => {
|
|
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 = await 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', async () => {
|
|
const phases = await readPhaseFiles(testDir);
|
|
expect(phases).toEqual([]);
|
|
});
|
|
|
|
it('handles phases with no dependencies', async () => {
|
|
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 = await readPhaseFiles(testDir);
|
|
expect(phases[0].dependencies).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('readTaskFiles', () => {
|
|
it('reads task files from tasks/ directory', async () => {
|
|
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 = await 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', async () => {
|
|
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 = await readTaskFiles(testDir);
|
|
expect(tasks[0].category).toBe('execute');
|
|
expect(tasks[0].type).toBe('auto');
|
|
});
|
|
|
|
it('returns empty array when directory does not exist', async () => {
|
|
expect(await readTaskFiles(testDir)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('readDecisionFiles', () => {
|
|
it('reads decision files from decisions/ directory', async () => {
|
|
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 = await 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', async () => {
|
|
expect(await readDecisionFiles(testDir)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('readPageFiles', () => {
|
|
it('reads page files from pages/ directory', async () => {
|
|
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 = await 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', async () => {
|
|
expect(await readPageFiles(testDir)).toEqual([]);
|
|
});
|
|
|
|
it('ignores non-.md files', async () => {
|
|
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 = await readPageFiles(testDir);
|
|
expect(pages).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('writeErrandManifest', () => {
|
|
let errandTestDir: string;
|
|
|
|
beforeEach(() => {
|
|
errandTestDir = join(tmpdir(), `cw-errand-test-${randomUUID()}`);
|
|
mkdirSync(errandTestDir, { recursive: true });
|
|
});
|
|
|
|
afterAll(() => {
|
|
// no-op: beforeEach creates dirs, afterEach in outer scope cleans up
|
|
});
|
|
|
|
it('writes manifest.json with correct shape', async () => {
|
|
await writeErrandManifest({
|
|
agentWorkdir: errandTestDir,
|
|
errandId: 'errand-abc',
|
|
description: 'fix typo',
|
|
branch: 'cw/errand/fix-typo-errandabc',
|
|
projectName: 'my-project',
|
|
agentId: 'agent-1',
|
|
agentName: 'swift-owl',
|
|
});
|
|
|
|
const manifestPath = join(errandTestDir, '.cw', 'input', 'manifest.json');
|
|
expect(existsSync(manifestPath)).toBe(true);
|
|
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
expect(manifest).toEqual({
|
|
errandId: 'errand-abc',
|
|
agentId: 'agent-1',
|
|
agentName: 'swift-owl',
|
|
mode: 'errand',
|
|
});
|
|
expect('files' in manifest).toBe(false);
|
|
expect('contextFiles' in manifest).toBe(false);
|
|
});
|
|
|
|
it('writes errand.md with correct YAML frontmatter', async () => {
|
|
await writeErrandManifest({
|
|
agentWorkdir: errandTestDir,
|
|
errandId: 'errand-abc',
|
|
description: 'fix typo',
|
|
branch: 'cw/errand/fix-typo-errandabc',
|
|
projectName: 'my-project',
|
|
agentId: 'agent-1',
|
|
agentName: 'swift-owl',
|
|
});
|
|
|
|
const errandMdPath = join(errandTestDir, '.cw', 'input', 'errand.md');
|
|
expect(existsSync(errandMdPath)).toBe(true);
|
|
const content = readFileSync(errandMdPath, 'utf-8');
|
|
expect(content).toContain('id: errand-abc');
|
|
expect(content).toContain('description: fix typo');
|
|
expect(content).toContain('branch: cw/errand/fix-typo-errandabc');
|
|
expect(content).toContain('project: my-project');
|
|
});
|
|
|
|
it('writes expected-pwd.txt with agentWorkdir path', async () => {
|
|
await writeErrandManifest({
|
|
agentWorkdir: errandTestDir,
|
|
errandId: 'errand-abc',
|
|
description: 'fix typo',
|
|
branch: 'cw/errand/fix-typo-errandabc',
|
|
projectName: 'my-project',
|
|
agentId: 'agent-1',
|
|
agentName: 'swift-owl',
|
|
});
|
|
|
|
const pwdPath = join(errandTestDir, '.cw', 'expected-pwd.txt');
|
|
expect(existsSync(pwdPath)).toBe(true);
|
|
const content = readFileSync(pwdPath, 'utf-8').trim();
|
|
expect(content).toBe(errandTestDir);
|
|
});
|
|
|
|
it('creates input directory if it does not exist', async () => {
|
|
const freshDir = join(tmpdir(), `cw-errand-fresh-${randomUUID()}`);
|
|
mkdirSync(freshDir, { recursive: true });
|
|
|
|
await writeErrandManifest({
|
|
agentWorkdir: freshDir,
|
|
errandId: 'errand-xyz',
|
|
description: 'add feature',
|
|
branch: 'cw/errand/add-feature-errandxyz',
|
|
projectName: 'other-project',
|
|
agentId: 'agent-2',
|
|
agentName: 'brave-eagle',
|
|
});
|
|
|
|
expect(existsSync(join(freshDir, '.cw', 'input', 'manifest.json'))).toBe(true);
|
|
expect(existsSync(join(freshDir, '.cw', 'input', 'errand.md'))).toBe(true);
|
|
expect(existsSync(join(freshDir, '.cw', 'expected-pwd.txt'))).toBe(true);
|
|
|
|
rmSync(freshDir, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
describe('buildErrandPrompt', () => {
|
|
it('includes the description in the output', () => {
|
|
const result = buildErrandPrompt('fix typo in README');
|
|
expect(result).toContain('fix typo in README');
|
|
});
|
|
|
|
it('includes signal.json instruction', () => {
|
|
const result = buildErrandPrompt('some change');
|
|
expect(result).toContain('signal.json');
|
|
expect(result).toContain('"status": "done"');
|
|
});
|
|
|
|
it('includes error signal format', () => {
|
|
const result = buildErrandPrompt('some change');
|
|
expect(result).toContain('"status": "error"');
|
|
});
|
|
});
|