import { describe, expect, it } from 'vitest'; import { TeamTaskStallPolicy } from '../../../../../src/main/services/team/stallMonitor/TeamTaskStallPolicy'; import type { TeamTaskStallExactRow, TeamTaskStallSnapshot, } from '../../../../../src/main/services/team/stallMonitor/TeamTaskStallTypes'; import type { BoardTaskActivityRecord } from '../../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecord'; import type { ParsedMessage } from '../../../../../src/main/types'; import type { TeamTask } from '../../../../../src/shared/types'; function createParsedMessage(overrides: Partial): ParsedMessage { return { uuid: 'msg-default', parentUuid: null, type: 'assistant', timestamp: new Date('2026-04-19T12:00:00.000Z'), content: '', isSidechain: true, isMeta: false, toolCalls: [], toolResults: [], ...overrides, }; } function createExactRow(overrides: Partial = {}): TeamTaskStallExactRow { return { filePath: '/tmp/session.jsonl', sourceOrder: 1, messageUuid: 'msg-touch', timestamp: '2026-04-19T12:00:00.000Z', parsedMessage: createParsedMessage({ uuid: 'msg-touch' }), toolUseIds: [], toolResultIds: [], ...overrides, }; } function createRecord(overrides: Partial = {}): BoardTaskActivityRecord { return { id: 'rec-1', timestamp: '2026-04-19T12:00:00.000Z', task: { locator: { ref: 'task-a', refKind: 'canonical', canonicalId: 'task-a', }, resolution: 'resolved', taskRef: { taskId: 'task-a', displayId: 'abcd1234', teamName: 'demo', }, }, linkKind: 'board_action', targetRole: 'subject', actor: { memberName: 'alice', role: 'member', sessionId: 'session-a', isSidechain: true, }, actorContext: { relation: 'same_task', }, action: { canonicalToolName: 'task_start', category: 'status', toolUseId: 'tool-1', }, source: { messageUuid: 'msg-touch', filePath: '/tmp/session.jsonl', toolUseId: 'tool-1', sourceOrder: 1, }, ...overrides, }; } function createSnapshot(overrides: Partial): TeamTaskStallSnapshot { return { teamName: 'demo', scannedAt: '2026-04-19T12:30:00.000Z', projectDir: '/tmp/project', projectId: 'project-id', leadName: 'team-lead', transcriptFiles: ['/tmp/session.jsonl'], activityReadsEnabled: true, exactReadsEnabled: true, activeTasks: [], deletedTasks: [], allTasksById: new Map(), inProgressTasks: [], reviewOpenTasks: [], resolvedReviewersByTaskId: new Map(), recordsByTaskId: new Map(), freshnessByTaskId: new Map(), exactRowsByFilePath: new Map(), providerByMemberName: new Map(), ...overrides, }; } describe('TeamTaskStallPolicy', () => { const policy = new TeamTaskStallPolicy(); it('does not treat malformed empty completedAt as an open work interval', () => { const task: TeamTask = { id: 'task-closed-empty', displayId: 'feed0000', subject: 'Malformed closed interval', owner: 'alice', status: 'in_progress', workIntervals: [{ startedAt: '2026-04-19T11:50:00.000Z', completedAt: '' }], }; const evaluation = policy.evaluateWork({ now: new Date('2026-04-19T12:30:00.000Z'), task, snapshot: createSnapshot({ activeTasks: [task], allTasksById: new Map([[task.id, task]]), inProgressTasks: [task], }), }); expect(evaluation).toMatchObject({ status: 'skip', taskId: 'task-closed-empty', skipReason: 'no_open_work_interval', }); }); it('alerts for work stall after turn ended and threshold elapsed', () => { const task: TeamTask = { id: 'task-a', displayId: 'abcd1234', subject: 'Task A', owner: 'alice', status: 'in_progress', workIntervals: [{ startedAt: '2026-04-19T11:50:00.000Z' }], }; const record = createRecord(); const snapshot = createSnapshot({ activeTasks: [task], allTasksById: new Map([['task-a', task]]), inProgressTasks: [task], recordsByTaskId: new Map([['task-a', [record]]]), exactRowsByFilePath: new Map([ [ '/tmp/session.jsonl', [ createExactRow({ messageUuid: 'msg-touch', toolUseIds: ['tool-1'], }), createExactRow({ sourceOrder: 2, messageUuid: 'msg-turn-end', systemSubtype: 'turn_duration', parsedMessage: createParsedMessage({ uuid: 'msg-turn-end', type: 'system', }), }), ], ], ]), }); const evaluation = policy.evaluateWork({ now: new Date('2026-04-19T12:30:00.000Z'), task, snapshot, }); expect(evaluation).toMatchObject({ status: 'alert', taskId: 'task-a', branch: 'work', signal: 'turn_ended_after_touch', }); }); it.each([ ['turn_ended_after_touch', 4], ['touch_then_other_turns', 5], ['mid_turn_after_touch', 10], ] as const)('uses the aggressive work threshold for %s', (signal, thresholdMinutes) => { const task: TeamTask = { id: 'task-work-threshold', displayId: 'abcd4444', subject: 'Work threshold', owner: 'alice', status: 'in_progress', workIntervals: [{ startedAt: '2026-04-19T11:50:00.000Z' }], }; const turnEndRow = createExactRow({ sourceOrder: 2, messageUuid: 'msg-turn-end', systemSubtype: 'turn_duration', parsedMessage: createParsedMessage({ uuid: 'msg-turn-end', type: 'system', }), }); const laterAssistantRow = createExactRow({ sourceOrder: 3, messageUuid: 'msg-later', parsedMessage: createParsedMessage({ uuid: 'msg-later', type: 'assistant', }), }); const postTouchRows = signal === 'touch_then_other_turns' ? [turnEndRow, laterAssistantRow] : signal === 'mid_turn_after_touch' ? [laterAssistantRow] : [turnEndRow]; const snapshot = createSnapshot({ activeTasks: [task], allTasksById: new Map([[task.id, task]]), inProgressTasks: [task], recordsByTaskId: new Map([[task.id, [createRecord()]]]), exactRowsByFilePath: new Map([ [ '/tmp/session.jsonl', [ createExactRow({ messageUuid: 'msg-touch', toolUseIds: ['tool-1'], }), ...postTouchRows, ], ], ]), }); const touchAtMs = Date.parse('2026-04-19T12:00:00.000Z'); expect( policy.evaluateWork({ now: new Date(touchAtMs + thresholdMinutes * 60_000 - 1), task, snapshot, }) ).toMatchObject({ status: 'skip', skipReason: 'below_threshold', }); expect( policy.evaluateWork({ now: new Date(touchAtMs + thresholdMinutes * 60_000), task, snapshot, }) ).toMatchObject({ status: 'alert', signal, }); }); it('alerts OpenCode-owned tasks faster after weak start-only task comments', () => { const task: TeamTask = { id: 'task-open-weak', displayId: 'feed1111', subject: 'OpenCode weak start', owner: 'alice', status: 'in_progress', workIntervals: [{ startedAt: '2026-04-19T11:50:00.000Z' }], comments: [ { id: 'comment-weak', author: 'alice', text: 'Начинаю работу.', createdAt: '2026-04-19T12:00:00.000Z', type: 'regular', }, ], }; const record = createRecord({ task: { locator: { ref: 'task-open-weak', refKind: 'canonical', canonicalId: 'task-open-weak', }, resolution: 'resolved', taskRef: { taskId: 'task-open-weak', displayId: 'feed1111', teamName: 'demo', }, }, action: { canonicalToolName: 'task_add_comment', category: 'comment', toolUseId: 'tool-weak', details: { commentId: 'comment-weak' }, }, source: { messageUuid: 'msg-touch', filePath: '/tmp/session.jsonl', toolUseId: 'tool-weak', sourceOrder: 1, }, }); const snapshot = createSnapshot({ activeTasks: [task], allTasksById: new Map([[task.id, task]]), inProgressTasks: [task], providerByMemberName: new Map([['alice', 'opencode']]), recordsByTaskId: new Map([[task.id, [record]]]), exactRowsByFilePath: new Map([ [ '/tmp/session.jsonl', [ createExactRow({ messageUuid: 'msg-touch', toolUseIds: ['tool-weak'], }), createExactRow({ sourceOrder: 2, messageUuid: 'msg-turn-end', systemSubtype: 'turn_duration', parsedMessage: createParsedMessage({ uuid: 'msg-turn-end', type: 'system', }), }), ], ], ]), }); const touchAtMs = Date.parse('2026-04-19T12:00:00.000Z'); expect( policy.evaluateWork({ now: new Date(touchAtMs + 100_000 - 1), task, snapshot, }) ).toMatchObject({ status: 'skip', taskId: 'task-open-weak', skipReason: 'below_threshold', }); expect( policy.evaluateWork({ now: new Date(touchAtMs + 100_000), task, snapshot, }) ).toMatchObject({ status: 'alert', taskId: 'task-open-weak', progressSignal: 'weak_start_only', reason: 'Potential work stall after weak start-only task comment.', }); }); it('uses normal work thresholds for weak comments from non-OpenCode owners', () => { const task: TeamTask = { id: 'task-codex-weak', displayId: 'feed2222', subject: 'Codex weak start', owner: 'alice', status: 'in_progress', workIntervals: [{ startedAt: '2026-04-19T11:50:00.000Z' }], comments: [ { id: 'comment-weak', author: 'alice', text: 'Will start.', createdAt: '2026-04-19T12:00:00.000Z', type: 'regular', }, ], }; const record = createRecord({ task: { locator: { ref: 'task-codex-weak', refKind: 'canonical', canonicalId: 'task-codex-weak', }, resolution: 'resolved', taskRef: { taskId: 'task-codex-weak', displayId: 'feed2222', teamName: 'demo', }, }, action: { canonicalToolName: 'task_add_comment', category: 'comment', toolUseId: 'tool-weak', details: { commentId: 'comment-weak' }, }, source: { messageUuid: 'msg-touch', filePath: '/tmp/session.jsonl', toolUseId: 'tool-weak', sourceOrder: 1, }, }); const snapshot = createSnapshot({ activeTasks: [task], allTasksById: new Map([[task.id, task]]), inProgressTasks: [task], providerByMemberName: new Map([['alice', 'codex']]), recordsByTaskId: new Map([[task.id, [record]]]), exactRowsByFilePath: new Map([ [ '/tmp/session.jsonl', [ createExactRow({ messageUuid: 'msg-touch', toolUseIds: ['tool-weak'], }), createExactRow({ sourceOrder: 2, messageUuid: 'msg-turn-end', systemSubtype: 'turn_duration', parsedMessage: createParsedMessage({ uuid: 'msg-turn-end', type: 'system', }), }), ], ], ]), }); const evaluation = policy.evaluateWork({ now: new Date('2026-04-19T12:03:00.000Z'), task, snapshot, }); expect(evaluation).toMatchObject({ status: 'skip', taskId: 'task-codex-weak', skipReason: 'below_threshold', }); }); it('does not apply weak-start threshold to concrete task comments', () => { const task: TeamTask = { id: 'task-open-strong', displayId: 'feed3333', subject: 'OpenCode concrete progress', owner: 'alice', status: 'in_progress', workIntervals: [{ startedAt: '2026-04-19T11:50:00.000Z' }], comments: [ { id: 'comment-strong', author: 'alice', text: 'Found the failing test in src/app.ts and reproduced it with pnpm test.', createdAt: '2026-04-19T12:00:00.000Z', type: 'regular', }, ], }; const record = createRecord({ task: { locator: { ref: 'task-open-strong', refKind: 'canonical', canonicalId: 'task-open-strong', }, resolution: 'resolved', taskRef: { taskId: 'task-open-strong', displayId: 'feed3333', teamName: 'demo', }, }, action: { canonicalToolName: 'task_add_comment', category: 'comment', toolUseId: 'tool-strong', details: { commentId: 'comment-strong' }, }, source: { messageUuid: 'msg-touch', filePath: '/tmp/session.jsonl', toolUseId: 'tool-strong', sourceOrder: 1, }, }); const snapshot = createSnapshot({ activeTasks: [task], allTasksById: new Map([[task.id, task]]), inProgressTasks: [task], providerByMemberName: new Map([['alice', 'opencode']]), recordsByTaskId: new Map([[task.id, [record]]]), exactRowsByFilePath: new Map([ [ '/tmp/session.jsonl', [ createExactRow({ messageUuid: 'msg-touch', toolUseIds: ['tool-strong'], }), createExactRow({ sourceOrder: 2, messageUuid: 'msg-turn-end', systemSubtype: 'turn_duration', parsedMessage: createParsedMessage({ uuid: 'msg-turn-end', type: 'system', }), }), ], ], ]), }); const evaluation = policy.evaluateWork({ now: new Date('2026-04-19T12:03:00.000Z'), task, snapshot, }); expect(evaluation).toMatchObject({ status: 'skip', taskId: 'task-open-strong', skipReason: 'below_threshold', }); }); it('alerts OpenCode-owned tasks with no instrumented owner progress after threshold', () => { const task: TeamTask = { id: 'task-open-no-progress', displayId: 'feed4444', subject: 'OpenCode no progress', owner: 'alice', status: 'in_progress', workIntervals: [{ startedAt: '2026-04-19T12:00:00.000Z' }], }; const snapshot = createSnapshot({ activeTasks: [task], allTasksById: new Map([[task.id, task]]), inProgressTasks: [task], providerByMemberName: new Map([['alice', 'opencode']]), }); const evaluation = policy.evaluateWork({ now: new Date('2026-04-19T12:07:00.000Z'), task, snapshot, }); expect(evaluation).toMatchObject({ status: 'alert', taskId: 'task-open-no-progress', branch: 'work', signal: 'mid_turn_after_touch', progressSignal: 'unknown', reason: 'Potential OpenCode task stall without owner progress evidence.', }); expect(evaluation.epochKey).toContain('opencode_no_owner_progress'); }); it('keeps non-OpenCode no-progress tasks on the existing non-instrumented skip path', () => { const task: TeamTask = { id: 'task-codex-no-progress', displayId: 'feed5555', subject: 'Codex no progress', owner: 'alice', status: 'in_progress', workIntervals: [{ startedAt: '2026-04-19T12:00:00.000Z' }], }; const evaluation = policy.evaluateWork({ now: new Date('2026-04-19T12:30:00.000Z'), task, snapshot: createSnapshot({ activeTasks: [task], allTasksById: new Map([[task.id, task]]), inProgressTasks: [task], providerByMemberName: new Map([['alice', 'codex']]), }), }); expect(evaluation).toMatchObject({ status: 'skip', taskId: 'task-codex-no-progress', skipReason: 'non_instrumented_run', }); }); it('alerts OpenCode-owned tasks with records but no owner work touch after threshold', () => { const task: TeamTask = { id: 'task-open-no-touch', displayId: 'feed6666', subject: 'OpenCode no owner touch', owner: 'alice', status: 'in_progress', workIntervals: [{ startedAt: '2026-04-19T12:00:00.000Z' }], }; const record = createRecord({ actor: { memberName: 'bob', role: 'member', sessionId: 'session-b', isSidechain: true, }, }); const evaluation = policy.evaluateWork({ now: new Date('2026-04-19T12:07:00.000Z'), task, snapshot: createSnapshot({ activeTasks: [task], allTasksById: new Map([[task.id, task]]), inProgressTasks: [task], providerByMemberName: new Map([['alice', 'opencode']]), recordsByTaskId: new Map([[task.id, [record]]]), }), }); expect(evaluation).toMatchObject({ status: 'alert', taskId: 'task-open-no-touch', reason: 'Potential OpenCode task stall without owner work touch.', }); }); it('fails closed on review branch when review has not started yet', () => { const task: TeamTask = { id: 'task-b', displayId: 'deadbeef', subject: 'Task B', status: 'completed', reviewState: 'review', historyEvents: [ { id: 'evt-review-requested', type: 'review_requested', timestamp: '2026-04-19T12:00:00.000Z', from: 'none', to: 'review', }, ], }; const evaluation = policy.evaluateReview({ now: new Date('2026-04-19T12:30:00.000Z'), task, snapshot: createSnapshot({ activeTasks: [task], allTasksById: new Map([['task-b', task]]), reviewOpenTasks: [task], }), }); expect(evaluation).toMatchObject({ status: 'skip', taskId: 'task-b', skipReason: 'no_open_review_window', }); }); it('fails closed on review branch when reviewer cannot be resolved after review has started', () => { const task: TeamTask = { id: 'task-b2', displayId: 'deadbe12', subject: 'Task B2', status: 'completed', reviewState: 'review', historyEvents: [ { id: 'evt-review-started', type: 'review_started', timestamp: '2026-04-19T12:01:00.000Z', from: 'review', to: 'review', }, ], }; const evaluation = policy.evaluateReview({ now: new Date('2026-04-19T12:30:00.000Z'), task, snapshot: createSnapshot({ activeTasks: [task], allTasksById: new Map([['task-b2', task]]), reviewOpenTasks: [task], }), }); expect(evaluation).toMatchObject({ status: 'skip', taskId: 'task-b2', skipReason: 'reviewer_unresolved', }); }); it('does not treat review_requested alone as started-review evidence', () => { const task: TeamTask = { id: 'task-review-requested-only', displayId: 'feedbeef', subject: 'Task review requested only', status: 'completed', reviewState: 'review', historyEvents: [ { id: 'evt-review-requested', type: 'review_requested', timestamp: '2026-04-19T12:00:00.000Z', from: 'none', to: 'review', reviewer: 'bob', }, ], }; const evaluation = policy.evaluateReview({ now: new Date('2026-04-19T12:30:00.000Z'), task, snapshot: createSnapshot({ activeTasks: [task], allTasksById: new Map([['task-review-requested-only', task]]), reviewOpenTasks: [task], resolvedReviewersByTaskId: new Map([ [ 'task-review-requested-only', { reviewer: 'bob', source: 'history_review_requested_reviewer' }, ], ]), }), }); expect(evaluation).toMatchObject({ status: 'skip', taskId: 'task-review-requested-only', skipReason: 'no_open_review_window', }); }); it('alerts for started-review stall after explicit review_start evidence', () => { const task: TeamTask = { id: 'task-c', displayId: 'c0ffee12', subject: 'Task C', status: 'completed', reviewState: 'review', historyEvents: [ { id: 'evt-review-requested', type: 'review_requested', timestamp: '2026-04-19T12:00:00.000Z', from: 'none', to: 'review', reviewer: 'bob', }, { id: 'evt-review-started', type: 'review_started', timestamp: '2026-04-19T12:01:00.000Z', from: 'review', to: 'review', actor: 'bob', }, ], }; const record = createRecord({ id: 'rec-review', timestamp: '2026-04-19T12:01:00.000Z', actor: { memberName: 'bob', role: 'member', sessionId: 'session-b', isSidechain: true, }, actorContext: { relation: 'same_task', activePhase: 'review', }, action: { canonicalToolName: 'review_start', category: 'review', toolUseId: 'tool-review', }, source: { messageUuid: 'msg-review-touch', filePath: '/tmp/review.jsonl', toolUseId: 'tool-review', sourceOrder: 1, }, }); const evaluation = policy.evaluateReview({ now: new Date('2026-04-19T12:20:30.000Z'), task, snapshot: createSnapshot({ activeTasks: [task], allTasksById: new Map([['task-c', task]]), reviewOpenTasks: [task], resolvedReviewersByTaskId: new Map([ ['task-c', { reviewer: 'bob', source: 'history_review_started_actor' }], ]), recordsByTaskId: new Map([['task-c', [record]]]), exactRowsByFilePath: new Map([ [ '/tmp/review.jsonl', [ createExactRow({ filePath: '/tmp/review.jsonl', messageUuid: 'msg-review-touch', toolUseIds: ['tool-review'], }), createExactRow({ filePath: '/tmp/review.jsonl', sourceOrder: 2, messageUuid: 'msg-review-turn-end', systemSubtype: 'turn_duration', parsedMessage: createParsedMessage({ uuid: 'msg-review-turn-end', type: 'system', }), }), ], ], ]), }), }); expect(evaluation).toMatchObject({ status: 'alert', taskId: 'task-c', branch: 'review', signal: 'turn_ended_after_touch', }); }); it.each([ ['turn_ended_after_touch', 5], ['touch_then_other_turns', 6], ['mid_turn_after_touch', 12], ] as const)('uses the aggressive review threshold for %s', (signal, thresholdMinutes) => { const task: TeamTask = { id: 'task-review-threshold', displayId: 'c0ffee55', subject: 'Review threshold', status: 'completed', reviewState: 'review', historyEvents: [ { id: 'evt-review-started', type: 'review_started', timestamp: '2026-04-19T12:00:00.000Z', from: 'review', to: 'review', actor: 'bob', }, ], }; const turnEndRow = createExactRow({ filePath: '/tmp/review-threshold.jsonl', sourceOrder: 2, messageUuid: 'msg-review-threshold-end', systemSubtype: 'turn_duration', parsedMessage: createParsedMessage({ uuid: 'msg-review-threshold-end', type: 'system', }), }); const laterAssistantRow = createExactRow({ filePath: '/tmp/review-threshold.jsonl', sourceOrder: 3, messageUuid: 'msg-review-threshold-later', parsedMessage: createParsedMessage({ uuid: 'msg-review-threshold-later', type: 'assistant', }), }); const postTouchRows = signal === 'touch_then_other_turns' ? [turnEndRow, laterAssistantRow] : signal === 'mid_turn_after_touch' ? [laterAssistantRow] : [turnEndRow]; const record = createRecord({ timestamp: '2026-04-19T12:00:00.000Z', actor: { memberName: 'bob', role: 'member', sessionId: 'session-b', isSidechain: true, }, actorContext: { relation: 'same_task', activePhase: 'review', }, action: { canonicalToolName: 'review_start', category: 'review', toolUseId: 'tool-review-threshold', }, source: { messageUuid: 'msg-review-threshold', filePath: '/tmp/review-threshold.jsonl', toolUseId: 'tool-review-threshold', sourceOrder: 1, }, }); const snapshot = createSnapshot({ activeTasks: [task], allTasksById: new Map([[task.id, task]]), reviewOpenTasks: [task], resolvedReviewersByTaskId: new Map([ [task.id, { reviewer: 'bob', source: 'history_review_started_actor' }], ]), recordsByTaskId: new Map([[task.id, [record]]]), exactRowsByFilePath: new Map([ [ '/tmp/review-threshold.jsonl', [ createExactRow({ filePath: '/tmp/review-threshold.jsonl', messageUuid: 'msg-review-threshold', toolUseIds: ['tool-review-threshold'], }), ...postTouchRows, ], ], ]), }); const touchAtMs = Date.parse('2026-04-19T12:00:00.000Z'); expect( policy.evaluateReview({ now: new Date(touchAtMs + thresholdMinutes * 60_000 - 1), task, snapshot, }) ).toMatchObject({ status: 'skip', skipReason: 'below_threshold', }); expect( policy.evaluateReview({ now: new Date(touchAtMs + thresholdMinutes * 60_000), task, snapshot, }) ).toMatchObject({ status: 'alert', signal, }); }); it('alerts for started-review stall when review_started actor is missing but same-task reviewer touch exists after the review start', () => { const task: TeamTask = { id: 'task-d', displayId: 'ddaa5511', subject: 'Task D', status: 'completed', reviewState: 'review', historyEvents: [ { id: 'evt-review-requested', type: 'review_requested', timestamp: '2026-04-19T12:00:00.000Z', from: 'none', to: 'review', reviewer: 'bob', }, { id: 'evt-review-started', type: 'review_started', timestamp: '2026-04-19T12:01:00.000Z', from: 'review', to: 'review', }, ], }; const record = createRecord({ id: 'rec-review-comment', timestamp: '2026-04-19T12:02:00.000Z', actor: { memberName: 'bob', role: 'member', sessionId: 'session-b', isSidechain: true, }, actorContext: { relation: 'same_task', activePhase: 'review', }, action: { canonicalToolName: 'task_add_comment', category: 'comment', toolUseId: 'tool-review-comment', }, source: { messageUuid: 'msg-review-comment', filePath: '/tmp/review-missing-actor.jsonl', toolUseId: 'tool-review-comment', sourceOrder: 1, }, }); const evaluation = policy.evaluateReview({ now: new Date('2026-04-19T12:20:30.000Z'), task, snapshot: createSnapshot({ activeTasks: [task], allTasksById: new Map([['task-d', task]]), reviewOpenTasks: [task], resolvedReviewersByTaskId: new Map([ ['task-d', { reviewer: 'bob', source: 'history_review_requested_reviewer' }], ]), recordsByTaskId: new Map([['task-d', [record]]]), exactRowsByFilePath: new Map([ [ '/tmp/review-missing-actor.jsonl', [ createExactRow({ filePath: '/tmp/review-missing-actor.jsonl', messageUuid: 'msg-review-comment', toolUseIds: ['tool-review-comment'], }), createExactRow({ filePath: '/tmp/review-missing-actor.jsonl', sourceOrder: 2, messageUuid: 'msg-review-turn-end', systemSubtype: 'turn_duration', parsedMessage: createParsedMessage({ uuid: 'msg-review-turn-end', type: 'system', }), }), ], ], ]), }), }); expect(evaluation).toMatchObject({ status: 'alert', taskId: 'task-d', branch: 'review', signal: 'turn_ended_after_touch', }); }); });