feat: Extend AgentInfo with exitCode + add getAgentInputFiles/getAgentPrompt tRPC procedures

Adds exitCode to AgentInfo type and propagates it through all toAgentInfo()
implementations. Enhances getAgent to also return taskName and initiativeName
from their respective repositories. Adds two new filesystem-reading tRPC
procedures for the Agent Details tab: getAgentInputFiles (reads .cw/input/
files with binary detection, 500 KB cap, sorted) and getAgentPrompt (reads
.cw/agent-logs/<name>/PROMPT.md with 1 MB cap and structured ENOENT handling).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas May
2026-03-05 21:39:29 +01:00
parent 573e1b40d2
commit 269a2d2616
9 changed files with 421 additions and 3 deletions

View File

@@ -18,6 +18,7 @@ export interface AgentInfo {
status: string; status: string;
initiativeId?: string | null; initiativeId?: string | null;
worktreeId: string; worktreeId: string;
exitCode?: number | null;
} }
export interface CleanupStrategy { export interface CleanupStrategy {

View File

@@ -353,6 +353,7 @@ export class AgentLifecycleController {
status: agent.status, status: agent.status,
initiativeId: agent.initiativeId, initiativeId: agent.initiativeId,
worktreeId: agent.worktreeId, worktreeId: agent.worktreeId,
exitCode: agent.exitCode ?? null,
}; };
} }
} }

View File

@@ -1085,6 +1085,7 @@ export class MultiProviderAgentManager implements AgentManager {
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
userDismissedAt?: Date | null; userDismissedAt?: Date | null;
exitCode?: number | null;
}): AgentInfo { }): AgentInfo {
return { return {
id: agent.id, id: agent.id,
@@ -1100,6 +1101,7 @@ export class MultiProviderAgentManager implements AgentManager {
createdAt: agent.createdAt, createdAt: agent.createdAt,
updatedAt: agent.updatedAt, updatedAt: agent.updatedAt,
userDismissedAt: agent.userDismissedAt, userDismissedAt: agent.userDismissedAt,
exitCode: agent.exitCode ?? null,
}; };
} }
} }

View File

@@ -142,6 +142,7 @@ export class MockAgentManager implements AgentManager {
accountId: null, accountId: null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
exitCode: null,
}; };
const record: MockAgentRecord = { const record: MockAgentRecord = {

View File

@@ -93,6 +93,8 @@ export interface AgentInfo {
updatedAt: Date; updatedAt: Date;
/** When the user dismissed this agent (null if not dismissed) */ /** When the user dismissed this agent (null if not dismissed) */
userDismissedAt?: Date | null; userDismissedAt?: Date | null;
/** Process exit code — null while running or if not yet exited */
exitCode: number | null;
} }
/** /**

View File

@@ -70,6 +70,7 @@ function createMockAgentManager(
accountId: null, accountId: null,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
exitCode: null,
}; };
mockAgents.push(newAgent); mockAgents.push(newAgent);
return newAgent; return newAgent;
@@ -101,6 +102,7 @@ function createIdleAgent(id: string, name: string): AgentInfo {
accountId: null, accountId: null,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
exitCode: null,
}; };
} }

View File

@@ -0,0 +1,288 @@
/**
* 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,
...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 })),
};
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 });
});
});

View File

@@ -5,11 +5,13 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import { tracked, type TrackedEnvelope } from '@trpc/server'; import { tracked, type TrackedEnvelope } from '@trpc/server';
import path from 'path';
import fs from 'fs/promises';
import type { ProcedureBuilder } from '../trpc.js'; import type { ProcedureBuilder } from '../trpc.js';
import type { TRPCContext } from '../context.js'; import type { TRPCContext } from '../context.js';
import type { AgentInfo, AgentResult, PendingQuestions } from '../../agent/types.js'; import type { AgentInfo, AgentResult, PendingQuestions } from '../../agent/types.js';
import type { AgentOutputEvent } from '../../events/types.js'; import type { AgentOutputEvent } from '../../events/types.js';
import { requireAgentManager, requireLogChunkRepository } from './_helpers.js'; import { requireAgentManager, requireLogChunkRepository, requireTaskRepository, requireInitiativeRepository } from './_helpers.js';
export const spawnAgentInputSchema = z.object({ export const spawnAgentInputSchema = z.object({
name: z.string().min(1).optional(), name: z.string().min(1).optional(),
@@ -120,7 +122,23 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
getAgent: publicProcedure getAgent: publicProcedure
.input(agentIdentifierSchema) .input(agentIdentifierSchema)
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
return resolveAgent(ctx, input); const agent = await resolveAgent(ctx, input);
let taskName: string | null = null;
let initiativeName: string | null = null;
if (agent.taskId) {
const taskRepo = requireTaskRepository(ctx);
const task = await taskRepo.findById(agent.taskId);
taskName = task?.name ?? null;
}
if (agent.initiativeId) {
const initiativeRepo = requireInitiativeRepository(ctx);
const initiative = await initiativeRepo.findById(agent.initiativeId);
initiativeName = initiative?.name ?? null;
}
return { ...agent, taskName, initiativeName };
}), }),
getAgentByName: publicProcedure getAgentByName: publicProcedure
@@ -246,5 +264,106 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
cleanup(); cleanup();
} }
}), }),
getAgentInputFiles: publicProcedure
.input(z.object({ id: z.string().min(1) }))
.output(z.object({
files: z.array(z.object({
name: z.string(),
content: z.string(),
sizeBytes: z.number(),
})),
reason: z.enum(['worktree_missing', 'input_dir_missing']).optional(),
}))
.query(async ({ ctx, input }) => {
const agent = await resolveAgent(ctx, { id: input.id });
const worktreeRoot = path.join(ctx.workspaceRoot!, 'agent-workdirs', agent.worktreeId);
const inputDir = path.join(worktreeRoot, '.cw', 'input');
// Check worktree root exists
try {
await fs.stat(worktreeRoot);
} catch {
return { files: [], reason: 'worktree_missing' as const };
}
// Check input dir exists
try {
await fs.stat(inputDir);
} catch {
return { files: [], reason: 'input_dir_missing' as const };
}
// Walk inputDir recursively
const entries = await fs.readdir(inputDir, { recursive: true, withFileTypes: true });
const MAX_SIZE = 500 * 1024;
const results: Array<{ name: string; content: string; sizeBytes: number }> = [];
for (const entry of entries) {
if (!entry.isFile()) continue;
// entry.parentPath is available in Node 20+
const dir = (entry as any).parentPath ?? (entry as any).path;
const fullPath = path.join(dir, entry.name);
const relativeName = path.relative(inputDir, fullPath);
try {
// Binary detection: read first 512 bytes
const fd = await fs.open(fullPath, 'r');
const headerBuf = Buffer.alloc(512);
const { bytesRead } = await fd.read(headerBuf, 0, 512, 0);
await fd.close();
if (headerBuf.slice(0, bytesRead).includes(0)) continue; // skip binary
const raw = await fs.readFile(fullPath);
const sizeBytes = raw.length;
let content: string;
if (sizeBytes > MAX_SIZE) {
content = raw.slice(0, MAX_SIZE).toString('utf-8') + '\n\n[truncated — file exceeds 500 KB]';
} else {
content = raw.toString('utf-8');
}
results.push({ name: relativeName, content, sizeBytes });
} catch {
continue; // skip unreadable files
}
}
results.sort((a, b) => a.name.localeCompare(b.name));
return { files: results };
}),
getAgentPrompt: publicProcedure
.input(z.object({ id: z.string().min(1) }))
.output(z.object({
content: z.string().nullable(),
reason: z.enum(['prompt_not_written']).optional(),
}))
.query(async ({ ctx, input }) => {
const agent = await resolveAgent(ctx, { id: input.id });
const promptPath = path.join(ctx.workspaceRoot!, '.cw', 'agent-logs', agent.name, 'PROMPT.md');
let raw: string;
try {
raw = await fs.readFile(promptPath, 'utf-8');
} catch (err: any) {
if (err?.code === 'ENOENT') {
return { content: null, reason: 'prompt_not_written' as const };
}
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: `Failed to read prompt file: ${String(err)}`,
});
}
const MAX_BYTES = 1024 * 1024; // 1 MB
if (Buffer.byteLength(raw, 'utf-8') > MAX_BYTES) {
const buf = Buffer.from(raw, 'utf-8');
raw = buf.slice(0, MAX_BYTES).toString('utf-8') + '\n\n[truncated — prompt exceeds 1 MB]';
}
return { content: raw };
}),
}; };
} }

View File

@@ -59,10 +59,12 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
| dismissAgent | mutation | Dismiss agent (set userDismissedAt) | | dismissAgent | mutation | Dismiss agent (set userDismissedAt) |
| resumeAgent | mutation | Resume with answers | | resumeAgent | mutation | Resume with answers |
| listAgents | query | All agents | | listAgents | query | All agents |
| getAgent | query | Single agent by name or ID | | getAgent | query | Single agent by name or ID; also returns `taskName`, `initiativeName`, `exitCode` |
| getAgentResult | query | Execution result | | getAgentResult | query | Execution result |
| getAgentQuestions | query | Pending questions | | getAgentQuestions | query | Pending questions |
| getAgentOutput | query | Full output from DB log chunks | | getAgentOutput | query | Full output from DB log chunks |
| getAgentInputFiles | query | Files written to agent's `.cw/input/` dir (text only, sorted, 500 KB cap) |
| getAgentPrompt | query | Content of `.cw/agent-logs/<name>/PROMPT.md` (1 MB cap) |
| getActiveRefineAgent | query | Active refine agent for initiative | | getActiveRefineAgent | query | Active refine agent for initiative |
| listWaitingAgents | query | Agents waiting for input | | listWaitingAgents | query | Agents waiting for input |
| onAgentOutput | subscription | Live raw JSONL output stream via EventBus | | onAgentOutput | subscription | Live raw JSONL output stream via EventBus |