@moxxy/core 0.3.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/LICENSE +21 -0
- package/README.md +125 -0
- package/dist/events/factory.d.ts +6 -0
- package/dist/events/factory.d.ts.map +1 -0
- package/dist/events/factory.js +14 -0
- package/dist/events/factory.js.map +1 -0
- package/dist/events/log.d.ts +92 -0
- package/dist/events/log.d.ts.map +1 -0
- package/dist/events/log.js +244 -0
- package/dist/events/log.js.map +1 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +41 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +16 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +27 -0
- package/dist/logger.js.map +1 -0
- package/dist/permissions/engine.d.ts +94 -0
- package/dist/permissions/engine.d.ts.map +1 -0
- package/dist/permissions/engine.js +203 -0
- package/dist/permissions/engine.js.map +1 -0
- package/dist/permissions/resolvers.d.ts +8 -0
- package/dist/permissions/resolvers.d.ts.map +1 -0
- package/dist/permissions/resolvers.js +8 -0
- package/dist/permissions/resolvers.js.map +1 -0
- package/dist/plugins/discovery.d.ts +9 -0
- package/dist/plugins/discovery.d.ts.map +1 -0
- package/dist/plugins/discovery.js +112 -0
- package/dist/plugins/discovery.js.map +1 -0
- package/dist/plugins/host-options.d.ts +72 -0
- package/dist/plugins/host-options.d.ts.map +1 -0
- package/dist/plugins/host-options.js +2 -0
- package/dist/plugins/host-options.js.map +1 -0
- package/dist/plugins/host.d.ts +53 -0
- package/dist/plugins/host.d.ts.map +1 -0
- package/dist/plugins/host.js +269 -0
- package/dist/plugins/host.js.map +1 -0
- package/dist/plugins/lifecycle.d.ts +31 -0
- package/dist/plugins/lifecycle.d.ts.map +1 -0
- package/dist/plugins/lifecycle.js +100 -0
- package/dist/plugins/lifecycle.js.map +1 -0
- package/dist/plugins/loader.d.ts +7 -0
- package/dist/plugins/loader.d.ts.map +1 -0
- package/dist/plugins/loader.js +68 -0
- package/dist/plugins/loader.js.map +1 -0
- package/dist/plugins/package-requirements.d.ts +16 -0
- package/dist/plugins/package-requirements.d.ts.map +1 -0
- package/dist/plugins/package-requirements.js +52 -0
- package/dist/plugins/package-requirements.js.map +1 -0
- package/dist/plugins/registry-kinds.d.ts +58 -0
- package/dist/plugins/registry-kinds.d.ts.map +1 -0
- package/dist/plugins/registry-kinds.js +139 -0
- package/dist/plugins/registry-kinds.js.map +1 -0
- package/dist/plugins/toposort.d.ts +24 -0
- package/dist/plugins/toposort.d.ts.map +1 -0
- package/dist/plugins/toposort.js +67 -0
- package/dist/plugins/toposort.js.map +1 -0
- package/dist/preferences.d.ts +40 -0
- package/dist/preferences.d.ts.map +1 -0
- package/dist/preferences.js +58 -0
- package/dist/preferences.js.map +1 -0
- package/dist/registries/active-backend-registry.d.ts +65 -0
- package/dist/registries/active-backend-registry.d.ts.map +1 -0
- package/dist/registries/active-backend-registry.js +108 -0
- package/dist/registries/active-backend-registry.js.map +1 -0
- package/dist/registries/active-def-registry.d.ts +59 -0
- package/dist/registries/active-def-registry.d.ts.map +1 -0
- package/dist/registries/active-def-registry.js +63 -0
- package/dist/registries/active-def-registry.js.map +1 -0
- package/dist/registries/agents.d.ts +16 -0
- package/dist/registries/agents.d.ts.map +1 -0
- package/dist/registries/agents.js +17 -0
- package/dist/registries/agents.js.map +1 -0
- package/dist/registries/cache-strategies.d.ts +12 -0
- package/dist/registries/cache-strategies.d.ts.map +1 -0
- package/dist/registries/cache-strategies.js +13 -0
- package/dist/registries/cache-strategies.js.map +1 -0
- package/dist/registries/channels.d.ts +16 -0
- package/dist/registries/channels.d.ts.map +1 -0
- package/dist/registries/channels.js +35 -0
- package/dist/registries/channels.js.map +1 -0
- package/dist/registries/commands.d.ts +29 -0
- package/dist/registries/commands.d.ts.map +1 -0
- package/dist/registries/commands.js +72 -0
- package/dist/registries/commands.js.map +1 -0
- package/dist/registries/compactors.d.ts +12 -0
- package/dist/registries/compactors.d.ts.map +1 -0
- package/dist/registries/compactors.js +13 -0
- package/dist/registries/compactors.js.map +1 -0
- package/dist/registries/def-map-registry.d.ts +33 -0
- package/dist/registries/def-map-registry.d.ts.map +1 -0
- package/dist/registries/def-map-registry.js +36 -0
- package/dist/registries/def-map-registry.js.map +1 -0
- package/dist/registries/embedders.d.ts +16 -0
- package/dist/registries/embedders.d.ts.map +1 -0
- package/dist/registries/embedders.js +17 -0
- package/dist/registries/embedders.js.map +1 -0
- package/dist/registries/isolators.d.ts +21 -0
- package/dist/registries/isolators.d.ts.map +1 -0
- package/dist/registries/isolators.js +32 -0
- package/dist/registries/isolators.js.map +1 -0
- package/dist/registries/modes.d.ts +28 -0
- package/dist/registries/modes.d.ts.map +1 -0
- package/dist/registries/modes.js +81 -0
- package/dist/registries/modes.js.map +1 -0
- package/dist/registries/providers.d.ts +44 -0
- package/dist/registries/providers.d.ts.map +1 -0
- package/dist/registries/providers.js +95 -0
- package/dist/registries/providers.js.map +1 -0
- package/dist/registries/skills.d.ts +24 -0
- package/dist/registries/skills.d.ts.map +1 -0
- package/dist/registries/skills.js +81 -0
- package/dist/registries/skills.js.map +1 -0
- package/dist/registries/surfaces.d.ts +13 -0
- package/dist/registries/surfaces.d.ts.map +1 -0
- package/dist/registries/surfaces.js +14 -0
- package/dist/registries/surfaces.js.map +1 -0
- package/dist/registries/synthesizers.d.ts +26 -0
- package/dist/registries/synthesizers.d.ts.map +1 -0
- package/dist/registries/synthesizers.js +34 -0
- package/dist/registries/synthesizers.js.map +1 -0
- package/dist/registries/tools.d.ts +49 -0
- package/dist/registries/tools.d.ts.map +1 -0
- package/dist/registries/tools.js +98 -0
- package/dist/registries/tools.js.map +1 -0
- package/dist/registries/transcribers.d.ts +17 -0
- package/dist/registries/transcribers.d.ts.map +1 -0
- package/dist/registries/transcribers.js +18 -0
- package/dist/registries/transcribers.js.map +1 -0
- package/dist/registries/tunnel-providers.d.ts +12 -0
- package/dist/registries/tunnel-providers.d.ts.map +1 -0
- package/dist/registries/tunnel-providers.js +13 -0
- package/dist/registries/tunnel-providers.js.map +1 -0
- package/dist/registries/view-renderers.d.ts +13 -0
- package/dist/registries/view-renderers.d.ts.map +1 -0
- package/dist/registries/view-renderers.js +14 -0
- package/dist/registries/view-renderers.js.map +1 -0
- package/dist/registries/workflow-executors.d.ts +13 -0
- package/dist/registries/workflow-executors.d.ts.map +1 -0
- package/dist/registries/workflow-executors.js +14 -0
- package/dist/registries/workflow-executors.js.map +1 -0
- package/dist/requirements.d.ts +59 -0
- package/dist/requirements.d.ts.map +1 -0
- package/dist/requirements.js +149 -0
- package/dist/requirements.js.map +1 -0
- package/dist/run-turn.d.ts +6 -0
- package/dist/run-turn.d.ts.map +1 -0
- package/dist/run-turn.js +141 -0
- package/dist/run-turn.js.map +1 -0
- package/dist/session-runtime.d.ts +67 -0
- package/dist/session-runtime.d.ts.map +1 -0
- package/dist/session-runtime.js +2 -0
- package/dist/session-runtime.js.map +1 -0
- package/dist/session.d.ts +203 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +399 -0
- package/dist/session.js.map +1 -0
- package/dist/sessions/persistence.d.ts +184 -0
- package/dist/sessions/persistence.d.ts.map +1 -0
- package/dist/sessions/persistence.js +442 -0
- package/dist/sessions/persistence.js.map +1 -0
- package/dist/setup-agent.d.ts +91 -0
- package/dist/setup-agent.d.ts.map +1 -0
- package/dist/setup-agent.js +138 -0
- package/dist/setup-agent.js.map +1 -0
- package/dist/skills/index.d.ts +4 -0
- package/dist/skills/index.d.ts.map +1 -0
- package/dist/skills/index.js +4 -0
- package/dist/skills/index.js.map +1 -0
- package/dist/skills/loader.d.ts +16 -0
- package/dist/skills/loader.d.ts.map +1 -0
- package/dist/skills/loader.js +99 -0
- package/dist/skills/loader.js.map +1 -0
- package/dist/skills/parse.d.ts +5 -0
- package/dist/skills/parse.d.ts.map +1 -0
- package/dist/skills/parse.js +8 -0
- package/dist/skills/parse.js.map +1 -0
- package/dist/skills/synthesize-draft.d.ts +8 -0
- package/dist/skills/synthesize-draft.d.ts.map +1 -0
- package/dist/skills/synthesize-draft.js +32 -0
- package/dist/skills/synthesize-draft.js.map +1 -0
- package/dist/skills/synthesize.d.ts +33 -0
- package/dist/skills/synthesize.d.ts.map +1 -0
- package/dist/skills/synthesize.js +241 -0
- package/dist/skills/synthesize.js.map +1 -0
- package/dist/subagents/events.d.ts +20 -0
- package/dist/subagents/events.d.ts.map +1 -0
- package/dist/subagents/events.js +146 -0
- package/dist/subagents/events.js.map +1 -0
- package/dist/subagents/registry.d.ts +40 -0
- package/dist/subagents/registry.d.ts.map +1 -0
- package/dist/subagents/registry.js +42 -0
- package/dist/subagents/registry.js.map +1 -0
- package/dist/subagents/run-child.d.ts +43 -0
- package/dist/subagents/run-child.d.ts.map +1 -0
- package/dist/subagents/run-child.js +276 -0
- package/dist/subagents/run-child.js.map +1 -0
- package/dist/subagents/spawn.d.ts +8 -0
- package/dist/subagents/spawn.d.ts.map +1 -0
- package/dist/subagents/spawn.js +8 -0
- package/dist/subagents/spawn.js.map +1 -0
- package/dist/subagents/tools.d.ts +4 -0
- package/dist/subagents/tools.d.ts.map +1 -0
- package/dist/subagents/tools.js +13 -0
- package/dist/subagents/tools.js.map +1 -0
- package/dist/subagents.d.ts +3 -0
- package/dist/subagents.d.ts.map +1 -0
- package/dist/subagents.js +3 -0
- package/dist/subagents.js.map +1 -0
- package/dist/surfaces/host.d.ts +53 -0
- package/dist/surfaces/host.d.ts.map +1 -0
- package/dist/surfaces/host.js +177 -0
- package/dist/surfaces/host.js.map +1 -0
- package/dist/tunnel/localhost.d.ts +7 -0
- package/dist/tunnel/localhost.d.ts.map +1 -0
- package/dist/tunnel/localhost.js +21 -0
- package/dist/tunnel/localhost.js.map +1 -0
- package/dist/usage-stats.d.ts +42 -0
- package/dist/usage-stats.d.ts.map +1 -0
- package/dist/usage-stats.js +83 -0
- package/dist/usage-stats.js.map +1 -0
- package/dist/view/default-renderer.d.ts +7 -0
- package/dist/view/default-renderer.d.ts.map +1 -0
- package/dist/view/default-renderer.js +14 -0
- package/dist/view/default-renderer.js.map +1 -0
- package/dist/view/parse.d.ts +7 -0
- package/dist/view/parse.d.ts.map +1 -0
- package/dist/view/parse.js +479 -0
- package/dist/view/parse.js.map +1 -0
- package/package.json +61 -0
- package/src/events/factory.ts +27 -0
- package/src/events/log.test.ts +470 -0
- package/src/events/log.ts +256 -0
- package/src/index.ts +102 -0
- package/src/logger.ts +46 -0
- package/src/permissions/engine.test.ts +206 -0
- package/src/permissions/engine.ts +247 -0
- package/src/permissions/resolvers.test.ts +40 -0
- package/src/permissions/resolvers.ts +17 -0
- package/src/plugins/discovery.test.ts +115 -0
- package/src/plugins/discovery.ts +124 -0
- package/src/plugins/host-options.ts +73 -0
- package/src/plugins/host-view-tunnel.test.ts +86 -0
- package/src/plugins/host.test.ts +434 -0
- package/src/plugins/host.ts +327 -0
- package/src/plugins/lifecycle.test.ts +187 -0
- package/src/plugins/lifecycle.ts +136 -0
- package/src/plugins/loader.ts +79 -0
- package/src/plugins/package-requirements.ts +50 -0
- package/src/plugins/registry-kinds.test.ts +162 -0
- package/src/plugins/registry-kinds.ts +210 -0
- package/src/plugins/toposort.test.ts +66 -0
- package/src/plugins/toposort.ts +69 -0
- package/src/preferences.test.ts +105 -0
- package/src/preferences.ts +84 -0
- package/src/registries/active-backend-registry.ts +142 -0
- package/src/registries/active-def-registry.test.ts +103 -0
- package/src/registries/active-def-registry.ts +97 -0
- package/src/registries/agents.ts +18 -0
- package/src/registries/cache-strategies.ts +14 -0
- package/src/registries/channels.ts +42 -0
- package/src/registries/commands.test.ts +71 -0
- package/src/registries/commands.ts +78 -0
- package/src/registries/compactors.ts +14 -0
- package/src/registries/def-map-registry.test.ts +67 -0
- package/src/registries/def-map-registry.ts +59 -0
- package/src/registries/embedders.test.ts +54 -0
- package/src/registries/embedders.ts +18 -0
- package/src/registries/isolators.test.ts +43 -0
- package/src/registries/isolators.ts +38 -0
- package/src/registries/modes.test.ts +66 -0
- package/src/registries/modes.ts +80 -0
- package/src/registries/providers.test.ts +50 -0
- package/src/registries/providers.ts +97 -0
- package/src/registries/semantics.test.ts +117 -0
- package/src/registries/skills.ts +87 -0
- package/src/registries/surfaces.ts +18 -0
- package/src/registries/synthesizers.test.ts +63 -0
- package/src/registries/synthesizers.ts +36 -0
- package/src/registries/tools.test.ts +142 -0
- package/src/registries/tools.ts +145 -0
- package/src/registries/transcribers.test.ts +70 -0
- package/src/registries/transcribers.ts +19 -0
- package/src/registries/tunnel-providers.test.ts +30 -0
- package/src/registries/tunnel-providers.ts +14 -0
- package/src/registries/view-renderers.test.ts +68 -0
- package/src/registries/view-renderers.ts +15 -0
- package/src/registries/workflow-executors.test.ts +53 -0
- package/src/registries/workflow-executors.ts +15 -0
- package/src/requirements.targetinfo.test.ts +70 -0
- package/src/requirements.test.ts +111 -0
- package/src/requirements.ts +262 -0
- package/src/run-turn.test.ts +178 -0
- package/src/run-turn.ts +156 -0
- package/src/session-runtime.ts +75 -0
- package/src/session.test.ts +216 -0
- package/src/session.ts +491 -0
- package/src/sessions/persistence.flush.test.ts +100 -0
- package/src/sessions/persistence.ready.test.ts +91 -0
- package/src/sessions/persistence.test.ts +274 -0
- package/src/sessions/persistence.ts +498 -0
- package/src/setup-agent.test.ts +72 -0
- package/src/setup-agent.ts +189 -0
- package/src/skills/index.ts +14 -0
- package/src/skills/loader.test.ts +102 -0
- package/src/skills/loader.ts +114 -0
- package/src/skills/parse.test.ts +59 -0
- package/src/skills/parse.ts +11 -0
- package/src/skills/synthesize-draft.test.ts +79 -0
- package/src/skills/synthesize-draft.ts +46 -0
- package/src/skills/synthesize.test.ts +170 -0
- package/src/skills/synthesize.ts +304 -0
- package/src/subagents/events.test.ts +56 -0
- package/src/subagents/events.ts +194 -0
- package/src/subagents/registry.ts +62 -0
- package/src/subagents/run-child.test.ts +185 -0
- package/src/subagents/run-child.ts +425 -0
- package/src/subagents/spawn.ts +12 -0
- package/src/subagents/tools.ts +19 -0
- package/src/subagents.ts +2 -0
- package/src/surfaces/host.test.ts +330 -0
- package/src/surfaces/host.ts +192 -0
- package/src/tunnel/localhost.test.ts +22 -0
- package/src/tunnel/localhost.ts +21 -0
- package/src/usage-stats.test.ts +77 -0
- package/src/usage-stats.ts +119 -0
- package/src/view/default-renderer.test.ts +29 -0
- package/src/view/default-renderer.ts +14 -0
- package/src/view/parse-extended.test.ts +255 -0
- package/src/view/parse.test.ts +232 -0
- package/src/view/parse.ts +520 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
defineSurface,
|
|
4
|
+
type SurfaceDataMessage,
|
|
5
|
+
type SurfaceInstance,
|
|
6
|
+
type SurfaceSize,
|
|
7
|
+
} from '@moxxy/sdk';
|
|
8
|
+
import { SurfaceHostImpl } from './host.js';
|
|
9
|
+
import { SurfaceRegistryImpl } from '../registries/surfaces.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A fake surface whose instance records the input it receives and whether it has
|
|
13
|
+
* been closed — enough to assert that the host routes a viewer's keystrokes to
|
|
14
|
+
* the live instance and only tears it down when the last viewer detaches.
|
|
15
|
+
*/
|
|
16
|
+
function fakeSurface(kind = 'terminal') {
|
|
17
|
+
const state = { inputs: [] as unknown[], closed: 0, opens: 0 };
|
|
18
|
+
const def = defineSurface({
|
|
19
|
+
kind,
|
|
20
|
+
open: () => {
|
|
21
|
+
state.opens += 1;
|
|
22
|
+
const instance: SurfaceInstance = {
|
|
23
|
+
id: kind,
|
|
24
|
+
kind,
|
|
25
|
+
onData: () => () => {},
|
|
26
|
+
snapshot: () => ({ type: 'snapshot' }),
|
|
27
|
+
input: (msg) => {
|
|
28
|
+
state.inputs.push(msg);
|
|
29
|
+
},
|
|
30
|
+
close: () => {
|
|
31
|
+
state.closed += 1;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
return instance;
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
return { def, state };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeHost(def: ReturnType<typeof fakeSurface>['def']): SurfaceHostImpl {
|
|
41
|
+
const registry = new SurfaceRegistryImpl();
|
|
42
|
+
registry.register(def);
|
|
43
|
+
return new SurfaceHostImpl(registry, { cwd: '/tmp' });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('SurfaceHostImpl viewer ref-counting', () => {
|
|
47
|
+
it('keeps a shared instance alive until the last viewer closes', async () => {
|
|
48
|
+
const { def, state } = fakeSurface();
|
|
49
|
+
const host = makeHost(def);
|
|
50
|
+
|
|
51
|
+
const a = await host.open('terminal');
|
|
52
|
+
const b = await host.open('terminal');
|
|
53
|
+
expect(state.opens).toBe(1); // shared: one underlying instance
|
|
54
|
+
expect(a.surfaceId).toBe(b.surfaceId);
|
|
55
|
+
|
|
56
|
+
// First viewer detaches — instance must survive for the second.
|
|
57
|
+
await host.close(a.surfaceId);
|
|
58
|
+
expect(state.closed).toBe(0);
|
|
59
|
+
|
|
60
|
+
// Input from the still-attached viewer reaches the live instance.
|
|
61
|
+
await host.input(b.surfaceId, { type: 'data', data: 'ls\n' });
|
|
62
|
+
expect(state.inputs).toEqual([{ type: 'data', data: 'ls\n' }]);
|
|
63
|
+
|
|
64
|
+
// Last viewer detaches — now it tears down.
|
|
65
|
+
await host.close(b.surfaceId);
|
|
66
|
+
expect(state.closed).toBe(1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('survives the StrictMode mount→unmount→remount close race', async () => {
|
|
70
|
+
// React StrictMode double-invokes effects: the first mount opens, unmounts
|
|
71
|
+
// (its late-resolving open then fires a close), and a second mount opens the
|
|
72
|
+
// shared instance. The stray close must NOT destroy the instance the live
|
|
73
|
+
// mount is using — otherwise input/resize silently vanish.
|
|
74
|
+
const { def, state } = fakeSurface();
|
|
75
|
+
const host = makeHost(def);
|
|
76
|
+
|
|
77
|
+
const first = await host.open('terminal'); // mount A
|
|
78
|
+
const second = await host.open('terminal'); // mount B (remount)
|
|
79
|
+
await host.close(first.surfaceId); // mount A's disposed-open close
|
|
80
|
+
|
|
81
|
+
expect(state.closed).toBe(0); // instance survived
|
|
82
|
+
await host.input(second.surfaceId, { type: 'data', data: 'x' });
|
|
83
|
+
expect(state.inputs).toEqual([{ type: 'data', data: 'x' }]); // input still lands
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('closeAll tears down regardless of outstanding viewer refs', async () => {
|
|
87
|
+
const { def, state } = fakeSurface();
|
|
88
|
+
const host = makeHost(def);
|
|
89
|
+
await host.open('terminal');
|
|
90
|
+
await host.open('terminal'); // ref count 2
|
|
91
|
+
await host.closeAll();
|
|
92
|
+
expect(state.closed).toBe(1);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A controllable surface whose instance lets the test PUSH frames (`emit`),
|
|
98
|
+
* record resize calls, and run unsubscribe on close — enough to assert the
|
|
99
|
+
* host's multiplex fan-out, the per-instance unsub drop, and id-based routing.
|
|
100
|
+
*/
|
|
101
|
+
function controllableSurface(kind = 'terminal') {
|
|
102
|
+
const subscribers = new Set<(payload: unknown) => void>();
|
|
103
|
+
const state = {
|
|
104
|
+
opens: 0,
|
|
105
|
+
closed: 0,
|
|
106
|
+
inputs: [] as unknown[],
|
|
107
|
+
resizes: [] as SurfaceSize[],
|
|
108
|
+
unsubs: 0,
|
|
109
|
+
};
|
|
110
|
+
const instance: SurfaceInstance = {
|
|
111
|
+
id: `${kind}-id`,
|
|
112
|
+
kind,
|
|
113
|
+
onData: (cb) => {
|
|
114
|
+
subscribers.add(cb);
|
|
115
|
+
return () => {
|
|
116
|
+
subscribers.delete(cb);
|
|
117
|
+
state.unsubs += 1;
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
snapshot: () => ({ scrollback: 'hello' }),
|
|
121
|
+
input: (msg) => {
|
|
122
|
+
state.inputs.push(msg);
|
|
123
|
+
},
|
|
124
|
+
resize: (size) => {
|
|
125
|
+
state.resizes.push(size);
|
|
126
|
+
},
|
|
127
|
+
close: () => {
|
|
128
|
+
state.closed += 1;
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
const def = defineSurface({
|
|
132
|
+
kind,
|
|
133
|
+
description: 'controllable',
|
|
134
|
+
open: () => {
|
|
135
|
+
state.opens += 1;
|
|
136
|
+
return instance;
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
/** Push one frame as the underlying instance would. */
|
|
140
|
+
const emit = (payload: unknown): void => {
|
|
141
|
+
for (const cb of subscribers) cb(payload);
|
|
142
|
+
};
|
|
143
|
+
return { def, state, instance, emit };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
describe('SurfaceHostImpl open() idempotence + snapshot', () => {
|
|
147
|
+
it('returns the existing instance + its snapshot on a second open(kind)', async () => {
|
|
148
|
+
const { def, state } = controllableSurface();
|
|
149
|
+
const host = makeHost(def);
|
|
150
|
+
|
|
151
|
+
const a = await host.open('terminal');
|
|
152
|
+
const b = await host.open('terminal');
|
|
153
|
+
|
|
154
|
+
expect(state.opens).toBe(1); // def.open ran exactly once
|
|
155
|
+
expect(a.surfaceId).toBe(b.surfaceId);
|
|
156
|
+
expect(a.kind).toBe('terminal');
|
|
157
|
+
// The catch-up snapshot rides back to a late-joining viewer.
|
|
158
|
+
expect(a.snapshot).toEqual({ scrollback: 'hello' });
|
|
159
|
+
expect(b.snapshot).toEqual({ scrollback: 'hello' });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('two concurrent open(kind) calls share ONE instance (def.open runs once)', async () => {
|
|
163
|
+
const { def, state } = controllableSurface();
|
|
164
|
+
const host = makeHost(def);
|
|
165
|
+
|
|
166
|
+
const [a, b] = await Promise.all([host.open('terminal'), host.open('terminal')]);
|
|
167
|
+
|
|
168
|
+
expect(state.opens).toBe(1);
|
|
169
|
+
expect(a.surfaceId).toBe(b.surfaceId);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('throws a clear error opening an unregistered kind', async () => {
|
|
173
|
+
const { def } = controllableSurface();
|
|
174
|
+
const host = makeHost(def);
|
|
175
|
+
await expect(host.open('browser')).rejects.toThrow(/No surface registered for kind: browser/);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('SurfaceHostImpl onData multiplex fan-out', () => {
|
|
180
|
+
it('re-emits each instance frame as a SurfaceDataMessage carrying surfaceId/kind/payload', async () => {
|
|
181
|
+
const { def, emit } = controllableSurface();
|
|
182
|
+
const host = makeHost(def);
|
|
183
|
+
const { surfaceId } = await host.open('terminal');
|
|
184
|
+
|
|
185
|
+
const seen: SurfaceDataMessage[] = [];
|
|
186
|
+
host.onData((msg) => seen.push(msg));
|
|
187
|
+
emit({ bytes: 'ls\n' });
|
|
188
|
+
|
|
189
|
+
expect(seen).toEqual([{ surfaceId, kind: 'terminal', payload: { bytes: 'ls\n' } }]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('fans every frame out to all subscribers', async () => {
|
|
193
|
+
const { def, emit } = controllableSurface();
|
|
194
|
+
const host = makeHost(def);
|
|
195
|
+
await host.open('terminal');
|
|
196
|
+
|
|
197
|
+
const a: SurfaceDataMessage[] = [];
|
|
198
|
+
const b: SurfaceDataMessage[] = [];
|
|
199
|
+
host.onData((m) => a.push(m));
|
|
200
|
+
host.onData((m) => b.push(m));
|
|
201
|
+
emit({ x: 1 });
|
|
202
|
+
|
|
203
|
+
expect(a).toHaveLength(1);
|
|
204
|
+
expect(b).toHaveLength(1);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('a throwing listener does not suppress delivery to the others', async () => {
|
|
208
|
+
const { def, emit } = controllableSurface();
|
|
209
|
+
const host = makeHost(def);
|
|
210
|
+
await host.open('terminal');
|
|
211
|
+
|
|
212
|
+
const good: SurfaceDataMessage[] = [];
|
|
213
|
+
host.onData(() => {
|
|
214
|
+
throw new Error('bad listener');
|
|
215
|
+
});
|
|
216
|
+
host.onData((m) => good.push(m));
|
|
217
|
+
|
|
218
|
+
expect(() => emit({ x: 1 })).not.toThrow();
|
|
219
|
+
expect(good).toHaveLength(1);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('the onData unsubscribe stops further frames for that subscriber', async () => {
|
|
223
|
+
const { def, emit } = controllableSurface();
|
|
224
|
+
const host = makeHost(def);
|
|
225
|
+
await host.open('terminal');
|
|
226
|
+
|
|
227
|
+
const seen: SurfaceDataMessage[] = [];
|
|
228
|
+
const off = host.onData((m) => seen.push(m));
|
|
229
|
+
emit({ n: 1 });
|
|
230
|
+
off();
|
|
231
|
+
emit({ n: 2 });
|
|
232
|
+
|
|
233
|
+
expect(seen).toHaveLength(1);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('drops the per-instance unsub on close so no frames flow after teardown', async () => {
|
|
237
|
+
const { def, state, emit } = controllableSurface();
|
|
238
|
+
const host = makeHost(def);
|
|
239
|
+
const { surfaceId } = await host.open('terminal');
|
|
240
|
+
|
|
241
|
+
const seen: SurfaceDataMessage[] = [];
|
|
242
|
+
host.onData((m) => seen.push(m));
|
|
243
|
+
await host.close(surfaceId); // last viewer → real teardown
|
|
244
|
+
|
|
245
|
+
expect(state.closed).toBe(1);
|
|
246
|
+
expect(state.unsubs).toBe(1); // the host ran the stored unsub
|
|
247
|
+
emit({ late: true }); // frame from a still-live resource, post-close
|
|
248
|
+
expect(seen).toHaveLength(0); // host no longer forwards it
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe('SurfaceHostImpl input/resize routing', () => {
|
|
253
|
+
it('routes input + resize to the instance by surfaceId', async () => {
|
|
254
|
+
const { def, state } = controllableSurface();
|
|
255
|
+
const host = makeHost(def);
|
|
256
|
+
const { surfaceId } = await host.open('terminal');
|
|
257
|
+
|
|
258
|
+
await host.input(surfaceId, { type: 'data', data: 'x' });
|
|
259
|
+
await host.resize(surfaceId, { cols: 80, rows: 24 });
|
|
260
|
+
|
|
261
|
+
expect(state.inputs).toEqual([{ type: 'data', data: 'x' }]);
|
|
262
|
+
expect(state.resizes).toEqual([{ cols: 80, rows: 24 }]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('no-ops on an unknown surfaceId (input + resize)', async () => {
|
|
266
|
+
const { def, state } = controllableSurface();
|
|
267
|
+
const host = makeHost(def);
|
|
268
|
+
await host.open('terminal');
|
|
269
|
+
|
|
270
|
+
await host.input('does-not-exist', { type: 'data', data: 'x' });
|
|
271
|
+
await host.resize('does-not-exist', { cols: 1, rows: 1 });
|
|
272
|
+
|
|
273
|
+
expect(state.inputs).toEqual([]);
|
|
274
|
+
expect(state.resizes).toEqual([]);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('close on an unknown surfaceId is a no-op (does not tear down the live one)', async () => {
|
|
278
|
+
const { def, state } = controllableSurface();
|
|
279
|
+
const host = makeHost(def);
|
|
280
|
+
await host.open('terminal');
|
|
281
|
+
|
|
282
|
+
await host.close('does-not-exist');
|
|
283
|
+
expect(state.closed).toBe(0);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe('SurfaceHostImpl list()', () => {
|
|
288
|
+
it('reports the registered kinds with availability', async () => {
|
|
289
|
+
const registry = new SurfaceRegistryImpl();
|
|
290
|
+
registry.register(controllableSurface('terminal').def);
|
|
291
|
+
registry.register(
|
|
292
|
+
defineSurface({
|
|
293
|
+
kind: 'browser',
|
|
294
|
+
description: 'headless browser',
|
|
295
|
+
open: () => {
|
|
296
|
+
throw new Error('unused');
|
|
297
|
+
},
|
|
298
|
+
isAvailable: () => ({ ok: false, reason: 'node-pty missing' }),
|
|
299
|
+
}),
|
|
300
|
+
);
|
|
301
|
+
const host = new SurfaceHostImpl(registry, { cwd: '/tmp' });
|
|
302
|
+
|
|
303
|
+
const list = await host.list();
|
|
304
|
+
const byKind = new Map(list.map((i) => [i.kind, i]));
|
|
305
|
+
expect(byKind.get('terminal')).toMatchObject({ available: true, description: 'controllable' });
|
|
306
|
+
expect(byKind.get('browser')).toMatchObject({
|
|
307
|
+
available: false,
|
|
308
|
+
reason: 'node-pty missing',
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('treats a throwing isAvailable as unavailable with the error as reason', async () => {
|
|
313
|
+
const registry = new SurfaceRegistryImpl();
|
|
314
|
+
registry.register(
|
|
315
|
+
defineSurface({
|
|
316
|
+
kind: 'terminal',
|
|
317
|
+
open: () => {
|
|
318
|
+
throw new Error('unused');
|
|
319
|
+
},
|
|
320
|
+
isAvailable: () => {
|
|
321
|
+
throw new Error('probe blew up');
|
|
322
|
+
},
|
|
323
|
+
}),
|
|
324
|
+
);
|
|
325
|
+
const host = new SurfaceHostImpl(registry, { cwd: '/tmp' });
|
|
326
|
+
|
|
327
|
+
const [info] = await host.list();
|
|
328
|
+
expect(info).toMatchObject({ kind: 'terminal', available: false, reason: 'probe blew up' });
|
|
329
|
+
});
|
|
330
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
OpenSurfaceResult,
|
|
3
|
+
SurfaceContext,
|
|
4
|
+
SurfaceDataMessage,
|
|
5
|
+
SurfaceHost,
|
|
6
|
+
SurfaceInfo,
|
|
7
|
+
SurfaceInputMessage,
|
|
8
|
+
SurfaceInstance,
|
|
9
|
+
SurfaceKind,
|
|
10
|
+
SurfaceSize,
|
|
11
|
+
} from '@moxxy/sdk';
|
|
12
|
+
import type { Logger } from '../logger.js';
|
|
13
|
+
import type { SurfaceRegistryImpl } from '../registries/surfaces.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Manages the live, open {@link SurfaceInstance}s for a Session and multiplexes
|
|
17
|
+
* every instance's output through a single {@link onData} fan-out. The runner
|
|
18
|
+
* subscribes once and rebroadcasts each frame as a `surface.data` notification;
|
|
19
|
+
* the agent's tools reach the SAME underlying resource (PTY, page) through their
|
|
20
|
+
* plugin's module state, so a `terminal`/`browser_session` tool call appears in
|
|
21
|
+
* the very pane the user is watching.
|
|
22
|
+
*
|
|
23
|
+
* Opening a kind is idempotent: a second `open` returns the instance the first
|
|
24
|
+
* created (surfaces are shared), so a late-joining viewer attaches to the
|
|
25
|
+
* existing resource and gets its {@link SurfaceInstance.snapshot}.
|
|
26
|
+
*/
|
|
27
|
+
export class SurfaceHostImpl implements SurfaceHost {
|
|
28
|
+
/** Open instances, keyed by kind (one shared instance per kind). */
|
|
29
|
+
private readonly instances = new Map<SurfaceKind, SurfaceInstance>();
|
|
30
|
+
/** Per-instance unsubscribe from its onData, dropped on close. */
|
|
31
|
+
private readonly unsubs = new Map<string, () => void>();
|
|
32
|
+
private readonly dataListeners = new Set<(msg: SurfaceDataMessage) => void>();
|
|
33
|
+
/** In-flight opens, so concurrent `open(kind)` calls share one instance. */
|
|
34
|
+
private readonly opening = new Map<SurfaceKind, Promise<OpenSurfaceResult>>();
|
|
35
|
+
/**
|
|
36
|
+
* Viewer attach count per kind. A surface is SHARED (the agent's tool + every
|
|
37
|
+
* attached viewer drive the same PTY/page), so `open` retains and `close`
|
|
38
|
+
* releases; the instance is only torn down when the last viewer detaches.
|
|
39
|
+
* Without this, a single viewer's `close` would destroy the resource out from
|
|
40
|
+
* under the others — and React StrictMode (dev) makes that routine: it
|
|
41
|
+
* mounts → unmounts → remounts, so the first mount's late-resolving `open`
|
|
42
|
+
* closes the very instance the remount just attached to, leaving `input`/
|
|
43
|
+
* `resize` to hit a now-missing instance (output still flows from the
|
|
44
|
+
* snapshot, but keystrokes/navigation silently vanish).
|
|
45
|
+
*/
|
|
46
|
+
private readonly refs = new Map<SurfaceKind, number>();
|
|
47
|
+
|
|
48
|
+
constructor(
|
|
49
|
+
private readonly registry: SurfaceRegistryImpl,
|
|
50
|
+
private readonly ctx: SurfaceContext,
|
|
51
|
+
private readonly logger?: Logger,
|
|
52
|
+
) {}
|
|
53
|
+
|
|
54
|
+
async list(): Promise<ReadonlyArray<SurfaceInfo>> {
|
|
55
|
+
const out: SurfaceInfo[] = [];
|
|
56
|
+
for (const def of this.registry.list()) {
|
|
57
|
+
let available = true;
|
|
58
|
+
let reason: string | undefined;
|
|
59
|
+
if (def.isAvailable) {
|
|
60
|
+
try {
|
|
61
|
+
const a = await def.isAvailable(this.ctx);
|
|
62
|
+
available = a.ok;
|
|
63
|
+
reason = a.reason;
|
|
64
|
+
} catch (err) {
|
|
65
|
+
available = false;
|
|
66
|
+
reason = err instanceof Error ? err.message : String(err);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
out.push({
|
|
70
|
+
kind: def.kind,
|
|
71
|
+
...(def.description ? { description: def.description } : {}),
|
|
72
|
+
available,
|
|
73
|
+
...(reason ? { reason } : {}),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async open(kind: SurfaceKind): Promise<OpenSurfaceResult> {
|
|
80
|
+
const existing = this.instances.get(kind);
|
|
81
|
+
if (existing) {
|
|
82
|
+
this.retain(kind);
|
|
83
|
+
return this.describe(existing);
|
|
84
|
+
}
|
|
85
|
+
const pending = this.opening.get(kind);
|
|
86
|
+
if (pending) {
|
|
87
|
+
// Ride the in-flight create, but still count this viewer's attach.
|
|
88
|
+
const res = await pending;
|
|
89
|
+
this.retain(kind);
|
|
90
|
+
return res;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const def = this.registry.get(kind);
|
|
94
|
+
if (!def) throw new Error(`No surface registered for kind: ${kind}`);
|
|
95
|
+
|
|
96
|
+
const promise = (async (): Promise<OpenSurfaceResult> => {
|
|
97
|
+
const instance = await def.open(this.ctx);
|
|
98
|
+
this.instances.set(kind, instance);
|
|
99
|
+
this.refs.set(kind, 1);
|
|
100
|
+
// Re-emit this instance's frames as multiplexed SurfaceDataMessages.
|
|
101
|
+
const unsub = instance.onData((payload) =>
|
|
102
|
+
this.emit({ surfaceId: instance.id, kind: instance.kind, payload }),
|
|
103
|
+
);
|
|
104
|
+
this.unsubs.set(instance.id, unsub);
|
|
105
|
+
return this.describe(instance);
|
|
106
|
+
})();
|
|
107
|
+
this.opening.set(kind, promise);
|
|
108
|
+
try {
|
|
109
|
+
return await promise;
|
|
110
|
+
} finally {
|
|
111
|
+
this.opening.delete(kind);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async input(surfaceId: string, msg: SurfaceInputMessage): Promise<void> {
|
|
116
|
+
const instance = this.byId(surfaceId);
|
|
117
|
+
if (!instance) return;
|
|
118
|
+
await instance.input(msg);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async resize(surfaceId: string, size: SurfaceSize): Promise<void> {
|
|
122
|
+
const instance = this.byId(surfaceId);
|
|
123
|
+
if (!instance?.resize) return;
|
|
124
|
+
await instance.resize(size);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async close(surfaceId: string): Promise<void> {
|
|
128
|
+
const instance = this.byId(surfaceId);
|
|
129
|
+
if (!instance) return;
|
|
130
|
+
const kind = instance.kind;
|
|
131
|
+
// Release this viewer; keep the shared instance alive while others hold it.
|
|
132
|
+
const remaining = (this.refs.get(kind) ?? 1) - 1;
|
|
133
|
+
if (remaining > 0) {
|
|
134
|
+
this.refs.set(kind, remaining);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
this.refs.delete(kind);
|
|
138
|
+
this.unsubs.get(surfaceId)?.();
|
|
139
|
+
this.unsubs.delete(surfaceId);
|
|
140
|
+
this.instances.delete(kind);
|
|
141
|
+
try {
|
|
142
|
+
await instance.close();
|
|
143
|
+
} catch (err) {
|
|
144
|
+
this.logger?.warn?.('SurfaceHost: close failed', {
|
|
145
|
+
surfaceId,
|
|
146
|
+
err: err instanceof Error ? err.message : String(err),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Bump the viewer attach count for an already-open kind. */
|
|
152
|
+
private retain(kind: SurfaceKind): void {
|
|
153
|
+
this.refs.set(kind, (this.refs.get(kind) ?? 0) + 1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private describe(instance: SurfaceInstance): OpenSurfaceResult {
|
|
157
|
+
return {
|
|
158
|
+
surfaceId: instance.id,
|
|
159
|
+
kind: instance.kind,
|
|
160
|
+
...(instance.snapshot ? { snapshot: instance.snapshot() } : {}),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
onData(cb: (msg: SurfaceDataMessage) => void): () => void {
|
|
165
|
+
this.dataListeners.add(cb);
|
|
166
|
+
return () => this.dataListeners.delete(cb);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async closeAll(): Promise<void> {
|
|
170
|
+
// Session shutdown: tear every instance down regardless of viewer refs.
|
|
171
|
+
for (const kind of [...this.instances.keys()]) this.refs.set(kind, 1);
|
|
172
|
+
const ids = [...this.unsubs.keys()];
|
|
173
|
+
for (const id of ids) await this.close(id);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private byId(surfaceId: string): SurfaceInstance | undefined {
|
|
177
|
+
for (const instance of this.instances.values()) {
|
|
178
|
+
if (instance.id === surfaceId) return instance;
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private emit(msg: SurfaceDataMessage): void {
|
|
184
|
+
for (const fn of this.dataListeners) {
|
|
185
|
+
try {
|
|
186
|
+
fn(msg);
|
|
187
|
+
} catch {
|
|
188
|
+
/* a bad listener must not break the stream */
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { localhostTunnel } from './localhost.js';
|
|
3
|
+
|
|
4
|
+
describe('localhostTunnel', () => {
|
|
5
|
+
it('returns the local URL and a no-op close', async () => {
|
|
6
|
+
const handle = await localhostTunnel.open({ host: '127.0.0.1', port: 4040 });
|
|
7
|
+
expect(handle.url).toBe('http://127.0.0.1:4040');
|
|
8
|
+
await expect(handle.close()).resolves.toBeUndefined();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('brackets an IPv6 literal host so the URL is valid', async () => {
|
|
12
|
+
// u49-1: a bare IPv6 host yields an ambiguous, unparseable URL.
|
|
13
|
+
const handle = await localhostTunnel.open({ host: '::1', port: 4040 });
|
|
14
|
+
expect(handle.url).toBe('http://[::1]:4040');
|
|
15
|
+
// …and the result actually parses.
|
|
16
|
+
expect(new URL(handle.url).hostname).toBe('[::1]');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('is always available', async () => {
|
|
20
|
+
expect(await localhostTunnel.isAvailable?.()).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineTunnelProvider } from '@moxxy/sdk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The default tunnel provider: no tunnel at all, just the local URL. Used when
|
|
5
|
+
* the surface and the user share a machine (TUI), or when no real tunnel
|
|
6
|
+
* (cloudflared) is configured/installed.
|
|
7
|
+
*/
|
|
8
|
+
export const localhostTunnel = defineTunnelProvider({
|
|
9
|
+
name: 'localhost',
|
|
10
|
+
open: (opts) => {
|
|
11
|
+
// Bracket an IPv6 literal host (e.g. `::1` → `[::1]`) so the `:port`
|
|
12
|
+
// delimiter is unambiguous and the result is a valid URL. A bare IPv6
|
|
13
|
+
// host (`http://::1:4040`) is rejected by most URL parsers.
|
|
14
|
+
const host = opts.host.includes(':') ? `[${opts.host}]` : opts.host;
|
|
15
|
+
return Promise.resolve({
|
|
16
|
+
url: `http://${host}:${opts.port}`,
|
|
17
|
+
close: () => Promise.resolve(),
|
|
18
|
+
});
|
|
19
|
+
},
|
|
20
|
+
isAvailable: () => Promise.resolve(true),
|
|
21
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { clearUsageStats, loadUsageStats, mergeUsageStats } from './usage-stats.js';
|
|
6
|
+
import type { ModelUsageTotals } from '@moxxy/sdk';
|
|
7
|
+
|
|
8
|
+
const totals = (over: Partial<ModelUsageTotals> = {}): ModelUsageTotals => ({
|
|
9
|
+
calls: 1,
|
|
10
|
+
inputTokens: 0,
|
|
11
|
+
outputTokens: 0,
|
|
12
|
+
cacheReadTokens: 0,
|
|
13
|
+
cacheCreationTokens: 0,
|
|
14
|
+
...over,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
let tmpDir: string;
|
|
18
|
+
let statsPath: string;
|
|
19
|
+
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-usage-'));
|
|
22
|
+
statsPath = path.join(tmpDir, 'usage.json');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(async () => {
|
|
26
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('usage-stats store', () => {
|
|
30
|
+
it('returns an empty aggregate when the file is missing', async () => {
|
|
31
|
+
const file = await loadUsageStats(statsPath);
|
|
32
|
+
expect(file.models).toEqual({});
|
|
33
|
+
expect(file.version).toBe(1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('merges a delta and round-trips through disk', async () => {
|
|
37
|
+
await mergeUsageStats({ 'anthropic/opus': totals({ inputTokens: 100, outputTokens: 10 }) }, statsPath);
|
|
38
|
+
const file = await loadUsageStats(statsPath);
|
|
39
|
+
expect(file.models['anthropic/opus']!.inputTokens).toBe(100);
|
|
40
|
+
expect(file.models['anthropic/opus']!.firstSeen).toBeTruthy();
|
|
41
|
+
expect(file.models['anthropic/opus']!.lastSeen).toBeTruthy();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('accumulates additively across merges and preserves firstSeen', async () => {
|
|
45
|
+
const first = await mergeUsageStats(
|
|
46
|
+
{ 'anthropic/opus': totals({ inputTokens: 100 }) },
|
|
47
|
+
statsPath,
|
|
48
|
+
);
|
|
49
|
+
const firstSeen = first.models['anthropic/opus']!.firstSeen;
|
|
50
|
+
|
|
51
|
+
await mergeUsageStats({ 'anthropic/opus': totals({ inputTokens: 50, calls: 2 }) }, statsPath);
|
|
52
|
+
const file = await loadUsageStats(statsPath);
|
|
53
|
+
|
|
54
|
+
expect(file.models['anthropic/opus']!.inputTokens).toBe(150);
|
|
55
|
+
expect(file.models['anthropic/opus']!.calls).toBe(3);
|
|
56
|
+
expect(file.models['anthropic/opus']!.firstSeen).toBe(firstSeen);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('ignores empty deltas and zero-call entries', async () => {
|
|
60
|
+
await mergeUsageStats({}, statsPath);
|
|
61
|
+
expect((await loadUsageStats(statsPath)).models).toEqual({});
|
|
62
|
+
|
|
63
|
+
await mergeUsageStats({ 'a/m': totals({ calls: 0, inputTokens: 5 }) }, statsPath);
|
|
64
|
+
expect((await loadUsageStats(statsPath)).models).toEqual({});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('clears the aggregate', async () => {
|
|
68
|
+
await mergeUsageStats({ 'anthropic/opus': totals({ inputTokens: 100 }) }, statsPath);
|
|
69
|
+
await clearUsageStats(statsPath);
|
|
70
|
+
expect((await loadUsageStats(statsPath)).models).toEqual({});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('reads as empty when the file is malformed', async () => {
|
|
74
|
+
await fs.writeFile(statsPath, '{ not json', 'utf8');
|
|
75
|
+
expect((await loadUsageStats(statsPath)).models).toEqual({});
|
|
76
|
+
});
|
|
77
|
+
});
|