@moxxy/reflector-default 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,461 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import {
3
+ asEventId,
4
+ asSessionId,
5
+ asToolCallId,
6
+ asTurnId,
7
+ type EventLogReader,
8
+ type LLMProvider,
9
+ type MoxxyEvent,
10
+ type ProviderEvent,
11
+ type ProviderRequest,
12
+ type ReflectContext,
13
+ type ReflectionProposal,
14
+ type ReflectorDef,
15
+ type ServiceRegistry,
16
+ type TurnContext,
17
+ } from '@moxxy/sdk';
18
+ import { FakeProvider, createFakeSession, textReply } from '@moxxy/testing';
19
+ import type { Session } from '@moxxy/core';
20
+ import {
21
+ REFLECT_MIN_ITERATIONS,
22
+ REFLECT_MIN_TOOL_RESULTS,
23
+ buildReflectorPlugin,
24
+ buildTurnDigest,
25
+ parseReflectionReply,
26
+ reflectorDefaultDef,
27
+ shouldReflect,
28
+ } from './index.js';
29
+
30
+ // ── event + context fakes ───────────────────────────────────────────────────
31
+
32
+ const sid = asSessionId('s1');
33
+ const tid = asTurnId('t1');
34
+
35
+ function makeEvents(): {
36
+ userPrompt: (text: string) => MoxxyEvent;
37
+ toolCall: (name: string) => MoxxyEvent;
38
+ toolResult: (ok: boolean, errMsg?: string) => MoxxyEvent;
39
+ errorEv: (message: string) => MoxxyEvent;
40
+ modeIter: (iteration: number) => MoxxyEvent;
41
+ assistantMsg: (content: string) => MoxxyEvent;
42
+ } {
43
+ let seq = 0;
44
+ const base = (): { id: ReturnType<typeof asEventId>; seq: number; ts: number; sessionId: typeof sid; turnId: typeof tid; source: 'system' } => ({
45
+ id: asEventId(`e${seq}`),
46
+ seq: seq++,
47
+ ts: seq,
48
+ sessionId: sid,
49
+ turnId: tid,
50
+ source: 'system',
51
+ });
52
+ return {
53
+ userPrompt: (text) => ({ ...base(), type: 'user_prompt', text }) as MoxxyEvent,
54
+ toolCall: (name) => ({ ...base(), type: 'tool_call_requested', callId: asToolCallId(`c${name}`), name, input: {} }) as MoxxyEvent,
55
+ toolResult: (ok, errMsg) =>
56
+ ({
57
+ ...base(),
58
+ type: 'tool_result',
59
+ callId: asToolCallId('c'),
60
+ ok,
61
+ ...(errMsg ? { error: { message: errMsg, kind: 'threw' } } : {}),
62
+ }) as MoxxyEvent,
63
+ errorEv: (message) => ({ ...base(), type: 'error', kind: 'fatal', message }) as MoxxyEvent,
64
+ modeIter: (iteration) => ({ ...base(), type: 'mode_iteration', strategy: 'default', iteration }) as MoxxyEvent,
65
+ assistantMsg: (content) => ({ ...base(), type: 'assistant_message', content, stopReason: 'end_turn' }) as MoxxyEvent,
66
+ };
67
+ }
68
+
69
+ /** A busy turn that trips the gate (5 tool results) + a prompt + assistant text. */
70
+ function busyTurn(): MoxxyEvent[] {
71
+ const e = makeEvents();
72
+ return [
73
+ e.userPrompt('deploy the service'),
74
+ e.toolCall('Bash'),
75
+ e.toolResult(true),
76
+ e.toolResult(true),
77
+ e.toolResult(true),
78
+ e.toolResult(true),
79
+ e.toolResult(true),
80
+ e.assistantMsg('done, deployed'),
81
+ ];
82
+ }
83
+
84
+ function reader(events: ReadonlyArray<MoxxyEvent>): EventLogReader {
85
+ const arr = [...events];
86
+ const base = arr.length > 0 ? arr[0]!.seq : 0;
87
+ return {
88
+ length: arr.length,
89
+ at: (seq: number) => arr[seq - base],
90
+ slice: (from = base, to = base + arr.length) =>
91
+ arr.slice(Math.max(0, from - base), Math.max(0, to - base)),
92
+ ofType: ((type: string) => arr.filter((ev) => ev.type === type)) as EventLogReader['ofType'],
93
+ byTurn: (turnId) => arr.filter((ev) => ev.turnId === turnId),
94
+ toJSON: () => arr,
95
+ };
96
+ }
97
+
98
+ function services(map: Record<string, unknown>): ServiceRegistry {
99
+ return {
100
+ register: () => {},
101
+ get: (<T>(name: string) => map[name] as T | undefined) as ServiceRegistry['get'],
102
+ require: (<T>(name: string) => {
103
+ const v = map[name];
104
+ if (v === undefined) throw new Error(`missing service ${name}`);
105
+ return v as T;
106
+ }) as ServiceRegistry['require'],
107
+ has: (name: string) => name in map,
108
+ };
109
+ }
110
+
111
+ function providersStub(provider: LLMProvider | null): unknown {
112
+ return {
113
+ getActiveName: () => (provider ? provider.name : null),
114
+ getActive: () => {
115
+ if (!provider) throw new Error('no active provider');
116
+ return provider;
117
+ },
118
+ };
119
+ }
120
+
121
+ function reflectorsStub(def: ReflectorDef | null): unknown {
122
+ return { getActive: () => def };
123
+ }
124
+
125
+ function turnCtx(events: ReadonlyArray<MoxxyEvent>, svc: ServiceRegistry): TurnContext {
126
+ return { sessionId: sid, turnId: tid, cwd: '/tmp', log: reader(events), env: {}, services: svc, iteration: 0 };
127
+ }
128
+
129
+ function reflectCtx(events: ReadonlyArray<MoxxyEvent>, svc: ServiceRegistry): ReflectContext {
130
+ return {
131
+ sessionId: sid,
132
+ turnId: tid,
133
+ cwd: '/tmp',
134
+ log: reader(events),
135
+ services: svc,
136
+ signal: AbortSignal.timeout(30_000),
137
+ };
138
+ }
139
+
140
+ const replyJson = (proposals: ReadonlyArray<ReflectionProposal>): string => JSON.stringify(proposals);
141
+
142
+ // ── shouldReflect (the gate) ────────────────────────────────────────────────
143
+
144
+ describe('shouldReflect', () => {
145
+ const e = makeEvents();
146
+
147
+ it('is false for a quiet turn (below every threshold)', () => {
148
+ const events = [e.userPrompt('hi'), e.toolCall('Bash'), e.toolResult(true), e.assistantMsg('hello')];
149
+ expect(shouldReflect(events)).toBe(false);
150
+ });
151
+
152
+ it(`fires at ${REFLECT_MIN_TOOL_RESULTS} tool results`, () => {
153
+ const events = Array.from({ length: REFLECT_MIN_TOOL_RESULTS }, () => e.toolResult(true));
154
+ expect(shouldReflect(events)).toBe(true);
155
+ expect(shouldReflect(events.slice(0, REFLECT_MIN_TOOL_RESULTS - 1))).toBe(false);
156
+ });
157
+
158
+ it('fires on a single error event', () => {
159
+ expect(shouldReflect([e.errorEv('boom')])).toBe(true);
160
+ });
161
+
162
+ it(`fires at ${REFLECT_MIN_ITERATIONS} mode iterations`, () => {
163
+ const events = Array.from({ length: REFLECT_MIN_ITERATIONS }, (_, i) => e.modeIter(i));
164
+ expect(shouldReflect(events)).toBe(true);
165
+ expect(shouldReflect(events.slice(0, REFLECT_MIN_ITERATIONS - 1))).toBe(false);
166
+ });
167
+ });
168
+
169
+ // ── buildTurnDigest ─────────────────────────────────────────────────────────
170
+
171
+ describe('buildTurnDigest', () => {
172
+ it('summarizes prompt, tool names, error snippets, and final assistant text', () => {
173
+ const e = makeEvents();
174
+ const digest = buildTurnDigest([
175
+ e.userPrompt('fix the build'),
176
+ e.toolCall('Bash'),
177
+ e.toolCall('Bash'),
178
+ e.toolResult(false, 'exit code 1'),
179
+ e.assistantMsg('fixed it'),
180
+ ]);
181
+ expect(digest).toContain('USER: fix the build');
182
+ expect(digest).toContain('Bash×2');
183
+ expect(digest).toContain('ERRORS: exit code 1');
184
+ expect(digest).toContain('ASSISTANT: fixed it');
185
+ });
186
+
187
+ it('is empty for an empty turn', () => {
188
+ expect(buildTurnDigest([])).toBe('');
189
+ });
190
+ });
191
+
192
+ // ── parseReflectionReply ────────────────────────────────────────────────────
193
+
194
+ describe('parseReflectionReply', () => {
195
+ it('parses a well-formed array', () => {
196
+ const out = parseReflectionReply(
197
+ replyJson([{ kind: 'memory', title: 'prefers pnpm', nudge: 'save that they use pnpm' }]),
198
+ );
199
+ expect(out).toEqual([{ kind: 'memory', title: 'prefers pnpm', nudge: 'save that they use pnpm' }]);
200
+ });
201
+
202
+ it('unwraps a ```json fenced block', () => {
203
+ const out = parseReflectionReply('```json\n[{"kind":"skill","title":"t","nudge":"n"}]\n```');
204
+ expect(out).toHaveLength(1);
205
+ expect(out[0]!.kind).toBe('skill');
206
+ });
207
+
208
+ it('clamps to at most 2 proposals', () => {
209
+ const out = parseReflectionReply(
210
+ replyJson([
211
+ { kind: 'memory', title: 'a', nudge: 'x' },
212
+ { kind: 'memory', title: 'b', nudge: 'y' },
213
+ { kind: 'skill', title: 'c', nudge: 'z' },
214
+ ]),
215
+ );
216
+ expect(out).toHaveLength(2);
217
+ });
218
+
219
+ it('returns [] on malformed JSON', () => {
220
+ expect(parseReflectionReply('not json at all')).toEqual([]);
221
+ expect(parseReflectionReply('[{"kind": "memory"')).toEqual([]);
222
+ });
223
+
224
+ it('returns [] on a wrong-shape item (bad kind / missing field)', () => {
225
+ expect(parseReflectionReply(replyJson([{ kind: 'other' as 'memory', title: 't', nudge: 'n' }]))).toEqual([]);
226
+ expect(parseReflectionReply('[{"kind":"memory","title":"t"}]')).toEqual([]);
227
+ });
228
+
229
+ it('returns [] for a non-array and an empty array', () => {
230
+ expect(parseReflectionReply('{"kind":"memory"}')).toEqual([]);
231
+ expect(parseReflectionReply('[]')).toEqual([]);
232
+ });
233
+ });
234
+
235
+ // ── reflectorDefaultDef.reflect (the strategy) ──────────────────────────────
236
+
237
+ describe('reflectorDefaultDef.reflect', () => {
238
+ it('runs one provider pass and returns parsed proposals', async () => {
239
+ const provider = new FakeProvider({
240
+ name: 'fake',
241
+ script: [textReply(replyJson([{ kind: 'skill', title: 'deploy flow', nudge: 'capture it' }]))],
242
+ });
243
+ const svc = services({ providers: providersStub(provider) });
244
+ const out = await reflectorDefaultDef.reflect(reflectCtx(busyTurn(), svc));
245
+ expect(out).toEqual([{ kind: 'skill', title: 'deploy flow', nudge: 'capture it' }]);
246
+ expect(provider.received).toHaveLength(1);
247
+ expect(provider.received[0]!.system).toContain('review a single finished agent turn');
248
+ });
249
+
250
+ it('skips silently when there is no active provider', async () => {
251
+ const svc = services({ providers: providersStub(null) });
252
+ expect(await reflectorDefaultDef.reflect(reflectCtx(busyTurn(), svc))).toEqual([]);
253
+ });
254
+
255
+ it('skips silently when the providers service is absent', async () => {
256
+ const svc = services({});
257
+ expect(await reflectorDefaultDef.reflect(reflectCtx(busyTurn(), svc))).toEqual([]);
258
+ });
259
+
260
+ it('returns [] (never throws) on a provider error', async () => {
261
+ const errorScript: ReadonlyArray<ProviderEvent> = [
262
+ { type: 'message_start', model: 'fake' },
263
+ { type: 'error', message: 'upstream 500', retryable: false },
264
+ ];
265
+ const provider = new FakeProvider({ name: 'fake', script: [errorScript] });
266
+ const svc = services({ providers: providersStub(provider) });
267
+ expect(await reflectorDefaultDef.reflect(reflectCtx(busyTurn(), svc))).toEqual([]);
268
+ });
269
+
270
+ it('does not call the provider for an empty digest', async () => {
271
+ const provider = new FakeProvider({ name: 'fake', script: [] });
272
+ const svc = services({ providers: providersStub(provider) });
273
+ expect(await reflectorDefaultDef.reflect(reflectCtx([], svc))).toEqual([]);
274
+ expect(provider.received).toHaveLength(0);
275
+ });
276
+ });
277
+
278
+ // ── the driver (buildReflectorPlugin hooks) ─────────────────────────────────
279
+
280
+ function driverServices(provider: LLMProvider | null, def: ReflectorDef | null = reflectorDefaultDef): ServiceRegistry {
281
+ return services({ reflectors: reflectorsStub(def), providers: providersStub(provider) });
282
+ }
283
+
284
+ const goodReflection = (): FakeProvider =>
285
+ new FakeProvider({
286
+ name: 'fake',
287
+ script: [textReply(replyJson([{ kind: 'memory', title: 'uses pnpm', nudge: 'remember pnpm' }]))],
288
+ });
289
+
290
+ describe('reflector driver', () => {
291
+ it('does not reflect on a quiet turn (gate)', async () => {
292
+ const { plugin, internals } = buildReflectorPlugin();
293
+ const provider = goodReflection();
294
+ const e = makeEvents();
295
+ const quiet = [e.userPrompt('hi'), e.toolResult(true), e.assistantMsg('hello')];
296
+ await plugin.hooks!.onTurnEnd!(turnCtx(quiet, driverServices(provider)));
297
+ await internals.settle(sid);
298
+ expect(internals.budget(sid)).toBeUndefined();
299
+ expect(internals.pendingNudge(sid)).toBeNull();
300
+ expect(provider.received).toHaveLength(0);
301
+ });
302
+
303
+ it('reflects past the gate and stages a pending nudge', async () => {
304
+ const { plugin, internals } = buildReflectorPlugin();
305
+ const provider = goodReflection();
306
+ await plugin.hooks!.onTurnEnd!(turnCtx(busyTurn(), driverServices(provider)));
307
+ await internals.settle(sid);
308
+ expect(internals.budget(sid)?.count).toBe(1);
309
+ const nudge = internals.pendingNudge(sid);
310
+ expect(nudge).toContain('[reflection]');
311
+ expect(nudge).toContain('uses pnpm');
312
+ expect(nudge).toContain('memory_save');
313
+ });
314
+
315
+ it('enforces a one-reflection-per-session budget', async () => {
316
+ const { plugin, internals } = buildReflectorPlugin();
317
+ const provider = goodReflection();
318
+ await plugin.hooks!.onTurnEnd!(turnCtx(busyTurn(), driverServices(provider)));
319
+ await internals.settle(sid);
320
+ // A second busy turn must NOT fire a second reflection.
321
+ await plugin.hooks!.onTurnEnd!(turnCtx(busyTurn(), driverServices(provider)));
322
+ await internals.settle(sid);
323
+ expect(provider.received).toHaveLength(1);
324
+ expect(internals.budget(sid)?.count).toBe(1);
325
+ });
326
+
327
+ it('does not reflect (or spend budget) when no reflector is active', async () => {
328
+ const { plugin, internals } = buildReflectorPlugin();
329
+ const provider = goodReflection();
330
+ await plugin.hooks!.onTurnEnd!(turnCtx(busyTurn(), driverServices(provider, null)));
331
+ await internals.settle(sid);
332
+ expect(internals.budget(sid)).toBeUndefined();
333
+ expect(provider.received).toHaveLength(0);
334
+ });
335
+
336
+ it('injects the nudge exactly once on the next provider call, then clears it', async () => {
337
+ const { plugin, internals } = buildReflectorPlugin();
338
+ const provider = goodReflection();
339
+ const ctx = turnCtx(busyTurn(), driverServices(provider));
340
+ await plugin.hooks!.onTurnEnd!(ctx);
341
+ await internals.settle(sid);
342
+
343
+ const req: ProviderRequest = { model: 'm', system: 'BASE', messages: [] };
344
+ const first = (await plugin.hooks!.onBeforeProviderCall!(req, ctx)) as ProviderRequest | undefined;
345
+ expect(first?.system).toContain('BASE');
346
+ expect(first?.system).toContain('uses pnpm');
347
+ expect(internals.pendingNudge(sid)).toBeNull();
348
+
349
+ const second = await plugin.hooks!.onBeforeProviderCall!(req, ctx);
350
+ expect(second).toBeUndefined();
351
+ });
352
+
353
+ it('onTurnEnd returns without blocking on the reflection (fire-and-forget)', async () => {
354
+ const { plugin, internals } = buildReflectorPlugin();
355
+ const provider = goodReflection();
356
+ const ret = plugin.hooks!.onTurnEnd!(turnCtx(busyTurn(), driverServices(provider)));
357
+ // Resolves immediately; the reflection has NOT completed yet.
358
+ expect(internals.pendingNudge(sid)).toBeNull();
359
+ await ret; // onTurnEnd itself resolves fast
360
+ await internals.settle(sid); // now the detached reflection has finished
361
+ expect(internals.pendingNudge(sid)).not.toBeNull();
362
+ });
363
+
364
+ it('never throws into the turn when the reflector rejects', async () => {
365
+ const throwing: ReflectorDef = {
366
+ name: 'boom',
367
+ reflect: () => Promise.reject(new Error('reflector exploded')),
368
+ };
369
+ const { plugin, internals } = buildReflectorPlugin();
370
+ const svc = services({ reflectors: reflectorsStub(throwing), providers: providersStub(goodReflection()) });
371
+ // onTurnEnd is synchronous (fire-and-forget): it must not throw, and the
372
+ // rejected reflection must not surface after it settles.
373
+ expect(() => plugin.hooks!.onTurnEnd!(turnCtx(busyTurn(), svc))).not.toThrow();
374
+ await internals.settle(sid);
375
+ expect(internals.pendingNudge(sid)).toBeNull();
376
+ });
377
+
378
+ it('never throws into the turn when the log reader is hostile', async () => {
379
+ const { plugin } = buildReflectorPlugin();
380
+ const hostileLog = {
381
+ ...reader(busyTurn()),
382
+ byTurn: () => {
383
+ throw new Error('reader exploded');
384
+ },
385
+ } as EventLogReader;
386
+ const ctx: TurnContext = {
387
+ sessionId: sid,
388
+ turnId: tid,
389
+ cwd: '/tmp',
390
+ log: hostileLog,
391
+ env: {},
392
+ services: driverServices(goodReflection()),
393
+ iteration: 0,
394
+ };
395
+ // The synchronous gate swallows the hostile reader rather than throwing.
396
+ expect(() => plugin.hooks!.onTurnEnd!(ctx)).not.toThrow();
397
+ });
398
+
399
+ it('is silent when the provider errors (no nudge, no throw)', async () => {
400
+ const provider = new FakeProvider({
401
+ name: 'fake',
402
+ script: [[
403
+ { type: 'message_start', model: 'fake' },
404
+ { type: 'error', message: 'boom', retryable: false },
405
+ ]],
406
+ });
407
+ const { plugin, internals } = buildReflectorPlugin();
408
+ await plugin.hooks!.onTurnEnd!(turnCtx(busyTurn(), driverServices(provider)));
409
+ await internals.settle(sid);
410
+ expect(internals.pendingNudge(sid)).toBeNull();
411
+ // Budget is still spent — one attempt was made.
412
+ expect(internals.budget(sid)?.count).toBe(1);
413
+ });
414
+
415
+ it('clears per-session state on shutdown', async () => {
416
+ const { plugin, internals } = buildReflectorPlugin();
417
+ const ctx = turnCtx(busyTurn(), driverServices(goodReflection()));
418
+ await plugin.hooks!.onTurnEnd!(ctx);
419
+ await internals.settle(sid);
420
+ expect(internals.pendingNudge(sid)).not.toBeNull();
421
+ await plugin.hooks!.onShutdown!(ctx);
422
+ expect(internals.pendingNudge(sid)).toBeNull();
423
+ expect(internals.budget(sid)).toBeUndefined();
424
+ });
425
+ });
426
+
427
+ // ── integration through a real Session (registry + provider resolution) ─────
428
+
429
+ describe('reflector integration (createFakeSession)', () => {
430
+ let session: Session | undefined;
431
+ afterEach(async () => {
432
+ await session?.close();
433
+ session = undefined;
434
+ });
435
+
436
+ it('auto-adopts the default reflector and resolves it through the live registry', async () => {
437
+ const provider = new FakeProvider({
438
+ name: 'fake',
439
+ script: [textReply(replyJson([{ kind: 'skill', title: 'deploy runbook', nudge: 'capture the deploy steps' }]))],
440
+ });
441
+ const { plugin, internals } = buildReflectorPlugin();
442
+ session = createFakeSession({ provider, plugins: [plugin] });
443
+
444
+ // The reflector def registered + auto-adopted through the real registry.
445
+ expect(session.reflectors.getActiveName()).toBe('default');
446
+
447
+ // Drive the driver against the REAL session services (reflectors + providers).
448
+ const ctx = turnCtx(busyTurn(), session.services);
449
+ await plugin.hooks!.onTurnEnd!(ctx);
450
+ await internals.settle(sid);
451
+
452
+ const nudge = internals.pendingNudge(sid);
453
+ expect(nudge).toContain('deploy runbook');
454
+
455
+ const injected = (await plugin.hooks!.onBeforeProviderCall!(
456
+ { model: 'm', messages: [] },
457
+ ctx,
458
+ )) as ProviderRequest;
459
+ expect(injected.system).toContain('deploy runbook');
460
+ });
461
+ });