groove-dev 0.26.38 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/CLAUDE.md +24 -19
- package/node_modules/@groove-dev/cli/bin/groove.js +2 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/cli/src/commands/nuke.js +16 -4
- package/node_modules/@groove-dev/cli/src/commands/stop.js +17 -2
- package/node_modules/@groove-dev/daemon/integrations-registry.json +681 -75
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/adaptive.js +23 -25
- package/node_modules/@groove-dev/daemon/src/api.js +346 -22
- package/node_modules/@groove-dev/daemon/src/classifier.js +53 -6
- package/node_modules/@groove-dev/daemon/src/firstrun.js +14 -1
- package/node_modules/@groove-dev/daemon/src/gateways/manager.js +2 -2
- package/node_modules/@groove-dev/daemon/src/index.js +28 -4
- package/node_modules/@groove-dev/daemon/src/integrations.js +215 -14
- package/node_modules/@groove-dev/daemon/src/introducer.js +84 -11
- package/node_modules/@groove-dev/daemon/src/journalist.js +43 -1
- package/node_modules/@groove-dev/daemon/src/lockmanager.js +60 -0
- package/node_modules/@groove-dev/daemon/src/mcp-manager.js +270 -0
- package/node_modules/@groove-dev/daemon/src/memory.js +370 -0
- package/node_modules/@groove-dev/daemon/src/pm.js +1 -1
- package/node_modules/@groove-dev/daemon/src/process.js +141 -9
- package/node_modules/@groove-dev/daemon/src/registry.js +1 -1
- package/node_modules/@groove-dev/daemon/src/rotator.js +334 -31
- package/node_modules/@groove-dev/daemon/src/router.js +43 -0
- package/node_modules/@groove-dev/daemon/src/tokentracker.js +70 -18
- package/node_modules/@groove-dev/daemon/src/validate.js +5 -13
- package/node_modules/@groove-dev/daemon/templates/groove-slides.cjs +306 -0
- package/node_modules/@groove-dev/daemon/test/classifier.test.js +3 -5
- package/node_modules/@groove-dev/daemon/test/lockmanager.test.js +64 -0
- package/node_modules/@groove-dev/daemon/test/memory.test.js +252 -0
- package/node_modules/@groove-dev/daemon/test/rotator.test.js +108 -0
- package/node_modules/@groove-dev/daemon/test/router.test.js +64 -0
- package/node_modules/@groove-dev/daemon/test/slides-engine.test.js +230 -0
- package/node_modules/@groove-dev/daemon/test/tokentracker.test.js +78 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-DjORRpF0.css +1 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-eCrVowF0.js +652 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -4
- package/node_modules/@groove-dev/gui/src/components/agents/agent-chat.jsx +26 -17
- package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +22 -1
- package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +53 -21
- package/node_modules/@groove-dev/gui/src/components/agents/agent-node.jsx +132 -90
- package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +212 -1
- package/node_modules/@groove-dev/gui/src/components/dashboard/cache-ring.jsx +6 -2
- package/node_modules/@groove-dev/gui/src/components/dashboard/intel-panel.jsx +495 -174
- package/node_modules/@groove-dev/gui/src/components/dashboard/kpi-card.jsx +12 -2
- package/node_modules/@groove-dev/gui/src/components/dashboard/team-burn-panel.jsx +55 -0
- package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/components/layout/app-shell.jsx +24 -19
- package/node_modules/@groove-dev/gui/src/components/layout/command-palette.jsx +2 -2
- package/node_modules/@groove-dev/gui/src/components/marketplace/integration-wizard.jsx +391 -61
- package/node_modules/@groove-dev/gui/src/components/marketplace/marketplace-card.jsx +29 -7
- package/node_modules/@groove-dev/gui/src/lib/format.js +0 -6
- package/node_modules/@groove-dev/gui/src/lib/hooks/use-dashboard.js +23 -5
- package/node_modules/@groove-dev/gui/src/stores/groove.js +59 -9
- package/node_modules/@groove-dev/gui/src/views/agents.jsx +84 -10
- package/node_modules/@groove-dev/gui/src/views/dashboard.jsx +24 -21
- package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +153 -85
- package/package.json +2 -8
- package/packages/cli/bin/groove.js +2 -0
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/commands/nuke.js +16 -4
- package/packages/cli/src/commands/stop.js +17 -2
- package/packages/daemon/integrations-registry.json +681 -75
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/adaptive.js +23 -25
- package/packages/daemon/src/api.js +346 -22
- package/packages/daemon/src/classifier.js +53 -6
- package/packages/daemon/src/firstrun.js +14 -1
- package/packages/daemon/src/gateways/manager.js +2 -2
- package/packages/daemon/src/index.js +28 -4
- package/packages/daemon/src/integrations.js +215 -14
- package/packages/daemon/src/introducer.js +84 -11
- package/packages/daemon/src/journalist.js +43 -1
- package/packages/daemon/src/lockmanager.js +60 -0
- package/packages/daemon/src/mcp-manager.js +270 -0
- package/packages/daemon/src/memory.js +370 -0
- package/packages/daemon/src/pm.js +1 -1
- package/packages/daemon/src/process.js +141 -9
- package/packages/daemon/src/registry.js +1 -1
- package/packages/daemon/src/rotator.js +334 -31
- package/packages/daemon/src/router.js +43 -0
- package/packages/daemon/src/tokentracker.js +70 -18
- package/packages/daemon/src/validate.js +5 -13
- package/packages/daemon/templates/groove-slides.cjs +306 -0
- package/packages/gui/dist/assets/index-DjORRpF0.css +1 -0
- package/packages/gui/dist/assets/index-eCrVowF0.js +652 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -4
- package/packages/gui/src/components/agents/agent-chat.jsx +26 -17
- package/packages/gui/src/components/agents/agent-config.jsx +22 -1
- package/packages/gui/src/components/agents/agent-feed.jsx +53 -21
- package/packages/gui/src/components/agents/agent-node.jsx +132 -90
- package/packages/gui/src/components/agents/spawn-wizard.jsx +212 -1
- package/packages/gui/src/components/dashboard/cache-ring.jsx +6 -2
- package/packages/gui/src/components/dashboard/intel-panel.jsx +495 -174
- package/packages/gui/src/components/dashboard/kpi-card.jsx +12 -2
- package/packages/gui/src/components/dashboard/team-burn-panel.jsx +55 -0
- package/packages/gui/src/components/layout/activity-bar.jsx +3 -3
- package/packages/gui/src/components/layout/app-shell.jsx +24 -19
- package/packages/gui/src/components/layout/command-palette.jsx +2 -2
- package/packages/gui/src/components/marketplace/integration-wizard.jsx +391 -61
- package/packages/gui/src/components/marketplace/marketplace-card.jsx +29 -7
- package/packages/gui/src/lib/format.js +0 -6
- package/packages/gui/src/lib/hooks/use-dashboard.js +23 -5
- package/packages/gui/src/stores/groove.js +59 -9
- package/packages/gui/src/views/agents.jsx +84 -10
- package/packages/gui/src/views/dashboard.jsx +24 -21
- package/packages/gui/src/views/marketplace.jsx +153 -85
- package/node_modules/@groove-dev/gui/dist/assets/index-CEFKgLGB.css +0 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-CaKBNWcK.js +0 -638
- package/node_modules/@groove-dev/gui/dist/groove-logo-short.png +0 -0
- package/node_modules/@groove-dev/gui/dist/groove-logo.png +0 -0
- package/node_modules/@groove-dev/gui/public/groove-logo-short.png +0 -0
- package/node_modules/@groove-dev/gui/public/groove-logo.png +0 -0
- package/node_modules/@groove-dev/gui/src/components/ui/dropdown-menu.jsx +0 -60
- package/node_modules/@groove-dev/gui/src/lib/hooks/use-media-query.js +0 -18
- package/node_modules/@radix-ui/react-dropdown-menu/LICENSE +0 -21
- package/node_modules/@radix-ui/react-dropdown-menu/README.md +0 -3
- package/node_modules/@radix-ui/react-dropdown-menu/dist/index.d.mts +0 -97
- package/node_modules/@radix-ui/react-dropdown-menu/dist/index.d.ts +0 -97
- package/node_modules/@radix-ui/react-dropdown-menu/dist/index.js +0 -337
- package/node_modules/@radix-ui/react-dropdown-menu/dist/index.js.map +0 -7
- package/node_modules/@radix-ui/react-dropdown-menu/dist/index.mjs +0 -305
- package/node_modules/@radix-ui/react-dropdown-menu/dist/index.mjs.map +0 -7
- package/node_modules/@radix-ui/react-dropdown-menu/package.json +0 -75
- package/node_modules/@radix-ui/react-popover/LICENSE +0 -21
- package/node_modules/@radix-ui/react-popover/README.md +0 -3
- package/node_modules/@radix-ui/react-popover/dist/index.d.mts +0 -85
- package/node_modules/@radix-ui/react-popover/dist/index.d.ts +0 -85
- package/node_modules/@radix-ui/react-popover/dist/index.js +0 -352
- package/node_modules/@radix-ui/react-popover/dist/index.js.map +0 -7
- package/node_modules/@radix-ui/react-popover/dist/index.mjs +0 -320
- package/node_modules/@radix-ui/react-popover/dist/index.mjs.map +0 -7
- package/node_modules/@radix-ui/react-popover/package.json +0 -82
- package/node_modules/@radix-ui/react-separator/LICENSE +0 -21
- package/node_modules/@radix-ui/react-separator/README.md +0 -3
- package/node_modules/@radix-ui/react-separator/dist/index.d.mts +0 -21
- package/node_modules/@radix-ui/react-separator/dist/index.d.ts +0 -21
- package/node_modules/@radix-ui/react-separator/dist/index.js +0 -65
- package/node_modules/@radix-ui/react-separator/dist/index.js.map +0 -7
- package/node_modules/@radix-ui/react-separator/dist/index.mjs +0 -32
- package/node_modules/@radix-ui/react-separator/dist/index.mjs.map +0 -7
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/LICENSE +0 -21
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/README.md +0 -3
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.d.mts +0 -52
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.d.ts +0 -52
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.js +0 -80
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.js.map +0 -7
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.mjs +0 -47
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.mjs.map +0 -7
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/package.json +0 -69
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/LICENSE +0 -21
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/README.md +0 -3
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.d.mts +0 -22
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.d.ts +0 -22
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.js +0 -152
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.js.map +0 -7
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.mjs +0 -119
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.mjs.map +0 -7
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/package.json +0 -64
- package/node_modules/@radix-ui/react-separator/package.json +0 -69
- package/packages/gui/dist/assets/index-CEFKgLGB.css +0 -1
- package/packages/gui/dist/assets/index-CaKBNWcK.js +0 -638
- package/packages/gui/dist/groove-logo-short.png +0 -0
- package/packages/gui/dist/groove-logo.png +0 -0
- package/packages/gui/public/groove-logo-short.png +0 -0
- package/packages/gui/public/groove-logo.png +0 -0
- package/packages/gui/src/components/ui/dropdown-menu.jsx +0 -60
- package/packages/gui/src/lib/hooks/use-media-query.js +0 -18
|
@@ -162,4 +162,112 @@ describe('Rotator', () => {
|
|
|
162
162
|
assert.equal(stats.totalRotations, 2);
|
|
163
163
|
assert.equal(stats.totalTokensSaved, 8000);
|
|
164
164
|
});
|
|
165
|
+
|
|
166
|
+
describe('safety triggers', () => {
|
|
167
|
+
const SPAWNED = new Date(Date.now() - 60_000).toISOString(); // spawned 1 min ago
|
|
168
|
+
|
|
169
|
+
function mkAgent(overrides = {}) {
|
|
170
|
+
return {
|
|
171
|
+
id: 'a1', name: 'backend-1', role: 'backend',
|
|
172
|
+
provider: 'claude-code', scope: [], model: null,
|
|
173
|
+
tokensUsed: 0, contextUsage: 0.1, workingDir: '/tmp',
|
|
174
|
+
spawnedAt: SPAWNED, status: 'running',
|
|
175
|
+
...overrides,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
it('returns null when safety config is missing', () => {
|
|
180
|
+
mockDaemon.config = undefined;
|
|
181
|
+
const trigger = rotator._checkSafetyTriggers(mkAgent());
|
|
182
|
+
assert.equal(trigger, null);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('returns null when autoRotate is disabled', () => {
|
|
186
|
+
mockDaemon.config = { safety: { autoRotate: false, tokenCeilingPerAgent: 100 } };
|
|
187
|
+
mockDaemon.tokens.getTokensInWindow = () => 1000;
|
|
188
|
+
const trigger = rotator._checkSafetyTriggers(mkAgent());
|
|
189
|
+
assert.equal(trigger, null);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('fires token_limit_exceeded when instance tokens hit ceiling', () => {
|
|
193
|
+
mockDaemon.config = {
|
|
194
|
+
safety: {
|
|
195
|
+
autoRotate: true,
|
|
196
|
+
tokenCeilingPerAgent: 1_000_000,
|
|
197
|
+
velocityWindowSeconds: 300,
|
|
198
|
+
velocityTokenThreshold: 2_000_000,
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
mockDaemon.tokens.getTokensInWindow = () => 1_200_000;
|
|
202
|
+
mockDaemon.tokens.getVelocity = () => 0;
|
|
203
|
+
|
|
204
|
+
const trigger = rotator._checkSafetyTriggers(mkAgent());
|
|
205
|
+
assert.equal(trigger.reason, 'token_limit_exceeded');
|
|
206
|
+
assert.equal(trigger.instanceTokens, 1_200_000);
|
|
207
|
+
assert.equal(trigger.ceiling, 1_000_000);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('fires runaway_velocity when recent burn exceeds threshold', () => {
|
|
211
|
+
mockDaemon.config = {
|
|
212
|
+
safety: {
|
|
213
|
+
autoRotate: true,
|
|
214
|
+
tokenCeilingPerAgent: 10_000_000,
|
|
215
|
+
velocityWindowSeconds: 300,
|
|
216
|
+
velocityTokenThreshold: 1_000_000,
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
mockDaemon.tokens.getTokensInWindow = () => 500_000; // under ceiling
|
|
220
|
+
mockDaemon.tokens.getVelocity = () => 1_500_000;
|
|
221
|
+
|
|
222
|
+
const trigger = rotator._checkSafetyTriggers(mkAgent());
|
|
223
|
+
assert.equal(trigger.reason, 'runaway_velocity');
|
|
224
|
+
assert.equal(trigger.velocity, 1_500_000);
|
|
225
|
+
assert.equal(trigger.threshold, 1_000_000);
|
|
226
|
+
assert.equal(trigger.windowMs, 300_000);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('ceiling check takes priority over velocity check', () => {
|
|
230
|
+
mockDaemon.config = {
|
|
231
|
+
safety: {
|
|
232
|
+
autoRotate: true,
|
|
233
|
+
tokenCeilingPerAgent: 1_000_000,
|
|
234
|
+
velocityWindowSeconds: 300,
|
|
235
|
+
velocityTokenThreshold: 1_000_000,
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
mockDaemon.tokens.getTokensInWindow = () => 2_000_000;
|
|
239
|
+
mockDaemon.tokens.getVelocity = () => 2_000_000;
|
|
240
|
+
const trigger = rotator._checkSafetyTriggers(mkAgent());
|
|
241
|
+
assert.equal(trigger.reason, 'token_limit_exceeded');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('returns null when neither threshold hit', () => {
|
|
245
|
+
mockDaemon.config = {
|
|
246
|
+
safety: {
|
|
247
|
+
autoRotate: true,
|
|
248
|
+
tokenCeilingPerAgent: 5_000_000,
|
|
249
|
+
velocityWindowSeconds: 300,
|
|
250
|
+
velocityTokenThreshold: 1_500_000,
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
mockDaemon.tokens.getTokensInWindow = () => 100_000;
|
|
254
|
+
mockDaemon.tokens.getVelocity = () => 10_000;
|
|
255
|
+
const trigger = rotator._checkSafetyTriggers(mkAgent());
|
|
256
|
+
assert.equal(trigger, null);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('stats track safety-triggered rotations separately', async () => {
|
|
260
|
+
mockDaemon.registry.agents = [mkAgent({ tokensUsed: 1_200_000 })];
|
|
261
|
+
await rotator.rotate('a1', {
|
|
262
|
+
reason: 'token_limit_exceeded',
|
|
263
|
+
instanceTokens: 1_200_000,
|
|
264
|
+
ceiling: 1_000_000,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const stats = rotator.getStats();
|
|
268
|
+
assert.equal(stats.tokenLimitRotations, 1);
|
|
269
|
+
assert.equal(stats.velocityRotations, 0);
|
|
270
|
+
assert.equal(stats.totalRotations, 1);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
165
273
|
});
|
|
@@ -93,4 +93,68 @@ describe('ModelRouter', () => {
|
|
|
93
93
|
assert.equal(status.modes.AUTO, 'auto');
|
|
94
94
|
assert.equal(status.modes.AUTO_FLOOR, 'auto-floor');
|
|
95
95
|
});
|
|
96
|
+
|
|
97
|
+
describe('getSuggestion (downshift only, never auto-applied)', () => {
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
// Mock registry returns an agent on claude-code with heavy model
|
|
100
|
+
mockDaemon.registry.get = (id) => ({
|
|
101
|
+
id,
|
|
102
|
+
provider: 'claude-code',
|
|
103
|
+
model: 'claude-opus-4-6',
|
|
104
|
+
role: 'backend',
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('returns null when agent not found', () => {
|
|
109
|
+
mockDaemon.registry.get = () => null;
|
|
110
|
+
assert.equal(router.getSuggestion('missing'), null);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('returns null when classifier has too few events', () => {
|
|
114
|
+
// Add a handful — below the 40-event threshold
|
|
115
|
+
for (let i = 0; i < 10; i++) {
|
|
116
|
+
mockDaemon.classifier.addEvent('agent-1', { type: 'tool', tool: 'Read', input: `f${i}.js` });
|
|
117
|
+
}
|
|
118
|
+
assert.equal(router.getSuggestion('agent-1'), null);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('suggests a lighter model when classification is light with enough data', () => {
|
|
122
|
+
for (let i = 0; i < 50; i++) {
|
|
123
|
+
mockDaemon.classifier.addEvent('agent-1', { type: 'tool', tool: 'Read', input: `f${i}.js` });
|
|
124
|
+
}
|
|
125
|
+
const s = router.getSuggestion('agent-1');
|
|
126
|
+
assert.ok(s, 'expected a suggestion');
|
|
127
|
+
assert.equal(s.classifiedTier, 'light');
|
|
128
|
+
assert.equal(s.currentModel.tier, 'heavy');
|
|
129
|
+
assert.ok(['medium', 'light'].includes(s.suggestedModel.tier));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('does not suggest upshift (never silently escalates)', () => {
|
|
133
|
+
// Heavy-signal events
|
|
134
|
+
for (let i = 0; i < 50; i++) {
|
|
135
|
+
mockDaemon.classifier.addEvent('agent-1', {
|
|
136
|
+
type: 'tool', tool: 'Edit', input: `f${i}.js`, data: 'complex refactor migrate schema',
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
// Current model is already heavy; never suggest going heavier
|
|
140
|
+
const s = router.getSuggestion('agent-1');
|
|
141
|
+
// Either null (no lower tier) or only suggests lighter
|
|
142
|
+
if (s) {
|
|
143
|
+
const tierRank = { heavy: 3, medium: 2, light: 1 };
|
|
144
|
+
assert.ok(tierRank[s.suggestedModel.tier] < tierRank[s.currentModel.tier]);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('returns null when current model already matches classification', () => {
|
|
149
|
+
mockDaemon.registry.get = (id) => ({
|
|
150
|
+
id, provider: 'claude-code',
|
|
151
|
+
model: 'claude-haiku-4-5-20251001', // light tier
|
|
152
|
+
role: 'backend',
|
|
153
|
+
});
|
|
154
|
+
for (let i = 0; i < 50; i++) {
|
|
155
|
+
mockDaemon.classifier.addEvent('agent-1', { type: 'tool', tool: 'Read', input: `f${i}.js` });
|
|
156
|
+
}
|
|
157
|
+
assert.equal(router.getSuggestion('agent-1'), null);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
96
160
|
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// GROOVE — Slides Layout Engine Tests
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { describe, it } from 'node:test';
|
|
5
|
+
import assert from 'node:assert';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { dirname, resolve } from 'node:path';
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const engine = require(resolve(__dirname, '../templates/groove-slides.cjs'));
|
|
13
|
+
|
|
14
|
+
describe('slides-engine / LAYOUT', () => {
|
|
15
|
+
it('exposes frozen constants with the documented 16:9 safe zone', () => {
|
|
16
|
+
assert.equal(engine.LAYOUT.SLIDE_W, 10);
|
|
17
|
+
assert.equal(engine.LAYOUT.SLIDE_H, 5.625);
|
|
18
|
+
assert.equal(engine.LAYOUT.SAFE_BOT, 5.125);
|
|
19
|
+
assert.equal(engine.LAYOUT.CONTENT_START, 0.65);
|
|
20
|
+
assert.throws(() => { engine.LAYOUT.SLIDE_W = 99; }, /read only|Cannot assign/i);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('slides-engine / estimateTextHeight', () => {
|
|
25
|
+
it('returns single-line height for short text', () => {
|
|
26
|
+
const h = engine.estimateTextHeight('Hello', 18, 5.0);
|
|
27
|
+
const lineH = (18 * 1.3) / 72;
|
|
28
|
+
assert.ok(Math.abs(h - lineH) < 0.01, `expected ~${lineH}, got ${h}`);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('accounts for word wrap on long text in a narrow box', () => {
|
|
32
|
+
const short = engine.estimateTextHeight('One two', 24, 8.0);
|
|
33
|
+
const long = engine.estimateTextHeight(
|
|
34
|
+
'This sentence is long enough to wrap across multiple lines in a narrow box',
|
|
35
|
+
24, 2.0,
|
|
36
|
+
);
|
|
37
|
+
assert.ok(long > short * 2, `long (${long}) should be at least 2× short (${short})`);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('respects explicit newlines', () => {
|
|
41
|
+
const h = engine.estimateTextHeight('one\ntwo\nthree', 12, 8.0);
|
|
42
|
+
const lineH = (12 * 1.3) / 72;
|
|
43
|
+
assert.ok(h >= lineH * 3 - 0.01, `expected ≥ 3 lines, got ${h}`);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('does not divide by zero on empty text', () => {
|
|
47
|
+
assert.ok(engine.estimateTextHeight('', 18, 5.0) > 0);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('slides-engine / fitFontSize', () => {
|
|
52
|
+
it('returns maxSize when text already fits', () => {
|
|
53
|
+
const size = engine.fitFontSize('Short', 48, 18, 8.0, 2.0);
|
|
54
|
+
assert.equal(size, 48);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('shrinks text to fit a constrained box', () => {
|
|
58
|
+
const long = 'AI coding tools are broken at the multi-agent level.';
|
|
59
|
+
const fitted = engine.fitFontSize(long, 48, 18, 5.5, 0.85, { bold: true });
|
|
60
|
+
assert.ok(fitted < 48, `should have shrunk from 48, got ${fitted}`);
|
|
61
|
+
const needed = engine.estimateTextHeight(long, fitted, 5.5, { bold: true });
|
|
62
|
+
assert.ok(needed <= 0.85, `fitted size ${fitted} still overflows: needs ${needed}`);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('returns minSize when even that does not fit', () => {
|
|
66
|
+
const impossibleText = 'a '.repeat(500);
|
|
67
|
+
const size = engine.fitFontSize(impossibleText, 48, 10, 1.0, 0.3);
|
|
68
|
+
assert.equal(size, 10);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('slides-engine / checkCollisions', () => {
|
|
73
|
+
it('detects overlapping rectangles', () => {
|
|
74
|
+
const issues = engine.checkCollisions([
|
|
75
|
+
{ name: 'a', x: 0, y: 0, w: 2, h: 1 },
|
|
76
|
+
{ name: 'b', x: 1, y: 0.5, w: 2, h: 1 },
|
|
77
|
+
]);
|
|
78
|
+
assert.equal(issues.length, 1);
|
|
79
|
+
assert.match(issues[0], /OVERLAP/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('returns empty for non-overlapping rectangles', () => {
|
|
83
|
+
const issues = engine.checkCollisions([
|
|
84
|
+
{ name: 'a', x: 0, y: 0, w: 2, h: 1 },
|
|
85
|
+
{ name: 'b', x: 3, y: 0, w: 2, h: 1 },
|
|
86
|
+
]);
|
|
87
|
+
assert.deepEqual(issues, []);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('ignores skipCollision flag — no bypasses allowed', () => {
|
|
91
|
+
const issues = engine.checkCollisions([
|
|
92
|
+
{ name: 'a', x: 0, y: 0, w: 2, h: 1, skipCollision: true },
|
|
93
|
+
{ name: 'b', x: 1, y: 0.5, w: 2, h: 1, skipCollision: true },
|
|
94
|
+
]);
|
|
95
|
+
assert.equal(issues.length, 1, 'skipCollision flag must not bypass the check');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('slides-engine / checkBounds', () => {
|
|
100
|
+
it('flags elements that overflow the safe bottom', () => {
|
|
101
|
+
const issues = engine.checkBounds([
|
|
102
|
+
{ name: 'footer', x: 0.6, y: 5.0, w: 8.8, h: 0.5 },
|
|
103
|
+
]);
|
|
104
|
+
assert.equal(issues.length, 1);
|
|
105
|
+
assert.match(issues[0], /BOTTOM-OVERFLOW/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('flags top / left / right overflow', () => {
|
|
109
|
+
const issues = engine.checkBounds([
|
|
110
|
+
{ name: 'above', x: 0.6, y: -0.1, w: 1, h: 0.2 },
|
|
111
|
+
{ name: 'leftOut', x: -0.5, y: 1, w: 0.3, h: 0.2 },
|
|
112
|
+
{ name: 'rightOut', x: 9.8, y: 1, w: 0.5, h: 0.2 },
|
|
113
|
+
]);
|
|
114
|
+
assert.ok(issues.some((m) => /TOP-OVERFLOW/.test(m)));
|
|
115
|
+
assert.ok(issues.some((m) => /LEFT-OVERFLOW/.test(m)));
|
|
116
|
+
assert.ok(issues.some((m) => /RIGHT-OVERFLOW/.test(m)));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('ignores skipBounds flag — no bypasses allowed', () => {
|
|
120
|
+
const issues = engine.checkBounds([
|
|
121
|
+
{ name: 'footer', x: 0.6, y: 5.0, w: 8.8, h: 0.5, skipBounds: true },
|
|
122
|
+
]);
|
|
123
|
+
assert.equal(issues.length, 1, 'skipBounds flag must not bypass the check');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('slides-engine / checkTextWrap', () => {
|
|
128
|
+
it('flags text that needs more height than its box', () => {
|
|
129
|
+
const issues = engine.checkTextWrap([
|
|
130
|
+
{ name: 'title', x: 0, y: 0, w: 5.5, h: 0.85,
|
|
131
|
+
text: 'AI coding tools are broken at the multi-agent level.',
|
|
132
|
+
fontSize: 38, bold: true },
|
|
133
|
+
]);
|
|
134
|
+
assert.equal(issues.length, 1);
|
|
135
|
+
assert.match(issues[0], /TEXT-WRAP/);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('does NOT bypass check on shrinkText — that flag is ignored', () => {
|
|
139
|
+
const issues = engine.checkTextWrap([
|
|
140
|
+
{ name: 'title', x: 0, y: 0, w: 5.5, h: 0.85,
|
|
141
|
+
text: 'AI coding tools are broken at the multi-agent level.',
|
|
142
|
+
fontSize: 38, bold: true, shrinkText: true },
|
|
143
|
+
]);
|
|
144
|
+
assert.equal(issues.length, 1, 'shrinkText must NOT exempt an element from the wrap check');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('passes when text fits', () => {
|
|
148
|
+
const issues = engine.checkTextWrap([
|
|
149
|
+
{ name: 'label', x: 0, y: 0, w: 5.0, h: 0.3,
|
|
150
|
+
text: 'WHY THIS MATTERS', fontSize: 11, bold: true },
|
|
151
|
+
]);
|
|
152
|
+
assert.deepEqual(issues, []);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('slides-engine / hardGate', () => {
|
|
157
|
+
it('returns quietly on an empty / valid deck', () => {
|
|
158
|
+
// Capture stdout
|
|
159
|
+
const origLog = console.log;
|
|
160
|
+
const logs = [];
|
|
161
|
+
console.log = (msg) => logs.push(String(msg));
|
|
162
|
+
try {
|
|
163
|
+
engine.hardGate([
|
|
164
|
+
['slide-1', [{ name: 'title', x: 0.6, y: 0.65, w: 8.8, h: 1.0,
|
|
165
|
+
text: 'Hello world', fontSize: 24, bold: true }]],
|
|
166
|
+
]);
|
|
167
|
+
} finally {
|
|
168
|
+
console.log = origLog;
|
|
169
|
+
}
|
|
170
|
+
assert.ok(logs.some((l) => /Layout gate passed/.test(l)));
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('calls process.exit(1) when any issue is found', () => {
|
|
174
|
+
const origExit = process.exit;
|
|
175
|
+
const origError = console.error;
|
|
176
|
+
let exitCode = null;
|
|
177
|
+
process.exit = (code) => { exitCode = code; throw new Error('__exit__'); };
|
|
178
|
+
console.error = () => {};
|
|
179
|
+
try {
|
|
180
|
+
engine.hardGate([
|
|
181
|
+
['slide-1', [
|
|
182
|
+
{ name: 'a', x: 0, y: 0, w: 2, h: 1 },
|
|
183
|
+
{ name: 'b', x: 1, y: 0.5, w: 2, h: 1 },
|
|
184
|
+
]],
|
|
185
|
+
]);
|
|
186
|
+
assert.fail('hardGate should have exited');
|
|
187
|
+
} catch (e) {
|
|
188
|
+
if (e.message !== '__exit__') throw e;
|
|
189
|
+
} finally {
|
|
190
|
+
process.exit = origExit;
|
|
191
|
+
console.error = origError;
|
|
192
|
+
}
|
|
193
|
+
assert.equal(exitCode, 1);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe('slides-engine / tracker', () => {
|
|
198
|
+
it('collects placed elements in order', () => {
|
|
199
|
+
const { track, placed } = engine.tracker();
|
|
200
|
+
track('a', { x: 0, y: 0, w: 1, h: 1 });
|
|
201
|
+
track('b', { x: 2, y: 0, w: 1, h: 1 });
|
|
202
|
+
assert.equal(placed.length, 2);
|
|
203
|
+
assert.equal(placed[0].name, 'a');
|
|
204
|
+
assert.equal(placed[1].name, 'b');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('returns the opts object so it can be spread into addText', () => {
|
|
208
|
+
const { track } = engine.tracker();
|
|
209
|
+
const opts = track('a', { x: 0, y: 0, w: 1, h: 1, fontSize: 14 });
|
|
210
|
+
assert.equal(opts.fontSize, 14);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('rejects skipCollision / skipBounds / skipWrap flags with a clear error', () => {
|
|
214
|
+
const { track } = engine.tracker();
|
|
215
|
+
for (const flag of ['skipCollision', 'skipBounds', 'skipWrap']) {
|
|
216
|
+
assert.throws(
|
|
217
|
+
() => track('a', { x: 0, y: 0, w: 1, h: 1, [flag]: true }),
|
|
218
|
+
new RegExp(`"${flag}" is not a supported option`),
|
|
219
|
+
`${flag} must be rejected by tracker`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('rejects non-finite coordinates', () => {
|
|
225
|
+
const { track } = engine.tracker();
|
|
226
|
+
assert.throws(() => track('a', { x: NaN, y: 0, w: 1, h: 1 }));
|
|
227
|
+
assert.throws(() => track('a', { x: 0, y: Infinity, w: 1, h: 1 }));
|
|
228
|
+
assert.throws(() => track('a', { x: 0, y: 0, w: 'wide', h: 1 }));
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -109,4 +109,82 @@ describe('TokenTracker', () => {
|
|
|
109
109
|
assert.ok(summary.savings.percentage > 0);
|
|
110
110
|
assert.ok(summary.savings.estimatedWithoutGroove > summary.totalTokens);
|
|
111
111
|
});
|
|
112
|
+
|
|
113
|
+
it('cache hit rate returns 0 when no cacheable tokens exist', () => {
|
|
114
|
+
tracker.record('agent-1', { tokens: 1000, inputTokens: 1000 });
|
|
115
|
+
const summary = tracker.getSummary();
|
|
116
|
+
assert.equal(summary.cacheHitRate, 0);
|
|
117
|
+
assert.equal(tracker.getCacheHitRate(), 0);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('cache hit rate excludes fresh input tokens from denominator', () => {
|
|
121
|
+
// 800 cache reads + 200 cache creation = 1000 cacheable → 80% hit rate.
|
|
122
|
+
// Fresh inputTokens must NOT inflate the denominator.
|
|
123
|
+
tracker.record('agent-1', {
|
|
124
|
+
tokens: 6000,
|
|
125
|
+
inputTokens: 5000,
|
|
126
|
+
cacheReadTokens: 800,
|
|
127
|
+
cacheCreationTokens: 200,
|
|
128
|
+
});
|
|
129
|
+
const summary = tracker.getSummary();
|
|
130
|
+
assert.equal(summary.cacheHitRate, 0.8);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('cache hit rate is 1.0 when all cacheable tokens are reads', () => {
|
|
134
|
+
tracker.record('agent-1', {
|
|
135
|
+
tokens: 1000,
|
|
136
|
+
cacheReadTokens: 1000,
|
|
137
|
+
cacheCreationTokens: 0,
|
|
138
|
+
});
|
|
139
|
+
assert.equal(tracker.getCacheHitRate(), 1.0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('internal reserved IDs (__prefix) are segregated from user agents', () => {
|
|
143
|
+
tracker.record('agent-1', { tokens: 1000, inputTokens: 1000 });
|
|
144
|
+
tracker.record('agent-2', { tokens: 500, inputTokens: 500 });
|
|
145
|
+
tracker.record('__journalist__', { tokens: 300, inputTokens: 300 });
|
|
146
|
+
tracker.record('__pm__', { tokens: 200, inputTokens: 200 });
|
|
147
|
+
|
|
148
|
+
const summary = tracker.getSummary();
|
|
149
|
+
// perAgent excludes internal IDs
|
|
150
|
+
assert.equal(summary.perAgent.length, 2);
|
|
151
|
+
assert.ok(summary.perAgent.every((a) => !a.agentId.startsWith('__')));
|
|
152
|
+
// agentCount reflects user-facing agents only
|
|
153
|
+
assert.equal(summary.agentCount, 2);
|
|
154
|
+
// Internal overhead is exposed separately
|
|
155
|
+
assert.equal(summary.internalOverhead.tokens, 500);
|
|
156
|
+
assert.equal(summary.internalOverhead.components['__journalist__'].tokens, 300);
|
|
157
|
+
assert.equal(summary.internalOverhead.components['__pm__'].tokens, 200);
|
|
158
|
+
// totalTokens still includes internal (reflects real billing)
|
|
159
|
+
assert.equal(summary.totalTokens, 2000);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('empty internalOverhead when no internal IDs recorded', () => {
|
|
163
|
+
tracker.record('agent-1', { tokens: 100 });
|
|
164
|
+
const summary = tracker.getSummary();
|
|
165
|
+
assert.equal(summary.internalOverhead.tokens, 0);
|
|
166
|
+
assert.deepEqual(summary.internalOverhead.components, {});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('getTokensInWindow returns 0 for unknown agent', () => {
|
|
170
|
+
assert.equal(tracker.getTokensInWindow('nonexistent', 0), 0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('getTokensInWindow sums sessions since a given timestamp', () => {
|
|
174
|
+
tracker.record('agent-1', { tokens: 100 });
|
|
175
|
+
tracker.record('agent-1', { tokens: 200 });
|
|
176
|
+
tracker.record('agent-1', { tokens: 300 });
|
|
177
|
+
// sinceTs = 0 captures all
|
|
178
|
+
assert.equal(tracker.getTokensInWindow('agent-1', 0), 600);
|
|
179
|
+
// sinceTs = now + 10s captures nothing
|
|
180
|
+
assert.equal(tracker.getTokensInWindow('agent-1', Date.now() + 10_000), 0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('getVelocity returns tokens in a rolling window', () => {
|
|
184
|
+
tracker.record('agent-1', { tokens: 1000 });
|
|
185
|
+
// Large window captures the recent recording
|
|
186
|
+
assert.equal(tracker.getVelocity('agent-1', 60_000), 1000);
|
|
187
|
+
// Empty tracker returns 0
|
|
188
|
+
assert.equal(tracker.getVelocity('unknown-agent', 60_000), 0);
|
|
189
|
+
});
|
|
112
190
|
});
|