groove-dev 0.27.209 → 0.27.211

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/CLAUDE.md +8 -0
  2. package/node_modules/@groove-dev/cli/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/src/axom-connector.js +20 -6
  5. package/node_modules/@groove-dev/daemon/src/axom-runtimes.js +67 -1
  6. package/node_modules/@groove-dev/daemon/src/chatstore.js +57 -13
  7. package/node_modules/@groove-dev/daemon/src/index.js +2 -0
  8. package/node_modules/@groove-dev/daemon/src/routes/axom.js +17 -0
  9. package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +41 -0
  10. package/node_modules/@groove-dev/daemon/test/axom-runtimes.test.js +62 -0
  11. package/node_modules/@groove-dev/daemon/test/chatstore.test.js +67 -2
  12. package/node_modules/@groove-dev/gui/dist/assets/{index-217ZVIOc.js → index-BP4oE2UL.js} +227 -227
  13. package/node_modules/@groove-dev/gui/dist/assets/index-DPsim83z.css +1 -0
  14. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  15. package/node_modules/@groove-dev/gui/package.json +1 -1
  16. package/package.json +1 -1
  17. package/packages/cli/package.json +1 -1
  18. package/packages/daemon/package.json +1 -1
  19. package/packages/daemon/src/axom-connector.js +20 -6
  20. package/packages/daemon/src/axom-runtimes.js +67 -1
  21. package/packages/daemon/src/chatstore.js +57 -13
  22. package/packages/daemon/src/index.js +2 -0
  23. package/packages/daemon/src/routes/axom.js +17 -0
  24. package/packages/gui/dist/assets/{index-217ZVIOc.js → index-BP4oE2UL.js} +227 -227
  25. package/packages/gui/dist/assets/index-DPsim83z.css +1 -0
  26. package/packages/gui/dist/index.html +2 -2
  27. package/packages/gui/package.json +1 -1
  28. package/node_modules/@groove-dev/gui/dist/assets/index-DdCadtGL.css +0 -1
  29. package/packages/gui/dist/assets/index-DdCadtGL.css +0 -1
package/CLAUDE.md CHANGED
@@ -295,3 +295,11 @@ Audit-driven release. Multi-agent orchestration system with 7 coordination layer
295
295
  - Dashboard: routing donut, cache panel, context health gauges
296
296
  - Monitor/QC agent mode (stay active, loop)
297
297
  - Distribution: demo video, HN launch, Twitter content
298
+
299
+ <!-- GROOVE:START -->
300
+ ## GROOVE Orchestration (auto-injected)
301
+ Active agents: 0
302
+ See AGENTS_REGISTRY.md for full agent state, the names of agents on other teams,
303
+ and how to consult them directly (InnerChat).
304
+ **Memory policy:** GROOVE manages project memory automatically. Do not read or write MEMORY.md or .groove/memory/ files directly.
305
+ <!-- GROOVE:END -->
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.209",
3
+ "version": "0.27.211",
4
4
  "description": "GROOVE CLI — manage AI coding agents from your terminal",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.209",
3
+ "version": "0.27.211",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -33,8 +33,16 @@ export const KNOWN_KINDS = Object.freeze([
33
33
  'narration', 'narration_dropped',
34
34
  'candidate_arrived', 'evidence_scored', 'champion_changed',
35
35
  'confidence_updated', 'verifier_verdict',
36
+ 'resolution_delta', 'context_compile',
36
37
  ]);
37
38
 
39
+ // Cosmetic, append-only chunks of an answer still being written. The terminal
40
+ // `resolution` always follows with the authoritative text, so a delta is
41
+ // worthless the moment it lands — keeping them would crowd the ring with
42
+ // history that replay deliberately omits anyway (they are transient runtime
43
+ // side, so a `?since` replay never returns them either).
44
+ const TRANSIENT_KINDS = new Set(['resolution_delta']);
45
+
38
46
  const RING_SIZE = 4096;
39
47
  const SESSION_POLL_MS = 15000;
40
48
  const BACKOFF_BASE_MS = 1000;
@@ -243,13 +251,19 @@ export class AxomConnector {
243
251
  // Dedup on ring-buffer replay after reconnect — ids are monotonic.
244
252
  if (seq !== null && seq <= s.lastSeq) return;
245
253
  if (seq !== null) s.lastSeq = seq;
246
- if (s.ring.length >= this.ringSize) {
247
- s.ring.shift();
248
- // The ring bounds memory, not delivery: overflow is counted, never
249
- // silent, and every event still broadcasts — mirrors the runtime.
250
- s.overflow += 1;
254
+ // Transient kinds broadcast but are never retained: they are superseded
255
+ // by a terminal event, and a backfilled tab must see the same history a
256
+ // reconnecting one does. `lastSeq` still advances above, so dedup and
257
+ // ordering are unaffected.
258
+ if (!TRANSIENT_KINDS.has(envelope.kind)) {
259
+ if (s.ring.length >= this.ringSize) {
260
+ s.ring.shift();
261
+ // The ring bounds memory, not delivery: overflow is counted, never
262
+ // silent, and every event still broadcasts — mirrors the runtime.
263
+ s.overflow += 1;
264
+ }
265
+ s.ring.push(envelope);
251
266
  }
252
- s.ring.push(envelope);
253
267
  if (envelope.kind && !KNOWN_KINDS.includes(envelope.kind)) {
254
268
  s.unknownKinds[envelope.kind] = (s.unknownKinds[envelope.kind] || 0) + 1;
255
269
  }
@@ -32,6 +32,22 @@ function withBlessedEnv(launch) {
32
32
  return { ...launch, env: { ...BLESSED_ENV, ...(launch.env || {}) } };
33
33
  }
34
34
 
35
+ // A chat title is the opening message, trimmed to a glanceable length. It is
36
+ // a QUOTE, not a summary: GROOVE has no business paraphrasing what the user
37
+ // said, and an em-dash ellipsis makes the truncation visible rather than
38
+ // pretending the sentence ended there.
39
+ const TITLE_MAX = 48;
40
+ export function summarizeForTitle(text) {
41
+ if (typeof text !== 'string') return null;
42
+ const flat = text.replace(/\s+/g, ' ').trim();
43
+ if (!flat) return null;
44
+ if (flat.length <= TITLE_MAX) return flat;
45
+ // Prefer a word boundary so titles don't end mid-word.
46
+ const cut = flat.slice(0, TITLE_MAX);
47
+ const space = cut.lastIndexOf(' ');
48
+ return `${(space > TITLE_MAX * 0.6 ? cut.slice(0, space) : cut).trimEnd()}…`;
49
+ }
50
+
35
51
  // Single-quote for a POSIX shell. The spec is the user's own, but it crosses
36
52
  // an ssh command line — an unquoted path or value must not be able to end the
37
53
  // command and start another.
@@ -329,13 +345,29 @@ export class AxomRuntimes {
329
345
  this._save();
330
346
  }
331
347
 
348
+ // A chat titles itself from what it started with — "Chat 3" tells you
349
+ // nothing when you have six of them. Only ever replaces a PLACEHOLDER title:
350
+ // a name the user typed, or one already derived from the opening message, is
351
+ // never overwritten by a later turn.
352
+ titleFromFirstMessage(session, text) {
353
+ const chat = this.getChat(session);
354
+ if (!chat || chat.titled || chat.renamed) return null;
355
+ const title = summarizeForTitle(text);
356
+ if (!title) return null;
357
+ this._putChat({ ...chat, label: title, titled: true });
358
+ this.broadcastChats();
359
+ return title;
360
+ }
361
+
332
362
  renameChat(session, label) {
333
363
  const chat = this.getChat(session);
334
364
  if (!chat) throw new Error(`no chat "${session}"`);
335
365
  if (typeof label !== 'string' || !label.trim() || label.length > 80) {
336
366
  throw new Error('label must be a non-empty string of at most 80 chars');
337
367
  }
338
- this._putChat({ ...chat, label: label.trim() });
368
+ // `renamed` is sticky: once the user names a chat, no later auto-title
369
+ // may take it back.
370
+ this._putChat({ ...chat, label: label.trim(), renamed: true });
339
371
  this.broadcastChats();
340
372
  return this.getChat(session);
341
373
  }
@@ -348,10 +380,44 @@ export class AxomRuntimes {
348
380
  const chat = this.getChat(session);
349
381
  if (!chat) throw new Error(`no chat "${session}"`);
350
382
  this._putChat({ ...chat, hidden: true });
383
+ this._forgetPrompts(session);
384
+ this._save();
351
385
  this.broadcastChats();
352
386
  return { hidden: true, session, note: 'removed from the list; the conversation remains in Axom\'s memory' };
353
387
  }
354
388
 
389
+ // ── Prompts — what GROOVE sent, remembered where the events are ──────────
390
+ //
391
+ // The runtime's `pipeline_start` carries no prompt text, so the user's own
392
+ // words exist only in GROOVE. Keeping them in the browser meant a reload
393
+ // replayed every turn from the daemon's ring with its bubble gone — the
394
+ // answer with no question above it. This is OUR record of what WE sent, not
395
+ // invented telemetry, so the daemon is the right place for it.
396
+ recordPrompt(session, ref, text) {
397
+ if (!session || !ref) return null;
398
+ const all = this._cfg().prompts || {};
399
+ const forSession = (all[session] || []).filter((p) => p.ref !== ref);
400
+ // Bounded per session: a transcript this long is scrollback, not memory.
401
+ const next = [...forSession, { ref, text, ts: Date.now() }].slice(-200);
402
+ this._cfg().prompts = { ...all, [session]: next };
403
+ this._save();
404
+ return { ref, text };
405
+ }
406
+
407
+ prompts(session) {
408
+ return (this._cfg().prompts || {})[session] || [];
409
+ }
410
+
411
+ // A hidden chat's prompts go with it — the list is tidied, the ledger keeps
412
+ // the conversation itself.
413
+ _forgetPrompts(session) {
414
+ const all = this._cfg().prompts || {};
415
+ if (!all[session]) return;
416
+ const next = { ...all };
417
+ delete next[session];
418
+ this._cfg().prompts = next;
419
+ }
420
+
355
421
  broadcastChats() {
356
422
  this.daemon.broadcast({ type: 'axom:chats', data: { chats: this.chats() } });
357
423
  }
@@ -141,21 +141,37 @@ export class ChatStore {
141
141
  }
142
142
 
143
143
  /**
144
- * History for the GUI: live agents keyed by their CURRENT id (what the GUI
145
- * looks up by), everything else under its stored name key so an agent
146
- * respawned under the same name picks its history back up.
144
+ * History for the GUI: LIVE agents only, keyed by their current id (what the
145
+ * GUI looks up by). Buckets for agents that no longer exist stay on disk —
146
+ * a respawn under the same name picks them back up — but aren't shipped:
147
+ * they were 97% of a 5.6MB payload, which blew the browser's ~5MB
148
+ * localStorage quota and froze the local cache.
147
149
  */
148
150
  view() {
149
151
  const out = {};
150
- const agents = this.daemon.registry?.getAll?.() || [];
151
- const liveNames = new Map(agents.map((a) => [a.name, a.id]));
152
- for (const [key, msgs] of Object.entries(this.history)) {
153
- if (!Array.isArray(msgs) || !msgs.length) continue;
154
- out[liveNames.get(key) || key] = msgs;
152
+ for (const agent of this.daemon.registry?.getAll?.() || []) {
153
+ const msgs = this.history[agent.name];
154
+ if (Array.isArray(msgs) && msgs.length) out[agent.id] = msgs;
155
155
  }
156
156
  return out;
157
157
  }
158
158
 
159
+ /**
160
+ * Drop the oldest buckets belonging to agents that no longer exist, so a
161
+ * long-lived daemon's store stays bounded. Live agents are never pruned.
162
+ */
163
+ prune(maxDeadBuckets = 200) {
164
+ const liveNames = new Set((this.daemon.registry?.getAll?.() || []).map((a) => a.name));
165
+ const dead = Object.keys(this.history)
166
+ .filter((k) => !liveNames.has(k))
167
+ .map((k) => [k, lastTs(this.history[k])])
168
+ .sort((x, y) => y[1] - x[1]);
169
+ if (dead.length <= maxDeadBuckets) return 0;
170
+ for (const [key] of dead.slice(maxDeadBuckets)) delete this.history[key];
171
+ this._scheduleSave();
172
+ return dead.length - maxDeadBuckets;
173
+ }
174
+
159
175
  getAll() {
160
176
  return this.history;
161
177
  }
@@ -178,18 +194,46 @@ export class ChatStore {
178
194
  }
179
195
  }
180
196
 
181
- // Union of two message arrays, deduped on (timestamp, from, text), time-sorted,
182
- // capped. Exported for tests and the migration path.
197
+ function lastTs(msgs) {
198
+ return (Array.isArray(msgs) && msgs.length && msgs[msgs.length - 1]?.timestamp) || 0;
199
+ }
200
+
201
+ /**
202
+ * Union of two message arrays, time-sorted and capped.
203
+ *
204
+ * Messages carry a stable `id`. A streamed agent reply coalesces client-side
205
+ * into ONE growing message that keeps its id, so an id collision means "same
206
+ * message, later state" — we keep the longer text rather than accumulating a
207
+ * fragment per chunk. Messages without an id (older clients) fall back to a
208
+ * (timestamp, from, text) signature.
209
+ */
183
210
  export function mergeMessages(a, b) {
184
- const seen = new Set();
211
+ const byId = new Map();
212
+ const bySig = new Set();
185
213
  const out = [];
214
+
186
215
  for (const m of [...(a || []), ...(b || [])]) {
187
216
  if (!m || typeof m !== 'object') continue;
217
+
218
+ if (m.id) {
219
+ const prev = byId.get(m.id);
220
+ if (!prev) {
221
+ byId.set(m.id, m);
222
+ out.push(m);
223
+ } else if (String(m.text || '').length > String(prev.text || '').length) {
224
+ // Same message, further along — replace in place.
225
+ out[out.indexOf(prev)] = m;
226
+ byId.set(m.id, m);
227
+ }
228
+ continue;
229
+ }
230
+
188
231
  const sig = `${m.timestamp}:${m.from}:${typeof m.text === 'string' ? m.text.slice(0, 200) : ''}`;
189
- if (seen.has(sig)) continue;
190
- seen.add(sig);
232
+ if (bySig.has(sig)) continue;
233
+ bySig.add(sig);
191
234
  out.push(m);
192
235
  }
236
+
193
237
  out.sort((x, y) => (x.timestamp || 0) - (y.timestamp || 0));
194
238
  return out.slice(-MAX_PER_AGENT);
195
239
  }
@@ -653,6 +653,8 @@ export class Daemon {
653
653
  try {
654
654
  const moved = this.chatStore.migrate();
655
655
  if (moved) console.log(`[chat] migrated ${moved} id-keyed histories to agent names`);
656
+ const pruned = this.chatStore.prune();
657
+ if (pruned) console.log(`[chat] pruned ${pruned} histories for long-gone agents`);
656
658
  } catch { /* best effort */ }
657
659
 
658
660
  // Regenerate the on-disk registry files once on boot. They otherwise
@@ -46,6 +46,16 @@ export function registerAxomRoutes(app, daemon) {
46
46
  return res.status(400).json({ error: 'clientRef must be a string of at most 64 chars' });
47
47
  }
48
48
  const result = await daemon.axom.message(endpoint, req.params.id, text, clientRef);
49
+ // Title the chat from its opening message — but only once the runtime
50
+ // ACCEPTED the turn. A message rejected with 409/413 never ran, so it
51
+ // must not name the conversation it failed to start.
52
+ if (result.status === 202) {
53
+ daemon.axomRuntimes.titleFromFirstMessage(req.params.id, text);
54
+ // Remember what we sent, keyed by the §15 ref the runtime echoes in
55
+ // pipeline_start. This is what lets a reloaded tab put the user's own
56
+ // words back above the answer instead of "prompt not identified".
57
+ if (clientRef) daemon.axomRuntimes.recordPrompt(req.params.id, clientRef, text);
58
+ }
49
59
  daemon.audit.log('axom.message', { session: req.params.id, chars: text.length, status: result.status });
50
60
  res.status(result.status).json(result.body);
51
61
  } catch (err) {
@@ -167,6 +177,13 @@ export function registerAxomRoutes(app, daemon) {
167
177
  res.json({ chats: daemon.axomRuntimes.chats() });
168
178
  });
169
179
 
180
+ // What GROOVE sent on this session, so a reloaded tab can restore the user's
181
+ // bubbles. Only ever OUR OWN sends — a turn started from the REPL or another
182
+ // client has no entry here and must still render without a bubble.
183
+ app.get('/api/axom/sessions/:id/prompts', (req, res) => {
184
+ res.json({ prompts: daemon.axomRuntimes.prompts(req.params.id) });
185
+ });
186
+
170
187
  app.patch('/api/axom/chats/:session', (req, res) => {
171
188
  try {
172
189
  res.json(daemon.axomRuntimes.renameChat(req.params.session, req.body?.label));
@@ -463,6 +463,47 @@ describe('AxomConnector', () => {
463
463
  assert.ok(bridge.epochsSeen.includes('epoch-A')); // we did present the old epoch
464
464
  });
465
465
 
466
+ // Streaming answers: deltas are cosmetic chunks superseded by the terminal
467
+ // `resolution`. They must reach the GUI live but never enter the ring — the
468
+ // runtime omits them from its own replay, so a backfilled tab and a
469
+ // reconnecting one must see identical history.
470
+ it('delivers resolution deltas live but keeps them out of the replay ring', async () => {
471
+ connect();
472
+ await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
473
+ await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
474
+
475
+ bridge.emit('s-test0001', envelope(1, 'pipeline_start'));
476
+ bridge.emit('s-test0001', envelope(2, 'resolution_delta', { firing_id: 'f1', content: 'Hello ', index: 0 }));
477
+ bridge.emit('s-test0001', envelope(3, 'resolution_delta', { firing_id: 'f1', content: 'world', index: 1 }));
478
+ bridge.emit('s-test0001', envelope(4, 'resolution', { content: 'Hello world.' }));
479
+
480
+ // Every delta still BROADCASTS — delivery is untouched.
481
+ await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event').length === 4);
482
+ const kinds = daemon.broadcasts.filter((b) => b.type === 'axom:event').map((b) => b.envelope.kind);
483
+ assert.deepEqual(kinds, ['pipeline_start', 'resolution_delta', 'resolution_delta', 'resolution']);
484
+
485
+ // ...but the ring holds only the durable history.
486
+ const backfill = connector.events('local', 's-test0001');
487
+ assert.deepEqual(backfill.events.map((e) => e.kind), ['pipeline_start', 'resolution']);
488
+ // Excluded, not overflowed — dropping them is by design, not pressure.
489
+ assert.equal(connector.status().endpoints[0].sessions[0].overflow, 0);
490
+ // A known kind now, so it must not read as schema drift.
491
+ assert.equal(connector.status().endpoints[0].sessions[0].unknownKinds.resolution_delta, undefined);
492
+ });
493
+
494
+ it('a delta never breaks dedup for the events that follow it', async () => {
495
+ connect();
496
+ await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
497
+ await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
498
+ bridge.emit('s-test0001', envelope(1, 'resolution_delta', { firing_id: 'f1', content: 'x', index: 0 }));
499
+ bridge.emit('s-test0001', envelope(2, 'resolution', { content: 'x!' }));
500
+ await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event').length === 2);
501
+ // Replaying an id at or below the delta's must still be suppressed.
502
+ bridge.emit('s-test0001', envelope(1, 'resolution_delta', { firing_id: 'f1', content: 'x', index: 0 }));
503
+ await new Promise((r) => setTimeout(r, 120));
504
+ assert.equal(daemon.broadcasts.filter((b) => b.type === 'axom:event').length, 2);
505
+ });
506
+
466
507
  it('recheck collapses a stale connected state the moment the runtime is gone', async () => {
467
508
  connect();
468
509
  await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
@@ -291,6 +291,68 @@ describe('AxomRuntimes', () => {
291
291
  assert.equal(model.getChat(session).hidden, true);
292
292
  });
293
293
 
294
+ it('titles a chat from its opening message, quoting rather than paraphrasing', async () => {
295
+ model.add(SSH_RT);
296
+ daemon.axom.endpoints.set('spark', { status: 'connected', sessions: new Map() });
297
+ const { session } = await model.hook('spark');
298
+ assert.match(model.getChat(session).label, /^Chat /); // placeholder to start
299
+ model.titleFromFirstMessage(session, ' Hey good morning\n Axom! ');
300
+ assert.equal(model.getChat(session).label, 'Hey good morning Axom!');
301
+ // Only the FIRST message titles it — later turns don't rewrite history.
302
+ model.titleFromFirstMessage(session, 'something else entirely');
303
+ assert.equal(model.getChat(session).label, 'Hey good morning Axom!');
304
+ });
305
+
306
+ it('truncates a long opening message visibly and never mid-word', async () => {
307
+ model.add(SSH_RT);
308
+ daemon.axom.endpoints.set('spark', { status: 'connected', sessions: new Map() });
309
+ const { session } = await model.hook('spark');
310
+ model.titleFromFirstMessage(session, 'Can you walk me through how the memory ledger graduation policy actually works');
311
+ const { label } = model.getChat(session);
312
+ assert.ok(label.endsWith('…')); // truncation is visible, not silent
313
+ assert.ok(label.length <= 49);
314
+ assert.doesNotMatch(label, / …$/); // no dangling space before the ellipsis
315
+ assert.ok('Can you walk me through how the memory ledger graduation policy actually works'.startsWith(label.slice(0, -1)));
316
+ });
317
+
318
+ it('never lets an auto-title overwrite a name the user chose', async () => {
319
+ model.add(SSH_RT);
320
+ daemon.axom.endpoints.set('spark', { status: 'connected', sessions: new Map() });
321
+ const { session } = await model.hook('spark');
322
+ model.renameChat(session, 'Ledger work');
323
+ model.titleFromFirstMessage(session, 'Hey good morning Axom!');
324
+ assert.equal(model.getChat(session).label, 'Ledger work');
325
+ });
326
+
327
+ // The runtime's pipeline_start carries no prompt text, so the user's words
328
+ // exist only in GROOVE. Browser-only storage meant a reload replayed turns
329
+ // from the ring with their bubbles gone — the answer with no question.
330
+ it('remembers sent prompts by ref so a reloaded tab can restore bubbles', () => {
331
+ model.recordPrompt('s-1', 'g-aaa', 'Hey good morning Axom!');
332
+ model.recordPrompt('s-1', 'g-bbb', 'second one');
333
+ model.recordPrompt('s-2', 'g-ccc', 'other session');
334
+ assert.deepEqual(model.prompts('s-1').map((p) => p.ref), ['g-aaa', 'g-bbb']);
335
+ assert.equal(model.prompts('s-1')[0].text, 'Hey good morning Axom!');
336
+ assert.equal(model.prompts('s-2').length, 1); // sessions never bleed
337
+ // Survives a fresh model over the same config — that IS the reload case.
338
+ assert.equal(new AxomRuntimes(daemon).prompts('s-1').length, 2);
339
+ // Re-recording a ref replaces rather than duplicates.
340
+ model.recordPrompt('s-1', 'g-aaa', 'Hey good morning Axom!');
341
+ assert.equal(model.prompts('s-1').length, 2);
342
+ });
343
+
344
+ it('drops a hidden chat\'s prompts with it, and never another chat\'s', async () => {
345
+ model.add(SSH_RT);
346
+ daemon.axom.endpoints.set('spark', { status: 'connected', sessions: new Map() });
347
+ const a = await model.hook('spark');
348
+ const b = await model.hook('spark');
349
+ model.recordPrompt(a.session, 'g-a', 'mine');
350
+ model.recordPrompt(b.session, 'g-b', 'theirs');
351
+ model.hideChat(a.session);
352
+ assert.equal(model.prompts(a.session).length, 0);
353
+ assert.equal(model.prompts(b.session).length, 1);
354
+ });
355
+
294
356
  it('names the generation holder only when it is a chat we minted', async () => {
295
357
  model.add(SSH_RT);
296
358
  const sessions = new Map();
@@ -81,13 +81,16 @@ describe('ChatStore', () => {
81
81
  assert.equal(store.getAll()['ghost-id'][0].text, 'unresolvable');
82
82
  });
83
83
 
84
- it('view() keys live agents by CURRENT id and parks the rest by name', () => {
84
+ it('view() keys live agents by CURRENT id and omits departed ones', () => {
85
85
  store.append('a1', { from: 'user', text: 'live', timestamp: 1 });
86
86
  store.history['departed-agent'] = [{ from: 'user', text: 'old', timestamp: 2 }];
87
87
  const v = store.view();
88
88
  assert.equal(v.a1[0].text, 'live'); // fullstack-1 → its live id
89
89
  assert.equal(v['fullstack-1'], undefined);
90
- assert.equal(v['departed-agent'][0].text, 'old'); // no live agentname key
90
+ // Departed agents stay on disk but are not shipped the payload used to
91
+ // be 97% dead agents, which blew the browser's localStorage quota.
92
+ assert.equal(v['departed-agent'], undefined);
93
+ assert.equal(store.getAll()['departed-agent'][0].text, 'old');
91
94
  });
92
95
 
93
96
  it('merge() is a union — a sparse client can never truncate server history', () => {
@@ -176,3 +179,65 @@ describe('mergeMessages', () => {
176
179
  assert.equal(merged.length, 2);
177
180
  });
178
181
  });
182
+
183
+ describe('ChatStore — agent-reply persistence regression', () => {
184
+ let dir, daemon, store;
185
+
186
+ beforeEach(() => {
187
+ dir = mkdtempSync(resolve(tmpdir(), 'groove-chat-reg-'));
188
+ daemon = makeDaemon([{ id: 'live1', name: 'fullstack-1' }]);
189
+ daemon.grooveDir = dir;
190
+ store = new ChatStore(daemon);
191
+ });
192
+ afterEach(() => {
193
+ store.stop();
194
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
195
+ });
196
+
197
+ it('upserts a coalescing streamed reply instead of accumulating fragments', () => {
198
+ // The GUI grows one message as chunks stream in, keeping its id.
199
+ store.merge('live1', [{ id: 'm1', from: 'agent', text: 'Part one', timestamp: 10 }]);
200
+ store.merge('live1', [{ id: 'm1', from: 'agent', text: 'Part one\n\nPart two', timestamp: 11 }]);
201
+ store.merge('live1', [{ id: 'm1', from: 'agent', text: 'Part one\n\nPart two\n\nPart three', timestamp: 12 }]);
202
+
203
+ const h = store.get('live1');
204
+ assert.equal(h.length, 1, 'one message, not one per chunk');
205
+ assert.equal(h[0].text, 'Part one\n\nPart two\n\nPart three');
206
+ });
207
+
208
+ it('never regresses a message to an earlier, shorter state', () => {
209
+ store.merge('live1', [{ id: 'm1', from: 'agent', text: 'full long reply', timestamp: 20 }]);
210
+ store.merge('live1', [{ id: 'm1', from: 'agent', text: 'full', timestamp: 19 }]); // stale client
211
+ assert.equal(store.get('live1')[0].text, 'full long reply');
212
+ });
213
+
214
+ it('view() ships only live agents — dead buckets stay on disk but off the wire', () => {
215
+ store.append('live1', { id: 'a', from: 'agent', text: 'hi', timestamp: 1 });
216
+ store.history['long-gone-agent'] = [{ id: 'b', from: 'agent', text: 'old', timestamp: 2 }];
217
+
218
+ const v = store.view();
219
+ assert.deepEqual(Object.keys(v), ['live1'], 'payload carries live agents only');
220
+ assert.ok(store.getAll()['long-gone-agent'], 'but the data is retained on disk');
221
+ });
222
+
223
+ it('a respawned agent with the same name picks its history back up', () => {
224
+ store.append('live1', { id: 'a', from: 'agent', text: 'earlier work', timestamp: 1 });
225
+ // Agent dies and is later recreated with a different id, same name.
226
+ daemon.registry.delete('live1');
227
+ daemon.registry.set({ id: 'live1-new', name: 'fullstack-1' });
228
+ assert.equal(store.view()['live1-new'][0].text, 'earlier work');
229
+ });
230
+
231
+ it('prune() bounds dead buckets and never touches live agents', () => {
232
+ store.append('live1', { id: 'a', from: 'agent', text: 'keep me', timestamp: 999 });
233
+ for (let i = 0; i < 10; i++) {
234
+ store.history[`dead-${i}`] = [{ id: `d${i}`, from: 'agent', text: 'x', timestamp: i }];
235
+ }
236
+ const removed = store.prune(4);
237
+ assert.equal(removed, 6);
238
+ assert.equal(store.get('live1')[0].text, 'keep me');
239
+ assert.equal(Object.keys(store.getAll()).filter((k) => k.startsWith('dead-')).length, 4);
240
+ // The survivors are the most recently active dead buckets.
241
+ assert.ok(store.getAll()['dead-9'] && !store.getAll()['dead-0']);
242
+ });
243
+ });