Files
Codewalkers/apps/server/trpc/routers/agent.test.ts
Lukas May 28521e1c20 chore: merge main into cw/small-change-flow
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
2026-03-06 16:48:12 +01:00

328 lines
11 KiB
TypeScript

/**
* Agent Router Tests
*
* Tests for getAgent (exitCode, taskName, initiativeName),
* getAgentInputFiles, and getAgentPrompt procedures.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { appRouter, createCallerFactory } from '../index.js';
import type { TRPCContext } from '../context.js';
import type { EventBus } from '../../events/types.js';
const createCaller = createCallerFactory(appRouter);
function createMockEventBus(): EventBus {
return {
emit: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
};
}
function createTestContext(overrides: Partial<TRPCContext> = {}): TRPCContext {
return {
eventBus: createMockEventBus(),
serverStartedAt: new Date('2026-01-30T12:00:00Z'),
processCount: 0,
...overrides,
};
}
/** Minimal AgentInfo fixture matching the full interface */
function makeAgentInfo(overrides: Record<string, unknown> = {}) {
return {
id: 'agent-1',
name: 'test-agent',
taskId: null,
initiativeId: null,
sessionId: null,
worktreeId: 'test-agent',
status: 'stopped' as const,
mode: 'execute' as const,
provider: 'claude',
accountId: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
userDismissedAt: null,
exitCode: null,
prompt: null,
...overrides,
};
}
describe('getAgent', () => {
it('returns exitCode: 1 when agent has exitCode 1', async () => {
const mockManager = {
get: vi.fn().mockResolvedValue(makeAgentInfo({ exitCode: 1 })),
};
const ctx = createTestContext({ agentManager: mockManager as any });
const caller = createCaller(ctx);
const result = await caller.getAgent({ id: 'agent-1' });
expect(result.exitCode).toBe(1);
});
it('returns exitCode: null when agent has no exitCode', async () => {
const mockManager = {
get: vi.fn().mockResolvedValue(makeAgentInfo({ exitCode: null })),
};
const ctx = createTestContext({ agentManager: mockManager as any });
const caller = createCaller(ctx);
const result = await caller.getAgent({ id: 'agent-1' });
expect(result.exitCode).toBeNull();
});
it('returns taskName and initiativeName from repositories', async () => {
const mockManager = {
get: vi.fn().mockResolvedValue(makeAgentInfo({ taskId: 'task-1', initiativeId: 'init-1' })),
};
const mockTaskRepo = {
findById: vi.fn().mockResolvedValue({ id: 'task-1', name: 'My Task' }),
};
const mockInitiativeRepo = {
findById: vi.fn().mockResolvedValue({ id: 'init-1', name: 'My Initiative' }),
};
const ctx = createTestContext({
agentManager: mockManager as any,
taskRepository: mockTaskRepo as any,
initiativeRepository: mockInitiativeRepo as any,
});
const caller = createCaller(ctx);
const result = await caller.getAgent({ id: 'agent-1' });
expect(result.taskName).toBe('My Task');
expect(result.initiativeName).toBe('My Initiative');
});
it('returns taskName: null and initiativeName: null when agent has no taskId or initiativeId', async () => {
const mockManager = {
get: vi.fn().mockResolvedValue(makeAgentInfo({ taskId: null, initiativeId: null })),
};
const ctx = createTestContext({ agentManager: mockManager as any });
const caller = createCaller(ctx);
const result = await caller.getAgent({ id: 'agent-1' });
expect(result.taskName).toBeNull();
expect(result.initiativeName).toBeNull();
});
});
describe('getAgentInputFiles', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-test-'));
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
function makeAgentManagerWithWorktree(worktreeId = 'test-worktree', agentName = 'test-agent') {
return {
get: vi.fn().mockResolvedValue(makeAgentInfo({ worktreeId, name: agentName })),
};
}
it('returns worktree_missing when worktree dir does not exist', async () => {
const nonExistentRoot = path.join(tmpDir, 'no-such-dir');
const mockManager = makeAgentManagerWithWorktree('test-worktree');
const ctx = createTestContext({
agentManager: mockManager as any,
workspaceRoot: nonExistentRoot,
});
const caller = createCaller(ctx);
const result = await caller.getAgentInputFiles({ id: 'agent-1' });
expect(result).toEqual({ files: [], reason: 'worktree_missing' });
});
it('returns input_dir_missing when worktree exists but .cw/input does not', async () => {
const worktreeId = 'test-worktree';
const worktreeRoot = path.join(tmpDir, 'agent-workdirs', worktreeId);
await fs.mkdir(worktreeRoot, { recursive: true });
const mockManager = makeAgentManagerWithWorktree(worktreeId);
const ctx = createTestContext({
agentManager: mockManager as any,
workspaceRoot: tmpDir,
});
const caller = createCaller(ctx);
const result = await caller.getAgentInputFiles({ id: 'agent-1' });
expect(result).toEqual({ files: [], reason: 'input_dir_missing' });
});
it('returns sorted file list with correct name, content, sizeBytes', async () => {
const worktreeId = 'test-worktree';
const inputDir = path.join(tmpDir, 'agent-workdirs', worktreeId, '.cw', 'input');
await fs.mkdir(inputDir, { recursive: true });
await fs.mkdir(path.join(inputDir, 'pages'), { recursive: true });
const manifestContent = '{"files": ["a"]}';
const fooContent = '# Foo\nHello world';
await fs.writeFile(path.join(inputDir, 'manifest.json'), manifestContent);
await fs.writeFile(path.join(inputDir, 'pages', 'foo.md'), fooContent);
const mockManager = makeAgentManagerWithWorktree(worktreeId);
const ctx = createTestContext({
agentManager: mockManager as any,
workspaceRoot: tmpDir,
});
const caller = createCaller(ctx);
const result = await caller.getAgentInputFiles({ id: 'agent-1' });
expect(result.reason).toBeUndefined();
expect(result.files).toHaveLength(2);
// Sorted alphabetically: manifest.json before pages/foo.md
expect(result.files[0].name).toBe('manifest.json');
expect(result.files[0].content).toBe(manifestContent);
expect(result.files[0].sizeBytes).toBe(Buffer.byteLength(manifestContent));
expect(result.files[1].name).toBe(path.join('pages', 'foo.md'));
expect(result.files[1].content).toBe(fooContent);
expect(result.files[1].sizeBytes).toBe(Buffer.byteLength(fooContent));
});
it('skips binary files (containing null byte)', async () => {
const worktreeId = 'test-worktree';
const inputDir = path.join(tmpDir, 'agent-workdirs', worktreeId, '.cw', 'input');
await fs.mkdir(inputDir, { recursive: true });
// Binary file with null byte
const binaryData = Buffer.from([0x89, 0x50, 0x00, 0x4e, 0x47]);
await fs.writeFile(path.join(inputDir, 'image.png'), binaryData);
// Text file should still be returned
await fs.writeFile(path.join(inputDir, 'text.txt'), 'hello');
const mockManager = makeAgentManagerWithWorktree(worktreeId);
const ctx = createTestContext({
agentManager: mockManager as any,
workspaceRoot: tmpDir,
});
const caller = createCaller(ctx);
const result = await caller.getAgentInputFiles({ id: 'agent-1' });
expect(result.files).toHaveLength(1);
expect(result.files[0].name).toBe('text.txt');
});
it('truncates files larger than 500 KB and preserves original sizeBytes', async () => {
const worktreeId = 'test-worktree';
const inputDir = path.join(tmpDir, 'agent-workdirs', worktreeId, '.cw', 'input');
await fs.mkdir(inputDir, { recursive: true });
const MAX_SIZE = 500 * 1024;
const largeContent = Buffer.alloc(MAX_SIZE + 100 * 1024, 'a'); // 600 KB
await fs.writeFile(path.join(inputDir, 'big.txt'), largeContent);
const mockManager = makeAgentManagerWithWorktree(worktreeId);
const ctx = createTestContext({
agentManager: mockManager as any,
workspaceRoot: tmpDir,
});
const caller = createCaller(ctx);
const result = await caller.getAgentInputFiles({ id: 'agent-1' });
expect(result.files).toHaveLength(1);
expect(result.files[0].sizeBytes).toBe(largeContent.length);
expect(result.files[0].content).toContain('[truncated — file exceeds 500 KB]');
});
});
describe('getAgentPrompt', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-prompt-test-'));
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it('returns prompt_not_written when PROMPT.md does not exist', async () => {
const mockManager = {
get: vi.fn().mockResolvedValue(makeAgentInfo({ name: 'test-agent' })),
};
const ctx = createTestContext({
agentManager: mockManager as any,
workspaceRoot: tmpDir,
});
const caller = createCaller(ctx);
const result = await caller.getAgentPrompt({ id: 'agent-1' });
expect(result).toEqual({ content: null, reason: 'prompt_not_written' });
});
it('returns prompt content when PROMPT.md exists', async () => {
const agentName = 'test-agent';
const promptDir = path.join(tmpDir, '.cw', 'agent-logs', agentName);
await fs.mkdir(promptDir, { recursive: true });
const promptContent = '# System\nHello';
await fs.writeFile(path.join(promptDir, 'PROMPT.md'), promptContent);
const mockManager = {
get: vi.fn().mockResolvedValue(makeAgentInfo({ name: agentName, prompt: null })),
};
const ctx = createTestContext({
agentManager: mockManager as any,
workspaceRoot: tmpDir,
});
const caller = createCaller(ctx);
const result = await caller.getAgentPrompt({ id: 'agent-1' });
expect(result).toEqual({ content: promptContent });
});
it('returns prompt from DB when agent.prompt is set (no file needed)', async () => {
const dbPromptContent = '# DB Prompt\nThis is persisted in the database';
const mockManager = {
get: vi.fn().mockResolvedValue(makeAgentInfo({ name: 'test-agent', prompt: dbPromptContent })),
};
// workspaceRoot has no PROMPT.md — but DB value takes precedence
const ctx = createTestContext({
agentManager: mockManager as any,
workspaceRoot: tmpDir,
});
const caller = createCaller(ctx);
const result = await caller.getAgentPrompt({ id: 'agent-1' });
expect(result).toEqual({ content: dbPromptContent });
});
it('falls back to PROMPT.md when agent.prompt is null in DB', async () => {
const agentName = 'test-agent';
const promptDir = path.join(tmpDir, '.cw', 'agent-logs', agentName);
await fs.mkdir(promptDir, { recursive: true });
const fileContent = '# File Prompt\nThis is from the file (legacy)';
await fs.writeFile(path.join(promptDir, 'PROMPT.md'), fileContent);
const mockManager = {
get: vi.fn().mockResolvedValue(makeAgentInfo({ name: agentName, prompt: null })),
};
const ctx = createTestContext({
agentManager: mockManager as any,
workspaceRoot: tmpDir,
});
const caller = createCaller(ctx);
const result = await caller.getAgentPrompt({ id: 'agent-1' });
expect(result).toEqual({ content: fileContent });
});
});