Skip to content

Commit ab16910

Browse files
authored
feat(web): React UI v2.0 — Phase 2 Tasks 23-25 (SSE + WS + reducer)
* feat(web): useEventSource hook + MockEventSource test helper * feat(web): useWebSocket fallback transport hook * feat(web): sessionReducer pure delta-application with vm_seq watermark
1 parent 1548458 commit ab16910

8 files changed

Lines changed: 420 additions & 0 deletions

File tree

web/src/api/sse.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { useEffect } from 'react';
2+
import type { SessionEvent } from './types';
3+
4+
export function useEventSource(
5+
url: string,
6+
onMessage: (ev: SessionEvent) => void,
7+
enabled: boolean = true,
8+
) {
9+
useEffect(() => {
10+
if (!enabled) return;
11+
const es = new EventSource(url);
12+
es.onmessage = (ev) => {
13+
try {
14+
const data = JSON.parse(ev.data) as SessionEvent;
15+
onMessage(data);
16+
} catch {
17+
// skip malformed
18+
}
19+
};
20+
return () => es.close();
21+
// onMessage intentionally omitted from deps to avoid reconnect on every render
22+
// eslint-disable-next-line react-hooks/exhaustive-deps
23+
}, [url, enabled]);
24+
}

web/src/api/ws.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { useEffect } from 'react';
2+
import type { SessionEvent } from './types';
3+
4+
export function useWebSocket(
5+
url: string,
6+
onMessage: (ev: SessionEvent) => void,
7+
enabled: boolean = true,
8+
) {
9+
useEffect(() => {
10+
if (!enabled) return;
11+
const ws = new WebSocket(url);
12+
ws.onmessage = (ev) => {
13+
try {
14+
const data = JSON.parse(ev.data) as SessionEvent;
15+
onMessage(data);
16+
} catch {
17+
// skip malformed
18+
}
19+
};
20+
return () => {
21+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
22+
ws.close();
23+
}
24+
};
25+
// onMessage intentionally omitted from deps to avoid reconnect on every render
26+
// eslint-disable-next-line react-hooks/exhaustive-deps
27+
}, [url, enabled]);
28+
}

web/src/state/sessionReducer.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import type {
2+
Session, AgentRun, ToolCall, AgentDefinition,
3+
SessionEvent, SessionFullBundle,
4+
} from '@/api/types';
5+
6+
export interface SessionState {
7+
session: Session | null;
8+
agentsRun: AgentRun[];
9+
toolCalls: ToolCall[];
10+
events: SessionEvent[];
11+
agentDefinitions: Record<string, AgentDefinition>;
12+
vmSeq: number;
13+
}
14+
15+
export const initialSessionState: SessionState = {
16+
session: null,
17+
agentsRun: [],
18+
toolCalls: [],
19+
events: [],
20+
agentDefinitions: {},
21+
vmSeq: 0,
22+
};
23+
24+
type Action =
25+
| { type: 'bootstrap'; bundle: SessionFullBundle }
26+
| { type: 'event'; event: SessionEvent };
27+
28+
export function sessionReducer(state: SessionState, action: Action): SessionState {
29+
switch (action.type) {
30+
case 'bootstrap':
31+
return {
32+
session: action.bundle.session,
33+
agentsRun: action.bundle.agents_run,
34+
toolCalls: action.bundle.tool_calls,
35+
events: action.bundle.events,
36+
agentDefinitions: action.bundle.agent_definitions,
37+
vmSeq: action.bundle.vm_seq,
38+
};
39+
40+
case 'event': {
41+
const ev = action.event;
42+
if (ev.seq <= state.vmSeq) return state; // drop dupes / out-of-order
43+
const events = [...state.events, ev];
44+
let session = state.session;
45+
let agentsRun = state.agentsRun;
46+
let toolCalls = state.toolCalls;
47+
48+
switch (ev.kind) {
49+
case 'agent_finished': {
50+
const p = ev.payload as Partial<AgentRun>;
51+
agentsRun = [...agentsRun, {
52+
agent: p.agent ?? '',
53+
started_at: p.started_at ?? '',
54+
ended_at: p.ended_at ?? ev.ts,
55+
summary: p.summary ?? '',
56+
confidence: p.confidence ?? null,
57+
confidence_rationale: p.confidence_rationale ?? null,
58+
signal: p.signal ?? null,
59+
}];
60+
break;
61+
}
62+
case 'tool_invoked': {
63+
const p = ev.payload as Partial<ToolCall>;
64+
toolCalls = [...toolCalls, {
65+
agent: p.agent ?? '',
66+
tool: p.tool ?? '',
67+
args: p.args ?? {},
68+
result: p.result ?? null,
69+
ts: ev.ts,
70+
risk: p.risk ?? null,
71+
status: 'executed',
72+
approver: null,
73+
approved_at: null,
74+
approval_rationale: null,
75+
}];
76+
break;
77+
}
78+
case 'approval_pending': {
79+
const p = ev.payload as Partial<ToolCall>;
80+
toolCalls = [...toolCalls, {
81+
agent: p.agent ?? '',
82+
tool: p.tool ?? '',
83+
args: p.args ?? {},
84+
result: null,
85+
ts: ev.ts,
86+
risk: p.risk ?? null,
87+
status: 'pending_approval',
88+
approver: null,
89+
approved_at: null,
90+
approval_rationale: null,
91+
}];
92+
break;
93+
}
94+
case 'status_changed': {
95+
const p = ev.payload as { status?: Session['status'] };
96+
if (session && p.status) session = { ...session, status: p.status };
97+
break;
98+
}
99+
}
100+
101+
return { ...state, events, session, agentsRun, toolCalls, vmSeq: ev.seq };
102+
}
103+
}
104+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
type Listener = (ev: MessageEvent) => void;
2+
3+
export class MockEventSource {
4+
url: string;
5+
readyState: number = 0; // CONNECTING
6+
onopen: ((ev: Event) => void) | null = null;
7+
onmessage: Listener | null = null;
8+
onerror: ((ev: Event) => void) | null = null;
9+
private listeners: Map<string, Listener[]> = new Map();
10+
private static instances: MockEventSource[] = [];
11+
12+
constructor(url: string) {
13+
this.url = url;
14+
MockEventSource.instances.push(this);
15+
setTimeout(() => {
16+
this.readyState = 1; // OPEN
17+
this.onopen?.(new Event('open'));
18+
}, 0);
19+
}
20+
21+
addEventListener(type: string, listener: Listener) {
22+
const list = this.listeners.get(type) ?? [];
23+
list.push(listener);
24+
this.listeners.set(type, list);
25+
}
26+
27+
emit(data: string) {
28+
const ev = new MessageEvent('message', { data });
29+
this.onmessage?.(ev);
30+
this.listeners.get('message')?.forEach((l) => l(ev));
31+
}
32+
33+
close() {
34+
this.readyState = 2;
35+
}
36+
37+
static lastInstance(): MockEventSource | undefined {
38+
return MockEventSource.instances[MockEventSource.instances.length - 1];
39+
}
40+
41+
static reset() {
42+
MockEventSource.instances = [];
43+
}
44+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
type Listener = (ev: MessageEvent) => void;
2+
3+
export class MockWebSocket {
4+
static readonly CONNECTING = 0;
5+
static readonly OPEN = 1;
6+
static readonly CLOSING = 2;
7+
static readonly CLOSED = 3;
8+
9+
url: string;
10+
readyState: number = MockWebSocket.CONNECTING;
11+
onopen: ((ev: Event) => void) | null = null;
12+
onmessage: Listener | null = null;
13+
onerror: ((ev: Event) => void) | null = null;
14+
onclose: ((ev: CloseEvent) => void) | null = null;
15+
private listeners: Map<string, Listener[]> = new Map();
16+
private static instances: MockWebSocket[] = [];
17+
18+
constructor(url: string) {
19+
this.url = url;
20+
MockWebSocket.instances.push(this);
21+
setTimeout(() => {
22+
this.readyState = MockWebSocket.OPEN;
23+
this.onopen?.(new Event('open'));
24+
}, 0);
25+
}
26+
27+
addEventListener(type: string, listener: Listener) {
28+
const list = this.listeners.get(type) ?? [];
29+
list.push(listener);
30+
this.listeners.set(type, list);
31+
}
32+
33+
emit(data: string) {
34+
const ev = new MessageEvent('message', { data });
35+
this.onmessage?.(ev);
36+
this.listeners.get('message')?.forEach((l) => l(ev));
37+
}
38+
39+
send(_data: string) {
40+
// no-op in mock
41+
}
42+
43+
close() {
44+
this.readyState = MockWebSocket.CLOSED;
45+
this.onclose?.(new CloseEvent('close'));
46+
}
47+
48+
static lastInstance(): MockWebSocket | undefined {
49+
return MockWebSocket.instances[MockWebSocket.instances.length - 1];
50+
}
51+
52+
static reset() {
53+
MockWebSocket.instances = [];
54+
}
55+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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+
});

web/tests/unit/sse.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import { renderHook, act } from '@testing-library/react';
3+
import { useEventSource } from '@/api/sse';
4+
import { MockEventSource } from '../_helpers/MockEventSource';
5+
6+
describe('useEventSource', () => {
7+
beforeEach(() => {
8+
MockEventSource.reset();
9+
// @ts-expect-error global override
10+
global.EventSource = MockEventSource;
11+
});
12+
13+
it('opens an EventSource at the given URL', async () => {
14+
renderHook(() => useEventSource('/api/v1/sessions/SES-1/events', () => {}));
15+
await Promise.resolve();
16+
expect(MockEventSource.lastInstance()?.url).toBe('/api/v1/sessions/SES-1/events');
17+
});
18+
19+
it('calls onMessage with parsed JSON payload per data: line', async () => {
20+
const onMessage = vi.fn();
21+
renderHook(() => useEventSource('/x', onMessage));
22+
await Promise.resolve();
23+
act(() => {
24+
MockEventSource.lastInstance()!.emit('{"seq":1,"kind":"agent_started","payload":{},"ts":"x"}');
25+
});
26+
expect(onMessage).toHaveBeenCalledWith(
27+
expect.objectContaining({ seq: 1, kind: 'agent_started' }),
28+
);
29+
});
30+
});

0 commit comments

Comments
 (0)