|
| 1 | +import { describe, it, expect } from 'vitest'; |
| 2 | +import { sessionReducer, initialSessionState } from '@/state/sessionReducer'; |
| 3 | +import type { SessionFullBundle, SessionEvent } from '@/api/types'; |
| 4 | + |
| 5 | +const baseBundle: SessionFullBundle = { |
| 6 | + session: { |
| 7 | + id: 'SES-1', status: 'in_progress', |
| 8 | + created_at: 'x', updated_at: 'x', deleted_at: null, |
| 9 | + agents_run: [], tool_calls: [], findings: {}, |
| 10 | + pending_intervention: null, user_inputs: [], |
| 11 | + parent_session_id: null, dedup_rationale: null, |
| 12 | + extra_fields: {}, version: 1, |
| 13 | + }, |
| 14 | + agents_run: [], |
| 15 | + tool_calls: [], |
| 16 | + events: [], |
| 17 | + agent_definitions: {}, |
| 18 | + vm_seq: 0, |
| 19 | +}; |
| 20 | + |
| 21 | +describe('sessionReducer', () => { |
| 22 | + describe('action: bootstrap', () => { |
| 23 | + it('replaces full state with bundle', () => { |
| 24 | + const next = sessionReducer(initialSessionState, { type: 'bootstrap', bundle: baseBundle }); |
| 25 | + expect(next.vmSeq).toBe(0); |
| 26 | + expect(next.session?.id).toBe('SES-1'); |
| 27 | + }); |
| 28 | + }); |
| 29 | + |
| 30 | + describe('action: event', () => { |
| 31 | + it('drops events with seq <= vmSeq (idempotent)', () => { |
| 32 | + const state = sessionReducer(initialSessionState, { type: 'bootstrap', bundle: { ...baseBundle, vm_seq: 5 } }); |
| 33 | + const stale: SessionEvent = { seq: 3, kind: 'agent_started', payload: {}, ts: 'x' }; |
| 34 | + const next = sessionReducer(state, { type: 'event', event: stale }); |
| 35 | + expect(next).toBe(state); // no change |
| 36 | + }); |
| 37 | + |
| 38 | + it('appends events with seq > vmSeq and bumps vmSeq', () => { |
| 39 | + const state = sessionReducer(initialSessionState, { type: 'bootstrap', bundle: baseBundle }); |
| 40 | + const fresh: SessionEvent = { seq: 1, kind: 'agent_started', payload: { agent: 'intake' }, ts: 'x' }; |
| 41 | + const next = sessionReducer(state, { type: 'event', event: fresh }); |
| 42 | + expect(next.vmSeq).toBe(1); |
| 43 | + expect(next.events).toHaveLength(1); |
| 44 | + }); |
| 45 | + |
| 46 | + it('event "agent_finished" appends an AgentRun', () => { |
| 47 | + const state = sessionReducer(initialSessionState, { type: 'bootstrap', bundle: baseBundle }); |
| 48 | + const finished: SessionEvent = { |
| 49 | + seq: 1, kind: 'agent_finished', ts: 'x', |
| 50 | + payload: { agent: 'intake', summary: 'done', confidence: 0.9 }, |
| 51 | + }; |
| 52 | + const next = sessionReducer(state, { type: 'event', event: finished }); |
| 53 | + expect(next.agentsRun).toHaveLength(1); |
| 54 | + expect(next.agentsRun[0]?.agent).toBe('intake'); |
| 55 | + }); |
| 56 | + |
| 57 | + it('event "tool_invoked" appends a ToolCall with status="executed"', () => { |
| 58 | + const state = sessionReducer(initialSessionState, { type: 'bootstrap', bundle: baseBundle }); |
| 59 | + const tool: SessionEvent = { |
| 60 | + seq: 1, kind: 'tool_invoked', ts: 'x', |
| 61 | + payload: { agent: 'triage', tool: 'obs:get_logs', args: {}, result: 'ok' }, |
| 62 | + }; |
| 63 | + const next = sessionReducer(state, { type: 'event', event: tool }); |
| 64 | + expect(next.toolCalls).toHaveLength(1); |
| 65 | + expect(next.toolCalls[0]?.status).toBe('executed'); |
| 66 | + }); |
| 67 | + |
| 68 | + it('event "approval_pending" inserts a pending tool call', () => { |
| 69 | + const state = sessionReducer(initialSessionState, { type: 'bootstrap', bundle: baseBundle }); |
| 70 | + const ev: SessionEvent = { |
| 71 | + seq: 1, kind: 'approval_pending', ts: 'x', |
| 72 | + payload: { agent: 'investigate', tool: 'rem:propose_fix', args: {}, risk: 'high' }, |
| 73 | + }; |
| 74 | + const next = sessionReducer(state, { type: 'event', event: ev }); |
| 75 | + expect(next.toolCalls[0]?.status).toBe('pending_approval'); |
| 76 | + expect(next.toolCalls[0]?.risk).toBe('high'); |
| 77 | + }); |
| 78 | + |
| 79 | + it('event "status_changed" updates session.status', () => { |
| 80 | + const state = sessionReducer(initialSessionState, { type: 'bootstrap', bundle: baseBundle }); |
| 81 | + const ev: SessionEvent = { |
| 82 | + seq: 1, kind: 'status_changed', ts: 'x', |
| 83 | + payload: { status: 'resolved' }, |
| 84 | + }; |
| 85 | + const next = sessionReducer(state, { type: 'event', event: ev }); |
| 86 | + expect(next.session?.status).toBe('resolved'); |
| 87 | + }); |
| 88 | + }); |
| 89 | +}); |
0 commit comments