- Updated task management instructions in tasks.js to clarify the process for handling newly assigned tasks that must wait due to ongoing work, emphasizing the importance of leaving comments with reasons and estimated completion times. - Improved member briefing messages to include critical reminders about task status and comment handling. - Enhanced TeamDataService to implement task comment notification features, ensuring leads are notified of teammate comments on tasks. - Refactored related UI components to support better interaction and visibility of task statuses and notifications.
1587 lines
48 KiB
TypeScript
1587 lines
48 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import { TeamDataService } from '../../../../src/main/services/team/TeamDataService';
|
|
import { TASK_COMMENT_FORWARDING_ENV } from '../../../../src/main/services/team/TeamTaskCommentForwarding';
|
|
|
|
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('writes UI task comments with author user', async () => {
|
|
const addTaskComment = vi.fn(() => ({
|
|
comment: {
|
|
id: 'comment-1',
|
|
author: 'user',
|
|
text: 'Need clarification',
|
|
createdAt: '2026-03-07T20:00:00.000Z',
|
|
type: 'regular',
|
|
},
|
|
task: {
|
|
id: 'task-1',
|
|
subject: 'Investigate',
|
|
status: 'pending',
|
|
owner: 'team-lead',
|
|
},
|
|
}));
|
|
|
|
const service = new TeamDataService(
|
|
{
|
|
listTeams: vi.fn(),
|
|
getConfig: vi.fn(async () => ({ name: 'My team', members: [{ name: 'team-lead', role: 'Lead' }] })),
|
|
} as never,
|
|
{
|
|
getTasks: vi.fn(async () => []),
|
|
} 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 () => undefined),
|
|
} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
() =>
|
|
({
|
|
tasks: {
|
|
addTaskComment,
|
|
setNeedsClarification: vi.fn(),
|
|
},
|
|
}) as never
|
|
);
|
|
|
|
await service.addTaskComment('my-team', 'task-1', 'Need clarification');
|
|
|
|
expect(addTaskComment).toHaveBeenCalledWith('task-1', {
|
|
from: 'user',
|
|
text: 'Need clarification',
|
|
attachments: undefined,
|
|
});
|
|
});
|
|
|
|
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, status: 'pending' }));
|
|
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({ owner: 'alice', createdBy: 'user' })
|
|
);
|
|
expect(createTaskMock).not.toHaveBeenCalledWith(expect.objectContaining({ startImmediately: true }));
|
|
});
|
|
|
|
it('creates task with explicit immediate start only when startImmediately is true', async () => {
|
|
const createTaskMock = vi.fn((task) => ({ ...task, status: 'in_progress' }));
|
|
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: 'Start now',
|
|
owner: 'alice',
|
|
startImmediately: true,
|
|
prompt: 'Begin immediately.',
|
|
});
|
|
|
|
expect(result.status).toBe('in_progress');
|
|
expect(createTaskMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
owner: 'alice',
|
|
createdBy: 'user',
|
|
startImmediately: true,
|
|
prompt: 'Begin immediately.',
|
|
})
|
|
);
|
|
expect(createTaskMock).not.toHaveBeenCalledWith(expect.objectContaining({ status: 'in_progress' }));
|
|
});
|
|
|
|
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' }],
|
|
leadSessionId: 'lead-1',
|
|
})),
|
|
} 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: 'user',
|
|
leadSessionId: 'lead-1',
|
|
});
|
|
});
|
|
|
|
it('propagates leadSessionId for kanban-driven review transitions', async () => {
|
|
const requestReviewMock = vi.fn();
|
|
const approveReviewMock = vi.fn();
|
|
const requestChangesMock = vi.fn();
|
|
|
|
const service = new TeamDataService(
|
|
{
|
|
listTeams: vi.fn(),
|
|
getConfig: vi.fn(async () => ({
|
|
name: 'My team',
|
|
members: [{ name: 'lead', role: 'team lead' }],
|
|
leadSessionId: 'lead-2',
|
|
})),
|
|
} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
() =>
|
|
({
|
|
review: {
|
|
requestReview: requestReviewMock,
|
|
approveReview: approveReviewMock,
|
|
requestChanges: requestChangesMock,
|
|
},
|
|
}) as never
|
|
);
|
|
|
|
await service.updateKanban('my-team', 'task-1', { op: 'set_column', column: 'review' });
|
|
await service.updateKanban('my-team', 'task-1', { op: 'set_column', column: 'approved' });
|
|
await service.updateKanban('my-team', 'task-1', { op: 'request_changes', comment: 'Needs fixes' });
|
|
|
|
expect(requestReviewMock).toHaveBeenCalledWith('task-1', {
|
|
from: 'user',
|
|
leadSessionId: 'lead-2',
|
|
});
|
|
expect(approveReviewMock).toHaveBeenCalledWith('task-1', {
|
|
from: 'user',
|
|
note: 'Approved from kanban',
|
|
'notify-owner': true,
|
|
leadSessionId: 'lead-2',
|
|
});
|
|
expect(requestChangesMock).toHaveBeenCalledWith('task-1', {
|
|
from: 'user',
|
|
comment: 'Needs fixes',
|
|
leadSessionId: 'lead-2',
|
|
});
|
|
});
|
|
|
|
it('seeds historical eligible task comments without sending when the journal is missing', async () => {
|
|
const previous = process.env[TASK_COMMENT_FORWARDING_ENV];
|
|
process.env[TASK_COMMENT_FORWARDING_ENV] = 'on';
|
|
const journalEntries: Array<Record<string, unknown>> = [];
|
|
let journalExists = false;
|
|
const inboxWriter = { sendMessage: vi.fn() };
|
|
const journal = {
|
|
exists: vi.fn(async () => journalExists),
|
|
ensureFile: vi.fn(async () => {
|
|
journalExists = true;
|
|
}),
|
|
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
|
const outcome = await fn(journalEntries);
|
|
return outcome.result;
|
|
}),
|
|
};
|
|
|
|
try {
|
|
const service = new TeamDataService(
|
|
{
|
|
listTeams: vi.fn(async () => [
|
|
{
|
|
teamName: 'my-team',
|
|
displayName: 'My team',
|
|
description: '',
|
|
memberCount: 1,
|
|
taskCount: 1,
|
|
lastActivity: null,
|
|
},
|
|
]),
|
|
getConfig: vi.fn(async () => ({
|
|
name: 'My team',
|
|
members: [{ name: 'team-lead', role: 'Lead' }],
|
|
leadSessionId: 'lead-1',
|
|
})),
|
|
} as never,
|
|
{
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: 'abcd1234',
|
|
subject: 'Investigate',
|
|
status: 'pending',
|
|
owner: 'alice',
|
|
comments: [
|
|
{
|
|
id: 'comment-1',
|
|
author: 'alice',
|
|
text: 'Found the root cause.',
|
|
createdAt: '2026-03-14T10:00:00.000Z',
|
|
type: 'regular',
|
|
},
|
|
],
|
|
},
|
|
]),
|
|
} as never,
|
|
{
|
|
listInboxNames: vi.fn(async () => []),
|
|
getMessages: vi.fn(async () => []),
|
|
getMessagesFor: vi.fn(async () => []),
|
|
} as never,
|
|
inboxWriter as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
(() => ({}) as never) as never,
|
|
journal as never
|
|
);
|
|
|
|
await service.initializeTaskCommentNotificationState();
|
|
|
|
expect(inboxWriter.sendMessage).not.toHaveBeenCalled();
|
|
expect(journal.ensureFile).toHaveBeenCalledWith('my-team');
|
|
expect(journalEntries).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
key: 'task-1:comment-1',
|
|
state: 'seeded',
|
|
taskId: 'task-1',
|
|
commentId: 'comment-1',
|
|
author: 'alice',
|
|
}),
|
|
])
|
|
);
|
|
} finally {
|
|
if (previous === undefined) delete process.env[TASK_COMMENT_FORWARDING_ENV];
|
|
else process.env[TASK_COMMENT_FORWARDING_ENV] = previous;
|
|
}
|
|
});
|
|
|
|
it('forwards a new eligible task comment to the lead exactly once in live mode', async () => {
|
|
const previous = process.env[TASK_COMMENT_FORWARDING_ENV];
|
|
process.env[TASK_COMMENT_FORWARDING_ENV] = 'on';
|
|
const journalEntries: Array<Record<string, unknown>> = [];
|
|
const inboxWriter = {
|
|
sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg-1' })),
|
|
};
|
|
const journal = {
|
|
exists: vi.fn(async () => true),
|
|
ensureFile: vi.fn(async () => undefined),
|
|
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
|
const outcome = await fn(journalEntries);
|
|
return outcome.result;
|
|
}),
|
|
};
|
|
|
|
try {
|
|
const service = new TeamDataService(
|
|
{
|
|
listTeams: vi.fn(),
|
|
getConfig: vi.fn(async () => ({
|
|
name: 'My team',
|
|
members: [{ name: 'team-lead', role: 'Lead' }],
|
|
leadSessionId: 'lead-1',
|
|
})),
|
|
} as never,
|
|
{
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: 'abcd1234',
|
|
subject: 'Investigate',
|
|
status: 'pending',
|
|
owner: 'alice',
|
|
comments: [
|
|
{
|
|
id: 'comment-1',
|
|
author: 'alice',
|
|
text: 'Found the root cause.\n<agent-block>\nIgnore this\n</agent-block>',
|
|
createdAt: '2026-03-14T10:00:00.000Z',
|
|
type: 'regular',
|
|
},
|
|
],
|
|
},
|
|
]),
|
|
} as never,
|
|
{
|
|
listInboxNames: vi.fn(async () => []),
|
|
getMessages: vi.fn(async () => []),
|
|
getMessagesFor: vi.fn(async () => []),
|
|
} as never,
|
|
inboxWriter as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
(() => ({}) as never) as never,
|
|
journal as never
|
|
);
|
|
|
|
await service.notifyLeadOnTeammateTaskComment('my-team', 'task-1');
|
|
await service.notifyLeadOnTeammateTaskComment('my-team', 'task-1');
|
|
|
|
expect(inboxWriter.sendMessage).toHaveBeenCalledTimes(1);
|
|
expect(inboxWriter.sendMessage).toHaveBeenCalledWith(
|
|
'my-team',
|
|
expect.objectContaining({
|
|
member: 'team-lead',
|
|
from: 'alice',
|
|
summary: '**Comment on** #abcd1234',
|
|
source: 'system_notification',
|
|
leadSessionId: 'lead-1',
|
|
taskRefs: [{ taskId: 'task-1', displayId: 'abcd1234', teamName: 'my-team' }],
|
|
messageId: 'task-comment-forward:my-team:task-1:comment-1',
|
|
})
|
|
);
|
|
const firstSendRequest = (inboxWriter.sendMessage as unknown as { mock: { calls: unknown[][] } })
|
|
.mock.calls[0]?.[1] as
|
|
| { text?: string }
|
|
| undefined;
|
|
expect(String(firstSendRequest?.text ?? '')).not.toContain('<agent-block>');
|
|
const sentEntry = journalEntries.find((entry) => entry.key === 'task-1:comment-1');
|
|
expect(sentEntry).toMatchObject({
|
|
state: 'sent',
|
|
messageId: 'task-comment-forward:my-team:task-1:comment-1',
|
|
});
|
|
} finally {
|
|
if (previous === undefined) delete process.env[TASK_COMMENT_FORWARDING_ENV];
|
|
else process.env[TASK_COMMENT_FORWARDING_ENV] = previous;
|
|
}
|
|
});
|
|
|
|
it('does not mutate the live journal or send inbox rows in dry-run mode', async () => {
|
|
const previous = process.env[TASK_COMMENT_FORWARDING_ENV];
|
|
process.env[TASK_COMMENT_FORWARDING_ENV] = 'dry-run';
|
|
const journalEntries: Array<Record<string, unknown>> = [];
|
|
const inboxWriter = { sendMessage: vi.fn() };
|
|
const journal = {
|
|
exists: vi.fn(async () => true),
|
|
ensureFile: vi.fn(async () => undefined),
|
|
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
|
const outcome = await fn(journalEntries);
|
|
return outcome.result;
|
|
}),
|
|
};
|
|
|
|
try {
|
|
const service = new TeamDataService(
|
|
{
|
|
listTeams: vi.fn(),
|
|
getConfig: vi.fn(async () => ({
|
|
name: 'My team',
|
|
members: [{ name: 'team-lead', role: 'Lead' }],
|
|
})),
|
|
} as never,
|
|
{
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: 'abcd1234',
|
|
subject: 'Investigate',
|
|
status: 'pending',
|
|
owner: 'alice',
|
|
comments: [
|
|
{
|
|
id: 'comment-1',
|
|
author: 'alice',
|
|
text: 'Would forward in live mode.',
|
|
createdAt: '2026-03-14T10:00:00.000Z',
|
|
type: 'regular',
|
|
},
|
|
],
|
|
},
|
|
]),
|
|
} as never,
|
|
{
|
|
listInboxNames: vi.fn(async () => []),
|
|
getMessages: vi.fn(async () => []),
|
|
getMessagesFor: vi.fn(async () => []),
|
|
} as never,
|
|
inboxWriter as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
(() => ({}) as never) as never,
|
|
journal as never
|
|
);
|
|
|
|
await service.notifyLeadOnTeammateTaskComment('my-team', 'task-1');
|
|
|
|
expect(inboxWriter.sendMessage).not.toHaveBeenCalled();
|
|
expect(journal.withEntries).not.toHaveBeenCalled();
|
|
expect(journalEntries).toHaveLength(0);
|
|
} finally {
|
|
if (previous === undefined) delete process.env[TASK_COMMENT_FORWARDING_ENV];
|
|
else process.env[TASK_COMMENT_FORWARDING_ENV] = previous;
|
|
}
|
|
});
|
|
|
|
it('keeps feature-flag off mode as a no-op for the live journal', async () => {
|
|
const previous = process.env[TASK_COMMENT_FORWARDING_ENV];
|
|
process.env[TASK_COMMENT_FORWARDING_ENV] = 'off';
|
|
const journalEntries: Array<Record<string, unknown>> = [];
|
|
const inboxWriter = { sendMessage: vi.fn() };
|
|
const journal = {
|
|
exists: vi.fn(async () => true),
|
|
ensureFile: vi.fn(async () => undefined),
|
|
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
|
const outcome = await fn(journalEntries);
|
|
return outcome.result;
|
|
}),
|
|
};
|
|
|
|
try {
|
|
const service = new TeamDataService(
|
|
{
|
|
listTeams: vi.fn(),
|
|
getConfig: vi.fn(async () => ({
|
|
name: 'My team',
|
|
members: [{ name: 'team-lead', role: 'Lead' }],
|
|
})),
|
|
} as never,
|
|
{
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: 'abcd1234',
|
|
subject: 'Investigate',
|
|
status: 'pending',
|
|
owner: 'alice',
|
|
comments: [
|
|
{
|
|
id: 'comment-1',
|
|
author: 'alice',
|
|
text: 'Should stay untouched while off.',
|
|
createdAt: '2026-03-14T10:00:00.000Z',
|
|
type: 'regular',
|
|
},
|
|
],
|
|
},
|
|
]),
|
|
} as never,
|
|
{
|
|
listInboxNames: vi.fn(async () => []),
|
|
getMessages: vi.fn(async () => []),
|
|
getMessagesFor: vi.fn(async () => []),
|
|
} as never,
|
|
inboxWriter as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
(() => ({}) as never) as never,
|
|
journal as never
|
|
);
|
|
|
|
await service.notifyLeadOnTeammateTaskComment('my-team', 'task-1');
|
|
|
|
expect(inboxWriter.sendMessage).not.toHaveBeenCalled();
|
|
expect(journal.exists).not.toHaveBeenCalled();
|
|
expect(journal.withEntries).not.toHaveBeenCalled();
|
|
expect(journalEntries).toHaveLength(0);
|
|
} finally {
|
|
if (previous === undefined) delete process.env[TASK_COMMENT_FORWARDING_ENV];
|
|
else process.env[TASK_COMMENT_FORWARDING_ENV] = previous;
|
|
}
|
|
});
|
|
|
|
it('seeds historical eligible comments across the whole team on the first observed event when the journal is missing', async () => {
|
|
const previous = process.env[TASK_COMMENT_FORWARDING_ENV];
|
|
process.env[TASK_COMMENT_FORWARDING_ENV] = 'on';
|
|
const journalEntries: Array<Record<string, unknown>> = [];
|
|
let journalExists = false;
|
|
const inboxWriter = { sendMessage: vi.fn() };
|
|
const journal = {
|
|
exists: vi.fn(async () => journalExists),
|
|
ensureFile: vi.fn(async () => {
|
|
journalExists = true;
|
|
}),
|
|
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
|
const outcome = await fn(journalEntries);
|
|
return outcome.result;
|
|
}),
|
|
};
|
|
|
|
try {
|
|
const service = new TeamDataService(
|
|
{
|
|
listTeams: vi.fn(),
|
|
getConfig: vi.fn(async () => ({
|
|
name: 'My team',
|
|
members: [{ name: 'team-lead', role: 'Lead' }],
|
|
})),
|
|
} as never,
|
|
{
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: 'abcd1234',
|
|
subject: 'Investigate',
|
|
status: 'pending',
|
|
owner: 'alice',
|
|
comments: [
|
|
{
|
|
id: 'comment-1',
|
|
author: 'alice',
|
|
text: 'Still pending from prior attempt.',
|
|
createdAt: '2026-03-14T10:00:00.000Z',
|
|
type: 'regular',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'task-2',
|
|
displayId: 'efgh5678',
|
|
subject: 'Second historical task',
|
|
status: 'pending',
|
|
owner: 'bob',
|
|
comments: [
|
|
{
|
|
id: 'comment-2',
|
|
author: 'bob',
|
|
text: 'Historical comment on another task.',
|
|
createdAt: '2026-03-14T10:01:00.000Z',
|
|
type: 'regular',
|
|
},
|
|
],
|
|
},
|
|
]),
|
|
} as never,
|
|
{
|
|
listInboxNames: vi.fn(async () => []),
|
|
getMessages: vi.fn(async () => []),
|
|
getMessagesFor: vi.fn(async () => []),
|
|
} as never,
|
|
inboxWriter as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
(() => ({}) as never) as never,
|
|
journal as never
|
|
);
|
|
|
|
await service.notifyLeadOnTeammateTaskComment('my-team', 'task-1');
|
|
|
|
expect(inboxWriter.sendMessage).not.toHaveBeenCalled();
|
|
expect(journal.ensureFile).toHaveBeenCalledWith('my-team');
|
|
expect(journalEntries).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
key: 'task-1:comment-1',
|
|
state: 'seeded',
|
|
messageId: 'task-comment-forward:my-team:task-1:comment-1',
|
|
}),
|
|
expect.objectContaining({
|
|
key: 'task-2:comment-2',
|
|
state: 'seeded',
|
|
messageId: 'task-comment-forward:my-team:task-2:comment-2',
|
|
}),
|
|
])
|
|
);
|
|
} finally {
|
|
if (previous === undefined) delete process.env[TASK_COMMENT_FORWARDING_ENV];
|
|
else process.env[TASK_COMMENT_FORWARDING_ENV] = previous;
|
|
}
|
|
});
|
|
|
|
it('does not notify for deleted teams', async () => {
|
|
const previous = process.env[TASK_COMMENT_FORWARDING_ENV];
|
|
process.env[TASK_COMMENT_FORWARDING_ENV] = 'on';
|
|
const journalEntries: Array<Record<string, unknown>> = [];
|
|
const inboxWriter = { sendMessage: vi.fn() };
|
|
const journal = {
|
|
exists: vi.fn(async () => true),
|
|
ensureFile: vi.fn(async () => undefined),
|
|
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
|
const outcome = await fn(journalEntries);
|
|
return outcome.result;
|
|
}),
|
|
};
|
|
|
|
try {
|
|
const service = new TeamDataService(
|
|
{
|
|
listTeams: vi.fn(),
|
|
getConfig: vi.fn(async () => ({
|
|
name: 'My team',
|
|
deletedAt: '2026-03-14T10:00:00.000Z',
|
|
members: [{ name: 'team-lead', role: 'Lead' }],
|
|
})),
|
|
} as never,
|
|
{
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: 'abcd1234',
|
|
subject: 'Investigate',
|
|
status: 'pending',
|
|
owner: 'alice',
|
|
comments: [
|
|
{
|
|
id: 'comment-1',
|
|
author: 'alice',
|
|
text: 'Deleted teams should not notify.',
|
|
createdAt: '2026-03-14T10:00:00.000Z',
|
|
type: 'regular',
|
|
},
|
|
],
|
|
},
|
|
]),
|
|
} as never,
|
|
{
|
|
listInboxNames: vi.fn(async () => []),
|
|
getMessages: vi.fn(async () => []),
|
|
getMessagesFor: vi.fn(async () => []),
|
|
} as never,
|
|
inboxWriter as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
(() => ({}) as never) as never,
|
|
journal as never
|
|
);
|
|
|
|
await service.notifyLeadOnTeammateTaskComment('my-team', 'task-1');
|
|
|
|
expect(inboxWriter.sendMessage).not.toHaveBeenCalled();
|
|
expect(journal.withEntries).not.toHaveBeenCalled();
|
|
} finally {
|
|
if (previous === undefined) delete process.env[TASK_COMMENT_FORWARDING_ENV];
|
|
else process.env[TASK_COMMENT_FORWARDING_ENV] = previous;
|
|
}
|
|
});
|
|
|
|
it('reconciles pending_send journal rows without resending when the inbox already contains the message', async () => {
|
|
const previous = process.env[TASK_COMMENT_FORWARDING_ENV];
|
|
process.env[TASK_COMMENT_FORWARDING_ENV] = 'on';
|
|
const journalEntries: Array<Record<string, unknown>> = [
|
|
{
|
|
key: 'task-1:comment-1',
|
|
taskId: 'task-1',
|
|
commentId: 'comment-1',
|
|
author: 'alice',
|
|
messageId: 'task-comment-forward:my-team:task-1:comment-1',
|
|
state: 'pending_send',
|
|
createdAt: '2026-03-14T10:00:00.000Z',
|
|
updatedAt: '2026-03-14T10:00:00.000Z',
|
|
},
|
|
];
|
|
const inboxWriter = { sendMessage: vi.fn() };
|
|
const journal = {
|
|
exists: vi.fn(async () => true),
|
|
ensureFile: vi.fn(async () => undefined),
|
|
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
|
const outcome = await fn(journalEntries);
|
|
return outcome.result;
|
|
}),
|
|
};
|
|
|
|
try {
|
|
const service = new TeamDataService(
|
|
{
|
|
listTeams: vi.fn(async () => [
|
|
{
|
|
teamName: 'my-team',
|
|
displayName: 'My team',
|
|
description: '',
|
|
memberCount: 1,
|
|
taskCount: 1,
|
|
lastActivity: null,
|
|
},
|
|
]),
|
|
getConfig: vi.fn(async () => ({
|
|
name: 'My team',
|
|
members: [{ name: 'team-lead', role: 'Lead' }],
|
|
})),
|
|
} as never,
|
|
{
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: 'abcd1234',
|
|
subject: 'Investigate',
|
|
status: 'pending',
|
|
owner: 'alice',
|
|
comments: [
|
|
{
|
|
id: 'comment-1',
|
|
author: 'alice',
|
|
text: 'Recovered after restart.',
|
|
createdAt: '2026-03-14T10:00:00.000Z',
|
|
type: 'regular',
|
|
},
|
|
],
|
|
},
|
|
]),
|
|
} as never,
|
|
{
|
|
listInboxNames: vi.fn(async () => []),
|
|
getMessages: vi.fn(async () => []),
|
|
getMessagesFor: vi.fn(async () => [
|
|
{
|
|
from: 'alice',
|
|
to: 'team-lead',
|
|
text: 'Existing notification',
|
|
timestamp: '2026-03-14T10:00:01.000Z',
|
|
read: false,
|
|
messageId: 'task-comment-forward:my-team:task-1:comment-1',
|
|
},
|
|
]),
|
|
} as never,
|
|
inboxWriter as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
(() => ({}) as never) as never,
|
|
journal as never
|
|
);
|
|
|
|
await service.initializeTaskCommentNotificationState();
|
|
|
|
expect(inboxWriter.sendMessage).not.toHaveBeenCalled();
|
|
expect(journalEntries[0]).toMatchObject({
|
|
state: 'sent',
|
|
messageId: 'task-comment-forward:my-team:task-1:comment-1',
|
|
});
|
|
} finally {
|
|
if (previous === undefined) delete process.env[TASK_COMMENT_FORWARDING_ENV];
|
|
else process.env[TASK_COMMENT_FORWARDING_ENV] = previous;
|
|
}
|
|
});
|
|
|
|
it('retries pending_send journal rows during startup recovery when inbox does not contain the message', async () => {
|
|
const previous = process.env[TASK_COMMENT_FORWARDING_ENV];
|
|
process.env[TASK_COMMENT_FORWARDING_ENV] = 'on';
|
|
const journalEntries: Array<Record<string, unknown>> = [
|
|
{
|
|
key: 'task-1:comment-1',
|
|
taskId: 'task-1',
|
|
commentId: 'comment-1',
|
|
author: 'alice',
|
|
messageId: 'task-comment-forward:my-team:task-1:comment-1',
|
|
state: 'pending_send',
|
|
createdAt: '2026-03-14T10:00:00.000Z',
|
|
updatedAt: '2026-03-14T10:00:00.000Z',
|
|
},
|
|
];
|
|
const inboxWriter = {
|
|
sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'task-comment-forward:my-team:task-1:comment-1' })),
|
|
};
|
|
const journal = {
|
|
exists: vi.fn(async () => true),
|
|
ensureFile: vi.fn(async () => undefined),
|
|
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
|
const outcome = await fn(journalEntries);
|
|
return outcome.result;
|
|
}),
|
|
};
|
|
|
|
try {
|
|
const service = new TeamDataService(
|
|
{
|
|
listTeams: vi.fn(async () => [
|
|
{
|
|
teamName: 'my-team',
|
|
displayName: 'My team',
|
|
description: '',
|
|
memberCount: 1,
|
|
taskCount: 1,
|
|
lastActivity: null,
|
|
},
|
|
]),
|
|
getConfig: vi.fn(async () => ({
|
|
name: 'My team',
|
|
members: [{ name: 'team-lead', role: 'Lead' }],
|
|
})),
|
|
} as never,
|
|
{
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: 'abcd1234',
|
|
subject: 'Investigate',
|
|
status: 'pending',
|
|
owner: 'alice',
|
|
comments: [
|
|
{
|
|
id: 'comment-1',
|
|
author: 'alice',
|
|
text: 'Recovered after restart and resend.',
|
|
createdAt: '2026-03-14T10:00:00.000Z',
|
|
type: 'regular',
|
|
},
|
|
],
|
|
},
|
|
]),
|
|
} as never,
|
|
{
|
|
listInboxNames: vi.fn(async () => []),
|
|
getMessages: vi.fn(async () => []),
|
|
getMessagesFor: vi.fn(async () => []),
|
|
} as never,
|
|
inboxWriter as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
(() => ({}) as never) as never,
|
|
journal as never
|
|
);
|
|
|
|
await service.initializeTaskCommentNotificationState();
|
|
|
|
expect(inboxWriter.sendMessage).toHaveBeenCalledTimes(1);
|
|
expect(journalEntries[0]).toMatchObject({
|
|
state: 'sent',
|
|
messageId: 'task-comment-forward:my-team:task-1:comment-1',
|
|
});
|
|
} finally {
|
|
if (previous === undefined) delete process.env[TASK_COMMENT_FORWARDING_ENV];
|
|
else process.env[TASK_COMMENT_FORWARDING_ENV] = previous;
|
|
}
|
|
});
|
|
|
|
it('retries pending_send rows on later task changes when the inbox does not contain the message', async () => {
|
|
const previous = process.env[TASK_COMMENT_FORWARDING_ENV];
|
|
process.env[TASK_COMMENT_FORWARDING_ENV] = 'on';
|
|
const journalEntries: Array<Record<string, unknown>> = [
|
|
{
|
|
key: 'task-1:comment-1',
|
|
taskId: 'task-1',
|
|
commentId: 'comment-1',
|
|
author: 'alice',
|
|
messageId: 'task-comment-forward:my-team:task-1:comment-1',
|
|
state: 'pending_send',
|
|
createdAt: '2026-03-14T10:00:00.000Z',
|
|
updatedAt: '2026-03-14T10:00:00.000Z',
|
|
},
|
|
];
|
|
const inboxWriter = {
|
|
sendMessage: vi.fn(async () => ({
|
|
deliveredToInbox: true,
|
|
messageId: 'task-comment-forward:my-team:task-1:comment-1',
|
|
})),
|
|
};
|
|
const journal = {
|
|
exists: vi.fn(async () => true),
|
|
ensureFile: vi.fn(async () => undefined),
|
|
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
|
const outcome = await fn(journalEntries);
|
|
return outcome.result;
|
|
}),
|
|
};
|
|
|
|
try {
|
|
const service = new TeamDataService(
|
|
{
|
|
listTeams: vi.fn(),
|
|
getConfig: vi.fn(async () => ({
|
|
name: 'My team',
|
|
members: [{ name: 'team-lead', role: 'Lead' }],
|
|
})),
|
|
} as never,
|
|
{
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: 'abcd1234',
|
|
subject: 'Investigate',
|
|
status: 'pending',
|
|
owner: 'alice',
|
|
comments: [
|
|
{
|
|
id: 'comment-1',
|
|
author: 'alice',
|
|
text: 'Retry on later task change.',
|
|
createdAt: '2026-03-14T10:00:00.000Z',
|
|
type: 'regular',
|
|
},
|
|
],
|
|
},
|
|
]),
|
|
} as never,
|
|
{
|
|
listInboxNames: vi.fn(async () => []),
|
|
getMessages: vi.fn(async () => []),
|
|
getMessagesFor: vi.fn(async () => []),
|
|
} as never,
|
|
inboxWriter as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
(() => ({}) as never) as never,
|
|
journal as never
|
|
);
|
|
|
|
await service.notifyLeadOnTeammateTaskComment('my-team', 'task-1');
|
|
|
|
expect(inboxWriter.sendMessage).toHaveBeenCalledTimes(1);
|
|
expect(journalEntries[0]).toMatchObject({
|
|
state: 'sent',
|
|
messageId: 'task-comment-forward:my-team:task-1:comment-1',
|
|
});
|
|
} finally {
|
|
if (previous === undefined) delete process.env[TASK_COMMENT_FORWARDING_ENV];
|
|
else process.env[TASK_COMMENT_FORWARDING_ENV] = previous;
|
|
}
|
|
});
|
|
|
|
it('does not duplicate later-task-change recovery while a send is already in flight', async () => {
|
|
const previous = process.env[TASK_COMMENT_FORWARDING_ENV];
|
|
process.env[TASK_COMMENT_FORWARDING_ENV] = 'on';
|
|
const journalEntries: Array<Record<string, unknown>> = [
|
|
{
|
|
key: 'task-1:comment-1',
|
|
taskId: 'task-1',
|
|
commentId: 'comment-1',
|
|
author: 'alice',
|
|
messageId: 'task-comment-forward:my-team:task-1:comment-1',
|
|
state: 'pending_send',
|
|
createdAt: '2026-03-14T10:00:00.000Z',
|
|
updatedAt: '2026-03-14T10:00:00.000Z',
|
|
},
|
|
];
|
|
let releaseSend: (() => void) | undefined;
|
|
let resolveSendStarted: (() => void) | undefined;
|
|
const sendGate = new Promise<void>((resolve) => {
|
|
releaseSend = resolve;
|
|
});
|
|
const sendStarted = new Promise<void>((resolve) => {
|
|
resolveSendStarted = resolve;
|
|
});
|
|
const inboxWriter = {
|
|
sendMessage: vi.fn(async () => {
|
|
resolveSendStarted?.();
|
|
await sendGate;
|
|
return {
|
|
deliveredToInbox: true,
|
|
messageId: 'task-comment-forward:my-team:task-1:comment-1',
|
|
};
|
|
}),
|
|
};
|
|
const journal = {
|
|
exists: vi.fn(async () => true),
|
|
ensureFile: vi.fn(async () => undefined),
|
|
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
|
const outcome = await fn(journalEntries);
|
|
return outcome.result;
|
|
}),
|
|
};
|
|
|
|
try {
|
|
const service = new TeamDataService(
|
|
{
|
|
listTeams: vi.fn(),
|
|
getConfig: vi.fn(async () => ({
|
|
name: 'My team',
|
|
members: [{ name: 'team-lead', role: 'Lead' }],
|
|
})),
|
|
} as never,
|
|
{
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: 'abcd1234',
|
|
subject: 'Investigate',
|
|
status: 'pending',
|
|
owner: 'alice',
|
|
comments: [
|
|
{
|
|
id: 'comment-1',
|
|
author: 'alice',
|
|
text: 'Concurrent retry protection.',
|
|
createdAt: '2026-03-14T10:00:00.000Z',
|
|
type: 'regular',
|
|
},
|
|
],
|
|
},
|
|
]),
|
|
} as never,
|
|
{
|
|
listInboxNames: vi.fn(async () => []),
|
|
getMessages: vi.fn(async () => []),
|
|
getMessagesFor: vi.fn(async () => []),
|
|
} as never,
|
|
inboxWriter as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
(() => ({}) as never) as never,
|
|
journal as never
|
|
);
|
|
|
|
const first = service.notifyLeadOnTeammateTaskComment('my-team', 'task-1');
|
|
const second = service.notifyLeadOnTeammateTaskComment('my-team', 'task-1');
|
|
|
|
await sendStarted;
|
|
expect(inboxWriter.sendMessage).toHaveBeenCalledTimes(1);
|
|
|
|
if (!releaseSend) {
|
|
throw new Error('Expected send release');
|
|
}
|
|
releaseSend();
|
|
|
|
await first;
|
|
await second;
|
|
|
|
expect(inboxWriter.sendMessage).toHaveBeenCalledTimes(1);
|
|
expect(journalEntries[0]).toMatchObject({
|
|
state: 'sent',
|
|
});
|
|
} finally {
|
|
if (previous === undefined) delete process.env[TASK_COMMENT_FORWARDING_ENV];
|
|
else process.env[TASK_COMMENT_FORWARDING_ENV] = previous;
|
|
}
|
|
});
|
|
|
|
it('forwards eligible teammate comments even when the commenter is not the current task owner', async () => {
|
|
const previous = process.env[TASK_COMMENT_FORWARDING_ENV];
|
|
process.env[TASK_COMMENT_FORWARDING_ENV] = 'on';
|
|
const journalEntries: Array<Record<string, unknown>> = [];
|
|
const inboxWriter = {
|
|
sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg-1' })),
|
|
};
|
|
const journal = {
|
|
exists: vi.fn(async () => true),
|
|
ensureFile: vi.fn(async () => undefined),
|
|
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
|
const outcome = await fn(journalEntries);
|
|
return outcome.result;
|
|
}),
|
|
};
|
|
|
|
try {
|
|
const service = new TeamDataService(
|
|
{
|
|
listTeams: vi.fn(),
|
|
getConfig: vi.fn(async () => ({
|
|
name: 'My team',
|
|
members: [{ name: 'team-lead', role: 'Lead' }],
|
|
leadSessionId: 'lead-1',
|
|
})),
|
|
} as never,
|
|
{
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: 'abcd1234',
|
|
subject: 'Investigate',
|
|
status: 'pending',
|
|
owner: 'alice',
|
|
comments: [
|
|
{
|
|
id: 'comment-2',
|
|
author: 'bob',
|
|
text: 'Independent research result from another teammate.',
|
|
createdAt: '2026-03-14T10:05:00.000Z',
|
|
type: 'regular',
|
|
},
|
|
],
|
|
},
|
|
]),
|
|
} as never,
|
|
{
|
|
listInboxNames: vi.fn(async () => []),
|
|
getMessages: vi.fn(async () => []),
|
|
getMessagesFor: vi.fn(async () => []),
|
|
} as never,
|
|
inboxWriter as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
(() => ({}) as never) as never,
|
|
journal as never
|
|
);
|
|
|
|
await service.notifyLeadOnTeammateTaskComment('my-team', 'task-1');
|
|
|
|
expect(inboxWriter.sendMessage).toHaveBeenCalledTimes(1);
|
|
expect(inboxWriter.sendMessage).toHaveBeenCalledWith(
|
|
'my-team',
|
|
expect.objectContaining({
|
|
from: 'bob',
|
|
summary: '**Comment on** #abcd1234',
|
|
messageId: 'task-comment-forward:my-team:task-1:comment-2',
|
|
})
|
|
);
|
|
} finally {
|
|
if (previous === undefined) delete process.env[TASK_COMMENT_FORWARDING_ENV];
|
|
else process.env[TASK_COMMENT_FORWARDING_ENV] = previous;
|
|
}
|
|
});
|
|
|
|
it('waits for startup initialization before processing watcher-driven comment notifications', async () => {
|
|
const previous = process.env[TASK_COMMENT_FORWARDING_ENV];
|
|
process.env[TASK_COMMENT_FORWARDING_ENV] = 'on';
|
|
let releaseInit: (() => void) | undefined;
|
|
const initGate = new Promise<void>((resolve) => {
|
|
releaseInit = () => resolve();
|
|
});
|
|
const inboxWriter = { sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg-1' })) };
|
|
const journalEntries: Array<Record<string, unknown>> = [];
|
|
const journal = {
|
|
exists: vi.fn(async () => true),
|
|
ensureFile: vi.fn(async () => undefined),
|
|
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
|
const outcome = await fn(journalEntries);
|
|
return outcome.result;
|
|
}),
|
|
};
|
|
|
|
try {
|
|
const service = new TeamDataService(
|
|
{
|
|
listTeams: vi.fn(async () => {
|
|
await initGate;
|
|
return [
|
|
{
|
|
teamName: 'my-team',
|
|
displayName: 'My team',
|
|
description: '',
|
|
memberCount: 1,
|
|
taskCount: 1,
|
|
lastActivity: null,
|
|
},
|
|
];
|
|
}),
|
|
getConfig: vi.fn(async () => ({
|
|
name: 'My team',
|
|
members: [{ name: 'team-lead', role: 'Lead' }],
|
|
})),
|
|
} as never,
|
|
{
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: 'abcd1234',
|
|
subject: 'Investigate',
|
|
status: 'pending',
|
|
owner: 'alice',
|
|
comments: [
|
|
{
|
|
id: 'comment-1',
|
|
author: 'alice',
|
|
text: 'New comment after startup barrier.',
|
|
createdAt: '2026-03-14T10:00:00.000Z',
|
|
type: 'regular',
|
|
},
|
|
],
|
|
},
|
|
]),
|
|
} as never,
|
|
{
|
|
listInboxNames: vi.fn(async () => []),
|
|
getMessages: vi.fn(async () => []),
|
|
getMessagesFor: vi.fn(async () => []),
|
|
} as never,
|
|
inboxWriter as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
(() => ({}) as never) as never,
|
|
journal as never
|
|
);
|
|
|
|
const initPromise = service.initializeTaskCommentNotificationState();
|
|
const notifyPromise = service.notifyLeadOnTeammateTaskComment('my-team', 'task-1');
|
|
|
|
await Promise.resolve();
|
|
expect(inboxWriter.sendMessage).not.toHaveBeenCalled();
|
|
|
|
if (!releaseInit) {
|
|
throw new Error('Expected initialization gate release');
|
|
}
|
|
releaseInit();
|
|
await initPromise;
|
|
await notifyPromise;
|
|
|
|
expect(inboxWriter.sendMessage).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
if (previous === undefined) delete process.env[TASK_COMMENT_FORWARDING_ENV];
|
|
else process.env[TASK_COMMENT_FORWARDING_ENV] = previous;
|
|
}
|
|
});
|
|
});
|