454 lines
15 KiB
TypeScript
454 lines
15 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import { TeamMemberResolver } from '../../../../src/main/services/team/TeamMemberResolver';
|
|
|
|
import type { TeamConfig, TeamTask, TeamTaskWithKanban } from '../../../../src/shared/types/team';
|
|
|
|
describe('TeamMemberResolver', () => {
|
|
it('builds roster from config + meta + inbox only', () => {
|
|
const resolver = new TeamMemberResolver();
|
|
const config: TeamConfig = {
|
|
name: 'My Team',
|
|
members: [{ name: 'team-lead', agentType: 'team-lead', role: 'lead' }],
|
|
};
|
|
const metaMembers: TeamConfig['members'] = [
|
|
{ name: 'alice', role: 'developer', agentType: 'general-purpose', color: 'blue' },
|
|
];
|
|
const inboxNames = ['bob'];
|
|
const tasks: TeamTask[] = [
|
|
{ id: '1', subject: 'Visible task', status: 'pending', owner: 'alice' },
|
|
{ id: '2', subject: 'Ghost task', status: 'pending', owner: 'stranger' },
|
|
];
|
|
|
|
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks);
|
|
const names = members.map((member) => member.name);
|
|
|
|
expect(names).toHaveLength(3);
|
|
expect(names).toEqual(expect.arrayContaining(['alice', 'bob', 'team-lead']));
|
|
expect(names).not.toContain('stranger');
|
|
expect(names).not.toContain('user');
|
|
|
|
const alice = members.find((member) => member.name === 'alice');
|
|
expect(alice?.role).toBe('developer');
|
|
expect(alice?.color).toBe('blue');
|
|
|
|
const lead = members.find((member) => member.name === 'team-lead');
|
|
expect(lead?.role).toBe('lead');
|
|
expect(lead?.agentType).toBe('team-lead');
|
|
});
|
|
|
|
it('does not expose completed, deleted, or approved tasks as current work', () => {
|
|
const resolver = new TeamMemberResolver();
|
|
const config: TeamConfig = {
|
|
name: 'Team',
|
|
members: [
|
|
{ name: 'team-lead', agentType: 'team-lead', role: 'lead' },
|
|
{ name: 'alice', agentType: 'general-purpose' },
|
|
{ name: 'bob', agentType: 'general-purpose' },
|
|
{ name: 'carol', agentType: 'general-purpose' },
|
|
{ name: 'dave', agentType: 'general-purpose' },
|
|
],
|
|
};
|
|
const tasks: TeamTaskWithKanban[] = [
|
|
{
|
|
id: 'task-completed',
|
|
subject: 'Done',
|
|
status: 'completed',
|
|
owner: 'alice',
|
|
},
|
|
{
|
|
id: 'task-deleted',
|
|
subject: 'Deleted',
|
|
status: 'deleted',
|
|
owner: 'bob',
|
|
deletedAt: '2026-05-06T00:00:00.000Z',
|
|
},
|
|
{
|
|
id: 'task-approved-review',
|
|
subject: 'Approved review',
|
|
status: 'in_progress',
|
|
owner: 'carol',
|
|
reviewState: 'approved',
|
|
},
|
|
{
|
|
id: 'task-approved-kanban',
|
|
subject: 'Approved kanban',
|
|
status: 'in_progress',
|
|
owner: 'dave',
|
|
kanbanColumn: 'approved',
|
|
},
|
|
{
|
|
id: 'task-review-kanban',
|
|
subject: 'Review kanban',
|
|
status: 'in_progress',
|
|
owner: 'dave',
|
|
kanbanColumn: 'review',
|
|
},
|
|
];
|
|
|
|
const members = resolver.resolveMembers(config, [], [], tasks);
|
|
|
|
expect(members.find((member) => member.name === 'alice')?.currentTaskId).toBeNull();
|
|
expect(members.find((member) => member.name === 'bob')?.currentTaskId).toBeNull();
|
|
expect(members.find((member) => member.name === 'carol')?.currentTaskId).toBeNull();
|
|
expect(members.find((member) => member.name === 'dave')?.currentTaskId).toBeNull();
|
|
});
|
|
|
|
it('keeps real in-progress task as current work', () => {
|
|
const resolver = new TeamMemberResolver();
|
|
const config: TeamConfig = {
|
|
name: 'Team',
|
|
members: [
|
|
{ name: 'team-lead', agentType: 'team-lead', role: 'lead' },
|
|
{ name: 'alice', agentType: 'general-purpose' },
|
|
],
|
|
};
|
|
const tasks: TeamTaskWithKanban[] = [
|
|
{
|
|
id: 'task-active',
|
|
subject: 'Active',
|
|
status: 'in_progress',
|
|
owner: 'alice',
|
|
},
|
|
];
|
|
|
|
const members = resolver.resolveMembers(config, [], [], tasks);
|
|
|
|
expect(members.find((member) => member.name === 'alice')?.currentTaskId).toBe('task-active');
|
|
});
|
|
|
|
it('does not leak stale Codex backend metadata into Anthropic members', () => {
|
|
const resolver = new TeamMemberResolver();
|
|
const config: TeamConfig = {
|
|
name: 'Team',
|
|
members: [
|
|
{
|
|
name: 'team-lead',
|
|
agentType: 'team-lead',
|
|
providerId: 'anthropic',
|
|
providerBackendId: 'codex-native',
|
|
model: 'opus[1m]',
|
|
effort: 'low',
|
|
},
|
|
{
|
|
name: 'bob',
|
|
agentType: 'general-purpose',
|
|
providerId: 'anthropic',
|
|
providerBackendId: 'codex-native',
|
|
model: 'opus',
|
|
},
|
|
],
|
|
};
|
|
const metaMembers: TeamConfig['members'] = [
|
|
{
|
|
name: 'jack',
|
|
agentType: 'general-purpose',
|
|
providerId: 'anthropic',
|
|
providerBackendId: 'codex-native',
|
|
model: 'haiku',
|
|
},
|
|
];
|
|
|
|
const members = resolver.resolveMembers(config, metaMembers, [], [], {
|
|
leadProviderId: 'anthropic',
|
|
leadProviderBackendId: 'codex-native',
|
|
});
|
|
|
|
expect(
|
|
members
|
|
.filter((member) => member.providerId === 'anthropic')
|
|
.map((member) => [member.name, member.providerBackendId])
|
|
).toEqual([
|
|
['team-lead', undefined],
|
|
['bob', undefined],
|
|
['jack', undefined],
|
|
]);
|
|
});
|
|
|
|
it('filters out "user" pseudo-member even when present in config, meta, or inboxNames', () => {
|
|
const resolver = new TeamMemberResolver();
|
|
const config: TeamConfig = {
|
|
name: 'Team',
|
|
members: [
|
|
{ name: 'team-lead', agentType: 'team-lead', role: 'lead' },
|
|
{ name: 'user', agentType: 'general-purpose' },
|
|
],
|
|
};
|
|
const metaMembers: TeamConfig['members'] = [
|
|
{ name: 'user', agentType: 'general-purpose' },
|
|
{ name: 'alice', role: 'dev', agentType: 'general-purpose' },
|
|
];
|
|
const inboxNames = ['user', 'alice'];
|
|
const tasks: TeamTask[] = [];
|
|
|
|
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks);
|
|
const names = members.map((m) => m.name);
|
|
|
|
expect(names).not.toContain('user');
|
|
expect(names).toContain('team-lead');
|
|
expect(names).toContain('alice');
|
|
});
|
|
|
|
it('ignores qualified external inbox names unless explicitly configured', () => {
|
|
const resolver = new TeamMemberResolver();
|
|
const config: TeamConfig = {
|
|
name: 'Team',
|
|
members: [{ name: 'team-lead', agentType: 'team-lead', role: 'lead' }],
|
|
};
|
|
const metaMembers: TeamConfig['members'] = [{ name: 'alice', agentType: 'general-purpose' }];
|
|
const inboxNames = ['alice', 'team-best.user', 'dream-team.team-lead'];
|
|
const tasks: TeamTask[] = [];
|
|
|
|
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks);
|
|
const names = members.map((m) => m.name);
|
|
|
|
expect(names).toContain('alice');
|
|
expect(names).toContain('team-lead');
|
|
expect(names).not.toContain('team-best.user');
|
|
expect(names).not.toContain('dream-team.team-lead');
|
|
});
|
|
|
|
it('ignores leaked generated agent ids from inbox file names', () => {
|
|
const resolver = new TeamMemberResolver();
|
|
const config: TeamConfig = {
|
|
name: 'Team',
|
|
members: [{ name: 'team-lead', agentType: 'team-lead', role: 'lead' }],
|
|
};
|
|
const metaMembers: TeamConfig['members'] = [
|
|
{ name: 'alice', agentType: 'general-purpose' },
|
|
{ name: 'bob', agentType: 'general-purpose' },
|
|
];
|
|
const inboxNames = ['a3975f80d37fbcea1', 'alice', 'a68a8f6a643e59bfd'];
|
|
|
|
const members = resolver.resolveMembers(config, metaMembers, inboxNames, []);
|
|
const names = members.map((m) => m.name);
|
|
|
|
expect(names).toContain('alice');
|
|
expect(names).toContain('bob');
|
|
expect(names).toContain('team-lead');
|
|
expect(names).not.toContain('a3975f80d37fbcea1');
|
|
expect(names).not.toContain('a68a8f6a643e59bfd');
|
|
});
|
|
|
|
it('keeps dotted names when they are explicitly configured members', () => {
|
|
const resolver = new TeamMemberResolver();
|
|
const config: TeamConfig = {
|
|
name: 'Team',
|
|
members: [
|
|
{ name: 'team-lead', agentType: 'team-lead', role: 'lead' },
|
|
{ name: 'ops.bot', agentType: 'general-purpose' },
|
|
],
|
|
};
|
|
|
|
const members = resolver.resolveMembers(config, [], ['ops.bot'], []);
|
|
const names = members.map((m) => m.name);
|
|
|
|
expect(names).toContain('ops.bot');
|
|
});
|
|
|
|
it('ignores pseudo cross-team inbox names', () => {
|
|
const resolver = new TeamMemberResolver();
|
|
const config: TeamConfig = {
|
|
name: 'Team',
|
|
members: [{ name: 'team-lead', agentType: 'team-lead', role: 'lead' }],
|
|
};
|
|
|
|
const members = resolver.resolveMembers(
|
|
config,
|
|
[],
|
|
['cross-team:team-alpha-super', 'cross-team-team-alpha-super', 'alice'],
|
|
[]
|
|
);
|
|
const names = members.map((m) => m.name);
|
|
|
|
expect(names).toContain('alice');
|
|
expect(names).toContain('team-lead');
|
|
expect(names).not.toContain('cross-team:team-alpha-super');
|
|
expect(names).not.toContain('cross-team-team-alpha-super');
|
|
});
|
|
|
|
it('ignores tool-like cross-team inbox names', () => {
|
|
const resolver = new TeamMemberResolver();
|
|
const config: TeamConfig = {
|
|
name: 'Team',
|
|
members: [{ name: 'team-lead', agentType: 'team-lead', role: 'lead' }],
|
|
};
|
|
|
|
const members = resolver.resolveMembers(
|
|
config,
|
|
[],
|
|
['cross_team_send', 'cross_team_list_targets', 'alice'],
|
|
[]
|
|
);
|
|
const names = members.map((m) => m.name);
|
|
|
|
expect(names).toContain('alice');
|
|
expect(names).toContain('team-lead');
|
|
expect(names).not.toContain('cross_team_send');
|
|
expect(names).not.toContain('cross_team_list_targets');
|
|
});
|
|
|
|
it('ignores malformed underscore-style pseudo cross-team inbox names', () => {
|
|
const resolver = new TeamMemberResolver();
|
|
const config: TeamConfig = {
|
|
name: 'Team',
|
|
members: [{ name: 'team-lead', agentType: 'team-lead', role: 'lead' }],
|
|
};
|
|
|
|
const members = resolver.resolveMembers(
|
|
config,
|
|
[],
|
|
['cross_team::team-alpha-super', 'cross_team--team-alpha-super', 'alice'],
|
|
[]
|
|
);
|
|
const names = members.map((m) => m.name);
|
|
|
|
expect(names).toContain('alice');
|
|
expect(names).toContain('team-lead');
|
|
expect(names).not.toContain('cross_team::team-alpha-super');
|
|
expect(names).not.toContain('cross_team--team-alpha-super');
|
|
});
|
|
|
|
it('keeps dotted names when config casing differs from inbox casing', () => {
|
|
const resolver = new TeamMemberResolver();
|
|
const config: TeamConfig = {
|
|
name: 'Team',
|
|
members: [
|
|
{ name: 'team-lead', agentType: 'team-lead', role: 'lead' },
|
|
{ name: 'Ops.Bot', agentType: 'general-purpose' },
|
|
],
|
|
};
|
|
|
|
const members = resolver.resolveMembers(config, [], ['ops.bot'], []);
|
|
const names = members.map((m) => m.name);
|
|
|
|
expect(names).toContain('Ops.Bot');
|
|
expect(names).not.toContain('ops.bot');
|
|
});
|
|
|
|
it('does not let a removed base member hide an active suffixed teammate', () => {
|
|
const resolver = new TeamMemberResolver();
|
|
const config: TeamConfig = {
|
|
name: 'Team',
|
|
members: [
|
|
{ name: 'team-lead', agentType: 'team-lead', role: 'lead' },
|
|
{ name: 'alice-2', agentType: 'general-purpose' },
|
|
],
|
|
};
|
|
const metaMembers: TeamConfig['members'] = [
|
|
{
|
|
name: 'alice',
|
|
agentType: 'general-purpose',
|
|
removedAt: 1715000000000,
|
|
},
|
|
];
|
|
|
|
const members = resolver.resolveMembers(config, metaMembers, [], []);
|
|
const names = members.map((member) => member.name);
|
|
|
|
expect(names).toContain('alice-2');
|
|
expect(names).toContain('alice');
|
|
});
|
|
|
|
it('sets currentTaskId for in_progress task', () => {
|
|
const resolver = new TeamMemberResolver();
|
|
const config: TeamConfig = {
|
|
name: 'Team',
|
|
members: [{ name: 'bob', agentType: 'general-purpose' }],
|
|
};
|
|
const tasks: TeamTaskWithKanban[] = [
|
|
{ id: 't1', subject: 'Work', status: 'in_progress', owner: 'bob' },
|
|
];
|
|
const members = resolver.resolveMembers(config, [], [], tasks);
|
|
const bob = members.find((m) => m.name === 'bob');
|
|
expect(bob?.currentTaskId).toBe('t1');
|
|
});
|
|
|
|
it('clears currentTaskId when task is approved via kanbanColumn', () => {
|
|
const resolver = new TeamMemberResolver();
|
|
const config: TeamConfig = {
|
|
name: 'Team',
|
|
members: [{ name: 'bob', agentType: 'general-purpose' }],
|
|
};
|
|
const tasks: TeamTaskWithKanban[] = [
|
|
{
|
|
id: 't1',
|
|
subject: 'Work',
|
|
status: 'in_progress',
|
|
owner: 'bob',
|
|
reviewState: 'approved',
|
|
kanbanColumn: 'approved',
|
|
},
|
|
];
|
|
const members = resolver.resolveMembers(config, [], [], tasks);
|
|
const bob = members.find((m) => m.name === 'bob');
|
|
expect(bob?.currentTaskId).toBeNull();
|
|
});
|
|
|
|
it('clears currentTaskId when task reviewState is approved even without kanbanColumn', () => {
|
|
const resolver = new TeamMemberResolver();
|
|
const config: TeamConfig = {
|
|
name: 'Team',
|
|
members: [{ name: 'bob', agentType: 'general-purpose' }],
|
|
};
|
|
const tasks: TeamTaskWithKanban[] = [
|
|
{
|
|
id: 't1',
|
|
subject: 'Work',
|
|
status: 'in_progress',
|
|
owner: 'bob',
|
|
reviewState: 'approved',
|
|
// kanbanColumn not set — stale data scenario
|
|
},
|
|
];
|
|
const members = resolver.resolveMembers(config, [], [], tasks);
|
|
const bob = members.find((m) => m.name === 'bob');
|
|
expect(bob?.currentTaskId).toBeNull();
|
|
});
|
|
|
|
it('merges inbox-derived "lead" alias into canonical "team-lead"', () => {
|
|
const resolver = new TeamMemberResolver();
|
|
const config: TeamConfig = {
|
|
name: 'Team',
|
|
members: [
|
|
{ name: 'team-lead', agentType: 'team-lead', role: 'lead' },
|
|
{ name: 'alice', agentType: 'general-purpose' },
|
|
],
|
|
};
|
|
// Teammates sometimes send messages to "lead" instead of "team-lead",
|
|
// creating a separate inbox file that the resolver picks up.
|
|
const inboxNames = ['team-lead', 'lead', 'alice'];
|
|
const members = resolver.resolveMembers(config, [], inboxNames, []);
|
|
const names = members.map((m) => m.name);
|
|
|
|
expect(names).toContain('team-lead');
|
|
expect(names).not.toContain('lead');
|
|
expect(names).toContain('alice');
|
|
});
|
|
|
|
it('keeps "lead" as a member when "team-lead" is not present', () => {
|
|
const resolver = new TeamMemberResolver();
|
|
const config: TeamConfig = {
|
|
name: 'Team',
|
|
members: [{ name: 'lead', agentType: 'team-lead', role: 'lead' }],
|
|
};
|
|
const members = resolver.resolveMembers(config, [], ['lead'], []);
|
|
const names = members.map((m) => m.name);
|
|
|
|
expect(names).toContain('lead');
|
|
});
|
|
|
|
it('clears currentTaskId when task status is completed', () => {
|
|
const resolver = new TeamMemberResolver();
|
|
const config: TeamConfig = {
|
|
name: 'Team',
|
|
members: [{ name: 'bob', agentType: 'general-purpose' }],
|
|
};
|
|
const tasks: TeamTaskWithKanban[] = [
|
|
{ id: 't1', subject: 'Work', status: 'completed', owner: 'bob' },
|
|
];
|
|
const members = resolver.resolveMembers(config, [], [], tasks);
|
|
const bob = members.find((m) => m.name === 'bob');
|
|
expect(bob?.currentTaskId).toBeNull();
|
|
});
|
|
});
|