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>
156 lines
5.3 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|