Skip to content

Commit 2ff92c4

Browse files
committed
feat(web): sessionReducer pure delta-application with vm_seq watermark
1 parent 5940489 commit 2ff92c4

2 files changed

Lines changed: 193 additions & 0 deletions

File tree

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: 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+
});

0 commit comments

Comments
 (0)