Files
Codewalkers/apps/server/agent/lifecycle/controller.test.ts
Lukas May 52e238924c feat: Add agent spawn infrastructure for errand mode
Implements three primitives needed before errand tRPC procedures can be wired up:

- agentManager.sendUserMessage(agentId, message): resumes an errand agent with a
  raw user message, bypassing the conversations table and conversationResumeLocks.
  Throws on missing agent, invalid status, or absent sessionId.

- writeErrandManifest(options): writes .cw/input/errand.md (YAML frontmatter),
  .cw/input/manifest.json (errandId/agentId/agentName/mode, no files/contextFiles),
  and .cw/expected-pwd.txt to an agent workdir.

- buildErrandPrompt(description): minimal prompt for errand agents; exported from
  prompts/errand.ts and re-exported from prompts/index.ts.

Also fixes a pre-existing TypeScript error in lifecycle/controller.test.ts (missing
backoffMs property in RetryPolicy mock introduced by a concurrent agent commit).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 13:22:15 +01:00

156 lines
5.3 KiB
TypeScript

/**
* 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<AgentRepository>;
accountRepository?: Partial<AccountRepository>;
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<AgentRepository> = {
findById: vi.fn().mockResolvedValue(agentRecord),
};
const accountRepository: Partial<AccountRepository> = {
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<AgentRepository> = {
findById: vi.fn().mockResolvedValue(agentRecord),
};
const accountRepository: Partial<AccountRepository> = {
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<AgentRepository> = {
findById: vi.fn().mockResolvedValue({ id: 'agent-3', name: 'x', accountId: null }),
};
const accountRepository: Partial<AccountRepository> = {
markExhausted: vi.fn(),
findNextAvailable: vi.fn(),
};
const controller = makeController({ repository, accountRepository, eventBus });
await (controller as any).handleAccountExhaustion('agent-3');
expect(eventBus.emit).not.toHaveBeenCalled();
});
});
});