Files
Codewalkers/apps/server/agent/lifecycle/controller.test.ts
Lukas May 28521e1c20 chore: merge main into cw/small-change-flow
Integrates main branch changes (headquarters dashboard, task retry count,
agent prompt persistence, remote sync improvements) with the initiative's
errand agent feature. Both features coexist in the merged result.

Key resolutions:
- Schema: take main's errands table (nullable projectId, no conflictFiles,
  with errandsRelations); migrate to 0035_faulty_human_fly
- Router: keep both errandProcedures and headquartersProcedures
- Errand prompt: take main's simpler version (no question-asking flow)
- Manager: take main's status check (running|idle only, no waiting_for_input)
- Tests: update to match removed conflictFiles field and undefined vs null
2026-03-06 16:48:12 +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();
});
});
});