groove-dev 0.27.196 → 0.27.198
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/axom-integration/Screenshot_2026-07-25_at_5.25.30_PM.png +0 -0
- package/axom-integration/Screenshot_2026-07-25_at_5.33.26_PM.png +0 -0
- package/axom-integration/Screenshot_2026-07-25_at_6.11.53_PM.png +0 -0
- 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/api.js +5 -5
- package/node_modules/@groove-dev/daemon/src/axom-connector.js +340 -0
- package/node_modules/@groove-dev/daemon/src/axom-install.js +140 -0
- package/node_modules/@groove-dev/daemon/src/axom-server.js +229 -0
- package/node_modules/@groove-dev/daemon/src/chatstore.js +104 -0
- package/node_modules/@groove-dev/daemon/src/federation.js +6 -0
- package/node_modules/@groove-dev/daemon/src/index.js +14 -2
- package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +8 -1
- package/node_modules/@groove-dev/daemon/src/innerchat-relay.js +89 -0
- package/node_modules/@groove-dev/daemon/src/innerchat.js +261 -1
- package/node_modules/@groove-dev/daemon/src/introducer.js +1 -1
- package/node_modules/@groove-dev/daemon/src/network-guard.js +56 -0
- package/node_modules/@groove-dev/daemon/src/process.js +2 -2
- package/node_modules/@groove-dev/daemon/src/providers/axom.js +66 -0
- package/node_modules/@groove-dev/daemon/src/providers/index.js +2 -0
- package/node_modules/@groove-dev/daemon/src/routes/agents.js +1 -175
- package/node_modules/@groove-dev/daemon/src/routes/axom.js +196 -0
- package/node_modules/@groove-dev/daemon/src/routes/chat-history.js +34 -0
- package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +152 -20
- package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +412 -0
- package/node_modules/@groove-dev/daemon/test/axom-server.test.js +187 -0
- package/node_modules/@groove-dev/daemon/test/chatstore.test.js +86 -0
- package/node_modules/@groove-dev/daemon/test/innerchat-relay.test.js +251 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-RbtaI6l7.css +1 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-b9dKN6cq.js +1074 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/App.jsx +2 -2
- package/node_modules/@groove-dev/gui/src/app.css +53 -0
- package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +2 -2
- package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/components/layout/breadcrumb-bar.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/components/layout/command-palette.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +24 -163
- package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +52 -2
- package/node_modules/@groove-dev/gui/src/stores/slices/axom-slice.js +197 -0
- package/node_modules/@groove-dev/gui/src/views/axom.jsx +1060 -0
- package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/views/settings.jsx +178 -64
- package/package.json +2 -2
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +5 -5
- package/packages/daemon/src/axom-connector.js +340 -0
- package/packages/daemon/src/axom-install.js +140 -0
- package/packages/daemon/src/axom-server.js +229 -0
- package/packages/daemon/src/chatstore.js +104 -0
- package/packages/daemon/src/federation.js +6 -0
- package/packages/daemon/src/index.js +14 -2
- package/packages/daemon/src/innerchat-docs.js +8 -1
- package/packages/daemon/src/innerchat-relay.js +89 -0
- package/packages/daemon/src/innerchat.js +261 -1
- package/packages/daemon/src/introducer.js +1 -1
- package/packages/daemon/src/network-guard.js +56 -0
- package/packages/daemon/src/process.js +2 -2
- package/packages/daemon/src/providers/axom.js +66 -0
- package/packages/daemon/src/providers/index.js +2 -0
- package/packages/daemon/src/routes/agents.js +1 -175
- package/packages/daemon/src/routes/axom.js +196 -0
- package/packages/daemon/src/routes/chat-history.js +34 -0
- package/packages/daemon/src/routes/innerchat.js +152 -20
- package/packages/gui/dist/assets/index-RbtaI6l7.css +1 -0
- package/packages/gui/dist/assets/index-b9dKN6cq.js +1074 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/App.jsx +2 -2
- package/packages/gui/src/app.css +53 -0
- package/packages/gui/src/components/agents/agent-config.jsx +2 -2
- package/packages/gui/src/components/agents/spawn-wizard.jsx +3 -3
- package/packages/gui/src/components/layout/activity-bar.jsx +3 -3
- package/packages/gui/src/components/layout/breadcrumb-bar.jsx +1 -1
- package/packages/gui/src/components/layout/command-palette.jsx +1 -1
- package/packages/gui/src/stores/groove.js +24 -163
- package/packages/gui/src/stores/slices/agents-slice.js +52 -2
- package/packages/gui/src/stores/slices/axom-slice.js +197 -0
- package/packages/gui/src/views/axom.jsx +1060 -0
- package/packages/gui/src/views/marketplace.jsx +1 -1
- package/packages/gui/src/views/settings.jsx +178 -64
- package/node_modules/@groove-dev/daemon/src/conversations.js +0 -576
- package/node_modules/@groove-dev/gui/dist/assets/index-Cat5pJUx.css +0 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-L8xSnvHj.js +0 -1070
- package/node_modules/@groove-dev/gui/src/components/chat/chat-header.jsx +0 -198
- package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +0 -367
- package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +0 -527
- package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +0 -212
- package/node_modules/@groove-dev/gui/src/components/chat/conversation-list.jsx +0 -163
- package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +0 -201
- package/node_modules/@groove-dev/gui/src/stores/slices/chat-slice.js +0 -227
- package/node_modules/@groove-dev/gui/src/views/chat.jsx +0 -6
- package/packages/daemon/src/conversations.js +0 -576
- package/packages/gui/dist/assets/index-Cat5pJUx.css +0 -1
- package/packages/gui/dist/assets/index-L8xSnvHj.js +0 -1070
- package/packages/gui/src/components/chat/chat-header.jsx +0 -198
- package/packages/gui/src/components/chat/chat-input.jsx +0 -367
- package/packages/gui/src/components/chat/chat-messages.jsx +0 -527
- package/packages/gui/src/components/chat/chat-view.jsx +0 -212
- package/packages/gui/src/components/chat/conversation-list.jsx +0 -163
- package/packages/gui/src/components/chat/model-picker.jsx +0 -201
- package/packages/gui/src/stores/slices/chat-slice.js +0 -227
- package/packages/gui/src/views/chat.jsx +0 -6
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
// GROOVE — Axom Connector Tests
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// Runs the connector against a mock bridge speaking the Axom provider
|
|
5
|
+
// protocol (GROOVE ⇄ Axom Integration Contract v0). The mock is contract-
|
|
6
|
+
// faithful: /about with kinds, /sessions, per-session WS with ?since replay,
|
|
7
|
+
// interrupt with 2000-char truncation, sticky idempotent stop.
|
|
8
|
+
|
|
9
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { createServer } from 'http';
|
|
12
|
+
import { WebSocketServer } from 'ws';
|
|
13
|
+
import { AxomConnector, KNOWN_KINDS, validateEndpoint } from '../src/axom-connector.js';
|
|
14
|
+
|
|
15
|
+
function envelope(seq, kind, payload = {}, session = 's-test0001') {
|
|
16
|
+
return {
|
|
17
|
+
id: `ev-${String(seq).padStart(6, '0')}`,
|
|
18
|
+
ts: 1753000000 + seq,
|
|
19
|
+
session,
|
|
20
|
+
firing_id: null,
|
|
21
|
+
step: null,
|
|
22
|
+
kind,
|
|
23
|
+
payload,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Contract-faithful mock of `axom serve`'s bridge surface.
|
|
28
|
+
class MockBridge {
|
|
29
|
+
constructor({ kinds = [...KNOWN_KINDS], sessions } = {}) {
|
|
30
|
+
this.kinds = kinds;
|
|
31
|
+
this.sessions = sessions || { 's-test0001': { started: 1753000000, live: true, events: [] } };
|
|
32
|
+
this.interrupts = [];
|
|
33
|
+
this.stops = [];
|
|
34
|
+
this.sinceSeen = []; // ?since values observed on WS connects
|
|
35
|
+
this.sockets = new Set();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async start(port = 0) {
|
|
39
|
+
this.server = createServer((req, res) => this._http(req, res));
|
|
40
|
+
this.wss = new WebSocketServer({ noServer: true });
|
|
41
|
+
this.server.on('upgrade', (req, socket, head) => {
|
|
42
|
+
const url = new URL(req.url, 'http://localhost');
|
|
43
|
+
const match = url.pathname.match(/^\/ws\/session\/([^/]+)$/);
|
|
44
|
+
const session = match && this.sessions[decodeURIComponent(match[1])];
|
|
45
|
+
if (!session) { socket.destroy(); return; }
|
|
46
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
|
47
|
+
this.sockets.add(ws);
|
|
48
|
+
ws.on('close', () => this.sockets.delete(ws));
|
|
49
|
+
const since = url.searchParams.get('since');
|
|
50
|
+
this.sinceSeen.push(since);
|
|
51
|
+
// Ring replay: everything after `since`, then live.
|
|
52
|
+
const from = since ? parseInt(since.slice(3), 10) : 0;
|
|
53
|
+
for (const e of session.events) {
|
|
54
|
+
if (parseInt(e.id.slice(3), 10) > from) ws.send(JSON.stringify(e));
|
|
55
|
+
}
|
|
56
|
+
session.socket = ws;
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
await new Promise((resolve, reject) => {
|
|
60
|
+
this.server.once('error', reject);
|
|
61
|
+
this.server.listen(port, '127.0.0.1', resolve);
|
|
62
|
+
});
|
|
63
|
+
this.url = `http://127.0.0.1:${this.server.address().port}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
emit(sessionId, env) {
|
|
67
|
+
const session = this.sessions[sessionId];
|
|
68
|
+
session.events.push(env);
|
|
69
|
+
if (session.socket && session.socket.readyState === 1) {
|
|
70
|
+
session.socket.send(JSON.stringify(env));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_http(req, res) {
|
|
75
|
+
const url = new URL(req.url, 'http://localhost');
|
|
76
|
+
const json = (code, body) => {
|
|
77
|
+
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
78
|
+
res.end(JSON.stringify(body));
|
|
79
|
+
};
|
|
80
|
+
if (req.method === 'GET' && url.pathname === '/about') {
|
|
81
|
+
return json(200, {
|
|
82
|
+
name: 'axom', version: '0.1.0', family: 'phi4mini-s1',
|
|
83
|
+
record: { benchmark: 'HumanEval+', 'pass@1': '73.78%' },
|
|
84
|
+
chassis: { model: 'phi4mini_stock_q8.gguf', loaded: true },
|
|
85
|
+
leaves: [{ name: 'chat', loaded: true }, { name: 'agentic', loaded: true }],
|
|
86
|
+
narrator: 'template',
|
|
87
|
+
kinds: this.kinds,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (req.method === 'GET' && url.pathname === '/sessions') {
|
|
91
|
+
return json(200, Object.entries(this.sessions).map(([id, s]) => ({
|
|
92
|
+
session: id, started: s.started, live: s.live,
|
|
93
|
+
})));
|
|
94
|
+
}
|
|
95
|
+
const interrupt = url.pathname.match(/^\/session\/([^/]+)\/interrupt$/);
|
|
96
|
+
const stop = url.pathname.match(/^\/session\/([^/]+)\/stop$/);
|
|
97
|
+
const message = url.pathname.match(/^\/session\/([^/]+)\/message$/);
|
|
98
|
+
if (req.method === 'POST' && (interrupt || stop || message)) {
|
|
99
|
+
let body = '';
|
|
100
|
+
req.on('data', (c) => { body += c; });
|
|
101
|
+
req.on('end', () => {
|
|
102
|
+
if (interrupt) {
|
|
103
|
+
const { text } = JSON.parse(body || '{}');
|
|
104
|
+
this.interrupts.push({ session: interrupt[1], text });
|
|
105
|
+
return json(200, { id: `int-${this.interrupts.length}`, truncated: text.length > 2000 });
|
|
106
|
+
}
|
|
107
|
+
if (message) {
|
|
108
|
+
// §12 semantics: caller-chosen id, first message creates; one turn
|
|
109
|
+
// at a time; hard max with 413, never truncation.
|
|
110
|
+
const id = decodeURIComponent(message[1]);
|
|
111
|
+
const { text } = JSON.parse(body || '{}');
|
|
112
|
+
if (text.length > 32768) return json(413, { error: 'too_long', max: 32768 });
|
|
113
|
+
let session = this.sessions[id];
|
|
114
|
+
if (!session) { session = this.sessions[id] = { started: Date.now() / 1000, live: false, events: [] }; }
|
|
115
|
+
if (session.live) return json(409, { error: 'busy' });
|
|
116
|
+
session.live = true;
|
|
117
|
+
return json(202, { session: id, accepted: true });
|
|
118
|
+
}
|
|
119
|
+
this.stops.push({ session: stop[1] });
|
|
120
|
+
return json(200, { ok: true });
|
|
121
|
+
});
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
json(404, { error: 'not found' });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async close() {
|
|
128
|
+
for (const ws of this.sockets) ws.terminate();
|
|
129
|
+
this.wss?.close();
|
|
130
|
+
this.server.closeAllConnections?.();
|
|
131
|
+
await new Promise((r) => this.server.close(r));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function fakeDaemon() {
|
|
136
|
+
const broadcasts = [];
|
|
137
|
+
return {
|
|
138
|
+
broadcasts,
|
|
139
|
+
config: { axom: { endpoints: [] } },
|
|
140
|
+
broadcast: (m) => broadcasts.push(m),
|
|
141
|
+
audit: { log() {} },
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function waitFor(predicate, ms = 3000) {
|
|
146
|
+
const deadline = Date.now() + ms;
|
|
147
|
+
while (Date.now() < deadline) {
|
|
148
|
+
if (predicate()) return;
|
|
149
|
+
await new Promise((r) => setTimeout(r, 15));
|
|
150
|
+
}
|
|
151
|
+
throw new Error('waitFor timed out');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
describe('AxomConnector', () => {
|
|
155
|
+
let bridge, daemon, connector;
|
|
156
|
+
|
|
157
|
+
beforeEach(async () => {
|
|
158
|
+
bridge = new MockBridge();
|
|
159
|
+
await bridge.start();
|
|
160
|
+
daemon = fakeDaemon();
|
|
161
|
+
connector = new AxomConnector(daemon, { backoffBaseMs: 50, sessionPollMs: 100 });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
afterEach(async () => {
|
|
165
|
+
connector.destroy();
|
|
166
|
+
await bridge.close();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
function connect(name = 'local') {
|
|
170
|
+
connector.configure([{ name, url: bridge.url }]);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
it('handshakes via /about and reports a clean schema match', async () => {
|
|
174
|
+
connect();
|
|
175
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
176
|
+
const ep = connector.status().endpoints[0];
|
|
177
|
+
assert.equal(ep.about.family, 'phi4mini-s1');
|
|
178
|
+
assert.equal(ep.about.narrator, 'template');
|
|
179
|
+
assert.deepEqual(ep.drift, { novel: [], missing: [] });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('surfaces schema drift in both directions without dropping anything', async () => {
|
|
183
|
+
await bridge.close();
|
|
184
|
+
bridge = new MockBridge({ kinds: [...KNOWN_KINDS.filter((k) => k !== 'leaf_swap'), 'brand_new_kind'] });
|
|
185
|
+
await bridge.start();
|
|
186
|
+
connect();
|
|
187
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
188
|
+
const ep = connector.status().endpoints[0];
|
|
189
|
+
assert.deepEqual(ep.drift.novel, ['brand_new_kind']);
|
|
190
|
+
assert.deepEqual(ep.drift.missing, ['leaf_swap']);
|
|
191
|
+
assert.equal(ep.status, 'connected'); // drift is surfaced, never fatal
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('discovers live sessions and passes envelopes through verbatim', async () => {
|
|
195
|
+
connect();
|
|
196
|
+
await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
|
|
197
|
+
const env = envelope(1, 'pipeline_start', { firing_id: 'f-1' });
|
|
198
|
+
bridge.emit('s-test0001', env);
|
|
199
|
+
await waitFor(() => daemon.broadcasts.some((b) => b.type === 'axom:event'));
|
|
200
|
+
const msg = daemon.broadcasts.find((b) => b.type === 'axom:event');
|
|
201
|
+
assert.equal(msg.endpoint, 'local');
|
|
202
|
+
assert.equal(msg.session, 's-test0001');
|
|
203
|
+
assert.deepEqual(msg.envelope, env); // byte-faithful passthrough
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('tolerates unknown kinds — passed through and counted, never dropped', async () => {
|
|
207
|
+
connect();
|
|
208
|
+
await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
|
|
209
|
+
bridge.emit('s-test0001', envelope(1, 'kind_from_the_future', { x: 1 }));
|
|
210
|
+
await waitFor(() => daemon.broadcasts.some((b) => b.type === 'axom:event'));
|
|
211
|
+
assert.equal(daemon.broadcasts.find((b) => b.type === 'axom:event').envelope.kind, 'kind_from_the_future');
|
|
212
|
+
const s = connector.status().endpoints[0].sessions[0];
|
|
213
|
+
assert.equal(s.unknownKinds.kind_from_the_future, 1);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('resumes with ?since after reconnect and dedups the ring replay', async () => {
|
|
217
|
+
connect();
|
|
218
|
+
await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
|
|
219
|
+
bridge.emit('s-test0001', envelope(1, 'pipeline_start'));
|
|
220
|
+
bridge.emit('s-test0001', envelope(2, 'thought', { text: 'hm' }));
|
|
221
|
+
await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event').length === 2);
|
|
222
|
+
|
|
223
|
+
// Kill the socket server-side; connector should reconnect with since=ev-000002.
|
|
224
|
+
bridge.sessions['s-test0001'].socket.terminate();
|
|
225
|
+
await waitFor(() => bridge.sinceSeen.length === 2);
|
|
226
|
+
assert.equal(bridge.sinceSeen[1], 'ev-000002');
|
|
227
|
+
|
|
228
|
+
// Replayed history must not re-broadcast; a genuinely new event must.
|
|
229
|
+
bridge.emit('s-test0001', envelope(3, 'resolution'));
|
|
230
|
+
await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event').length === 3);
|
|
231
|
+
const seqs = daemon.broadcasts.filter((b) => b.type === 'axom:event').map((b) => b.envelope.id);
|
|
232
|
+
assert.deepEqual(seqs, ['ev-000001', 'ev-000002', 'ev-000003']);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('proxies interrupt verbatim including the runtime truncation flag', async () => {
|
|
236
|
+
connect();
|
|
237
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
238
|
+
const short = await connector.interrupt('local', 's-test0001', 'skip the auth module');
|
|
239
|
+
assert.deepEqual(short, { id: 'int-1', truncated: false });
|
|
240
|
+
const long = await connector.interrupt('local', 's-test0001', 'x'.repeat(2500));
|
|
241
|
+
assert.equal(long.truncated, true); // the runtime is the truncation authority
|
|
242
|
+
assert.equal(bridge.interrupts.length, 2);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('proxies stop and resolves the endpoint when unambiguous', async () => {
|
|
246
|
+
connect();
|
|
247
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
248
|
+
// Omitted endpoint name resolves with exactly one endpoint configured.
|
|
249
|
+
const result = await connector.stop(undefined, 's-test0001');
|
|
250
|
+
assert.deepEqual(result, { ok: true });
|
|
251
|
+
assert.deepEqual(bridge.stops, [{ session: 's-test0001' }]);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('counts ring overflow instead of dropping silently', async () => {
|
|
255
|
+
connector.destroy();
|
|
256
|
+
connector = new AxomConnector(daemon, { ringSize: 2, backoffBaseMs: 50, sessionPollMs: 100 });
|
|
257
|
+
connect();
|
|
258
|
+
await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
|
|
259
|
+
for (let i = 1; i <= 5; i++) bridge.emit('s-test0001', envelope(i, 'thought'));
|
|
260
|
+
await waitFor(() => connector.status().endpoints[0].sessions[0].overflow === 3);
|
|
261
|
+
const s = connector.status().endpoints[0].sessions[0];
|
|
262
|
+
assert.equal(s.buffered, 2);
|
|
263
|
+
assert.equal(s.overflow, 3);
|
|
264
|
+
// All five still broadcast — the ring bounds memory, not delivery.
|
|
265
|
+
assert.equal(daemon.broadcasts.filter((b) => b.type === 'axom:event').length, 5);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('serves buffered events with a since cursor for GUI backfill', async () => {
|
|
269
|
+
connect();
|
|
270
|
+
await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
|
|
271
|
+
for (let i = 1; i <= 3; i++) bridge.emit('s-test0001', envelope(i, 'thought'));
|
|
272
|
+
await waitFor(() => connector.status().endpoints[0].sessions[0].buffered === 3);
|
|
273
|
+
const all = connector.events('local', 's-test0001');
|
|
274
|
+
assert.equal(all.events.length, 3);
|
|
275
|
+
const tail = connector.events('local', 's-test0001', 2);
|
|
276
|
+
assert.deepEqual(tail.events.map((e) => e.id), ['ev-000003']);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('isolates concurrent sessions — streams, backfill cursors, and rings never cross', async () => {
|
|
280
|
+
connector.destroy();
|
|
281
|
+
await bridge.close();
|
|
282
|
+
bridge = new MockBridge({
|
|
283
|
+
sessions: {
|
|
284
|
+
's-alpha': { started: 1, live: true, events: [] },
|
|
285
|
+
's-beta': { started: 2, live: true, events: [] },
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
await bridge.start();
|
|
289
|
+
connector = new AxomConnector(daemon, { ringSize: 2, backoffBaseMs: 50, sessionPollMs: 100 });
|
|
290
|
+
connect();
|
|
291
|
+
await waitFor(() => {
|
|
292
|
+
const sessions = connector.status().endpoints[0]?.sessions || [];
|
|
293
|
+
return sessions.length === 2 && sessions.every((s) => s.watching);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
bridge.emit('s-alpha', envelope(1, 'pipeline_start', {}, 's-alpha'));
|
|
297
|
+
bridge.emit('s-beta', envelope(1, 'thought', { text: 'b' }, 's-beta'));
|
|
298
|
+
for (let i = 2; i <= 4; i++) bridge.emit('s-alpha', envelope(i, 'thought', {}, 's-alpha'));
|
|
299
|
+
await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event').length === 5);
|
|
300
|
+
|
|
301
|
+
// Broadcast routing: every envelope tagged with its own session, only.
|
|
302
|
+
for (const b of daemon.broadcasts.filter((x) => x.type === 'axom:event')) {
|
|
303
|
+
assert.equal(b.envelope.session, b.session);
|
|
304
|
+
}
|
|
305
|
+
// Backfill cursors never leak across sessions.
|
|
306
|
+
const alpha = connector.events('local', 's-alpha');
|
|
307
|
+
const beta = connector.events('local', 's-beta');
|
|
308
|
+
assert.ok(alpha.events.every((e) => e.session === 's-alpha'));
|
|
309
|
+
assert.deepEqual(beta.events.map((e) => e.session), ['s-beta']);
|
|
310
|
+
// Rings overflow independently: alpha (4 events, ring 2) overflowed, beta did not.
|
|
311
|
+
assert.equal(alpha.overflow, 2);
|
|
312
|
+
assert.equal(beta.overflow, 0);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('passes a second stop through cleanly — sticky idempotence is preserved end to end', async () => {
|
|
316
|
+
connect();
|
|
317
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
318
|
+
const first = await connector.stop('local', 's-test0001');
|
|
319
|
+
const second = await connector.stop('local', 's-test0001');
|
|
320
|
+
// The runtime's stop is sticky-idempotent; the proxy must neither error,
|
|
321
|
+
// dedupe, nor add semantics — both calls land, both return {ok}.
|
|
322
|
+
assert.deepEqual(first, { ok: true });
|
|
323
|
+
assert.deepEqual(second, { ok: true });
|
|
324
|
+
assert.equal(bridge.stops.length, 2);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('survives a WS drop mid-flow with no gap and no duplicate when the producer never paused', async () => {
|
|
328
|
+
connect();
|
|
329
|
+
await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
|
|
330
|
+
for (let i = 1; i <= 3; i++) bridge.emit('s-test0001', envelope(i, 'thought'));
|
|
331
|
+
await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event').length === 3);
|
|
332
|
+
|
|
333
|
+
// Drop the socket while the producer keeps firing into the runtime ring.
|
|
334
|
+
bridge.sessions['s-test0001'].socket.terminate();
|
|
335
|
+
for (let i = 4; i <= 6; i++) bridge.emit('s-test0001', envelope(i, 'thought'));
|
|
336
|
+
|
|
337
|
+
// Reconnect replays 4-6 from the ring via ?since, then goes live for 7-8.
|
|
338
|
+
await waitFor(() => bridge.sinceSeen.length === 2);
|
|
339
|
+
bridge.emit('s-test0001', envelope(7, 'thought'));
|
|
340
|
+
bridge.emit('s-test0001', envelope(8, 'resolution'));
|
|
341
|
+
await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event').length === 8);
|
|
342
|
+
const ids = daemon.broadcasts.filter((b) => b.type === 'axom:event').map((b) => b.envelope.id);
|
|
343
|
+
assert.deepEqual(ids, Array.from({ length: 8 }, (_, i) => `ev-${String(i + 1).padStart(6, '0')}`));
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('message creates a caller-chosen session (202) and the connector attaches to it', async () => {
|
|
347
|
+
connect();
|
|
348
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
349
|
+
const result = await connector.message('local', 's-mine-0001', 'hello axom');
|
|
350
|
+
assert.equal(result.status, 202);
|
|
351
|
+
assert.deepEqual(result.body, { session: 's-mine-0001', accepted: true });
|
|
352
|
+
// The post-202 poll discovers and watches the new session.
|
|
353
|
+
await waitFor(() => {
|
|
354
|
+
const s = connector.status().endpoints[0].sessions.find((x) => x.session === 's-mine-0001');
|
|
355
|
+
return s && s.watching;
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('mirrors §12 contract statuses verbatim — 409 busy, 413 too_long', async () => {
|
|
360
|
+
connect();
|
|
361
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
362
|
+
await connector.message('local', 's-busy', 'first turn');
|
|
363
|
+
const busy = await connector.message('local', 's-busy', 'second turn');
|
|
364
|
+
assert.equal(busy.status, 409);
|
|
365
|
+
assert.equal(busy.body.error, 'busy');
|
|
366
|
+
const tooLong = await connector.message('local', 's-long', 'x'.repeat(40000));
|
|
367
|
+
assert.equal(tooLong.status, 413);
|
|
368
|
+
assert.equal(tooLong.body.max, 32768);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('stays attached to a session between turns (live:false still watched)', async () => {
|
|
372
|
+
connect();
|
|
373
|
+
await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
|
|
374
|
+
bridge.sessions['s-test0001'].live = false;
|
|
375
|
+
// Force a poll cycle; the watch must not drop on live:false (§12: events
|
|
376
|
+
// for the next turn can start at any moment).
|
|
377
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
378
|
+
const s = connector.status().endpoints[0].sessions.find((x) => x.session === 's-test0001');
|
|
379
|
+
assert.equal(s.live, false);
|
|
380
|
+
assert.equal(s.watching, true);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('reports an unreachable endpoint honestly and recovers by retry', async () => {
|
|
384
|
+
const deadUrl = bridge.url;
|
|
385
|
+
const port = Number(new URL(deadUrl).port);
|
|
386
|
+
await bridge.close();
|
|
387
|
+
connector.configure([{ name: 'local', url: deadUrl }]);
|
|
388
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'error');
|
|
389
|
+
assert.ok(connector.status().endpoints[0].error);
|
|
390
|
+
|
|
391
|
+
// Bring a bridge back on the same port; backoff retry should reconnect.
|
|
392
|
+
bridge = new MockBridge();
|
|
393
|
+
await bridge.start(port);
|
|
394
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected', 5000);
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
describe('validateEndpoint', () => {
|
|
399
|
+
it('accepts a clean http endpoint', () => {
|
|
400
|
+
assert.equal(validateEndpoint({ name: 'local', url: 'http://127.0.0.1:8737' }), null);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('rejects bad names, schemes, and embedded credentials', () => {
|
|
404
|
+
assert.ok(validateEndpoint({ name: 'bad name!', url: 'http://127.0.0.1:8737' }));
|
|
405
|
+
assert.ok(validateEndpoint({ name: 'x', url: 'ftp://127.0.0.1' }));
|
|
406
|
+
assert.ok(validateEndpoint({ name: 'x', url: 'javascript:alert(1)' }));
|
|
407
|
+
assert.ok(validateEndpoint({ name: 'x', url: 'http://user:pass@host:8737' }));
|
|
408
|
+
assert.ok(validateEndpoint({ name: 'x', url: 'not a url' }));
|
|
409
|
+
assert.ok(validateEndpoint({ name: 'x' }));
|
|
410
|
+
assert.ok(validateEndpoint(null));
|
|
411
|
+
});
|
|
412
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// GROOVE — Axom Server Manager + Installer Tests
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// The server manager runs against a stub `axom` executable that honors the
|
|
5
|
+
// §11 CLI (serve --host --port --data-dir --model-dir) and serves /about.
|
|
6
|
+
// The installer runs against a local HTTP server hosting a §11 manifest.
|
|
7
|
+
|
|
8
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { createServer } from 'http';
|
|
11
|
+
import { createHash } from 'crypto';
|
|
12
|
+
import { mkdtempSync, writeFileSync, chmodSync, existsSync, readFileSync } from 'fs';
|
|
13
|
+
import { join } from 'path';
|
|
14
|
+
import { tmpdir } from 'os';
|
|
15
|
+
import { AxomServerManager } from '../src/axom-server.js';
|
|
16
|
+
import { AxomInstaller } from '../src/axom-install.js';
|
|
17
|
+
|
|
18
|
+
const STUB = `#!/usr/bin/env node
|
|
19
|
+
// Stub axom serve — §11 CLI, /about + /sessions, clean SIGTERM.
|
|
20
|
+
const http = require('http');
|
|
21
|
+
const args = process.argv.slice(2);
|
|
22
|
+
const get = (flag) => args[args.indexOf(flag) + 1];
|
|
23
|
+
if (args[0] !== 'serve') process.exit(2);
|
|
24
|
+
const server = http.createServer((req, res) => {
|
|
25
|
+
res.setHeader('Content-Type', 'application/json');
|
|
26
|
+
if (req.url === '/about') return res.end(JSON.stringify({ name: 'axom', version: 'stub', kinds: [], instance: { port: Number(get('--port')), data_dir: get('--data-dir'), pid: process.pid } }));
|
|
27
|
+
if (req.url === '/sessions') return res.end('[]');
|
|
28
|
+
res.statusCode = 404; res.end('{}');
|
|
29
|
+
});
|
|
30
|
+
server.listen(Number(get('--port')), get('--host'));
|
|
31
|
+
process.on('SIGTERM', () => { server.close(); process.exit(0); });
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
function fakeDaemon(grooveDir) {
|
|
35
|
+
const broadcasts = [];
|
|
36
|
+
return {
|
|
37
|
+
grooveDir,
|
|
38
|
+
broadcasts,
|
|
39
|
+
config: { axom: {} },
|
|
40
|
+
broadcast: (m) => broadcasts.push(m),
|
|
41
|
+
audit: { log() {} },
|
|
42
|
+
axom: {
|
|
43
|
+
lastConfigured: null,
|
|
44
|
+
endpoints: new Map(),
|
|
45
|
+
configure(entries) { this.lastConfigured = entries; },
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('AxomServerManager', () => {
|
|
51
|
+
let daemon, manager, stubPath;
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
const dir = mkdtempSync(join(tmpdir(), 'groove-axom-'));
|
|
55
|
+
stubPath = join(dir, 'axom-stub');
|
|
56
|
+
writeFileSync(stubPath, STUB);
|
|
57
|
+
chmodSync(stubPath, 0o755);
|
|
58
|
+
daemon = fakeDaemon(dir);
|
|
59
|
+
// Lifecycle tests inject ample RAM — the floor guard has its own test
|
|
60
|
+
// (and CI boxes/the 8GB dev Mac must not trip it on unrelated tests).
|
|
61
|
+
manager = new AxomServerManager(daemon, { command: stubPath, totalRamGb: 32 });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(async () => {
|
|
65
|
+
await manager.destroy();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('starts an instance with its own port and sovereign data-dir, and registers the endpoint', async () => {
|
|
69
|
+
const instance = await manager.start('proj-a');
|
|
70
|
+
assert.equal(instance.status, 'running');
|
|
71
|
+
assert.equal(instance.port, 8737);
|
|
72
|
+
assert.ok(instance.dataDir.endsWith(join('axom', 'instances', 'proj-a')));
|
|
73
|
+
assert.ok(existsSync(instance.dataDir));
|
|
74
|
+
|
|
75
|
+
const about = await (await fetch(`http://127.0.0.1:${instance.port}/about`)).json();
|
|
76
|
+
assert.equal(about.instance.data_dir, instance.dataDir);
|
|
77
|
+
|
|
78
|
+
const ep = daemon.config.axom.endpoints.find((e) => e.name === 'local-proj-a');
|
|
79
|
+
assert.equal(ep.url, 'http://127.0.0.1:8737');
|
|
80
|
+
assert.deepEqual(daemon.axom.lastConfigured, daemon.config.axom.endpoints);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('allocates distinct ports for concurrent instances', async () => {
|
|
84
|
+
const a = await manager.start('a');
|
|
85
|
+
const b = await manager.start('b');
|
|
86
|
+
assert.equal(a.port, 8737);
|
|
87
|
+
assert.equal(b.port, 8738);
|
|
88
|
+
assert.notEqual(a.dataDir, b.dataDir); // ledgers never shared
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('stops an instance and deregisters its endpoint', async () => {
|
|
92
|
+
await manager.start('gone');
|
|
93
|
+
await manager.stop('gone');
|
|
94
|
+
const instance = manager.list().find((i) => i.id === 'gone');
|
|
95
|
+
assert.equal(instance.status, 'stopped');
|
|
96
|
+
assert.ok(!daemon.config.axom.endpoints.some((e) => e.name === 'local-gone'));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('refuses non-mock start below the RAM floor — the host stays up', async () => {
|
|
100
|
+
manager = new AxomServerManager(daemon, { command: stubPath, totalRamGb: 8 });
|
|
101
|
+
await assert.rejects(() => manager.start('x'), /below Axom's 12GB floor/);
|
|
102
|
+
// Mock mode is weightless and exempt.
|
|
103
|
+
daemon.config.axom.mock = true;
|
|
104
|
+
const instance = await manager.start('x');
|
|
105
|
+
assert.equal(instance.status, 'running');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('reports a missing runtime binary honestly', async () => {
|
|
109
|
+
manager = new AxomServerManager(daemon, { command: '/nonexistent/axom', totalRamGb: 32 });
|
|
110
|
+
await assert.rejects(() => manager.start('x'), /not found — install the Axom runtime/);
|
|
111
|
+
assert.equal(manager.list().find((i) => i.id === 'x').status, 'error');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('AxomInstaller', () => {
|
|
116
|
+
let daemon, installer, server, baseUrl, modelBytes, modelSha;
|
|
117
|
+
|
|
118
|
+
beforeEach(async () => {
|
|
119
|
+
const dir = mkdtempSync(join(tmpdir(), 'groove-axom-inst-'));
|
|
120
|
+
daemon = fakeDaemon(dir);
|
|
121
|
+
installer = new AxomInstaller(daemon, { totalRamGb: 32 });
|
|
122
|
+
modelBytes = Buffer.from('fake-gguf-weights-'.repeat(1000));
|
|
123
|
+
modelSha = createHash('sha256').update(modelBytes).digest('hex');
|
|
124
|
+
server = createServer((req, res) => {
|
|
125
|
+
if (req.url === '/manifest.json') {
|
|
126
|
+
res.setHeader('Content-Type', 'application/json');
|
|
127
|
+
return res.end(JSON.stringify({
|
|
128
|
+
models: [{ file: 'chassis.gguf', url: `${baseUrl}/chassis.gguf`, sha256: modelSha, bytes: modelBytes.length }],
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
if (req.url === '/bad-manifest.json') {
|
|
132
|
+
res.setHeader('Content-Type', 'application/json');
|
|
133
|
+
return res.end(JSON.stringify({
|
|
134
|
+
models: [{ file: 'chassis.gguf', url: `${baseUrl}/chassis.gguf`, sha256: 'deadbeef'.repeat(8), bytes: modelBytes.length }],
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
if (req.url === '/chassis.gguf') return res.end(modelBytes);
|
|
138
|
+
res.statusCode = 404; res.end();
|
|
139
|
+
});
|
|
140
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
141
|
+
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
afterEach(async () => {
|
|
145
|
+
await new Promise((r) => server.close(r));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('downloads, verifies sha256, and lands the model file', async () => {
|
|
149
|
+
const result = await installer.install(`${baseUrl}/manifest.json`);
|
|
150
|
+
assert.deepEqual(result, { ok: true, models: 1 });
|
|
151
|
+
const landed = join(daemon.grooveDir, 'axom', 'models', 'chassis.gguf');
|
|
152
|
+
assert.ok(existsSync(landed));
|
|
153
|
+
assert.deepEqual(readFileSync(landed), modelBytes);
|
|
154
|
+
assert.equal(installer.getStatus().phase, 'done');
|
|
155
|
+
assert.ok(daemon.broadcasts.some((b) => b.type === 'axom:install:progress'));
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('refuses a sha256 mismatch — nothing lands, no partial left behind', async () => {
|
|
159
|
+
await assert.rejects(() => installer.install(`${baseUrl}/bad-manifest.json`), /sha256 mismatch/);
|
|
160
|
+
const modelDir = join(daemon.grooveDir, 'axom', 'models');
|
|
161
|
+
assert.ok(!existsSync(join(modelDir, 'chassis.gguf')));
|
|
162
|
+
assert.ok(!existsSync(join(modelDir, 'chassis.gguf.part')));
|
|
163
|
+
assert.equal(installer.getStatus().phase, 'error');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('refuses an unverifiable manifest entry (no sha256)', async () => {
|
|
167
|
+
const s2 = createServer((req, res) => res.end(JSON.stringify({
|
|
168
|
+
models: [{ file: 'x.gguf', url: `${baseUrl}/chassis.gguf` }],
|
|
169
|
+
})));
|
|
170
|
+
await new Promise((r) => s2.listen(0, '127.0.0.1', r));
|
|
171
|
+
const url = `http://127.0.0.1:${s2.address().port}/`;
|
|
172
|
+
try {
|
|
173
|
+
await assert.rejects(() => installer.install(url), /refusing unverifiable download/);
|
|
174
|
+
} finally {
|
|
175
|
+
await new Promise((r) => s2.close(r));
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('errors without a configured manifest url — no hardcoded source exists', async () => {
|
|
180
|
+
await assert.rejects(() => installer.install(undefined), /no install manifest configured/);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('refuses to download weights below the RAM floor', async () => {
|
|
184
|
+
installer = new AxomInstaller(daemon, { totalRamGb: 8 });
|
|
185
|
+
await assert.rejects(() => installer.install(`${baseUrl}/manifest.json`), /below Axom's 12GB floor/);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// GROOVE — ChatStore tests
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs';
|
|
7
|
+
import { tmpdir } from 'os';
|
|
8
|
+
import { resolve } from 'path';
|
|
9
|
+
import { ChatStore } from '../src/chatstore.js';
|
|
10
|
+
|
|
11
|
+
describe('ChatStore', () => {
|
|
12
|
+
let dir, store;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
dir = mkdtempSync(resolve(tmpdir(), 'groove-chat-'));
|
|
16
|
+
store = new ChatStore({ grooveDir: dir });
|
|
17
|
+
});
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
store.stop();
|
|
20
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('appends and returns per-agent history', () => {
|
|
24
|
+
store.append('a1', { from: 'user', text: 'hello', timestamp: 1 });
|
|
25
|
+
store.append('a1', { from: 'agent', text: 'hi', timestamp: 2 });
|
|
26
|
+
store.append('a2', { from: 'user', text: 'other', timestamp: 3 });
|
|
27
|
+
|
|
28
|
+
assert.equal(store.get('a1').length, 2);
|
|
29
|
+
assert.equal(store.get('a2').length, 1);
|
|
30
|
+
assert.deepEqual(store.getAll().a1.map((m) => m.text), ['hello', 'hi']);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('strips attachment data URLs but keeps metadata', () => {
|
|
34
|
+
store.append('a1', {
|
|
35
|
+
from: 'user', text: 'pic', timestamp: 1,
|
|
36
|
+
attachments: [{ name: 'x.png', size: 9, dataUrl: 'data:image/png;base64,AAAA' }],
|
|
37
|
+
});
|
|
38
|
+
const m = store.get('a1')[0];
|
|
39
|
+
assert.equal(m.attachments[0].dataUrl, undefined);
|
|
40
|
+
assert.equal(m.attachments[0].name, 'x.png');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('persists across instances (survives a daemon restart)', () => {
|
|
44
|
+
store.append('a1', { from: 'user', text: 'durable', timestamp: 1 });
|
|
45
|
+
store.stop(); // flushes synchronously
|
|
46
|
+
|
|
47
|
+
const reloaded = new ChatStore({ grooveDir: dir });
|
|
48
|
+
assert.equal(reloaded.get('a1')[0].text, 'durable');
|
|
49
|
+
reloaded.stop();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('writes the file atomically (no stray .tmp left)', () => {
|
|
53
|
+
store.append('a1', { from: 'user', text: 'x', timestamp: 1 });
|
|
54
|
+
store.stop();
|
|
55
|
+
assert.ok(existsSync(resolve(dir, 'chat-history.json')));
|
|
56
|
+
assert.ok(!existsSync(resolve(dir, 'chat-history.json.tmp')));
|
|
57
|
+
const onDisk = JSON.parse(readFileSync(resolve(dir, 'chat-history.json'), 'utf8'));
|
|
58
|
+
assert.equal(onDisk.a1[0].text, 'x');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('caps per-agent history', () => {
|
|
62
|
+
for (let i = 0; i < 250; i++) store.append('a1', { from: 'user', text: `m${i}`, timestamp: i });
|
|
63
|
+
const h = store.get('a1');
|
|
64
|
+
assert.equal(h.length, 200);
|
|
65
|
+
assert.equal(h[0].text, 'm50'); // oldest 50 dropped
|
|
66
|
+
assert.equal(h.at(-1).text, 'm249');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('replace() swaps an agent history wholesale', () => {
|
|
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', () => {
|
|
76
|
+
store.append('a1', { from: 'user', text: 'x' });
|
|
77
|
+
store.remove('a1');
|
|
78
|
+
assert.equal(store.get('a1').length, 0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('starts empty when the file is missing or corrupt', () => {
|
|
82
|
+
const empty = new ChatStore({ grooveDir: mkdtempSync(resolve(tmpdir(), 'groove-chat-empty-')) });
|
|
83
|
+
assert.deepEqual(empty.getAll(), {});
|
|
84
|
+
empty.stop();
|
|
85
|
+
});
|
|
86
|
+
});
|