groove-dev 0.27.206 → 0.27.207
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/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/chatstore.js +112 -21
- package/node_modules/@groove-dev/daemon/src/index.js +13 -0
- package/node_modules/@groove-dev/daemon/src/routes/agents.js +4 -0
- package/node_modules/@groove-dev/daemon/src/routes/chat-history.js +6 -3
- package/node_modules/@groove-dev/daemon/test/chatstore.test.js +111 -19
- package/node_modules/@groove-dev/gui/dist/assets/{index-BYQ4qIHh.js → index-OzNfp6-Y.js} +36 -36
- package/node_modules/@groove-dev/gui/dist/index.html +1 -1
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/chatstore.js +112 -21
- package/packages/daemon/src/index.js +13 -0
- package/packages/daemon/src/routes/agents.js +4 -0
- package/packages/daemon/src/routes/chat-history.js +6 -3
- package/packages/gui/dist/assets/{index-BYQ4qIHh.js → index-OzNfp6-Y.js} +36 -36
- package/packages/gui/dist/index.html +1 -1
- package/packages/gui/package.json +1 -1
|
@@ -9,14 +9,21 @@ const MAX_PER_AGENT = 200;
|
|
|
9
9
|
const SAVE_DEBOUNCE_MS = 1500;
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* Server-side chat history.
|
|
12
|
+
* Server-side chat history, keyed by AGENT NAME.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
14
|
+
* Two hard-won identity lessons live here:
|
|
15
|
+
*
|
|
16
|
+
* 1. localStorage is per-origin (scheme://host:PORT), and a tunnelled GUI's
|
|
17
|
+
* port changes across reconnects — so history kept only in the browser gets
|
|
18
|
+
* stranded on dead origins. Hence a server-side store at all.
|
|
19
|
+
*
|
|
20
|
+
* 2. Agent IDs are NOT stable: every rotation/resume mints a new id. A store
|
|
21
|
+
* keyed by id fragments on every rotation — the full history stays under
|
|
22
|
+
* the dead id while the new id starts near-empty, and a reconnect then
|
|
23
|
+
* "restores" that near-empty stub over the user's real history. The agent
|
|
24
|
+
* NAME survives rotation (the Watch system keys by name for the same
|
|
25
|
+
* reason), so name is the identity here. IDs are accepted at the API edge
|
|
26
|
+
* and resolved immediately.
|
|
20
27
|
*/
|
|
21
28
|
export class ChatStore {
|
|
22
29
|
constructor(daemon) {
|
|
@@ -36,6 +43,50 @@ export class ChatStore {
|
|
|
36
43
|
return {};
|
|
37
44
|
}
|
|
38
45
|
|
|
46
|
+
// Resolve an id-or-name to the stable storage key (the agent's name).
|
|
47
|
+
// Unknown refs are used verbatim so nothing is ever dropped — an orphaned
|
|
48
|
+
// id key re-merges at boot once its agent is known, or stays parked.
|
|
49
|
+
_keyFor(ref) {
|
|
50
|
+
if (!ref) return null;
|
|
51
|
+
const byId = this.daemon.registry?.get?.(ref);
|
|
52
|
+
if (byId?.name) return byId.name;
|
|
53
|
+
return String(ref);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Fold any id-keyed entries into their agent's name key. Runs at boot (after
|
|
58
|
+
* registry restore) so stores written by the old id-keyed code reattach to
|
|
59
|
+
* the agent wherever the id is still resolvable.
|
|
60
|
+
*/
|
|
61
|
+
migrate() {
|
|
62
|
+
const agents = this.daemon.registry?.getAll?.() || [];
|
|
63
|
+
const byId = new Map(agents.map((a) => [a.id, a]));
|
|
64
|
+
let moved = 0;
|
|
65
|
+
for (const key of Object.keys(this.history)) {
|
|
66
|
+
const agent = byId.get(key);
|
|
67
|
+
if (!agent || agent.name === key) continue;
|
|
68
|
+
this.history[agent.name] = mergeMessages(this.history[agent.name], this.history[key]);
|
|
69
|
+
delete this.history[key];
|
|
70
|
+
moved += 1;
|
|
71
|
+
}
|
|
72
|
+
if (moved) this._scheduleSave();
|
|
73
|
+
return moved;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Rotation-time hook: an entry parked under a dead id (written by the old
|
|
78
|
+
* id-keyed code, or by an old GUI posting under ids) follows the agent to
|
|
79
|
+
* its new identity.
|
|
80
|
+
*/
|
|
81
|
+
remap(oldRef, newRef) {
|
|
82
|
+
const oldKey = String(oldRef);
|
|
83
|
+
const newKey = this._keyFor(newRef);
|
|
84
|
+
if (!newKey || oldKey === newKey || !this.history[oldKey]) return;
|
|
85
|
+
this.history[newKey] = mergeMessages(this.history[newKey], this.history[oldKey]);
|
|
86
|
+
delete this.history[oldKey];
|
|
87
|
+
this._scheduleSave();
|
|
88
|
+
}
|
|
89
|
+
|
|
39
90
|
_scheduleSave() {
|
|
40
91
|
if (this._saveTimer) return;
|
|
41
92
|
this._saveTimer = setTimeout(() => {
|
|
@@ -64,35 +115,59 @@ export class ChatStore {
|
|
|
64
115
|
return out;
|
|
65
116
|
}
|
|
66
117
|
|
|
67
|
-
append(
|
|
68
|
-
|
|
118
|
+
append(ref, message) {
|
|
119
|
+
const key = this._keyFor(ref);
|
|
120
|
+
if (!key) return;
|
|
69
121
|
const clean = this._clean(message);
|
|
70
122
|
if (!clean) return;
|
|
71
|
-
const arr = this.history[
|
|
123
|
+
const arr = this.history[key] || [];
|
|
72
124
|
arr.push(clean);
|
|
73
|
-
this.history[
|
|
125
|
+
this.history[key] = arr.slice(-MAX_PER_AGENT);
|
|
74
126
|
this._scheduleSave();
|
|
75
127
|
}
|
|
76
128
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
129
|
+
/**
|
|
130
|
+
* Merge a batch from a client. This is a UNION by (timestamp, from, text) —
|
|
131
|
+
* never a replace — so a browser holding messages the server missed adds
|
|
132
|
+
* them, and a browser with less than the server can never truncate it.
|
|
133
|
+
* A "restore" must be incapable of destroying what it restores from.
|
|
134
|
+
*/
|
|
135
|
+
merge(ref, messages) {
|
|
136
|
+
const key = this._keyFor(ref);
|
|
137
|
+
if (!key || !Array.isArray(messages)) return;
|
|
138
|
+
const incoming = messages.map((m) => this._clean(m)).filter(Boolean);
|
|
139
|
+
this.history[key] = mergeMessages(this.history[key], incoming);
|
|
82
140
|
this._scheduleSave();
|
|
83
141
|
}
|
|
84
142
|
|
|
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.
|
|
147
|
+
*/
|
|
148
|
+
view() {
|
|
149
|
+
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;
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
85
159
|
getAll() {
|
|
86
160
|
return this.history;
|
|
87
161
|
}
|
|
88
162
|
|
|
89
|
-
get(
|
|
90
|
-
return this.history[
|
|
163
|
+
get(ref) {
|
|
164
|
+
return this.history[this._keyFor(ref)] || [];
|
|
91
165
|
}
|
|
92
166
|
|
|
93
|
-
remove(
|
|
94
|
-
|
|
95
|
-
|
|
167
|
+
remove(ref) {
|
|
168
|
+
const key = this._keyFor(ref);
|
|
169
|
+
if (key && this.history[key]) {
|
|
170
|
+
delete this.history[key];
|
|
96
171
|
this._scheduleSave();
|
|
97
172
|
}
|
|
98
173
|
}
|
|
@@ -102,3 +177,19 @@ export class ChatStore {
|
|
|
102
177
|
this._saveNow();
|
|
103
178
|
}
|
|
104
179
|
}
|
|
180
|
+
|
|
181
|
+
// Union of two message arrays, deduped on (timestamp, from, text), time-sorted,
|
|
182
|
+
// capped. Exported for tests and the migration path.
|
|
183
|
+
export function mergeMessages(a, b) {
|
|
184
|
+
const seen = new Set();
|
|
185
|
+
const out = [];
|
|
186
|
+
for (const m of [...(a || []), ...(b || [])]) {
|
|
187
|
+
if (!m || typeof m !== 'object') continue;
|
|
188
|
+
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);
|
|
191
|
+
out.push(m);
|
|
192
|
+
}
|
|
193
|
+
out.sort((x, y) => (x.timestamp || 0) - (y.timestamp || 0));
|
|
194
|
+
return out.slice(-MAX_PER_AGENT);
|
|
195
|
+
}
|
|
@@ -438,6 +438,12 @@ export class Daemon {
|
|
|
438
438
|
}
|
|
439
439
|
|
|
440
440
|
broadcast(message) {
|
|
441
|
+
// Every rotation/resume path announces itself here — the one chokepoint
|
|
442
|
+
// for "this agent has a new id". Chat history parked under the dead id
|
|
443
|
+
// (stale client posts, pre-migration data) follows the agent.
|
|
444
|
+
if (message.type === 'rotation:complete' && message.oldAgentId && message.agentId) {
|
|
445
|
+
try { this.chatStore.remap(message.oldAgentId, message.agentId); } catch { /* best effort */ }
|
|
446
|
+
}
|
|
441
447
|
if (!this.wss) return;
|
|
442
448
|
const payload = JSON.stringify(message);
|
|
443
449
|
for (const client of this.wss.clients) {
|
|
@@ -640,6 +646,13 @@ export class Daemon {
|
|
|
640
646
|
this.federation.initialize();
|
|
641
647
|
this._startGarbageCollector();
|
|
642
648
|
|
|
649
|
+
// Fold id-keyed chat history into name keys now that the registry is
|
|
650
|
+
// restored — reattaches histories written by the old id-keyed store.
|
|
651
|
+
try {
|
|
652
|
+
const moved = this.chatStore.migrate();
|
|
653
|
+
if (moved) console.log(`[chat] migrated ${moved} id-keyed histories to agent names`);
|
|
654
|
+
} catch { /* best effort */ }
|
|
655
|
+
|
|
643
656
|
// Regenerate the on-disk registry files once on boot. They otherwise
|
|
644
657
|
// only refresh on a registry change, so a daemon upgraded with new
|
|
645
658
|
// agent-facing docs (InnerChat, Watch) would serve stale AGENTS_REGISTRY.md
|
|
@@ -89,6 +89,10 @@ export function registerAgentRoutes(app, daemon) {
|
|
|
89
89
|
// Killed/completed agents stay visible so the user can review output.
|
|
90
90
|
const purge = req.query.purge === 'true';
|
|
91
91
|
if (purge) {
|
|
92
|
+
// Clear chat history while the id still resolves to a name — the
|
|
93
|
+
// store is name-keyed, and a purged name's history must not haunt a
|
|
94
|
+
// future agent spawned under the same name.
|
|
95
|
+
try { daemon.chatStore.remove(agent.name); } catch { /* best effort */ }
|
|
92
96
|
daemon.registry.remove(req.params.id);
|
|
93
97
|
}
|
|
94
98
|
|
|
@@ -4,7 +4,9 @@ export function registerChatHistoryRoutes(app, daemon) {
|
|
|
4
4
|
// Full history for all agents — the GUI loads this on connect so chats are
|
|
5
5
|
// present regardless of which origin/port the tunnel came up on.
|
|
6
6
|
app.get('/api/chat-history', (req, res) => {
|
|
7
|
-
|
|
7
|
+
// view() keys live agents by their CURRENT id — ids churn on rotation, so
|
|
8
|
+
// the store itself is name-keyed and this translates at the edge.
|
|
9
|
+
res.json({ history: daemon.chatStore.view() });
|
|
8
10
|
});
|
|
9
11
|
|
|
10
12
|
// Append a single message for an agent.
|
|
@@ -17,13 +19,14 @@ export function registerChatHistoryRoutes(app, daemon) {
|
|
|
17
19
|
res.json({ ok: true });
|
|
18
20
|
});
|
|
19
21
|
|
|
20
|
-
//
|
|
22
|
+
// Batch sync from a client. A UNION, deliberately not a replace — a client
|
|
23
|
+
// can add messages the server missed but can never truncate server history.
|
|
21
24
|
app.put('/api/chat-history/:agentId', (req, res) => {
|
|
22
25
|
const { messages } = req.body || {};
|
|
23
26
|
if (!Array.isArray(messages)) {
|
|
24
27
|
return res.status(400).json({ error: 'messages array required' });
|
|
25
28
|
}
|
|
26
|
-
daemon.chatStore.
|
|
29
|
+
daemon.chatStore.merge(req.params.agentId, messages);
|
|
27
30
|
res.json({ ok: true });
|
|
28
31
|
});
|
|
29
32
|
|
|
@@ -6,28 +6,101 @@ import assert from 'node:assert/strict';
|
|
|
6
6
|
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs';
|
|
7
7
|
import { tmpdir } from 'os';
|
|
8
8
|
import { resolve } from 'path';
|
|
9
|
-
import { ChatStore } from '../src/chatstore.js';
|
|
9
|
+
import { ChatStore, mergeMessages } from '../src/chatstore.js';
|
|
10
|
+
|
|
11
|
+
// Minimal registry: id -> {id, name}. Lets tests exercise the id→name
|
|
12
|
+
// resolution that makes history survive rotation.
|
|
13
|
+
function makeDaemon(agents = []) {
|
|
14
|
+
const map = new Map(agents.map((a) => [a.id, a]));
|
|
15
|
+
return {
|
|
16
|
+
grooveDir: null, // set per test
|
|
17
|
+
registry: {
|
|
18
|
+
_map: map,
|
|
19
|
+
get(id) { return map.get(id) || null; },
|
|
20
|
+
getAll() { return [...map.values()]; },
|
|
21
|
+
set(a) { map.set(a.id, a); },
|
|
22
|
+
delete(id) { map.delete(id); },
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
10
26
|
|
|
11
27
|
describe('ChatStore', () => {
|
|
12
|
-
let dir, store;
|
|
28
|
+
let dir, daemon, store;
|
|
13
29
|
|
|
14
30
|
beforeEach(() => {
|
|
15
31
|
dir = mkdtempSync(resolve(tmpdir(), 'groove-chat-'));
|
|
16
|
-
|
|
32
|
+
daemon = makeDaemon([{ id: 'a1', name: 'fullstack-1' }, { id: 'a2', name: 'planner-1' }]);
|
|
33
|
+
daemon.grooveDir = dir;
|
|
34
|
+
store = new ChatStore(daemon);
|
|
17
35
|
});
|
|
18
36
|
afterEach(() => {
|
|
19
37
|
store.stop();
|
|
20
38
|
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
21
39
|
});
|
|
22
40
|
|
|
23
|
-
it('appends
|
|
41
|
+
it('appends under the agent NAME when given an id', () => {
|
|
24
42
|
store.append('a1', { from: 'user', text: 'hello', timestamp: 1 });
|
|
25
|
-
store.append('
|
|
26
|
-
|
|
43
|
+
store.append('fullstack-1', { from: 'agent', text: 'hi', timestamp: 2 });
|
|
44
|
+
|
|
45
|
+
// Both routes land in one bucket — the name.
|
|
46
|
+
assert.deepEqual(store.get('a1').map((m) => m.text), ['hello', 'hi']);
|
|
47
|
+
assert.deepEqual(store.getAll()['fullstack-1'].map((m) => m.text), ['hello', 'hi']);
|
|
48
|
+
assert.equal(store.getAll().a1, undefined);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('history survives rotation: new id, same name, same history', () => {
|
|
52
|
+
store.append('a1', { from: 'user', text: 'before rotation', timestamp: 1 });
|
|
53
|
+
|
|
54
|
+
// Rotation mints a new id; the name is stable.
|
|
55
|
+
daemon.registry.delete('a1');
|
|
56
|
+
daemon.registry.set({ id: 'a1-r9', name: 'fullstack-1' });
|
|
57
|
+
|
|
58
|
+
store.append('a1-r9', { from: 'agent', text: 'after rotation', timestamp: 2 });
|
|
59
|
+
assert.deepEqual(store.get('a1-r9').map((m) => m.text), ['before rotation', 'after rotation']);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('remap() folds a dead-id entry into the live agent', () => {
|
|
63
|
+
// Simulates data written under an id while the registry didn't know it
|
|
64
|
+
// (stale client post mid-rotation) — the rotation:complete hook remaps it.
|
|
65
|
+
store.history['dead-id'] = [{ from: 'user', text: 'stranded', timestamp: 1 }];
|
|
66
|
+
store.remap('dead-id', 'a1');
|
|
67
|
+
assert.equal(store.history['dead-id'], undefined);
|
|
68
|
+
assert.deepEqual(store.get('a1').map((m) => m.text), ['stranded']);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('migrate() reattaches id-keyed histories from the old store format', () => {
|
|
72
|
+
store.history = {
|
|
73
|
+
a1: [{ from: 'user', text: 'legacy', timestamp: 1 }],
|
|
74
|
+
'fullstack-1': [{ from: 'agent', text: 'current', timestamp: 2 }],
|
|
75
|
+
'ghost-id': [{ from: 'user', text: 'unresolvable', timestamp: 3 }],
|
|
76
|
+
};
|
|
77
|
+
const moved = store.migrate();
|
|
78
|
+
assert.equal(moved, 1);
|
|
79
|
+
assert.deepEqual(store.getAll()['fullstack-1'].map((m) => m.text), ['legacy', 'current']);
|
|
80
|
+
// Unresolvable keys are parked, never dropped.
|
|
81
|
+
assert.equal(store.getAll()['ghost-id'][0].text, 'unresolvable');
|
|
82
|
+
});
|
|
27
83
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
84
|
+
it('view() keys live agents by CURRENT id and parks the rest by name', () => {
|
|
85
|
+
store.append('a1', { from: 'user', text: 'live', timestamp: 1 });
|
|
86
|
+
store.history['departed-agent'] = [{ from: 'user', text: 'old', timestamp: 2 }];
|
|
87
|
+
const v = store.view();
|
|
88
|
+
assert.equal(v.a1[0].text, 'live'); // fullstack-1 → its live id
|
|
89
|
+
assert.equal(v['fullstack-1'], undefined);
|
|
90
|
+
assert.equal(v['departed-agent'][0].text, 'old'); // no live agent — name key
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('merge() is a union — a sparse client can never truncate server history', () => {
|
|
94
|
+
for (let i = 0; i < 10; i++) store.append('a1', { from: 'user', text: `m${i}`, timestamp: i });
|
|
95
|
+
// Client syncs only 2 messages (1 duplicate, 1 new).
|
|
96
|
+
store.merge('a1', [
|
|
97
|
+
{ from: 'user', text: 'm3', timestamp: 3 },
|
|
98
|
+
{ from: 'agent', text: 'fresh', timestamp: 99 },
|
|
99
|
+
]);
|
|
100
|
+
const texts = store.get('a1').map((m) => m.text);
|
|
101
|
+
assert.equal(texts.length, 11);
|
|
102
|
+
assert.equal(texts.at(-1), 'fresh');
|
|
103
|
+
assert.ok(texts.includes('m0') && texts.includes('m9'));
|
|
31
104
|
});
|
|
32
105
|
|
|
33
106
|
it('strips attachment data URLs but keeps metadata', () => {
|
|
@@ -44,7 +117,7 @@ describe('ChatStore', () => {
|
|
|
44
117
|
store.append('a1', { from: 'user', text: 'durable', timestamp: 1 });
|
|
45
118
|
store.stop(); // flushes synchronously
|
|
46
119
|
|
|
47
|
-
const reloaded = new ChatStore(
|
|
120
|
+
const reloaded = new ChatStore(daemon);
|
|
48
121
|
assert.equal(reloaded.get('a1')[0].text, 'durable');
|
|
49
122
|
reloaded.stop();
|
|
50
123
|
});
|
|
@@ -55,7 +128,7 @@ describe('ChatStore', () => {
|
|
|
55
128
|
assert.ok(existsSync(resolve(dir, 'chat-history.json')));
|
|
56
129
|
assert.ok(!existsSync(resolve(dir, 'chat-history.json.tmp')));
|
|
57
130
|
const onDisk = JSON.parse(readFileSync(resolve(dir, 'chat-history.json'), 'utf8'));
|
|
58
|
-
assert.equal(onDisk
|
|
131
|
+
assert.equal(onDisk['fullstack-1'][0].text, 'x');
|
|
59
132
|
});
|
|
60
133
|
|
|
61
134
|
it('caps per-agent history', () => {
|
|
@@ -66,21 +139,40 @@ describe('ChatStore', () => {
|
|
|
66
139
|
assert.equal(h.at(-1).text, 'm249');
|
|
67
140
|
});
|
|
68
141
|
|
|
69
|
-
it('
|
|
70
|
-
store.append('a1', { from: 'user', text: 'old' });
|
|
71
|
-
store.replace('a1', [{ from: 'user', text: 'new1' }, { from: 'agent', text: 'new2' }]);
|
|
72
|
-
assert.deepEqual(store.get('a1').map((m) => m.text), ['new1', 'new2']);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('remove() deletes an agent history', () => {
|
|
142
|
+
it('remove() deletes by name so a purge cannot haunt a future same-name agent', () => {
|
|
76
143
|
store.append('a1', { from: 'user', text: 'x' });
|
|
77
|
-
store.remove('
|
|
144
|
+
store.remove('fullstack-1');
|
|
78
145
|
assert.equal(store.get('a1').length, 0);
|
|
79
146
|
});
|
|
80
147
|
|
|
148
|
+
it('works without a registry (refs pass through verbatim)', () => {
|
|
149
|
+
const bare = new ChatStore({ grooveDir: mkdtempSync(resolve(tmpdir(), 'groove-chat-bare-')) });
|
|
150
|
+
bare.append('some-id', { from: 'user', text: 'x', timestamp: 1 });
|
|
151
|
+
assert.equal(bare.get('some-id')[0].text, 'x');
|
|
152
|
+
bare.stop();
|
|
153
|
+
});
|
|
154
|
+
|
|
81
155
|
it('starts empty when the file is missing or corrupt', () => {
|
|
82
156
|
const empty = new ChatStore({ grooveDir: mkdtempSync(resolve(tmpdir(), 'groove-chat-empty-')) });
|
|
83
157
|
assert.deepEqual(empty.getAll(), {});
|
|
84
158
|
empty.stop();
|
|
85
159
|
});
|
|
86
160
|
});
|
|
161
|
+
|
|
162
|
+
describe('mergeMessages', () => {
|
|
163
|
+
it('dedupes on (timestamp, from, text) and sorts by time', () => {
|
|
164
|
+
const merged = mergeMessages(
|
|
165
|
+
[{ from: 'u', text: 'b', timestamp: 2 }, { from: 'u', text: 'a', timestamp: 1 }],
|
|
166
|
+
[{ from: 'u', text: 'a', timestamp: 1 }, { from: 'u', text: 'c', timestamp: 3 }],
|
|
167
|
+
);
|
|
168
|
+
assert.deepEqual(merged.map((m) => m.text), ['a', 'b', 'c']);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('treats same text at different timestamps as distinct messages', () => {
|
|
172
|
+
const merged = mergeMessages(
|
|
173
|
+
[{ from: 'u', text: 'ok', timestamp: 1 }],
|
|
174
|
+
[{ from: 'u', text: 'ok', timestamp: 2 }],
|
|
175
|
+
);
|
|
176
|
+
assert.equal(merged.length, 2);
|
|
177
|
+
});
|
|
178
|
+
});
|