import { describe, expect, it, vi } from 'vitest'; import { RuntimeTurnSettledIngestor } from '@features/member-work-sync/core/application'; import { ClaudeStopHookPayloadNormalizer } from '@features/member-work-sync/main/infrastructure/ClaudeStopHookPayloadNormalizer'; import { NodeHashAdapter } from '@features/member-work-sync/main/infrastructure/NodeHashAdapter'; import { OpenCodeTurnSettledPayloadNormalizer } from '@features/member-work-sync/main/infrastructure/OpenCodeTurnSettledPayloadNormalizer'; import type { MemberWorkSyncAuditEvent, RuntimeTurnSettledClaimedPayload, RuntimeTurnSettledEventStorePort, RuntimeTurnSettledInvalidResult, RuntimeTurnSettledProcessedResult, RuntimeTurnSettledTargetResolverPort, } from '@features/member-work-sync/core/application'; function makePayload(raw: string): RuntimeTurnSettledClaimedPayload { return { id: 'event-1.claude.json', fileName: 'event-1.claude.json', filePath: '/tmp/event-1.claude.json', provider: 'claude', raw, claimedAt: '2026-04-29T12:00:00.000Z', }; } function makeOpenCodePayload(raw: string): RuntimeTurnSettledClaimedPayload { return { id: 'event-1.opencode.json', fileName: 'event-1.opencode.json', filePath: '/tmp/event-1.opencode.json', provider: 'opencode', raw, claimedAt: '2026-04-29T12:00:00.000Z', }; } describe('RuntimeTurnSettledIngestor', () => { it('normalizes Claude Stop payloads and enqueues resolved member reconcile', async () => { const payload = makePayload( JSON.stringify({ hook_event_name: 'Stop', session_id: 'ses-1', transcript_path: '/tmp/ses-1.jsonl', }) ); const processed: RuntimeTurnSettledProcessedResult[] = []; const store: RuntimeTurnSettledEventStorePort = { claimPending: vi.fn(async () => [payload]), markProcessed: vi.fn(async (_payload, result) => { processed.push(result); }), markInvalid: vi.fn(), }; const resolver: RuntimeTurnSettledTargetResolverPort = { resolve: vi.fn(async () => ({ ok: true as const, teamName: 'team-a', memberName: 'alice' })), }; const enqueueRuntimeTurnSettled = vi.fn(); const auditEvents: MemberWorkSyncAuditEvent[] = []; const ingestor = new RuntimeTurnSettledIngestor({ eventStore: store, normalizer: new ClaudeStopHookPayloadNormalizer(new NodeHashAdapter()), targetResolver: resolver, reconcileQueue: { enqueueRuntimeTurnSettled }, clock: { now: () => new Date('2026-04-29T12:01:00.000Z') }, auditJournal: { append: async (event) => { auditEvents.push(event); }, }, }); await expect(ingestor.drainPending()).resolves.toEqual({ claimed: 1, enqueued: 1, unresolved: 0, ignored: 0, invalid: 0, failed: 0, }); expect(enqueueRuntimeTurnSettled).toHaveBeenCalledWith({ teamName: 'team-a', memberName: 'alice', event: expect.objectContaining({ provider: 'claude', hookEventName: 'Stop', sessionId: 'ses-1', transcriptPath: '/tmp/ses-1.jsonl', }), }); expect(processed[0]).toMatchObject({ outcome: 'enqueued', teamName: 'team-a', memberName: 'alice', }); }); it('quarantines malformed payloads without enqueueing reconcile', async () => { const payload = makePayload('not-json'); const invalid: RuntimeTurnSettledInvalidResult[] = []; const store: RuntimeTurnSettledEventStorePort = { claimPending: vi.fn(async () => [payload]), markProcessed: vi.fn(), markInvalid: vi.fn(async (_payload, result) => { invalid.push(result); }), }; const ingestor = new RuntimeTurnSettledIngestor({ eventStore: store, normalizer: new ClaudeStopHookPayloadNormalizer(new NodeHashAdapter()), targetResolver: { resolve: vi.fn() }, reconcileQueue: { enqueueRuntimeTurnSettled: vi.fn() }, clock: { now: () => new Date('2026-04-29T12:01:00.000Z') }, }); await expect(ingestor.drainPending()).resolves.toMatchObject({ claimed: 1, invalid: 1, enqueued: 0, }); expect(invalid[0]).toMatchObject({ reason: 'invalid_json' }); }); it('records unresolved Stop events as processed diagnostics', async () => { const payload = makePayload( JSON.stringify({ hook_event_name: 'Stop', transcript_path: '/tmp/unknown.jsonl' }) ); const processed: RuntimeTurnSettledProcessedResult[] = []; const store: RuntimeTurnSettledEventStorePort = { claimPending: vi.fn(async () => [payload]), markProcessed: vi.fn(async (_payload, result) => { processed.push(result); }), markInvalid: vi.fn(), }; const ingestor = new RuntimeTurnSettledIngestor({ eventStore: store, normalizer: new ClaudeStopHookPayloadNormalizer(new NodeHashAdapter()), targetResolver: { resolve: vi.fn(async () => ({ ok: false as const, reason: 'no_matching_member_session', })), }, reconcileQueue: { enqueueRuntimeTurnSettled: vi.fn() }, clock: { now: () => new Date('2026-04-29T12:01:00.000Z') }, }); await expect(ingestor.drainPending()).resolves.toMatchObject({ claimed: 1, unresolved: 1, ignored: 0, enqueued: 0, }); expect(processed[0]).toMatchObject({ outcome: 'unresolved', reason: 'no_matching_member_session', }); }); it('normalizes OpenCode turn-settled payloads and enqueues provider-owned member reconcile', async () => { const payload = makeOpenCodePayload( JSON.stringify({ schemaVersion: 1, provider: 'opencode', source: 'agent-teams-orchestrator-opencode', eventName: 'runtime_turn_settled', hookEventName: 'Stop', sessionId: 'ses-opencode-1', runtimePromptMessageId: 'msg_123', laneId: 'secondary:opencode:jack', memberName: 'jack', teamName: 'team-a', cwd: '/tmp/project', outcome: 'success', recordedAt: '2026-04-29T12:00:00.000Z', }) ); const processed: RuntimeTurnSettledProcessedResult[] = []; const store: RuntimeTurnSettledEventStorePort = { claimPending: vi.fn(async () => [payload]), markProcessed: vi.fn(async (_payload, result) => { processed.push(result); }), markInvalid: vi.fn(), }; const resolver: RuntimeTurnSettledTargetResolverPort = { resolve: vi.fn(async () => ({ ok: true as const, teamName: 'team-a', memberName: 'jack' })), }; const enqueueRuntimeTurnSettled = vi.fn(); const auditEvents: MemberWorkSyncAuditEvent[] = []; const ingestor = new RuntimeTurnSettledIngestor({ eventStore: store, normalizer: new OpenCodeTurnSettledPayloadNormalizer(new NodeHashAdapter()), targetResolver: resolver, reconcileQueue: { enqueueRuntimeTurnSettled }, clock: { now: () => new Date('2026-04-29T12:01:00.000Z') }, auditJournal: { append: async (event) => { auditEvents.push(event); }, }, }); await expect(ingestor.drainPending()).resolves.toMatchObject({ claimed: 1, enqueued: 1, invalid: 0, unresolved: 0, ignored: 0, }); expect(enqueueRuntimeTurnSettled).toHaveBeenCalledWith({ teamName: 'team-a', memberName: 'jack', event: expect.objectContaining({ provider: 'opencode', hookEventName: 'Stop', sessionId: 'ses-opencode-1', turnId: 'msg_123', teamName: 'team-a', memberName: 'jack', agentId: 'secondary:opencode:jack', outcome: 'success', }), }); expect(processed[0]).toMatchObject({ outcome: 'enqueued', teamName: 'team-a', memberName: 'jack', }); expect(auditEvents.map((event) => event.event)).toEqual([ 'turn_settled_claimed', 'turn_settled_resolved', ]); }); it.each([ 'timeout', 'stream_unavailable', 'prompt_rejected', 'idle_without_assistant_activity', 'unknown', ])('ignores non-terminal OpenCode outcome %s without enqueueing premature reconcile', async (outcome) => { const payload = makeOpenCodePayload( JSON.stringify({ schemaVersion: 1, provider: 'opencode', source: 'agent-teams-orchestrator-opencode', eventName: 'runtime_turn_settled', hookEventName: 'Stop', sessionId: 'ses-opencode-1', runtimePromptMessageId: 'msg_123', laneId: 'secondary:opencode:jack', memberName: 'jack', teamName: 'team-a', outcome, recordedAt: '2026-04-29T12:00:00.000Z', }) ); const processed: RuntimeTurnSettledProcessedResult[] = []; const store: RuntimeTurnSettledEventStorePort = { claimPending: vi.fn(async () => [payload]), markProcessed: vi.fn(async (_payload, result) => { processed.push(result); }), markInvalid: vi.fn(), }; const resolver: RuntimeTurnSettledTargetResolverPort = { resolve: vi.fn(), }; const enqueueRuntimeTurnSettled = vi.fn(); const ingestor = new RuntimeTurnSettledIngestor({ eventStore: store, normalizer: new OpenCodeTurnSettledPayloadNormalizer(new NodeHashAdapter()), targetResolver: resolver, reconcileQueue: { enqueueRuntimeTurnSettled }, clock: { now: () => new Date('2026-04-29T12:01:00.000Z') }, }); await expect(ingestor.drainPending()).resolves.toMatchObject({ claimed: 1, enqueued: 0, unresolved: 0, ignored: 1, invalid: 0, }); expect(resolver.resolve).not.toHaveBeenCalled(); expect(enqueueRuntimeTurnSettled).not.toHaveBeenCalled(); expect(processed[0]).toMatchObject({ outcome: 'ignored', reason: `opencode_non_terminal_outcome:${outcome}`, }); }); });