Skip to content

Commit 08db90e

Browse files
committed
feat(orchestration): add StateManager with partition management and reducers
1 parent a00e0cc commit 08db90e

2 files changed

Lines changed: 557 additions & 0 deletions

File tree

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
/**
2+
* @file state-manager.test.ts
3+
* @description Unit tests for {@link StateManager}.
4+
*
5+
* Each test group exercises a distinct method or reducer strategy in isolation so
6+
* failures are easy to pin-point without reading the full state object.
7+
*/
8+
9+
import { describe, it, expect } from 'vitest';
10+
import { StateManager } from '../runtime/StateManager.js';
11+
import type { GraphState, StateReducers } from '../ir/types.js';
12+
13+
// ---------------------------------------------------------------------------
14+
// Helpers
15+
// ---------------------------------------------------------------------------
16+
17+
/** Build a StateManager with the given reducers (defaults to none). */
18+
function makeManager(reducers: StateReducers = {}): StateManager {
19+
return new StateManager(reducers);
20+
}
21+
22+
// ---------------------------------------------------------------------------
23+
// initialize
24+
// ---------------------------------------------------------------------------
25+
26+
describe('StateManager.initialize', () => {
27+
it('initializes state with input and empty partitions', () => {
28+
const manager = makeManager();
29+
const state = manager.initialize({ prompt: 'hello' });
30+
31+
// Input is preserved.
32+
expect(state.input).toEqual({ prompt: 'hello' });
33+
34+
// Input is frozen.
35+
expect(Object.isFrozen(state.input)).toBe(true);
36+
37+
// Scratch and artifacts start empty.
38+
expect(state.scratch).toEqual({});
39+
expect(state.artifacts).toEqual({});
40+
41+
// Memory starts with zero-values.
42+
expect(state.memory.traces).toEqual([]);
43+
expect(state.memory.pendingWrites).toEqual([]);
44+
expect(state.memory.totalTracesRead).toBe(0);
45+
expect(state.memory.readLatencyMs).toBe(0);
46+
47+
// Diagnostics start at zero.
48+
expect(state.diagnostics.totalTokensUsed).toBe(0);
49+
expect(state.diagnostics.totalDurationMs).toBe(0);
50+
expect(state.diagnostics.checkpointsSaved).toBe(0);
51+
expect(state.diagnostics.memoryReads).toBe(0);
52+
expect(state.diagnostics.memoryWrites).toBe(0);
53+
54+
// Navigation fields start at defaults.
55+
expect(state.currentNodeId).toBe('');
56+
expect(state.visitedNodes).toEqual([]);
57+
expect(state.iteration).toBe(0);
58+
});
59+
});
60+
61+
// ---------------------------------------------------------------------------
62+
// updateScratch — simple set (no reducer)
63+
// ---------------------------------------------------------------------------
64+
65+
describe('StateManager.updateScratch (no reducer)', () => {
66+
it('applies scratch update via simple set when no reducer is registered', () => {
67+
const manager = makeManager();
68+
const state = manager.initialize({});
69+
const next = manager.updateScratch(state, { answer: 42 });
70+
71+
expect((next.scratch as any).answer).toBe(42);
72+
// Original state is not mutated.
73+
expect((state.scratch as any).answer).toBeUndefined();
74+
});
75+
76+
it('overwrites an existing scratch key when no reducer is registered', () => {
77+
const manager = makeManager();
78+
let state = manager.initialize({});
79+
state = manager.updateScratch(state, { count: 1 });
80+
state = manager.updateScratch(state, { count: 99 });
81+
82+
expect((state.scratch as any).count).toBe(99);
83+
});
84+
});
85+
86+
// ---------------------------------------------------------------------------
87+
// updateScratch — builtin reducers
88+
// ---------------------------------------------------------------------------
89+
90+
describe('StateManager.updateScratch — concat reducer', () => {
91+
it('applies concat reducer to array fields', () => {
92+
const manager = makeManager({ 'scratch.messages': 'concat' });
93+
let state = manager.initialize({});
94+
state = manager.updateScratch(state, { messages: ['a', 'b'] });
95+
state = manager.updateScratch(state, { messages: ['c'] });
96+
97+
expect((state.scratch as any).messages).toEqual(['a', 'b', 'c']);
98+
});
99+
});
100+
101+
describe('StateManager.updateScratch — max reducer', () => {
102+
it('applies max reducer keeping the larger numeric value', () => {
103+
const manager = makeManager({ 'scratch.score': 'max' });
104+
let state = manager.initialize({});
105+
state = manager.updateScratch(state, { score: 5 });
106+
state = manager.updateScratch(state, { score: 3 });
107+
expect((state.scratch as any).score).toBe(5);
108+
109+
state = manager.updateScratch(state, { score: 10 });
110+
expect((state.scratch as any).score).toBe(10);
111+
});
112+
});
113+
114+
describe('StateManager.updateScratch — min reducer', () => {
115+
it('applies min reducer keeping the smaller numeric value', () => {
116+
const manager = makeManager({ 'scratch.score': 'min' });
117+
let state = manager.initialize({});
118+
state = manager.updateScratch(state, { score: 5 });
119+
state = manager.updateScratch(state, { score: 3 });
120+
expect((state.scratch as any).score).toBe(3);
121+
122+
state = manager.updateScratch(state, { score: 10 });
123+
expect((state.scratch as any).score).toBe(3);
124+
});
125+
});
126+
127+
describe('StateManager.updateScratch — last reducer', () => {
128+
it('applies last reducer always taking the most recent value', () => {
129+
const manager = makeManager({ 'scratch.result': 'last' });
130+
let state = manager.initialize({});
131+
state = manager.updateScratch(state, { result: 'first' });
132+
state = manager.updateScratch(state, { result: 'second' });
133+
134+
expect((state.scratch as any).result).toBe('second');
135+
});
136+
});
137+
138+
describe('StateManager.updateScratch — first reducer', () => {
139+
it('applies first reducer keeping the initial value', () => {
140+
const manager = makeManager({ 'scratch.result': 'first' });
141+
let state = manager.initialize({});
142+
state = manager.updateScratch(state, { result: 'initial' });
143+
state = manager.updateScratch(state, { result: 'ignored' });
144+
145+
expect((state.scratch as any).result).toBe('initial');
146+
});
147+
});
148+
149+
describe('StateManager.updateScratch — sum reducer', () => {
150+
it('applies sum reducer accumulating numeric values', () => {
151+
const manager = makeManager({ 'scratch.total': 'sum' });
152+
let state = manager.initialize({});
153+
state = manager.updateScratch(state, { total: 10 });
154+
state = manager.updateScratch(state, { total: 5 });
155+
state = manager.updateScratch(state, { total: 3 });
156+
157+
expect((state.scratch as any).total).toBe(18);
158+
});
159+
});
160+
161+
describe('StateManager.updateScratch — avg reducer', () => {
162+
it('applies avg reducer computing running mean of two values', () => {
163+
const manager = makeManager({ 'scratch.confidence': 'avg' });
164+
let state = manager.initialize({});
165+
state = manager.updateScratch(state, { confidence: 0.6 });
166+
state = manager.updateScratch(state, { confidence: 1.0 });
167+
168+
expect((state.scratch as any).confidence).toBeCloseTo(0.8);
169+
});
170+
});
171+
172+
describe('StateManager.updateScratch — custom reducer function', () => {
173+
it('applies a custom ReducerFn', () => {
174+
const uppercaseConcat = (existing: unknown, incoming: unknown) =>
175+
`${existing},${String(incoming).toUpperCase()}`;
176+
177+
const manager = makeManager({ 'scratch.tags': uppercaseConcat });
178+
let state = manager.initialize({});
179+
state = manager.updateScratch(state, { tags: 'alpha' });
180+
state = manager.updateScratch(state, { tags: 'beta' });
181+
182+
expect((state.scratch as any).tags).toBe('alpha,BETA');
183+
});
184+
});
185+
186+
// ---------------------------------------------------------------------------
187+
// updateArtifacts
188+
// ---------------------------------------------------------------------------
189+
190+
describe('StateManager.updateArtifacts', () => {
191+
it('updates artifacts partition with last-write-wins semantics', () => {
192+
const manager = makeManager();
193+
let state = manager.initialize({});
194+
state = manager.updateArtifacts(state, { summary: 'first pass' });
195+
state = manager.updateArtifacts(state, { summary: 'final pass', score: 0.9 });
196+
197+
expect((state.artifacts as any).summary).toBe('final pass');
198+
expect((state.artifacts as any).score).toBe(0.9);
199+
// Original state is not mutated.
200+
expect((manager.initialize({}).artifacts as any).summary).toBeUndefined();
201+
});
202+
});
203+
204+
// ---------------------------------------------------------------------------
205+
// recordNodeVisit
206+
// ---------------------------------------------------------------------------
207+
208+
describe('StateManager.recordNodeVisit', () => {
209+
it('tracks visited nodes and increments iteration', () => {
210+
const manager = makeManager();
211+
let state = manager.initialize({});
212+
213+
state = manager.recordNodeVisit(state, 'node-a');
214+
expect(state.currentNodeId).toBe('node-a');
215+
expect(state.visitedNodes).toEqual(['node-a']);
216+
expect(state.iteration).toBe(1);
217+
218+
state = manager.recordNodeVisit(state, 'node-b');
219+
expect(state.currentNodeId).toBe('node-b');
220+
expect(state.visitedNodes).toEqual(['node-a', 'node-b']);
221+
expect(state.iteration).toBe(2);
222+
});
223+
});
224+
225+
// ---------------------------------------------------------------------------
226+
// mergeParallelBranches
227+
// ---------------------------------------------------------------------------
228+
229+
describe('StateManager.mergeParallelBranches', () => {
230+
it('merges parallel branch states using registered reducers', () => {
231+
const manager = makeManager({ 'scratch.results': 'concat' });
232+
const base = manager.initialize({});
233+
234+
// Simulate two branches each producing their own results list.
235+
const branch1 = manager.updateScratch(base, { results: ['r1'] });
236+
const branch2 = manager.updateScratch(base, { results: ['r2', 'r3'] });
237+
238+
const merged = manager.mergeParallelBranches(base, [branch1, branch2]);
239+
240+
expect((merged.scratch as any).results).toEqual(['r1', 'r2', 'r3']);
241+
});
242+
243+
it('uses last-write-wins when no reducer is registered for a branch key', () => {
244+
const manager = makeManager();
245+
const base = manager.initialize({});
246+
247+
const branch1 = manager.updateScratch(base, { answer: 'from-branch-1' });
248+
const branch2 = manager.updateScratch(base, { answer: 'from-branch-2' });
249+
250+
const merged = manager.mergeParallelBranches(base, [branch1, branch2]);
251+
252+
// Branch 2 is processed last → its value wins.
253+
expect((merged.scratch as any).answer).toBe('from-branch-2');
254+
});
255+
256+
it('preserves base state fields outside of scratch', () => {
257+
const manager = makeManager({ 'scratch.items': 'concat' });
258+
let base = manager.initialize({ query: 'test' });
259+
base = manager.updateArtifacts(base, { report: 'base-report' });
260+
261+
const branch = manager.updateScratch(base, { items: ['x'] });
262+
const merged = manager.mergeParallelBranches(base, [branch]);
263+
264+
// Artifacts from baseState are preserved.
265+
expect((merged.artifacts as any).report).toBe('base-report');
266+
// Input is preserved.
267+
expect((merged.input as any).query).toBe('test');
268+
});
269+
270+
it('handles an empty branch list by returning scratch equal to base', () => {
271+
const manager = makeManager();
272+
const base = manager.updateScratch(manager.initialize({}), { x: 1 });
273+
const merged = manager.mergeParallelBranches(base, []);
274+
275+
expect(merged.scratch).toEqual(base.scratch);
276+
});
277+
});

0 commit comments

Comments
 (0)