@moxxy/core 0.26.0 → 0.28.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.
Files changed (70) hide show
  1. package/dist/index.d.ts +4 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +4 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/plugins/host-options.d.ts +2 -0
  6. package/dist/plugins/host-options.d.ts.map +1 -1
  7. package/dist/plugins/host.d.ts +8 -0
  8. package/dist/plugins/host.d.ts.map +1 -1
  9. package/dist/plugins/host.js +14 -0
  10. package/dist/plugins/host.js.map +1 -1
  11. package/dist/plugins/registry-kinds.d.ts +1 -0
  12. package/dist/plugins/registry-kinds.d.ts.map +1 -1
  13. package/dist/plugins/registry-kinds.js +11 -0
  14. package/dist/plugins/registry-kinds.js.map +1 -1
  15. package/dist/registries/modes.d.ts +8 -0
  16. package/dist/registries/modes.d.ts.map +1 -1
  17. package/dist/registries/modes.js +11 -0
  18. package/dist/registries/modes.js.map +1 -1
  19. package/dist/registries/reflectors.d.ts +15 -0
  20. package/dist/registries/reflectors.d.ts.map +1 -0
  21. package/dist/registries/reflectors.js +16 -0
  22. package/dist/registries/reflectors.js.map +1 -0
  23. package/dist/run-turn.d.ts.map +1 -1
  24. package/dist/run-turn.js +5 -0
  25. package/dist/run-turn.js.map +1 -1
  26. package/dist/session-runtime.d.ts +4 -0
  27. package/dist/session-runtime.d.ts.map +1 -1
  28. package/dist/session.d.ts +9 -1
  29. package/dist/session.d.ts.map +1 -1
  30. package/dist/session.js +17 -0
  31. package/dist/session.js.map +1 -1
  32. package/dist/sessions/event-shape.d.ts +68 -0
  33. package/dist/sessions/event-shape.d.ts.map +1 -0
  34. package/dist/sessions/event-shape.js +171 -0
  35. package/dist/sessions/event-shape.js.map +1 -0
  36. package/dist/sessions/persistence.d.ts +7 -5
  37. package/dist/sessions/persistence.d.ts.map +1 -1
  38. package/dist/sessions/persistence.js +39 -13
  39. package/dist/sessions/persistence.js.map +1 -1
  40. package/dist/skill-usage.d.ts +62 -0
  41. package/dist/skill-usage.d.ts.map +1 -0
  42. package/dist/skill-usage.js +106 -0
  43. package/dist/skill-usage.js.map +1 -0
  44. package/dist/skills/synthesize.d.ts.map +1 -1
  45. package/dist/skills/synthesize.js +42 -0
  46. package/dist/skills/synthesize.js.map +1 -1
  47. package/dist/subagents/run-child.d.ts.map +1 -1
  48. package/dist/subagents/run-child.js +4 -0
  49. package/dist/subagents/run-child.js.map +1 -1
  50. package/package.json +4 -4
  51. package/src/index.ts +11 -0
  52. package/src/plugins/host-options.ts +2 -0
  53. package/src/plugins/host.ts +14 -0
  54. package/src/plugins/registry-kinds.test.ts +4 -0
  55. package/src/plugins/registry-kinds.ts +13 -0
  56. package/src/registries/modes.test.ts +27 -0
  57. package/src/registries/modes.ts +12 -0
  58. package/src/registries/reflectors.test.ts +50 -0
  59. package/src/registries/reflectors.ts +17 -0
  60. package/src/run-turn.ts +5 -0
  61. package/src/session-runtime.ts +4 -0
  62. package/src/session.ts +18 -0
  63. package/src/sessions/event-shape.test.ts +207 -0
  64. package/src/sessions/event-shape.ts +196 -0
  65. package/src/sessions/persistence.test.ts +132 -0
  66. package/src/sessions/persistence.ts +39 -13
  67. package/src/skill-usage.test.ts +102 -0
  68. package/src/skill-usage.ts +165 -0
  69. package/src/skills/synthesize.ts +42 -0
  70. package/src/subagents/run-child.ts +4 -0
@@ -37,6 +37,7 @@ import { EmbedderRegistry } from '../registries/embedders.js';
37
37
  import { IsolatorRegistry } from '../registries/isolators.js';
38
38
  import { WorkflowExecutorRegistry } from '../registries/workflow-executors.js';
39
39
  import { EventStoreRegistry } from '../registries/event-stores.js';
40
+ import { ReflectorRegistry } from '../registries/reflectors.js';
40
41
  import { RequirementRegistry } from '../requirements.js';
41
42
  import { HookDispatcherImpl } from './lifecycle.js';
42
43
  import { PluginHost } from './host.js';
@@ -87,6 +88,7 @@ const everyKindPlugin = definePlugin({
87
88
  readPage: async () => ({ events: [], prevCursor: null }),
88
89
  },
89
90
  ],
91
+ reflectors: [{ name: 'refl-1', reflect: async () => [] }],
90
92
  });
91
93
 
92
94
  function makeHost() {
@@ -108,6 +110,7 @@ function makeHost() {
108
110
  isolators: new IsolatorRegistry(),
109
111
  workflowExecutors: new WorkflowExecutorRegistry(),
110
112
  eventStores: new EventStoreRegistry(),
113
+ reflectors: new ReflectorRegistry(),
111
114
  };
112
115
  const requirements = new RequirementRegistry({
113
116
  tools: registries.tools,
@@ -147,6 +150,7 @@ function makeHost() {
147
150
  isolator: registries.isolators,
148
151
  workflowExecutor: registries.workflowExecutors,
149
152
  eventStore: registries.eventStores,
153
+ reflector: registries.reflectors,
150
154
  };
151
155
  return { host, byKind };
152
156
  }
@@ -17,6 +17,7 @@ import type {
17
17
  TunnelProviderDef,
18
18
  WorkflowExecutorDef,
19
19
  EventStoreDef,
20
+ ReflectorDef,
20
21
  } from '@moxxy/sdk';
21
22
  import type { PluginHostOptions } from './host-options.js';
22
23
 
@@ -44,6 +45,7 @@ export interface RegistryNameRecord {
44
45
  readonly isolatorNames: ReadonlyArray<string>;
45
46
  readonly workflowExecutorNames: ReadonlyArray<string>;
46
47
  readonly eventStoreNames: ReadonlyArray<string>;
48
+ readonly reflectorNames: ReadonlyArray<string>;
47
49
  }
48
50
 
49
51
  /**
@@ -243,4 +245,15 @@ export const REGISTRY_KINDS: ReadonlyArray<RegistryKind<unknown>> = [
243
245
  register: (o, e: EventStoreDef) => o.eventStores.register(e),
244
246
  unregister: (o, n) => o.eventStores.unregister(n),
245
247
  } satisfies RegistryKind<EventStoreDef>,
248
+ {
249
+ kind: 'reflector',
250
+ recordField: 'reflectorNames',
251
+ defs: (p) => p.reflectors ?? [],
252
+ nameOf: (r: ReflectorDef) => r.name,
253
+ // Throw-on-duplicate `register` (NOT `replace`): a discovered reflector is
254
+ // added but auto-adopts only when nothing is active yet (the registry is
255
+ // nullable — no core floor). The user swaps via `plugins.reflector.default`.
256
+ register: (o, r: ReflectorDef) => o.reflectors.register(r),
257
+ unregister: (o, n) => o.reflectors.unregister(n),
258
+ } satisfies RegistryKind<ReflectorDef>,
246
259
  ] as ReadonlyArray<RegistryKind<unknown>>;
@@ -84,3 +84,30 @@ describe('ModeRegistry legacy-name migration', () => {
84
84
  expect(after).toHaveBeenCalledTimes(1);
85
85
  });
86
86
  });
87
+
88
+ describe('ModeRegistry previous-mode tracking', () => {
89
+ it('records the previously active mode across switches', () => {
90
+ const reg = new ModeRegistry();
91
+ reg.register(mode('default')); // auto-active, no previous
92
+ expect(reg.getPreviousActiveName()).toBeNull();
93
+
94
+ reg.register(mode('goal'));
95
+ reg.setActive('goal');
96
+ expect(reg.getPreviousActiveName()).toBe('default');
97
+
98
+ // Reverting (a transient mode handing back) updates previous too.
99
+ reg.setActive('default');
100
+ expect(reg.getPreviousActiveName()).toBe('goal');
101
+ });
102
+
103
+ it('a same-name setActive does not clobber the previous mode', () => {
104
+ const reg = new ModeRegistry();
105
+ reg.register(mode('default'));
106
+ reg.register(mode('goal'));
107
+ reg.setActive('goal');
108
+ // Re-arming goal (e.g. /goal twice in a row) keeps 'default' as the mode
109
+ // to hand back to — activate() early-returns on the name match.
110
+ reg.setActive('goal');
111
+ expect(reg.getPreviousActiveName()).toBe('default');
112
+ });
113
+ });
@@ -3,6 +3,7 @@ import { migrateModeName, type ModeDef } from '@moxxy/sdk';
3
3
  export class ModeRegistry {
4
4
  private readonly modes = new Map<string, ModeDef>();
5
5
  private active: string | null = null;
6
+ private previous: string | null = null;
6
7
  private readonly changeListeners = new Set<() => void>();
7
8
 
8
9
  /** Observe active-mode changes — used by the runner to broadcast
@@ -67,6 +68,16 @@ export class ModeRegistry {
67
68
  return this.active;
68
69
  }
69
70
 
71
+ /**
72
+ * Name of the mode that was active before the current one, or null when
73
+ * there is no history (session boot). Lets a transient mode (goal) revert
74
+ * to whatever the user was in before arming it — carried onto the
75
+ * ModeContext as `previousModeName` by run-turn.
76
+ */
77
+ getPreviousActiveName(): string | null {
78
+ return this.previous;
79
+ }
80
+
70
81
  setActive(name: string): void {
71
82
  // Prefer the literal name; only when it isn't registered fall back to the
72
83
  // legacy-name map (e.g. a persisted "tool-use" → "default"). This never
@@ -86,6 +97,7 @@ export class ModeRegistry {
86
97
 
87
98
  private activate(mode: ModeDef): void {
88
99
  if (this.active === mode.name) return;
100
+ this.previous = this.active;
89
101
  this.active = mode.name;
90
102
  this.notifyChange();
91
103
  }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { ReflectorDef } from '@moxxy/sdk';
3
+ import { Session, autoAllowResolver, silentLogger } from '../index.js';
4
+
5
+ function makeSession(): Session {
6
+ return new Session({ cwd: '/tmp', logger: silentLogger, permissionResolver: autoAllowResolver });
7
+ }
8
+
9
+ const def = (name: string): ReflectorDef => ({ name, reflect: async () => [] });
10
+
11
+ describe('Reflector registry (nullable, no floor)', () => {
12
+ it('starts empty — no core-seeded floor, reflection is off by default', () => {
13
+ const session = makeSession();
14
+ expect(session.reflectors.getActiveName()).toBeNull();
15
+ expect(session.reflectors.getFloorName()).toBeNull();
16
+ expect(session.reflectors.list()).toEqual([]);
17
+ expect(session.reflectors.getActive()).toBeNull();
18
+ });
19
+
20
+ it('publishes the registry on the service registry for the driver to resolve', () => {
21
+ const session = makeSession();
22
+ expect(session.services.get('reflectors')).toBe(session.reflectors);
23
+ });
24
+
25
+ it('auto-adopts the first registered reflector as active', () => {
26
+ const session = makeSession();
27
+ session.reflectors.register(def('default'));
28
+ expect(session.reflectors.getActiveName()).toBe('default');
29
+ expect(session.reflectors.getActive()?.name).toBe('default');
30
+ });
31
+
32
+ it('throws on a duplicate name and swaps via setActive', () => {
33
+ const session = makeSession();
34
+ session.reflectors.register(def('default'));
35
+ expect(() => session.reflectors.register(def('default'))).toThrow(/already registered/);
36
+ session.reflectors.register(def('smart'));
37
+ // Second registration does NOT steal active from the first.
38
+ expect(session.reflectors.getActiveName()).toBe('default');
39
+ session.reflectors.setActive('smart');
40
+ expect(session.reflectors.getActiveName()).toBe('smart');
41
+ });
42
+
43
+ it('reverts to null (no floor) when the active reflector is unregistered', () => {
44
+ const session = makeSession();
45
+ session.reflectors.register(def('default'));
46
+ session.reflectors.unregister('default');
47
+ expect(session.reflectors.getActiveName()).toBeNull();
48
+ expect(session.reflectors.getActive()).toBeNull();
49
+ });
50
+ });
@@ -0,0 +1,17 @@
1
+ import type { ReflectorDef } from '@moxxy/sdk';
2
+ import { ActiveDefRegistry } from './active-def-registry.js';
3
+
4
+ /**
5
+ * The single-active Reflector registry (the learning-loop block that watches a
6
+ * finished turn and proposes memory/skill improvements). NULLABLE by design:
7
+ * unlike the event-store/compactor registries there is NO core-seeded protected
8
+ * floor, so `getActive()` returns null until a plugin registers one — reflection
9
+ * is opt-in exactly like transcriber/synthesizer. `autoAdoptFirst` (the default)
10
+ * means the first registered reflector becomes active, and `unregister` reverts
11
+ * to null (no floor to fall back to). Uses throw-on-duplicate `register`.
12
+ */
13
+ export class ReflectorRegistry extends ActiveDefRegistry<ReflectorDef> {
14
+ constructor() {
15
+ super({ noun: 'Reflector', autoAdoptFirst: true });
16
+ }
17
+ }
package/src/run-turn.ts CHANGED
@@ -94,6 +94,7 @@ export async function* runTurn(
94
94
  session.lastResolvedModel = model;
95
95
 
96
96
  const strategy = session.modes.getActive();
97
+ const previousModeName = session.modes.getPreviousActiveName();
97
98
  // Combine the session's signal, the per-turn one (if provided), and the
98
99
  // generator-scoped abandonment signal so any of them firing cancels the turn.
99
100
  const effectiveSignal = AbortSignal.any(
@@ -140,6 +141,10 @@ export async function* runTurn(
140
141
  requestModeSwitch: (modeName: string) => {
141
142
  requestedModeSwitch = modeName;
142
143
  },
144
+ // Only surface a previous mode that is STILL registered — a transient
145
+ // mode reverting to an since-unregistered name would throw in the
146
+ // post-turn setActive and strand the session in the transient mode.
147
+ ...(previousModeName && session.modes.has(previousModeName) ? { previousModeName } : {}),
143
148
  emit: (event: EmittedEvent) => session.log.append(event),
144
149
  };
145
150
 
@@ -48,9 +48,13 @@ export interface SessionRuntime {
48
48
  readonly modes: {
49
49
  getActive(): ModeDef;
50
50
  list(): ReadonlyArray<ModeDef>;
51
+ has(name: string): boolean;
51
52
  /** Post-turn mode hand-off (one mode handing back to another). Throws on an
52
53
  * unknown name; run-turn guards the call. */
53
54
  setActive(name: string): void;
55
+ /** Mode active before the current one (or null) — surfaced onto the
56
+ * ModeContext so a transient mode (goal) can revert to it. */
57
+ getPreviousActiveName(): string | null;
54
58
  };
55
59
  readonly compactors: { getActive(): CompactorDef | null };
56
60
  readonly cacheStrategies: { getActive(): CacheStrategyDef | null };
package/src/session.ts CHANGED
@@ -35,6 +35,7 @@ import { EmbedderRegistry } from './registries/embedders.js';
35
35
  import { IsolatorRegistry } from './registries/isolators.js';
36
36
  import { WorkflowExecutorRegistry } from './registries/workflow-executors.js';
37
37
  import { EventStoreRegistry } from './registries/event-stores.js';
38
+ import { ReflectorRegistry } from './registries/reflectors.js';
38
39
  import { ServiceRegistryImpl } from './registries/services.js';
39
40
  import { jsonlEventStore } from './sessions/jsonl-event-store.js';
40
41
  import { RequirementRegistry } from './requirements.js';
@@ -50,6 +51,7 @@ import type {
50
51
  WorkflowsView,
51
52
  PluginsAdminView,
52
53
  ProviderSetupView,
54
+ SessionLike,
53
55
  PendingToolCall,
54
56
  PermissionContext,
55
57
  PermissionDecision,
@@ -136,6 +138,12 @@ export class Session implements ClientSession, SessionRuntime {
136
138
  readonly isolators: IsolatorRegistry;
137
139
  readonly workflowExecutors: WorkflowExecutorRegistry;
138
140
  readonly eventStores: EventStoreRegistry;
141
+ /**
142
+ * The learning-loop block: watches finished turns and proposes memory/skill
143
+ * improvements. NULLABLE — no core-seeded floor, so it stays empty until a
144
+ * reflector plugin (e.g. @moxxy/reflector-default) registers one.
145
+ */
146
+ readonly reflectors: ReflectorRegistry;
139
147
  /** Inter-plugin service registry — plugins publish/consume services in onInit. */
140
148
  readonly services: ServiceRegistryImpl;
141
149
  readonly requirements: RequirementRegistry;
@@ -186,6 +194,7 @@ export class Session implements ClientSession, SessionRuntime {
186
194
  workflows?: WorkflowsView;
187
195
  pluginsAdmin?: PluginsAdminView;
188
196
  providerSetup?: ProviderSetupView;
197
+ configAdmin?: SessionLike['configAdmin'];
189
198
  readonly dispatcher: HookDispatcherImpl;
190
199
  readonly pluginHost: PluginHost;
191
200
  private readonly controller = new AbortController();
@@ -241,6 +250,8 @@ export class Session implements ClientSession, SessionRuntime {
241
250
  this.services.register('synthesizers', this.synthesizers);
242
251
  this.services.register('skills', this.skills);
243
252
  this.services.register('tunnelProviders', this.tunnelProviders);
253
+ // The memory plugin (discovery-loadable) resolves its lazy embedder here.
254
+ this.services.register('embedders', this.embedders);
244
255
  // A stable accessor for the active provider's stored credentials. The
245
256
  // resolver itself is installed late (by activateProvider, after plugins are
246
257
  // built), so close over `this` and read it lazily at call time — lets
@@ -270,6 +281,12 @@ export class Session implements ClientSession, SessionRuntime {
270
281
  // Seed the built-in JSONL store as the protected floor — the storage backend
271
282
  // behind the event log always exists and can be swapped but never removed.
272
283
  this.eventStores.register(jsonlEventStore, { protected: true });
284
+ // Reflectors are nullable — no core floor is seeded, so reflection stays off
285
+ // until a reflector plugin registers one. Published on the service registry
286
+ // so a discovery-loaded driver (the reflector plugin's own hooks) can resolve
287
+ // the active reflector in onTurnEnd without importing @moxxy/core.
288
+ this.reflectors = new ReflectorRegistry();
289
+ this.services.register('reflectors', this.reflectors);
273
290
  this.requirements = new RequirementRegistry({
274
291
  tools: this.tools,
275
292
  providers: this.providers,
@@ -317,6 +334,7 @@ export class Session implements ClientSession, SessionRuntime {
317
334
  isolators: this.isolators,
318
335
  workflowExecutors: this.workflowExecutors,
319
336
  eventStores: this.eventStores,
337
+ reflectors: this.reflectors,
320
338
  requirements: this.requirements,
321
339
  dispatcher: this.dispatcher,
322
340
  loader: opts.pluginLoader,
@@ -0,0 +1,207 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { MoxxyEventOfType, MoxxyEventType } from '@moxxy/sdk';
3
+ import { isMoxxyEventShape } from './event-shape.js';
4
+
5
+ /** Shared valid envelope for building variant samples. */
6
+ const base = {
7
+ id: 'e1' as never,
8
+ seq: 0,
9
+ ts: 1,
10
+ sessionId: 's1' as never,
11
+ turnId: 't1' as never,
12
+ source: 'user' as const,
13
+ };
14
+
15
+ /**
16
+ * One valid sample of EVERY variant, typed as an exhaustive record over
17
+ * `MoxxyEventType` — adding a variant to the sdk union fails this file's
18
+ * typecheck until a sample (and thus guard coverage) is added, and a sample
19
+ * that drifts from its variant's shape fails to compile.
20
+ */
21
+ const samples: { readonly [T in MoxxyEventType]: MoxxyEventOfType<T> } = {
22
+ user_prompt: { ...base, type: 'user_prompt', text: 'hello' },
23
+ assistant_chunk: { ...base, type: 'assistant_chunk', delta: 'hi ' },
24
+ assistant_message: {
25
+ ...base,
26
+ type: 'assistant_message',
27
+ content: 'hi there',
28
+ stopReason: 'end_turn',
29
+ },
30
+ reasoning_chunk: { ...base, type: 'reasoning_chunk', delta: 'thinking…' },
31
+ reasoning_message: { ...base, type: 'reasoning_message', content: 'thought about it' },
32
+ tool_call_requested: {
33
+ ...base,
34
+ type: 'tool_call_requested',
35
+ callId: 'c1' as never,
36
+ name: 'read_file',
37
+ input: { path: '/tmp/x' },
38
+ },
39
+ tool_call_approved: {
40
+ ...base,
41
+ type: 'tool_call_approved',
42
+ callId: 'c1' as never,
43
+ decidedBy: 'policy',
44
+ mode: 'allow',
45
+ },
46
+ tool_call_denied: {
47
+ ...base,
48
+ type: 'tool_call_denied',
49
+ callId: 'c1' as never,
50
+ decidedBy: 'resolver',
51
+ reason: 'denied by user',
52
+ },
53
+ tool_result: { ...base, type: 'tool_result', callId: 'c1' as never, ok: true, output: 'done' },
54
+ skill_invoked: {
55
+ ...base,
56
+ type: 'skill_invoked',
57
+ skillId: 'sk1' as never,
58
+ name: 'add-a-tool',
59
+ reason: 'manual',
60
+ },
61
+ skill_created: {
62
+ ...base,
63
+ type: 'skill_created',
64
+ skillId: 'sk1' as never,
65
+ name: 'new-skill',
66
+ path: '/tmp/skill.md',
67
+ scope: 'user',
68
+ originatingPrompt: 'make a skill',
69
+ },
70
+ plugin_registered: {
71
+ ...base,
72
+ type: 'plugin_registered',
73
+ pluginId: 'p1' as never,
74
+ name: '@moxxy/plugin-x',
75
+ version: '1.0.0',
76
+ kind: ['tools'],
77
+ },
78
+ plugin_unregistered: {
79
+ ...base,
80
+ type: 'plugin_unregistered',
81
+ pluginId: 'p1' as never,
82
+ name: '@moxxy/plugin-x',
83
+ reason: 'reload',
84
+ },
85
+ mode_iteration: { ...base, type: 'mode_iteration', strategy: 'default', iteration: 2 },
86
+ compaction: {
87
+ ...base,
88
+ type: 'compaction',
89
+ compactor: 'summarizer',
90
+ replacedRange: [0, 9],
91
+ summary: 'earlier chatter',
92
+ tokensSaved: 1234,
93
+ },
94
+ elision: {
95
+ ...base,
96
+ type: 'elision',
97
+ elidedThrough: 40,
98
+ stubbedRanges: [[0, 40]],
99
+ elideConversational: false,
100
+ conversationalRecallThreshold: 3,
101
+ maxRecallBytes: 65536,
102
+ neverElideTools: ['recall'],
103
+ tokensSaved: 5678,
104
+ },
105
+ provider_request: { ...base, type: 'provider_request', provider: 'anthropic', model: 'claude' },
106
+ provider_response: {
107
+ ...base,
108
+ type: 'provider_response',
109
+ provider: 'anthropic',
110
+ model: 'claude',
111
+ outputTokens: 10,
112
+ },
113
+ error: { ...base, type: 'error', kind: 'retryable', message: 'rate limited' },
114
+ abort: { ...base, type: 'abort', reason: 'user pressed esc' },
115
+ plugin_event: {
116
+ ...base,
117
+ type: 'plugin_event',
118
+ pluginId: 'p1' as never,
119
+ subtype: 'frame',
120
+ payload: { n: 1 },
121
+ },
122
+ };
123
+
124
+ /** Round-trip through JSON, faithful to how the real read path sees a line. */
125
+ function throughJson(value: unknown): unknown {
126
+ return JSON.parse(JSON.stringify(value)) as unknown;
127
+ }
128
+
129
+ describe('isMoxxyEventShape', () => {
130
+ it('accepts a valid sample of every event variant (JSON round-tripped)', () => {
131
+ for (const [type, sample] of Object.entries(samples)) {
132
+ expect(isMoxxyEventShape(throughJson(sample)), `variant ${type}`).toBe(true);
133
+ }
134
+ });
135
+
136
+ it('rejects non-object values and objects missing the envelope', () => {
137
+ expect(isMoxxyEventShape(null)).toBe(false);
138
+ expect(isMoxxyEventShape(undefined)).toBe(false);
139
+ expect(isMoxxyEventShape('user_prompt')).toBe(false);
140
+ expect(isMoxxyEventShape(42)).toBe(false);
141
+ expect(isMoxxyEventShape([samples.user_prompt])).toBe(false);
142
+ expect(isMoxxyEventShape({})).toBe(false);
143
+ expect(isMoxxyEventShape({ foo: 'bar' })).toBe(false);
144
+ // Each strict envelope field, individually missing or mistyped.
145
+ const { id: _id, ...noId } = samples.user_prompt;
146
+ expect(isMoxxyEventShape(noId)).toBe(false);
147
+ const { seq: _seq, ...noSeq } = samples.user_prompt;
148
+ expect(isMoxxyEventShape(noSeq)).toBe(false);
149
+ const { ts: _ts, ...noTs } = samples.user_prompt;
150
+ expect(isMoxxyEventShape(noTs)).toBe(false);
151
+ const { type: _type, ...noType } = samples.user_prompt;
152
+ expect(isMoxxyEventShape(noType)).toBe(false);
153
+ expect(isMoxxyEventShape({ ...samples.user_prompt, seq: '0' })).toBe(false);
154
+ expect(isMoxxyEventShape({ ...samples.user_prompt, id: 7 })).toBe(false);
155
+ });
156
+
157
+ it('rejects non-finite numbers (JSON.parse CAN produce them: 1e999 → Infinity)', () => {
158
+ const line = JSON.stringify({ ...samples.user_prompt, seq: 0 }).replace('"seq":0', '"seq":1e999');
159
+ const parsed: unknown = JSON.parse(line);
160
+ expect((parsed as { seq: number }).seq).toBe(Number.POSITIVE_INFINITY);
161
+ expect(isMoxxyEventShape(parsed)).toBe(false);
162
+ });
163
+
164
+ it('tolerates absent sessionId/turnId/source (legacy logs) but rejects present-wrong-type', () => {
165
+ const { sessionId: _s, turnId: _t, source: _src, ...bare } = samples.user_prompt;
166
+ expect(isMoxxyEventShape(bare)).toBe(true);
167
+ expect(isMoxxyEventShape({ ...bare, sessionId: null })).toBe(true);
168
+ expect(isMoxxyEventShape({ ...samples.user_prompt, sessionId: 42 })).toBe(false);
169
+ expect(isMoxxyEventShape({ ...samples.user_prompt, turnId: {} })).toBe(false);
170
+ expect(isMoxxyEventShape({ ...samples.user_prompt, source: 9 })).toBe(false);
171
+ });
172
+
173
+ it('keeps an unknown (newer) event type with a valid envelope — floor-rollback forward-compat', () => {
174
+ expect(isMoxxyEventShape({ ...base, type: 'surface_frame_from_the_future', blob: 'x' })).toBe(
175
+ true,
176
+ );
177
+ });
178
+
179
+ it('keeps a known variant carrying an unknown enum member — forward-compat on enum drift', () => {
180
+ expect(
181
+ isMoxxyEventShape({ ...samples.assistant_message, stopReason: 'brand_new_reason' }),
182
+ ).toBe(true);
183
+ });
184
+
185
+ it('rejects known variants missing or mistyping their required fields', () => {
186
+ const { text: _text, ...promptNoText } = samples.user_prompt;
187
+ expect(isMoxxyEventShape(promptNoText)).toBe(false);
188
+ expect(isMoxxyEventShape({ ...samples.user_prompt, text: 42 })).toBe(false);
189
+ expect(isMoxxyEventShape({ ...samples.tool_result, ok: 'yes' })).toBe(false);
190
+ const { replacedRange: _rr, ...compactionNoRange } = samples.compaction;
191
+ expect(isMoxxyEventShape(compactionNoRange)).toBe(false);
192
+ expect(isMoxxyEventShape({ ...samples.compaction, replacedRange: ['0', '9'] })).toBe(false);
193
+ expect(isMoxxyEventShape({ ...samples.compaction, replacedRange: [0] })).toBe(false);
194
+ expect(isMoxxyEventShape({ ...samples.compaction, summary: null })).toBe(false);
195
+ const { maxRecallBytes: _mrb, ...elisionSlim } = samples.elision;
196
+ expect(isMoxxyEventShape(elisionSlim)).toBe(false);
197
+ expect(isMoxxyEventShape({ ...samples.mode_iteration, iteration: 'two' })).toBe(false);
198
+ expect(isMoxxyEventShape({ ...samples.plugin_registered, kind: 'tools' })).toBe(false);
199
+ });
200
+
201
+ it('tolerates a tool_call_requested whose input key was dropped by JSON.stringify', () => {
202
+ // `input: undefined` at emit time serializes to a line with NO input key —
203
+ // a presence check would drop a legitimate event.
204
+ const line = { ...samples.tool_call_requested, input: undefined };
205
+ expect(isMoxxyEventShape(throughJson(line))).toBe(true);
206
+ });
207
+ });