diff --git a/apps/server/agent/file-io.test.ts b/apps/server/agent/file-io.test.ts index 396453f..ae0fb9a 100644 --- a/apps/server/agent/file-io.test.ts +++ b/apps/server/agent/file-io.test.ts @@ -2,7 +2,7 @@ * File-Based Agent I/O Tests */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +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'; @@ -15,7 +15,9 @@ import { 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; @@ -367,3 +369,116 @@ New content for the page. 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"'); + }); +}); diff --git a/apps/server/agent/file-io.ts b/apps/server/agent/file-io.ts index 84b9c3a..4bbc296 100644 --- a/apps/server/agent/file-io.ts +++ b/apps/server/agent/file-io.ts @@ -298,6 +298,50 @@ export async function writeInputFiles(options: WriteInputFilesOptions): Promise< ); } +// ============================================================================= +// ERRAND INPUT FILE WRITING +// ============================================================================= + +export async function writeErrandManifest(options: { + agentWorkdir: string; + errandId: string; + description: string; + branch: string; + projectName: string; + agentId: string; + agentName: string; +}): Promise { + await mkdir(join(options.agentWorkdir, '.cw', 'input'), { recursive: true }); + + // Write errand.md first (before manifest.json) + const errandMdContent = formatFrontmatter({ + id: options.errandId, + description: options.description, + branch: options.branch, + project: options.projectName, + }); + await writeFile(join(options.agentWorkdir, '.cw', 'input', 'errand.md'), errandMdContent, 'utf-8'); + + // Write manifest.json last (after all other files exist) + await writeFile( + join(options.agentWorkdir, '.cw', 'input', 'manifest.json'), + JSON.stringify({ + errandId: options.errandId, + agentId: options.agentId, + agentName: options.agentName, + mode: 'errand', + }) + '\n', + 'utf-8', + ); + + // Write expected-pwd.txt + await writeFile( + join(options.agentWorkdir, '.cw', 'expected-pwd.txt'), + options.agentWorkdir, + 'utf-8', + ); +} + // ============================================================================= // OUTPUT FILE READING // ============================================================================= diff --git a/apps/server/agent/lifecycle/controller.test.ts b/apps/server/agent/lifecycle/controller.test.ts new file mode 100644 index 0000000..1ce41b9 --- /dev/null +++ b/apps/server/agent/lifecycle/controller.test.ts @@ -0,0 +1,155 @@ +/** + * AgentLifecycleController Tests — Regression coverage for event emissions. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { AgentLifecycleController } from './controller.js'; +import type { AgentRepository } from '../../db/repositories/agent-repository.js'; +import type { AccountRepository } from '../../db/repositories/account-repository.js'; +import type { SignalManager } from './signal-manager.js'; +import type { RetryPolicy } from './retry-policy.js'; +import type { AgentErrorAnalyzer } from './error-analyzer.js'; +import type { ProcessManager } from '../process-manager.js'; +import type { CleanupManager } from '../cleanup-manager.js'; +import type { CleanupStrategy } from './cleanup-strategy.js'; +import type { EventBus, AgentAccountSwitchedEvent } from '../../events/types.js'; + +function makeController(overrides: { + repository?: Partial; + accountRepository?: Partial; + eventBus?: EventBus; +}): AgentLifecycleController { + const signalManager: SignalManager = { + clearSignal: vi.fn(), + checkSignalExists: vi.fn(), + readSignal: vi.fn(), + waitForSignal: vi.fn(), + validateSignalFile: vi.fn(), + }; + const retryPolicy: RetryPolicy = { + maxAttempts: 3, + backoffMs: [1000, 2000, 4000], + shouldRetry: vi.fn().mockReturnValue(false), + getRetryDelay: vi.fn().mockReturnValue(0), + }; + const errorAnalyzer = { analyzeError: vi.fn() } as unknown as AgentErrorAnalyzer; + const processManager = { getAgentWorkdir: vi.fn() } as unknown as ProcessManager; + const cleanupManager = {} as unknown as CleanupManager; + const cleanupStrategy = { + shouldCleanup: vi.fn(), + executeCleanup: vi.fn(), + } as unknown as CleanupStrategy; + + return new AgentLifecycleController( + signalManager, + retryPolicy, + errorAnalyzer, + processManager, + overrides.repository as AgentRepository, + cleanupManager, + cleanupStrategy, + overrides.accountRepository as AccountRepository | undefined, + false, + overrides.eventBus, + ); +} + +describe('AgentLifecycleController', () => { + describe('handleAccountExhaustion', () => { + it('emits agent:account_switched with correct payload when new account is available', async () => { + const emittedEvents: AgentAccountSwitchedEvent[] = []; + const eventBus: EventBus = { + emit: vi.fn((event) => { emittedEvents.push(event as AgentAccountSwitchedEvent); }), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + }; + + const agentRecord = { + id: 'agent-1', + name: 'test-agent', + accountId: 'old-account-id', + provider: 'claude', + }; + const newAccount = { id: 'new-account-id' }; + + const repository: Partial = { + findById: vi.fn().mockResolvedValue(agentRecord), + }; + const accountRepository: Partial = { + markExhausted: vi.fn().mockResolvedValue(agentRecord), + findNextAvailable: vi.fn().mockResolvedValue(newAccount), + }; + + const controller = makeController({ repository, accountRepository, eventBus }); + + // Call private method via any-cast + await (controller as any).handleAccountExhaustion('agent-1'); + + const accountSwitchedEvents = emittedEvents.filter( + (e) => e.type === 'agent:account_switched' + ); + expect(accountSwitchedEvents).toHaveLength(1); + const event = accountSwitchedEvents[0]; + expect(event.type).toBe('agent:account_switched'); + expect(event.payload.agentId).toBe('agent-1'); + expect(event.payload.name).toBe('test-agent'); + expect(event.payload.previousAccountId).toBe('old-account-id'); + expect(event.payload.newAccountId).toBe('new-account-id'); + expect(event.payload.reason).toBe('account_exhausted'); + }); + + it('does not emit agent:account_switched when no new account is available', async () => { + const eventBus: EventBus = { + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + }; + + const agentRecord = { + id: 'agent-2', + name: 'test-agent-2', + accountId: 'old-account-id', + provider: 'claude', + }; + + const repository: Partial = { + findById: vi.fn().mockResolvedValue(agentRecord), + }; + const accountRepository: Partial = { + markExhausted: vi.fn().mockResolvedValue(agentRecord), + findNextAvailable: vi.fn().mockResolvedValue(null), + }; + + const controller = makeController({ repository, accountRepository, eventBus }); + + await (controller as any).handleAccountExhaustion('agent-2'); + + expect(eventBus.emit).not.toHaveBeenCalled(); + }); + + it('does not emit when agent has no accountId', async () => { + const eventBus: EventBus = { + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + }; + + const repository: Partial = { + findById: vi.fn().mockResolvedValue({ id: 'agent-3', name: 'x', accountId: null }), + }; + const accountRepository: Partial = { + markExhausted: vi.fn(), + findNextAvailable: vi.fn(), + }; + + const controller = makeController({ repository, accountRepository, eventBus }); + + await (controller as any).handleAccountExhaustion('agent-3'); + + expect(eventBus.emit).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/server/agent/manager.ts b/apps/server/agent/manager.ts index d567fcc..0d8fad2 100644 --- a/apps/server/agent/manager.ts +++ b/apps/server/agent/manager.ts @@ -625,6 +625,73 @@ export class MultiProviderAgentManager implements AgentManager { } } + /** + * Deliver a user message to a running or idle errand agent. + * Does not use the conversations table — the message is injected directly + * as the next resume prompt for the agent's Claude Code session. + */ + async sendUserMessage(agentId: string, message: string): Promise { + const agent = await this.repository.findById(agentId); + if (!agent) throw new Error(`Agent not found: ${agentId}`); + + if (agent.status !== 'running' && agent.status !== 'idle') { + throw new Error(`Agent is not running (status: ${agent.status})`); + } + + if (!agent.sessionId) { + throw new Error('Agent has no session ID'); + } + + const provider = getProvider(agent.provider); + if (!provider) throw new Error(`Unknown provider: ${agent.provider}`); + + const agentCwd = this.processManager.getAgentWorkdir(agent.worktreeId); + + // Clear previous signal.json + const signalPath = join(agentCwd, '.cw/output/signal.json'); + try { + await unlink(signalPath); + } catch { + // File might not exist + } + + await this.repository.update(agentId, { status: 'running', result: null }); + + const { command, args, env: providerEnv } = this.processManager.buildResumeCommand(provider, agent.sessionId, message); + const { processEnv } = await this.credentialHandler.prepareProcessEnv(providerEnv, provider, agent.accountId); + + // Stop previous tailer/poll + const prevActive = this.activeAgents.get(agentId); + prevActive?.cancelPoll?.(); + if (prevActive?.tailer) { + await prevActive.tailer.stop(); + } + + let sessionNumber = 1; + if (this.logChunkRepository) { + sessionNumber = (await this.logChunkRepository.getSessionCount(agentId)) + 1; + } + + const { pid, outputFilePath, tailer } = await this.processManager.spawnDetached( + agentId, agent.name, command, args, agentCwd, processEnv, provider.name, message, + (event) => this.outputHandler.handleStreamEvent(agentId, event, this.activeAgents.get(agentId)), + this.createLogChunkCallback(agentId, agent.name, sessionNumber), + ); + + await this.repository.update(agentId, { pid, outputFilePath }); + const activeEntry: ActiveAgent = { agentId, pid, tailer, outputFilePath }; + this.activeAgents.set(agentId, activeEntry); + + const { cancel } = this.processManager.pollForCompletion( + agentId, pid, + () => this.handleDetachedAgentCompletion(agentId), + () => this.activeAgents.get(agentId)?.tailer, + ); + activeEntry.cancelPoll = cancel; + + log.info({ agentId, pid }, 'resumed errand agent for user message'); + } + /** * Sync credentials from agent's config dir back to DB after completion. * The subprocess may have refreshed tokens mid-session; this ensures diff --git a/apps/server/agent/prompts/errand.ts b/apps/server/agent/prompts/errand.ts new file mode 100644 index 0000000..e94b950 --- /dev/null +++ b/apps/server/agent/prompts/errand.ts @@ -0,0 +1,16 @@ +export function buildErrandPrompt(description: string): string { + return `You are working on a small, focused change in an isolated worktree. + +Description: ${description} + +Work interactively with the user. Make only the changes needed to fulfill the description. +When you are done, write .cw/output/signal.json: + +{ "status": "done", "result": { "message": "" } } + +If you cannot complete the change: + +{ "status": "error", "error": "" } + +Do not create any other output files.`; +} diff --git a/apps/server/agent/prompts/index.ts b/apps/server/agent/prompts/index.ts index 2186994..c7167db 100644 --- a/apps/server/agent/prompts/index.ts +++ b/apps/server/agent/prompts/index.ts @@ -13,6 +13,7 @@ export { buildDetailPrompt } from './detail.js'; export { buildRefinePrompt } from './refine.js'; export { buildChatPrompt } from './chat.js'; export type { ChatHistoryEntry } from './chat.js'; +export { buildErrandPrompt } from './errand.js'; export { buildWorkspaceLayout } from './workspace.js'; export { buildPreviewInstructions } from './preview.js'; export { buildConflictResolutionPrompt, buildConflictResolutionDescription } from './conflict-resolution.js'; diff --git a/docs/agent.md b/docs/agent.md index 7083585..0a1898e 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -11,7 +11,7 @@ | `process-manager.ts` | `AgentProcessManager` — worktree creation, command building, detached spawn | | `output-handler.ts` | `OutputHandler` — JSONL stream parsing, completion detection, proposal creation, task dedup, task dependency persistence | | `file-tailer.ts` | `FileTailer` — watches output files, fires parser + raw content callbacks | -| `file-io.ts` | Input/output file I/O: frontmatter writing, signal.json reading, tiptap conversion. Output files support `action` field (create/update/delete) for chat mode CRUD. | +| `file-io.ts` | Input/output file I/O: frontmatter writing, signal.json reading, tiptap conversion. Output files support `action` field (create/update/delete) for chat mode CRUD. Includes `writeErrandManifest()` for errand agent input files. | | `markdown-to-tiptap.ts` | Markdown to Tiptap JSON conversion using MarkdownManager | | `index.ts` | Public exports, `ClaudeAgentManager` deprecated alias | @@ -24,7 +24,7 @@ | `accounts/` | Account discovery, config dir setup, credential management, usage API | | `credentials/` | `AccountCredentialManager` — credential injection per account | | `lifecycle/` | `LifecycleController` — retry policy, signal recovery, missing signal instructions | -| `prompts/` | Mode-specific prompt builders (execute, discuss, plan, detail, refine, chat, conflict-resolution) + shared blocks (test integrity, deviation rules, git workflow, session startup, progress tracking) + inter-agent communication instructions | +| `prompts/` | Mode-specific prompt builders (execute, discuss, plan, detail, refine, chat, conflict-resolution, errand) + shared blocks (test integrity, deviation rules, git workflow, session startup, progress tracking) + inter-agent communication instructions | ## Key Flows @@ -115,6 +115,30 @@ cw account add --token --email user@example.com Stored as `credentials: {"claudeAiOauth":{"accessToken":""}}` and `configJson: {"hasCompletedOnboarding":true}`. +## Errand Agent Support + +### `sendUserMessage(agentId, message)` + +Delivers a user message directly to a running or idle errand agent without going through the conversations table. Used by the `errand.sendMessage` tRPC procedure. + +**Steps**: look up agent → validate status (`running`|`idle`) → validate `sessionId` → clear signal.json → update status to `running` → build resume command → stop active tailer/poll → spawn detached → start polling. + +**Key difference from `resumeForConversation`**: no `conversationResumeLocks`, no conversations table entry, raw message passed as resume prompt. + +### `writeErrandManifest(options)` + +Writes errand input files to `/.cw/input/`: + +- `errand.md` — YAML frontmatter with `id`, `description`, `branch`, `project` +- `manifest.json` — `{ errandId, agentId, agentName, mode: "errand" }` (no `files`/`contextFiles` arrays) +- `expected-pwd.txt` — the agent workdir path + +Written in order: `errand.md` first, `manifest.json` last (same discipline as `writeInputFiles`). + +### `buildErrandPrompt(description)` + +Builds the initial prompt for errand agents. Exported from `prompts/errand.ts` and re-exported from `prompts/index.ts`. The prompt instructs the agent to make only the changes needed for the description and write `signal.json` when done. + ## Auto-Resume for Conversations When Agent A asks Agent B a question via `cw ask` and Agent B is idle, the conversation router automatically resumes Agent B's session. This mirrors the `resumeForCommit()` pattern.