agent-ecosystem/test/main/services/team/TeamDataService.test.ts
iliya 48e5d9d6cd feat: introduce maintenance API for artifact reconciliation
- Added maintenance module to the agent-teams-controller, enabling the reconciliation of stale kanban entries and linked inbox comments.
- Implemented garbage collection logic in kanbanStore to remove invalid task references and clean up column orders.
- Updated controller to expose maintenance functionalities, allowing for better task and comment management.
- Enhanced tests to validate the new reconciliation process, ensuring idempotency and correctness in handling stale data.
- Integrated new CLI argument options for worktree and custom arguments in team launch and creation dialogs, improving user flexibility.
2026-03-07 18:48:57 +02:00

343 lines
9.2 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { TeamDataService } from '../../../../src/main/services/team/TeamDataService';
import type { TeamTask } from '../../../../src/shared/types/team';
describe('TeamDataService', () => {
it('keeps getTeamData read-only and skips kanban garbage-collect', async () => {
const order: string[] = [];
const tasks: TeamTask[] = [
{
id: '12',
subject: 'Task',
status: 'pending',
},
];
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({ name: 'My team', members: [] })),
} as never,
{
getTasks: vi.fn(async () => {
order.push('tasks');
return tasks;
}),
} as never,
{
listInboxNames: vi.fn(async () => []),
getMessages: vi.fn(async () => []),
} as never,
{} as never,
{} as never,
{
resolveMembers: vi.fn(() => []),
} as never,
{
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
garbageCollect: vi.fn(async () => {
order.push('gc');
}),
} as never
);
await service.getTeamData('my-team');
expect(order).toEqual(['tasks']);
});
it('delegates explicit reconcile to controller maintenance API', async () => {
const reconcileArtifacts = vi.fn();
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({ name: 'My team', members: [{ name: 'team-lead', role: 'Lead' }] })),
} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{
resolveMembers: vi.fn(() => []),
} as never,
{
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
garbageCollect: vi.fn(async () => undefined),
} as never,
{} as never,
{
readMembers: vi.fn(async () => []),
} as never,
{
readMessages: vi.fn(async () => []),
} as never,
() =>
({
maintenance: {
reconcileArtifacts,
},
}) as never
);
await service.reconcileTeamArtifacts('my-team');
expect(reconcileArtifacts).toHaveBeenCalledWith({ reason: 'file-watch' });
});
it('surfaces controller reconcile failures', async () => {
const reconcileArtifacts = vi.fn(() => {
throw new Error('reconcile failed');
});
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({ name: 'My team', members: [] })),
} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{
resolveMembers: vi.fn(() => []),
} as never,
{
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
garbageCollect: vi.fn(async () => undefined),
} as never,
{} as never,
{} as never,
{} as never,
() =>
({
maintenance: {
reconcileArtifacts,
},
}) as never
);
await expect(service.reconcileTeamArtifacts('my-team')).rejects.toThrow('reconcile failed');
});
it('includes projectPath from config when creating a task', async () => {
const createTaskMock = vi.fn((task) => task);
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({
name: 'My team',
members: [],
projectPath: '/Users/dev/my-project',
})),
} as never,
{
getNextTaskId: vi.fn(async () => '1'),
getTasks: vi.fn(async () => []),
} as never,
{
listInboxNames: vi.fn(async () => []),
getMessages: vi.fn(async () => []),
} as never,
{} as never,
{
createTask: createTaskMock,
addBlocksEntry: vi.fn(async () => undefined),
} as never,
{
resolveMembers: vi.fn(() => []),
} as never,
{
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
garbageCollect: vi.fn(async () => undefined),
} as never,
{} as never,
{} as never,
{} as never,
(teamName: string) =>
({
tasks: {
createTask: createTaskMock,
},
}) as never
);
const result = await service.createTask('my-team', { subject: 'Test' });
expect(result.projectPath).toBe('/Users/dev/my-project');
expect(createTaskMock).toHaveBeenCalledWith(
expect.objectContaining({ projectPath: '/Users/dev/my-project' })
);
});
it('creates task with status pending when startImmediately is false', async () => {
const createTaskMock = vi.fn((task) => task);
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({ name: 'My team', members: [] })),
} as never,
{
getNextTaskId: vi.fn(async () => '2'),
getTasks: vi.fn(async () => []),
} as never,
{
listInboxNames: vi.fn(async () => []),
getMessages: vi.fn(async () => []),
} as never,
{} as never,
{
createTask: createTaskMock,
addBlocksEntry: vi.fn(async () => undefined),
} as never,
{
resolveMembers: vi.fn(() => []),
} as never,
{
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
garbageCollect: vi.fn(async () => undefined),
} as never,
{} as never,
{} as never,
{} as never,
(teamName: string) =>
({
tasks: {
createTask: createTaskMock,
},
}) as never
);
const result = await service.createTask('my-team', {
subject: 'Review main file',
owner: 'alice',
startImmediately: false,
});
expect(result.status).toBe('pending');
expect(createTaskMock).toHaveBeenCalledWith(
expect.objectContaining({ status: 'pending', owner: 'alice', createdBy: 'user' })
);
});
it('persists explicit related task links when creating a task', async () => {
const createTaskMock = vi.fn((task) => task);
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({ name: 'My team', members: [] })),
} as never,
{
getNextTaskId: vi.fn(async () => '3'),
getTasks: vi.fn(async () => []),
} as never,
{
listInboxNames: vi.fn(async () => []),
getMessages: vi.fn(async () => []),
} as never,
{} as never,
{
createTask: createTaskMock,
addBlocksEntry: vi.fn(async () => undefined),
} as never,
{
resolveMembers: vi.fn(() => []),
} as never,
{
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
garbageCollect: vi.fn(async () => undefined),
} as never,
{} as never,
{} as never,
{} as never,
(teamName: string) =>
({
tasks: {
createTask: createTaskMock,
},
}) as never
);
const result = await service.createTask('my-team', {
subject: 'Review work task',
related: ['1', '2'],
});
expect(result.related).toEqual(['1', '2']);
expect(createTaskMock).toHaveBeenCalledWith(expect.objectContaining({ related: ['1', '2'] }));
});
it('routes durable inbox writes through controller message API', async () => {
const sendMessageMock = vi.fn(() => ({ deliveredToInbox: true, messageId: 'm-1' }));
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({ name: 'My team', members: [], leadSessionId: 'lead-1' })),
} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
() =>
({
messages: {
sendMessage: sendMessageMock,
},
}) as never
);
const result = await service.sendMessage('my-team', {
member: 'alice',
text: 'hello',
summary: 'ping',
});
expect(result).toEqual({ deliveredToInbox: true, messageId: 'm-1' });
expect(sendMessageMock).toHaveBeenCalledWith(
expect.objectContaining({
member: 'alice',
text: 'hello',
summary: 'ping',
leadSessionId: 'lead-1',
})
);
});
it('delegates review entry to controller review API', async () => {
const requestReviewMock = vi.fn();
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({
name: 'My team',
members: [{ name: 'lead', role: 'team lead' }],
})),
} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
() =>
({
review: {
requestReview: requestReviewMock,
},
}) as never
);
await service.requestReview('my-team', 'task-1');
expect(requestReviewMock).toHaveBeenCalledWith('task-1', { from: 'lead' });
});
});