@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.
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/plugins/host-options.d.ts +2 -0
- package/dist/plugins/host-options.d.ts.map +1 -1
- package/dist/plugins/host.d.ts +8 -0
- package/dist/plugins/host.d.ts.map +1 -1
- package/dist/plugins/host.js +14 -0
- package/dist/plugins/host.js.map +1 -1
- package/dist/plugins/registry-kinds.d.ts +1 -0
- package/dist/plugins/registry-kinds.d.ts.map +1 -1
- package/dist/plugins/registry-kinds.js +11 -0
- package/dist/plugins/registry-kinds.js.map +1 -1
- package/dist/registries/modes.d.ts +8 -0
- package/dist/registries/modes.d.ts.map +1 -1
- package/dist/registries/modes.js +11 -0
- package/dist/registries/modes.js.map +1 -1
- package/dist/registries/reflectors.d.ts +15 -0
- package/dist/registries/reflectors.d.ts.map +1 -0
- package/dist/registries/reflectors.js +16 -0
- package/dist/registries/reflectors.js.map +1 -0
- package/dist/run-turn.d.ts.map +1 -1
- package/dist/run-turn.js +5 -0
- package/dist/run-turn.js.map +1 -1
- package/dist/session-runtime.d.ts +4 -0
- package/dist/session-runtime.d.ts.map +1 -1
- package/dist/session.d.ts +9 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +17 -0
- package/dist/session.js.map +1 -1
- package/dist/sessions/event-shape.d.ts +68 -0
- package/dist/sessions/event-shape.d.ts.map +1 -0
- package/dist/sessions/event-shape.js +171 -0
- package/dist/sessions/event-shape.js.map +1 -0
- package/dist/sessions/persistence.d.ts +7 -5
- package/dist/sessions/persistence.d.ts.map +1 -1
- package/dist/sessions/persistence.js +39 -13
- package/dist/sessions/persistence.js.map +1 -1
- package/dist/skill-usage.d.ts +62 -0
- package/dist/skill-usage.d.ts.map +1 -0
- package/dist/skill-usage.js +106 -0
- package/dist/skill-usage.js.map +1 -0
- package/dist/skills/synthesize.d.ts.map +1 -1
- package/dist/skills/synthesize.js +42 -0
- package/dist/skills/synthesize.js.map +1 -1
- package/dist/subagents/run-child.d.ts.map +1 -1
- package/dist/subagents/run-child.js +4 -0
- package/dist/subagents/run-child.js.map +1 -1
- package/package.json +4 -4
- package/src/index.ts +11 -0
- package/src/plugins/host-options.ts +2 -0
- package/src/plugins/host.ts +14 -0
- package/src/plugins/registry-kinds.test.ts +4 -0
- package/src/plugins/registry-kinds.ts +13 -0
- package/src/registries/modes.test.ts +27 -0
- package/src/registries/modes.ts +12 -0
- package/src/registries/reflectors.test.ts +50 -0
- package/src/registries/reflectors.ts +17 -0
- package/src/run-turn.ts +5 -0
- package/src/session-runtime.ts +4 -0
- package/src/session.ts +18 -0
- package/src/sessions/event-shape.test.ts +207 -0
- package/src/sessions/event-shape.ts +196 -0
- package/src/sessions/persistence.test.ts +132 -0
- package/src/sessions/persistence.ts +39 -13
- package/src/skill-usage.test.ts +102 -0
- package/src/skill-usage.ts +165 -0
- package/src/skills/synthesize.ts +42 -0
- 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
|
+
});
|
package/src/registries/modes.ts
CHANGED
|
@@ -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
|
|
package/src/session-runtime.ts
CHANGED
|
@@ -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
|
+
});
|