agent-ecosystem/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts

504 lines
14 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { TeamGraphAdapter } from '@renderer/features/agent-graph/adapters/TeamGraphAdapter';
import type { InboxMessage, TeamData, TeamTaskWithKanban } from '@shared/types/team';
function createBaseTeamData(
overrides?: Partial<TeamData> & {
tasks?: TeamTaskWithKanban[];
messages?: InboxMessage[];
}
): TeamData {
return {
teamName: 'my-team',
config: {
name: 'My Team',
members: [{ name: 'team-lead' }, { name: 'alice' }, { name: 'bob' }],
projectPath: '/repo',
},
members: [
{
name: 'team-lead',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
agentType: 'team-lead',
},
{
name: 'alice',
status: 'active',
currentTaskId: null,
taskCount: 1,
lastActiveAt: null,
messageCount: 0,
},
{
name: 'bob',
status: 'active',
currentTaskId: null,
taskCount: 1,
lastActiveAt: null,
messageCount: 0,
},
],
tasks: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
isAlive: true,
...overrides,
};
}
describe('TeamGraphAdapter particles', () => {
it('creates a message particle for a new incoming message from the newest message set', () => {
const adapter = TeamGraphAdapter.create();
const baseline = createBaseTeamData();
adapter.adapt(baseline, 'my-team');
const next = createBaseTeamData({
messages: [
{
from: 'alice',
to: 'team-lead',
text: 'Please check the latest build output now',
timestamp: '2026-03-28T19:00:01.000Z',
read: false,
messageId: 'msg-new',
},
],
});
const graph = adapter.adapt(next, 'my-team');
expect(graph.particles).toHaveLength(1);
expect(graph.particles[0]).toMatchObject({
kind: 'inbox_message',
progress: 0,
label: '✉ Please check the latest build output now',
});
});
it('creates a comment particle for the first new task comment with preview text', () => {
const adapter = TeamGraphAdapter.create();
const baseline = createBaseTeamData({
tasks: [
{
id: 'task-1',
displayId: '#1',
subject: 'Investigate',
owner: 'alice',
status: 'in_progress',
comments: [],
reviewState: 'none',
} as TeamTaskWithKanban,
],
});
adapter.adapt(baseline, 'my-team');
const next = createBaseTeamData({
tasks: [
{
id: 'task-1',
displayId: '#1',
subject: 'Investigate',
owner: 'alice',
status: 'in_progress',
comments: [
{
id: 'comment-1',
author: 'alice',
text: 'Need clarification on the acceptance criteria before I continue',
createdAt: '2026-03-28T19:00:02.000Z',
type: 'regular',
},
],
reviewState: 'none',
} as TeamTaskWithKanban,
],
});
const graph = adapter.adapt(next, 'my-team');
expect(graph.particles).toHaveLength(1);
expect(graph.particles[0]).toMatchObject({
kind: 'task_comment',
label: '💬 Need clarification on the acceptance criteria befor…',
});
});
it('creates a synthetic message edge for comments from non-owner participants', () => {
const adapter = TeamGraphAdapter.create();
const baseline = createBaseTeamData({
tasks: [
{
id: 'task-2',
displayId: '#2',
subject: 'Fix regression',
owner: 'bob',
status: 'in_progress',
comments: [],
reviewState: 'none',
} as TeamTaskWithKanban,
],
});
adapter.adapt(baseline, 'my-team');
const next = createBaseTeamData({
tasks: [
{
id: 'task-2',
displayId: '#2',
subject: 'Fix regression',
owner: 'bob',
status: 'in_progress',
comments: [
{
id: 'comment-2',
author: 'alice',
text: 'I found the root cause, handing notes over now',
createdAt: '2026-03-28T19:00:03.000Z',
type: 'regular',
},
],
reviewState: 'none',
} as TeamTaskWithKanban,
],
});
const graph = adapter.adapt(next, 'my-team');
expect(graph.particles).toHaveLength(1);
expect(graph.particles[0]).toMatchObject({
kind: 'task_comment',
label: '💬 I found the root cause, handing notes over now',
});
expect(
graph.edges.some((edge) => edge.id === 'edge:msg:member:my-team:alice:task:my-team:task-2')
).toBe(true);
});
it('does not collapse two new inbox particles that share a timestamp but differ in content', () => {
const adapter = TeamGraphAdapter.create();
adapter.adapt(createBaseTeamData(), 'my-team');
const next = createBaseTeamData({
messages: [
{
from: 'alice',
to: 'team-lead',
text: 'First payload',
timestamp: '2026-03-28T19:00:01.000Z',
read: false,
},
{
from: 'bob',
to: 'team-lead',
text: 'Second payload',
timestamp: '2026-03-28T19:00:01.000Z',
read: false,
},
],
});
const graph = adapter.adapt(next, 'my-team');
expect(graph.particles).toHaveLength(2);
expect(graph.particles.every((particle) => particle.kind === 'inbox_message')).toBe(true);
});
it('uses peer-summary text for idle particles instead of generic idle', () => {
const adapter = TeamGraphAdapter.create();
adapter.adapt(createBaseTeamData(), 'my-team');
const next = createBaseTeamData({
messages: [
{
from: 'alice',
to: 'team-lead',
text: JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to bob] aligned on rollout order',
}),
timestamp: '2026-04-08T19:00:01.000Z',
read: true,
messageId: 'idle-summary-1',
},
],
});
const graph = adapter.adapt(next, 'my-team');
expect(graph.particles).toHaveLength(1);
expect(graph.particles[0]).toMatchObject({
kind: 'inbox_message',
label: '[to bob] aligned on rollout order',
});
});
it('creates particles for each newly appended task comment, not only the latest one', () => {
const adapter = TeamGraphAdapter.create();
const baseline = createBaseTeamData({
tasks: [
{
id: 'task-4',
displayId: '#4',
subject: 'Burst comments',
owner: 'alice',
status: 'in_progress',
comments: [],
reviewState: 'none',
} as TeamTaskWithKanban,
],
});
adapter.adapt(baseline, 'my-team');
const next = createBaseTeamData({
tasks: [
{
id: 'task-4',
displayId: '#4',
subject: 'Burst comments',
owner: 'alice',
status: 'in_progress',
comments: [
{
id: 'comment-4a',
author: 'alice',
text: 'First burst comment',
createdAt: '2026-03-28T19:00:06.000Z',
type: 'regular',
},
{
id: 'comment-4b',
author: 'bob',
text: 'Second burst comment',
createdAt: '2026-03-28T19:00:07.000Z',
type: 'regular',
},
],
reviewState: 'none',
} as TeamTaskWithKanban,
],
});
const graph = adapter.adapt(next, 'my-team');
expect(graph.particles).toHaveLength(2);
expect(graph.particles.every((particle) => particle.kind === 'task_comment')).toBe(true);
});
it('maps the real lead name to the lead node for inbox messages and task comments', () => {
const adapter = TeamGraphAdapter.create();
const baseline = createBaseTeamData({
config: {
name: 'My Team',
members: [{ name: 'olivia', agentType: 'lead' }, { name: 'alice' }],
projectPath: '/repo',
},
members: [
{
name: 'olivia',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
agentType: 'lead',
},
{
name: 'alice',
status: 'active',
currentTaskId: null,
taskCount: 1,
lastActiveAt: null,
messageCount: 0,
},
],
tasks: [
{
id: 'task-3',
displayId: '#3',
subject: 'Review notes',
owner: 'alice',
status: 'in_progress',
comments: [],
reviewState: 'none',
} as TeamTaskWithKanban,
],
messages: [],
});
adapter.adapt(baseline, 'my-team');
const next = createBaseTeamData({
config: baseline.config,
members: baseline.members,
tasks: [
{
id: 'task-3',
displayId: '#3',
subject: 'Review notes',
owner: 'alice',
status: 'in_progress',
comments: [
{
id: 'comment-3',
author: 'olivia',
text: 'Please tighten the acceptance criteria before merge',
createdAt: '2026-03-28T19:00:04.000Z',
type: 'regular',
},
],
reviewState: 'none',
} as TeamTaskWithKanban,
],
messages: [
{
from: 'olivia',
to: 'alice',
text: 'Please pick this up next',
timestamp: '2026-03-28T19:00:05.000Z',
read: false,
messageId: 'lead-msg-1',
},
],
});
const graph = adapter.adapt(next, 'my-team');
expect(graph.particles).toHaveLength(2);
expect(graph.particles.map((particle) => particle.kind).sort()).toEqual([
'inbox_message',
'task_comment',
]);
});
it('creates inbox particles for all unseen messages, not only the newest 20', () => {
const adapter = TeamGraphAdapter.create();
adapter.adapt(createBaseTeamData(), 'my-team');
const messages: InboxMessage[] = Array.from({ length: 25 }, (_, index) => ({
from: index % 2 === 0 ? 'alice' : 'bob',
to: 'team-lead',
text: `Payload ${index + 1}`,
timestamp: `2026-03-28T19:00:${String(index).padStart(2, '0')}.000Z`,
read: false,
messageId: `msg-${index + 1}`,
}));
const graph = adapter.adapt(createBaseTeamData({ messages }), 'my-team');
expect(graph.particles).toHaveLength(25);
expect(graph.particles.every((particle) => particle.kind === 'inbox_message')).toBe(true);
});
it('scopes inbox particle ids by team name to avoid cross-team collisions', () => {
const adapter = TeamGraphAdapter.create();
adapter.adapt(createBaseTeamData({ teamName: 'team-a' }), 'team-a');
const graph = adapter.adapt(
createBaseTeamData({
teamName: 'team-a',
messages: [
{
from: 'alice',
to: 'team-lead',
text: 'Same payload',
timestamp: '2026-03-28T19:10:00.000Z',
read: false,
messageId: 'shared-msg',
},
],
}),
'team-a'
);
expect(graph.particles[0]?.id).toBe('particle:msg:team-a:shared-msg');
});
it('does not return a cached snapshot when message content changes at the same list length', () => {
const adapter = TeamGraphAdapter.create();
adapter.adapt(
createBaseTeamData({
messages: [
{
from: 'alice',
to: 'team-lead',
text: 'Old payload',
timestamp: '2026-03-28T19:20:00.000Z',
read: false,
messageId: 'msg-old',
},
],
}),
'my-team'
);
const graph = adapter.adapt(
createBaseTeamData({
messages: [
{
from: 'bob',
to: 'team-lead',
text: 'New payload',
timestamp: '2026-03-28T19:20:01.000Z',
read: false,
messageId: 'msg-new',
},
],
}),
'my-team'
);
expect(graph.particles).toHaveLength(1);
expect(graph.particles[0]).toMatchObject({
id: 'particle:msg:my-team:msg-new',
kind: 'inbox_message',
});
});
it('does not return a cached snapshot when a member status changes at the same list size', () => {
const adapter = TeamGraphAdapter.create();
adapter.adapt(createBaseTeamData(), 'my-team');
const graph = adapter.adapt(
createBaseTeamData({
members: [
{
name: 'team-lead',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
agentType: 'team-lead',
},
{
name: 'alice',
status: 'idle',
currentTaskId: null,
taskCount: 1,
lastActiveAt: null,
messageCount: 0,
},
{
name: 'bob',
status: 'active',
currentTaskId: null,
taskCount: 1,
lastActiveAt: null,
messageCount: 0,
},
],
}),
'my-team'
);
const alice = graph.nodes.find((node) => node.id === 'member:my-team:alice');
expect(alice?.state).toBe('idle');
});
});