agent-ecosystem/test/main/services/team/stallMonitor/TeamTaskStallNotifier.test.ts

234 lines
8.5 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { TeamTaskStallNotifier } from '../../../../../src/main/services/team/stallMonitor/TeamTaskStallNotifier';
import type { TaskStallAlert } from '../../../../../src/main/services/team/stallMonitor/TeamTaskStallTypes';
function createAlert(overrides: Partial<TaskStallAlert> = {}): TaskStallAlert {
return {
teamName: 'demo',
taskId: 'task-a',
displayId: 'abcd1234',
subject: 'Task A',
branch: 'work',
signal: 'turn_ended_after_touch',
progressSignal: 'weak_start_only',
reason: 'Potential work stall after weak start-only task comment.',
epochKey: 'task-a:work:turn_ended_after_touch:stamp:file:msg:tool',
owner: 'alice',
ownerProviderId: 'opencode',
taskRef: {
taskId: 'task-a',
displayId: 'abcd1234',
teamName: 'demo',
},
...overrides,
};
}
describe('TeamTaskStallNotifier', () => {
it('sends OpenCode owner nudges with deterministic message ids', async () => {
const teamDataService = {
sendSystemNotificationToLead: vi.fn(async () => undefined),
};
const teamProvisioningService = {
relayOpenCodeMemberInboxMessages: vi.fn(async () => ({
relayed: 1,
attempted: 1,
delivered: 1,
failed: 0,
lastDelivery: { delivered: true, accepted: true },
})),
};
const inboxReader = {
getMessagesFor: vi.fn(async () => []),
};
const inboxWriter = {
sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg' })),
};
const notifier = new TeamTaskStallNotifier(
teamDataService as never,
teamProvisioningService as never,
inboxReader as never,
inboxWriter as never
);
const alert = createAlert();
const messageId = `task-stall:demo:task-a:${alert.epochKey}`;
await expect(notifier.notifyOpenCodeOwners('demo', [alert])).resolves.toEqual([alert]);
expect(inboxWriter.sendMessage).toHaveBeenCalledWith(
'demo',
expect.objectContaining({
member: 'alice',
from: 'system',
to: 'alice',
messageId,
summary: 'Potential stalled task',
taskRefs: [alert.taskRef],
actionMode: 'do',
source: 'system_notification',
})
);
expect(teamProvisioningService.relayOpenCodeMemberInboxMessages).toHaveBeenCalledWith(
'demo',
'alice',
{
onlyMessageId: messageId,
source: 'watchdog',
deliveryMetadata: {
replyRecipient: 'user',
actionMode: 'do',
taskRefs: [alert.taskRef],
},
}
);
expect(teamDataService.sendSystemNotificationToLead).not.toHaveBeenCalled();
});
it('skips non-OpenCode owners', async () => {
const notifier = new TeamTaskStallNotifier(
{ sendSystemNotificationToLead: vi.fn(async () => undefined) } as never,
{
relayOpenCodeMemberInboxMessages: vi.fn(async () => ({
lastDelivery: { delivered: true },
})),
} as never,
{ getMessagesFor: vi.fn(async () => []) } as never,
{ sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg' })) } as never
);
await expect(
notifier.notifyOpenCodeOwners('demo', [
createAlert({ ownerProviderId: 'codex', owner: 'alice' }),
])
).resolves.toEqual([]);
});
it('skips review alerts because task owner is not necessarily the reviewer', async () => {
const relay = vi.fn(async () => ({ lastDelivery: { delivered: true } }));
const notifier = new TeamTaskStallNotifier(
{ sendSystemNotificationToLead: vi.fn(async () => undefined) } as never,
{ relayOpenCodeMemberInboxMessages: relay } as never,
{ getMessagesFor: vi.fn(async () => []) } as never,
{ sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg' })) } as never
);
await expect(
notifier.notifyOpenCodeOwners('demo', [
createAlert({ branch: 'review', ownerProviderId: 'opencode', owner: 'alice' }),
])
).resolves.toEqual([]);
expect(relay).not.toHaveBeenCalled();
});
it('returns no remediated alert when OpenCode delivery is rejected', async () => {
const notifier = new TeamTaskStallNotifier(
{ sendSystemNotificationToLead: vi.fn(async () => undefined) } as never,
{
relayOpenCodeMemberInboxMessages: vi.fn(async () => ({
relayed: 0,
attempted: 1,
delivered: 0,
failed: 1,
lastDelivery: {
delivered: false,
reason: 'opencode_runtime_not_active',
},
})),
} as never,
{ getMessagesFor: vi.fn(async () => []) } as never,
{ sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg' })) } as never
);
await expect(notifier.notifyOpenCodeOwners('demo', [createAlert()])).resolves.toEqual([]);
});
it('does not mark queued-behind delivery as remediated even when active ledger exists', async () => {
const notifier = new TeamTaskStallNotifier(
{ sendSystemNotificationToLead: vi.fn(async () => undefined) } as never,
{
relayOpenCodeMemberInboxMessages: vi.fn(async () => ({
relayed: 0,
attempted: 1,
delivered: 0,
failed: 0,
lastDelivery: {
delivered: true,
accepted: false,
responsePending: true,
ledgerRecordId: 'active-ledger-record',
queuedBehindMessageId: 'msg-active',
reason: 'opencode_delivery_response_pending',
},
})),
} as never,
{ getMessagesFor: vi.fn(async () => []) } as never,
{ sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg' })) } as never
);
await expect(notifier.notifyOpenCodeOwners('demo', [createAlert()])).resolves.toEqual([]);
});
it('marks accepted response-pending delivery as remediated and leaves follow-up to the delivery ledger', async () => {
const relay = vi.fn(async () => ({
relayed: 1,
attempted: 1,
delivered: 1,
failed: 0,
lastDelivery: {
delivered: true,
accepted: true,
responsePending: true,
ledgerRecordId: 'active-ledger-record',
reason: 'opencode_delivery_response_pending',
},
}));
const notifier = new TeamTaskStallNotifier(
{ sendSystemNotificationToLead: vi.fn(async () => undefined) } as never,
{ relayOpenCodeMemberInboxMessages: relay } as never,
{ getMessagesFor: vi.fn(async () => []) } as never,
{ sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg' })) } as never
);
const alert = createAlert();
await expect(notifier.notifyOpenCodeOwners('demo', [alert])).resolves.toEqual([alert]);
expect(relay).toHaveBeenCalledTimes(1);
});
it('does not deliver runtime nudge when inbox write fails', async () => {
const relay = vi.fn(async () => ({ lastDelivery: { delivered: true } }));
const notifier = new TeamTaskStallNotifier(
{ sendSystemNotificationToLead: vi.fn(async () => undefined) } as never,
{ relayOpenCodeMemberInboxMessages: relay } as never,
{ getMessagesFor: vi.fn(async () => []) } as never,
{ sendMessage: vi.fn(async () => { throw new Error('disk full'); }) } as never
);
await expect(notifier.notifyOpenCodeOwners('demo', [createAlert()])).resolves.toEqual([]);
expect(relay).not.toHaveBeenCalled();
expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain(
'OpenCode task stall remediation inbox write failed'
);
vi.mocked(console.warn).mockClear();
});
it('does not write or relay when existing inbox read fails', async () => {
const relay = vi.fn(async () => ({ lastDelivery: { delivered: true } }));
const inboxWrite = vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg' }));
const notifier = new TeamTaskStallNotifier(
{ sendSystemNotificationToLead: vi.fn(async () => undefined) } as never,
{ relayOpenCodeMemberInboxMessages: relay } as never,
{ getMessagesFor: vi.fn(async () => { throw new Error('read failed'); }) } as never,
{ sendMessage: inboxWrite } as never
);
await expect(notifier.notifyOpenCodeOwners('demo', [createAlert()])).resolves.toEqual([]);
expect(inboxWrite).not.toHaveBeenCalled();
expect(relay).not.toHaveBeenCalled();
expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain(
'OpenCode task stall remediation inbox write failed'
);
vi.mocked(console.warn).mockClear();
});
});