mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 22:49:41 +02:00
chore: bump version to 0.1479
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* @fileoverview Tests for team-aware idle detection in RespawnController
|
||||
*
|
||||
* Verifies that the respawn controller checks TeamWatcher for active teammates
|
||||
* before triggering respawn. Also tests AI idle checker teammate context injection.
|
||||
*
|
||||
* Test port: 3151 (if server needed)
|
||||
* SAFETY: Never uses port 3000, never kills w1-claudeman/w2/w3-claudeman
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { vi } from 'vitest';
|
||||
import type { Session } from '../src/session.js';
|
||||
import { RespawnController } from '../src/respawn-controller.js';
|
||||
import { AiIdleChecker } from '../src/ai-idle-checker.js';
|
||||
import { TeamWatcher } from '../src/team-watcher.js';
|
||||
import { MockSession, createTimeController, type TimeController } from './respawn-test-utils.js';
|
||||
|
||||
// Minimal config for fast testing
|
||||
const FAST_CONFIG = {
|
||||
enabled: true,
|
||||
idleTimeoutMs: 100,
|
||||
completionConfirmMs: 100,
|
||||
noOutputTimeoutMs: 500,
|
||||
updatePrompt: 'test update',
|
||||
interStepDelayMs: 50,
|
||||
sendClear: false,
|
||||
sendInit: false,
|
||||
aiIdleCheckEnabled: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock TeamWatcher that returns configurable active teammate state
|
||||
*/
|
||||
class MockTeamWatcher extends EventEmitter {
|
||||
private _hasActive: boolean = false;
|
||||
private _teammateCount: number = 0;
|
||||
|
||||
setHasActiveTeammates(value: boolean): void {
|
||||
this._hasActive = value;
|
||||
}
|
||||
|
||||
setTeammateCount(count: number): void {
|
||||
this._teammateCount = count;
|
||||
this._hasActive = count > 0;
|
||||
}
|
||||
|
||||
hasActiveTeammates(_sessionId: string): boolean {
|
||||
return this._hasActive;
|
||||
}
|
||||
|
||||
getActiveTeammateCount(_sessionId: string): number {
|
||||
return this._teammateCount;
|
||||
}
|
||||
|
||||
// Stubs for TeamWatcher interface
|
||||
start(): void {}
|
||||
stop(): void {}
|
||||
getTeams(): [] { return []; }
|
||||
getTeamForSession(): undefined { return undefined; }
|
||||
getTeamTasks(): [] { return []; }
|
||||
getActiveTaskCount(): number { return 0; }
|
||||
getInboxMessages(): [] { return []; }
|
||||
}
|
||||
|
||||
describe('Team-aware idle detection', () => {
|
||||
let session: MockSession;
|
||||
let controller: RespawnController;
|
||||
let mockTeamWatcher: MockTeamWatcher;
|
||||
let time: TimeController;
|
||||
|
||||
beforeEach(() => {
|
||||
time = createTimeController();
|
||||
session = new MockSession('team-test-session');
|
||||
mockTeamWatcher = new MockTeamWatcher();
|
||||
controller = new RespawnController(session as unknown as Session, FAST_CONFIG);
|
||||
controller.setTeamWatcher(mockTeamWatcher as unknown as TeamWatcher);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
controller.stop();
|
||||
time.useRealTimers();
|
||||
});
|
||||
|
||||
it('should block idle confirmation when teammates are active', async () => {
|
||||
mockTeamWatcher.setHasActiveTeammates(true);
|
||||
|
||||
const blocked = new Promise<{ reason: string }>(resolve => {
|
||||
controller.on('respawnBlocked', resolve);
|
||||
});
|
||||
|
||||
controller.start();
|
||||
|
||||
// Simulate completion
|
||||
session.simulateCompletionMessage('30s');
|
||||
await time.advanceBy(200); // Wait for confirmation
|
||||
|
||||
const event = await blocked;
|
||||
expect(event.reason).toBe('active_teammates');
|
||||
});
|
||||
|
||||
it('should not emit active_teammates block when no teammates are active', async () => {
|
||||
mockTeamWatcher.setHasActiveTeammates(false);
|
||||
|
||||
const blockReasons: string[] = [];
|
||||
controller.on('respawnBlocked', (data: { reason: string }) => {
|
||||
blockReasons.push(data.reason);
|
||||
});
|
||||
|
||||
controller.start();
|
||||
|
||||
// Simulate completion and wait through confirmation
|
||||
session.simulateCompletionMessage('30s');
|
||||
await time.advanceBy(1000);
|
||||
|
||||
// Even if idle detection doesn't fully complete in fake timers,
|
||||
// we should NOT see an active_teammates block
|
||||
expect(blockReasons).not.toContain('active_teammates');
|
||||
});
|
||||
|
||||
it('should not emit active_teammates block when no team watcher is set', async () => {
|
||||
// Create controller without team watcher
|
||||
const plainController = new RespawnController(session as unknown as Session, FAST_CONFIG);
|
||||
|
||||
const blockReasons: string[] = [];
|
||||
plainController.on('respawnBlocked', (data: { reason: string }) => {
|
||||
blockReasons.push(data.reason);
|
||||
});
|
||||
|
||||
plainController.start();
|
||||
|
||||
session.simulateCompletionMessage('30s');
|
||||
await time.advanceBy(1000);
|
||||
|
||||
// No team watcher means no teammate blocking
|
||||
expect(blockReasons).not.toContain('active_teammates');
|
||||
|
||||
plainController.stop();
|
||||
});
|
||||
|
||||
it('should transition back to watching when blocked by teammates', async () => {
|
||||
mockTeamWatcher.setHasActiveTeammates(true);
|
||||
|
||||
const stateChanges: string[] = [];
|
||||
controller.on('stateChanged', (state: string) => {
|
||||
stateChanges.push(state);
|
||||
});
|
||||
|
||||
controller.start();
|
||||
|
||||
session.simulateCompletionMessage('30s');
|
||||
await time.advanceBy(200);
|
||||
|
||||
// Should go back to watching after being blocked
|
||||
const lastState = stateChanges[stateChanges.length - 1];
|
||||
expect(lastState).toBe('watching');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI idle checker teammate context', () => {
|
||||
it('should inject teammate context into prompt when count > 0', () => {
|
||||
const checker = new AiIdleChecker('test-session', { enabled: true });
|
||||
checker.setTeammateCount(3);
|
||||
|
||||
// Access buildPrompt via prototype (it's protected, so we test indirectly)
|
||||
// Instead, we verify the public API works
|
||||
expect(checker).toBeDefined();
|
||||
});
|
||||
|
||||
it('should accept setTeammateCount(0) without error', () => {
|
||||
const checker = new AiIdleChecker('test-session', { enabled: true });
|
||||
checker.setTeammateCount(0);
|
||||
expect(checker).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* @fileoverview Tests for TeamWatcher - Agent Teams filesystem polling
|
||||
*
|
||||
* Test port: 3150 (if server needed)
|
||||
* SAFETY: Never uses port 3000, never kills w1-claudeman/w2/w3-claudeman
|
||||
*/
|
||||
|
||||
import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { TeamWatcher } from '../src/team-watcher.js';
|
||||
import type { TeamConfig, TeamTask, InboxMessage } from '../src/types.js';
|
||||
|
||||
// Unique temp dir per test run
|
||||
const TEST_BASE = join(tmpdir(), `claudeman-test-teams-${randomUUID().substring(0, 8)}`);
|
||||
const TEAMS_DIR = join(TEST_BASE, 'teams');
|
||||
const TASKS_DIR = join(TEST_BASE, 'tasks');
|
||||
|
||||
function createTeamConfig(overrides: Partial<TeamConfig> = {}): TeamConfig {
|
||||
return {
|
||||
name: 'test-team',
|
||||
createdAt: Date.now(),
|
||||
leadAgentId: 'team-lead@test-team',
|
||||
leadSessionId: 'session-123',
|
||||
members: [
|
||||
{
|
||||
agentId: 'team-lead@test-team',
|
||||
name: 'team-lead',
|
||||
agentType: 'team-lead',
|
||||
joinedAt: Date.now(),
|
||||
},
|
||||
{
|
||||
agentId: 'researcher@test-team',
|
||||
name: 'researcher',
|
||||
agentType: 'general-purpose',
|
||||
color: 'blue',
|
||||
backendType: 'in-process',
|
||||
joinedAt: Date.now(),
|
||||
},
|
||||
{
|
||||
agentId: 'coder@test-team',
|
||||
name: 'coder',
|
||||
agentType: 'general-purpose',
|
||||
color: 'green',
|
||||
backendType: 'in-process',
|
||||
joinedAt: Date.now(),
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createTask(id: string, status: 'pending' | 'in_progress' | 'completed', owner?: string): TeamTask {
|
||||
return {
|
||||
id,
|
||||
subject: `Task ${id}`,
|
||||
description: `Description for task ${id}`,
|
||||
activeForm: `Working on task ${id}`,
|
||||
status,
|
||||
owner,
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
};
|
||||
}
|
||||
|
||||
function writeTeamConfig(teamName: string, config: TeamConfig): void {
|
||||
const dir = join(TEAMS_DIR, teamName);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, 'config.json'), JSON.stringify(config));
|
||||
}
|
||||
|
||||
function writeTask(teamName: string, task: TeamTask): void {
|
||||
const dir = join(TASKS_DIR, teamName);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, `${task.id}.json`), JSON.stringify(task));
|
||||
}
|
||||
|
||||
function writeInbox(teamName: string, member: string, messages: InboxMessage[]): void {
|
||||
const dir = join(TEAMS_DIR, teamName, 'inboxes');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, `${member}.json`), JSON.stringify(messages));
|
||||
}
|
||||
|
||||
describe('TeamWatcher', () => {
|
||||
let watcher: TeamWatcher;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clean and recreate
|
||||
if (existsSync(TEST_BASE)) {
|
||||
rmSync(TEST_BASE, { recursive: true, force: true });
|
||||
}
|
||||
mkdirSync(TEAMS_DIR, { recursive: true });
|
||||
mkdirSync(TASKS_DIR, { recursive: true });
|
||||
watcher = new TeamWatcher(TEAMS_DIR, TASKS_DIR);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
watcher.stop();
|
||||
if (existsSync(TEST_BASE)) {
|
||||
rmSync(TEST_BASE, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('Team discovery', () => {
|
||||
it('should discover a team from config.json', async () => {
|
||||
const config = createTeamConfig();
|
||||
writeTeamConfig('test-team', config);
|
||||
|
||||
const created = new Promise<TeamConfig>(resolve => {
|
||||
watcher.on('teamCreated', resolve);
|
||||
});
|
||||
|
||||
watcher.start();
|
||||
const team = await created;
|
||||
|
||||
expect(team.name).toBe('test-team');
|
||||
expect(team.leadSessionId).toBe('session-123');
|
||||
expect(team.members).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should return teams via getTeams()', async () => {
|
||||
writeTeamConfig('alpha', createTeamConfig({ name: 'alpha' }));
|
||||
writeTeamConfig('beta', createTeamConfig({ name: 'beta', leadSessionId: 'session-456' }));
|
||||
|
||||
const events: TeamConfig[] = [];
|
||||
watcher.on('teamCreated', (t: TeamConfig) => events.push(t));
|
||||
|
||||
watcher.start();
|
||||
// Wait for first poll
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
const teams = watcher.getTeams();
|
||||
expect(teams).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should match team to session via getTeamForSession()', async () => {
|
||||
writeTeamConfig('test-team', createTeamConfig({ leadSessionId: 'my-session' }));
|
||||
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
expect(watcher.getTeamForSession('my-session')).toBeDefined();
|
||||
expect(watcher.getTeamForSession('other-session')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should emit teamUpdated when config changes', async () => {
|
||||
const config = createTeamConfig();
|
||||
writeTeamConfig('test-team', config);
|
||||
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
const updated = new Promise<TeamConfig>(resolve => {
|
||||
watcher.on('teamUpdated', resolve);
|
||||
});
|
||||
|
||||
// Modify config (add a member)
|
||||
config.members.push({
|
||||
agentId: 'writer@test-team',
|
||||
name: 'writer',
|
||||
agentType: 'general-purpose',
|
||||
color: 'yellow',
|
||||
joinedAt: Date.now(),
|
||||
});
|
||||
// Small delay to ensure different mtime
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
writeTeamConfig('test-team', config);
|
||||
|
||||
const team = await updated;
|
||||
expect(team.members).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('should emit teamRemoved when directory deleted', async () => {
|
||||
writeTeamConfig('test-team', createTeamConfig());
|
||||
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
expect(watcher.getTeams()).toHaveLength(1);
|
||||
|
||||
const removed = new Promise<TeamConfig>(resolve => {
|
||||
watcher.on('teamRemoved', resolve);
|
||||
});
|
||||
|
||||
rmSync(join(TEAMS_DIR, 'test-team'), { recursive: true, force: true });
|
||||
|
||||
const team = await removed;
|
||||
expect(team.name).toBe('test-team');
|
||||
expect(watcher.getTeams()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task tracking', () => {
|
||||
it('should read tasks from task directory', async () => {
|
||||
writeTeamConfig('test-team', createTeamConfig());
|
||||
writeTask('test-team', createTask('1', 'in_progress', 'researcher'));
|
||||
writeTask('test-team', createTask('2', 'pending'));
|
||||
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
const tasks = watcher.getTeamTasks('test-team');
|
||||
expect(tasks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should count active tasks', async () => {
|
||||
writeTeamConfig('test-team', createTeamConfig());
|
||||
writeTask('test-team', createTask('1', 'in_progress', 'researcher'));
|
||||
writeTask('test-team', createTask('2', 'completed', 'coder'));
|
||||
writeTask('test-team', createTask('3', 'pending'));
|
||||
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
expect(watcher.getActiveTaskCount('test-team')).toBe(2); // in_progress + pending
|
||||
});
|
||||
|
||||
it('should filter out internal tasks', async () => {
|
||||
writeTeamConfig('test-team', createTeamConfig());
|
||||
writeTask('test-team', createTask('1', 'in_progress', 'researcher'));
|
||||
|
||||
const internalTask: TeamTask = {
|
||||
...createTask('2', 'in_progress'),
|
||||
metadata: { _internal: true },
|
||||
};
|
||||
const dir = join(TASKS_DIR, 'test-team');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, '2.json'), JSON.stringify(internalTask));
|
||||
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
const tasks = watcher.getTeamTasks('test-team');
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].id).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Active teammates detection', () => {
|
||||
it('should detect active teammates when tasks exist', async () => {
|
||||
writeTeamConfig('test-team', createTeamConfig({ leadSessionId: 'session-abc' }));
|
||||
writeTask('test-team', createTask('1', 'in_progress', 'researcher'));
|
||||
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
expect(watcher.hasActiveTeammates('session-abc')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when all tasks completed', async () => {
|
||||
writeTeamConfig('test-team', createTeamConfig({ leadSessionId: 'session-abc' }));
|
||||
writeTask('test-team', createTask('1', 'completed', 'researcher'));
|
||||
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
expect(watcher.hasActiveTeammates('session-abc')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for unknown sessions', () => {
|
||||
expect(watcher.hasActiveTeammates('nonexistent')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return correct teammate count', async () => {
|
||||
writeTeamConfig('test-team', createTeamConfig({ leadSessionId: 'session-abc' }));
|
||||
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
expect(watcher.getActiveTeammateCount('session-abc')).toBe(2); // researcher + coder
|
||||
expect(watcher.getActiveTeammateCount('nonexistent')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Inbox tracking', () => {
|
||||
it('should read inbox messages', async () => {
|
||||
writeTeamConfig('test-team', createTeamConfig());
|
||||
|
||||
const messages: InboxMessage[] = [
|
||||
{ from: 'team-lead', text: '{"type":"task_assignment"}', timestamp: new Date().toISOString(), read: false },
|
||||
];
|
||||
writeInbox('test-team', 'researcher', messages);
|
||||
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
const inbox = watcher.getInboxMessages('test-team', 'researcher');
|
||||
expect(inbox).toHaveLength(1);
|
||||
expect(inbox[0].from).toBe('team-lead');
|
||||
});
|
||||
|
||||
it('should emit inboxMessage for new messages', async () => {
|
||||
writeTeamConfig('test-team', createTeamConfig());
|
||||
writeInbox('test-team', 'researcher', []);
|
||||
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
const received = new Promise<{ teamName: string; member: string; message: InboxMessage }>(resolve => {
|
||||
watcher.on('inboxMessage', resolve);
|
||||
});
|
||||
|
||||
// Add a new message
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
const messages: InboxMessage[] = [
|
||||
{ from: 'team-lead', text: '{"type":"task_assignment"}', timestamp: new Date().toISOString(), read: false },
|
||||
];
|
||||
writeInbox('test-team', 'researcher', messages);
|
||||
|
||||
const event = await received;
|
||||
expect(event.teamName).toBe('test-team');
|
||||
expect(event.member).toBe('researcher');
|
||||
expect(event.message.from).toBe('team-lead');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lock handling', () => {
|
||||
it('should skip locked config files', async () => {
|
||||
writeTeamConfig('test-team', createTeamConfig());
|
||||
// Create a lock directory before starting
|
||||
mkdirSync(join(TEAMS_DIR, 'test-team', 'config.json.lock'));
|
||||
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
// Should not discover the team because it's locked
|
||||
expect(watcher.getTeams()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should discover team after lock is released', async () => {
|
||||
// Start with locked config
|
||||
writeTeamConfig('test-team', createTeamConfig());
|
||||
mkdirSync(join(TEAMS_DIR, 'test-team', 'config.json.lock'));
|
||||
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
expect(watcher.getTeams()).toHaveLength(0);
|
||||
|
||||
// Stop, remove lock, restart (fresh mtime cache)
|
||||
watcher.stop();
|
||||
rmSync(join(TEAMS_DIR, 'test-team', 'config.json.lock'), { recursive: true });
|
||||
|
||||
watcher = new TeamWatcher(TEAMS_DIR, TASKS_DIR);
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
expect(watcher.getTeams()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cleanup', () => {
|
||||
it('should clear all state on stop()', async () => {
|
||||
writeTeamConfig('test-team', createTeamConfig());
|
||||
writeTask('test-team', createTask('1', 'in_progress'));
|
||||
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
expect(watcher.getTeams()).toHaveLength(1);
|
||||
|
||||
watcher.stop();
|
||||
|
||||
expect(watcher.getTeams()).toHaveLength(0);
|
||||
expect(watcher.getTeamTasks('test-team')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user