The `getAgentPrompt` tRPC procedure previously read exclusively from `.cw/agent-logs/<name>/PROMPT.md`. Once the cleanup-manager removes that directory, the prompt is gone forever. Adds a `prompt` text column to the `agents` table and writes the fully assembled prompt (including workspace layout, inter-agent comms, and preview sections) to the DB in the same `repository.update()` call that saves pid/outputFilePath after spawn. `getAgentPrompt` now reads from DB first (`agent.prompt`) and falls back to the filesystem only for agents spawned before this change. Addresses review comment [MMcmVlEK16bBfkJuXvG6h]. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
328 lines
11 KiB
TypeScript
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 });
|
|
});
|
|
});
|