groove-dev 0.27.182 → 0.27.184

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 (57) hide show
  1. package/CLAUDE.md +0 -7
  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/deliver.js +130 -0
  5. package/node_modules/@groove-dev/daemon/src/index.js +7 -3
  6. package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +41 -0
  7. package/node_modules/@groove-dev/daemon/src/innerchat.js +267 -60
  8. package/node_modules/@groove-dev/daemon/src/introducer.js +30 -1
  9. package/node_modules/@groove-dev/daemon/src/process.js +22 -1
  10. package/node_modules/@groove-dev/daemon/src/registry.js +5 -1
  11. package/node_modules/@groove-dev/daemon/src/rename.js +72 -0
  12. package/node_modules/@groove-dev/daemon/src/routes/agents.js +22 -100
  13. package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +63 -16
  14. package/node_modules/@groove-dev/daemon/src/routes/teams.js +11 -0
  15. package/node_modules/@groove-dev/daemon/src/teams.js +35 -1
  16. package/node_modules/@groove-dev/daemon/test/innerchat.test.js +193 -134
  17. package/node_modules/@groove-dev/daemon/test/rename.test.js +108 -0
  18. package/node_modules/@groove-dev/daemon/test/teams.test.js +53 -0
  19. package/node_modules/@groove-dev/gui/dist/assets/{index-CTer01Vg.js → index-BpOyN6Zf.js} +227 -227
  20. package/node_modules/@groove-dev/gui/dist/assets/index-DiXB7yry.css +1 -0
  21. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  22. package/node_modules/@groove-dev/gui/package.json +1 -1
  23. package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +2 -95
  24. package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +3 -1
  25. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-agent-row.jsx +18 -2
  26. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-sidebar.jsx +56 -6
  27. package/node_modules/@groove-dev/gui/src/stores/groove.js +35 -44
  28. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +27 -4
  29. package/node_modules/@groove-dev/gui/src/stores/slices/teams-slice.js +22 -0
  30. package/package.json +1 -1
  31. package/packages/cli/package.json +1 -1
  32. package/packages/daemon/package.json +1 -1
  33. package/packages/daemon/src/deliver.js +130 -0
  34. package/packages/daemon/src/index.js +7 -3
  35. package/packages/daemon/src/innerchat-docs.js +41 -0
  36. package/packages/daemon/src/innerchat.js +267 -60
  37. package/packages/daemon/src/introducer.js +30 -1
  38. package/packages/daemon/src/process.js +22 -1
  39. package/packages/daemon/src/registry.js +5 -1
  40. package/packages/daemon/src/rename.js +72 -0
  41. package/packages/daemon/src/routes/agents.js +22 -100
  42. package/packages/daemon/src/routes/innerchat.js +63 -16
  43. package/packages/daemon/src/routes/teams.js +11 -0
  44. package/packages/daemon/src/teams.js +35 -1
  45. package/packages/gui/dist/assets/{index-CTer01Vg.js → index-BpOyN6Zf.js} +227 -227
  46. package/packages/gui/dist/assets/index-DiXB7yry.css +1 -0
  47. package/packages/gui/dist/index.html +2 -2
  48. package/packages/gui/package.json +1 -1
  49. package/packages/gui/src/components/agents/agent-panel.jsx +2 -95
  50. package/packages/gui/src/components/chat/chat-messages.jsx +3 -1
  51. package/packages/gui/src/components/fleet/fleet-agent-row.jsx +18 -2
  52. package/packages/gui/src/components/fleet/fleet-sidebar.jsx +56 -6
  53. package/packages/gui/src/stores/groove.js +35 -44
  54. package/packages/gui/src/stores/slices/agents-slice.js +27 -4
  55. package/packages/gui/src/stores/slices/teams-slice.js +22 -0
  56. package/node_modules/@groove-dev/gui/dist/assets/index-DTFtRtkx.css +0 -1
  57. package/packages/gui/dist/assets/index-DTFtRtkx.css +0 -1
@@ -3,201 +3,260 @@
3
3
 
4
4
  import { describe, it, beforeEach } from 'node:test';
5
5
  import assert from 'node:assert/strict';
6
- import { InnerChat } from '../src/innerchat.js';
6
+ import { InnerChat, MAX_EXCHANGES } from '../src/innerchat.js';
7
7
 
8
+ // Mirrors the daemon surface deliverInstruction touches. `_loops` marks an
9
+ // interactive loop, `_running` a busy CLI agent; anything in neither is
10
+ // stopped (resume path, which mints a new id).
8
11
  function makeDaemon() {
9
12
  const broadcasts = [];
10
13
  const audits = [];
11
- const sentMessages = [];
12
- const queuedMessages = [];
14
+ const sent = [];
15
+ const queued = [];
16
+ const resumes = [];
17
+ let idCounter = 0;
13
18
 
14
19
  return {
15
- broadcasts,
16
- audits,
17
- sentMessages,
18
- queuedMessages,
20
+ broadcasts, audits, sent, queued, resumes,
19
21
  registry: {
20
22
  _agents: new Map(),
21
23
  get(id) { return this._agents.get(id) || null; },
22
- add(agent) { this._agents.set(agent.id, agent); },
24
+ getAll() { return [...this._agents.values()]; },
25
+ add(agent) { this._agents.set(agent.id, agent); return agent; },
26
+ remove(id) { this._agents.delete(id); },
27
+ flushPendingRemovals() {},
28
+ update() {},
23
29
  },
30
+ locks: { release() {} },
24
31
  processes: {
25
32
  _loops: new Set(),
26
33
  _running: new Set(),
27
34
  hasAgentLoop(id) { return this._loops.has(id); },
28
35
  isRunning(id) { return this._running.has(id); },
29
- async sendMessage(id, msg, source) {
30
- sentMessages.push({ id, msg, source });
31
- return true;
32
- },
33
- queueMessage(id, msg) {
34
- queuedMessages.push({ id, msg });
36
+ async sendMessage(id, msg) { sent.push({ id, msg }); return true; },
37
+ queueMessage(id, msg) { queued.push({ id, msg }); },
38
+ async resume(id, msg) {
39
+ const old = this._agentsRef.get(id);
40
+ const fresh = { ...old, id: `${id}-r${++idCounter}` };
41
+ this._agentsRef.delete(id);
42
+ this._agentsRef.set(fresh.id, fresh);
43
+ resumes.push({ id, msg, newId: fresh.id });
44
+ return fresh;
35
45
  },
36
46
  },
47
+ rotator: {
48
+ async rotate(id, opts) { return this._daemon.processes.resume(id, opts.additionalPrompt); },
49
+ },
37
50
  broadcast(msg) { broadcasts.push(msg); },
38
51
  audit: { log(type, data) { audits.push({ type, data }); } },
39
52
  };
40
53
  }
41
54
 
55
+ const result = (text) => ({ type: 'result', data: text });
56
+ const tick = () => new Promise((r) => setImmediate(r));
57
+
42
58
  describe('InnerChat', () => {
43
- let daemon;
44
- let innerchat;
59
+ let daemon, innerchat;
45
60
 
46
61
  beforeEach(() => {
47
62
  daemon = makeDaemon();
63
+ daemon.processes._agentsRef = daemon.registry._agents;
64
+ daemon.rotator._daemon = daemon;
48
65
  innerchat = new InnerChat(daemon);
49
66
  daemon.innerchat = innerchat;
50
67
 
51
- daemon.registry.add({ id: 'a1', name: 'fullstack-1', role: 'fullstack' });
52
- daemon.registry.add({ id: 'a2', name: 'fullstack-14', role: 'fullstack' });
53
- daemon.processes._loops.add('a2');
54
- daemon.processes._running.add('a2');
68
+ for (const [id, name] of [['a1', 'fullstack-1'], ['a2', 'fullstack-14']]) {
69
+ daemon.registry.add({ id, name, role: 'fullstack', provider: 'claude-code' });
70
+ daemon.processes._loops.add(id);
71
+ daemon.processes._running.add(id);
72
+ }
55
73
  });
56
74
 
57
- it('should send a message between agents', async () => {
58
- const msg = await innerchat.send('a1', 'a2', 'What endpoint shape are you using?');
59
- assert.ok(msg.id);
60
- assert.equal(msg.from.id, 'a1');
61
- assert.equal(msg.from.name, 'fullstack-1');
62
- assert.equal(msg.to.id, 'a2');
63
- assert.equal(msg.to.name, 'fullstack-14');
64
- assert.equal(msg.message, 'What endpoint shape are you using?');
65
- assert.equal(msg.response, null);
66
- assert.equal(msg.status, 'delivered');
67
- });
75
+ // ── The blocking round trip ───────────────────────────────
68
76
 
69
- it('should deliver via sendMessage when agent has loop', async () => {
70
- await innerchat.send('a1', 'a2', 'Hello');
71
- assert.equal(daemon.sentMessages.length, 1);
72
- assert.equal(daemon.sentMessages[0].id, 'a2');
73
- assert.ok(daemon.sentMessages[0].msg.includes('InnerChat from fullstack-1'));
74
- assert.ok(daemon.sentMessages[0].msg.includes('Hello'));
75
- });
77
+ it('blocks until the target answers, then resolves with the reply', async () => {
78
+ const pending = innerchat.ask('a1', 'a2', 'What endpoint shape?');
76
79
 
77
- it('should queue when agent is running but no loop', async () => {
78
- daemon.processes._loops.delete('a2');
79
- await innerchat.send('a1', 'a2', 'Hello');
80
- assert.equal(daemon.queuedMessages.length, 1);
81
- assert.equal(daemon.queuedMessages[0].id, 'a2');
80
+ let settled = false;
81
+ pending.then(() => { settled = true; });
82
+ await tick();
83
+ assert.equal(settled, false, 'must not resolve before an answer arrives');
84
+
85
+ const delivered = daemon.sent.at(-1);
86
+ assert.equal(delivered.id, 'a2');
87
+ assert.match(delivered.msg, /fullstack-1 \(fullstack\) is asking you a question/);
88
+ assert.match(delivered.msg, /BLOCKED waiting on your answer/);
89
+
90
+ innerchat.onAgentOutput('a2', result('REST, /api/v2/orders'));
91
+ const res = await pending;
92
+
93
+ assert.equal(res.reply, 'REST, /api/v2/orders');
94
+ assert.equal(res.exchanges, 1);
95
+ assert.equal(res.remaining, MAX_EXCHANGES - 1);
82
96
  });
83
97
 
84
- it('should throw when target agent is not running', async () => {
85
- daemon.processes._running.delete('a2');
86
- daemon.processes._loops.delete('a2');
87
- await assert.rejects(() => innerchat.send('a1', 'a2', 'Hello'), /not running/);
98
+ it('ignores non-result and empty output while waiting', async () => {
99
+ const pending = innerchat.ask('a1', 'a2', 'What shape?');
100
+ await tick();
101
+
102
+ innerchat.onAgentOutput('a2', { type: 'assistant', data: 'thinking…' });
103
+ innerchat.onAgentOutput('a2', result(' '));
104
+ await tick();
105
+
106
+ assert.ok(innerchat.getPending('a2'), 'still waiting');
107
+ innerchat.onAgentOutput('a2', result('done'));
108
+ assert.equal((await pending).reply, 'done');
88
109
  });
89
110
 
90
- it('should throw when sender does not exist', async () => {
91
- await assert.rejects(() => innerchat.send('unknown', 'a2', 'Hello'), /not found/);
111
+ it('skips the in-flight result when the question queued behind live work', async () => {
112
+ daemon.processes._loops.delete('a2'); // running, no loop → queued
113
+ const pending = innerchat.ask('a1', 'a2', 'What shape?');
114
+ await tick();
115
+ assert.equal(daemon.queued.at(-1).id, 'a2');
116
+
117
+ innerchat.onAgentOutput('a2', result('finished the PREVIOUS task'));
118
+ await tick();
119
+ assert.ok(innerchat.getPending('a2'), 'prior task result must not be taken as the answer');
120
+
121
+ innerchat.onAgentOutput('a2', result('the actual answer'));
122
+ assert.equal((await pending).reply, 'the actual answer');
92
123
  });
93
124
 
94
- it('should throw when sending to self', async () => {
95
- // The route validates this, but let's verify it's caught
96
- daemon.processes._loops.add('a1');
97
- daemon.processes._running.add('a1');
98
- const msg = await innerchat.send('a1', 'a1', 'Hello');
99
- assert.ok(msg.id);
125
+ // ── Guardrails ────────────────────────────────────────────
126
+
127
+ it('refuses a call that would deadlock and says how to resolve it', async () => {
128
+ const outbound = innerchat.ask('a1', 'a2', 'question for 14');
129
+ await tick();
130
+
131
+ // a2 tries to ask a1 back while a1 is blocked on a2.
132
+ await assert.rejects(
133
+ () => innerchat.ask('a2', 'a1', 'counter-question'),
134
+ /waiting for YOUR answer/,
135
+ );
136
+
137
+ innerchat.onAgentOutput('a2', result('fine'));
138
+ await outbound;
100
139
  });
101
140
 
102
- it('should broadcast innerchat:sent on send', async () => {
103
- await innerchat.send('a1', 'a2', 'Hello');
104
- const sent = daemon.broadcasts.find(b => b.type === 'innerchat:sent');
105
- assert.ok(sent);
106
- assert.equal(sent.data.message, 'Hello');
141
+ it('refuses a second concurrent question to a busy target', async () => {
142
+ daemon.registry.add({ id: 'a3', name: 'fullstack-9', role: 'fullstack', provider: 'claude-code' });
143
+ daemon.processes._loops.add('a3');
144
+ daemon.processes._running.add('a3');
145
+
146
+ const first = innerchat.ask('a1', 'a2', 'q1');
147
+ await tick();
148
+ await assert.rejects(() => innerchat.ask('a3', 'a2', 'q2'), /already answering/);
149
+
150
+ innerchat.onAgentOutput('a2', result('a'));
151
+ await first;
107
152
  });
108
153
 
109
- it('should capture response on agent output', async () => {
110
- const msg = await innerchat.send('a1', 'a2', 'What shape?');
111
- innerchat.onAgentOutput('a2', { type: 'result', data: 'GET /api/projects/:id returns { id, name }' });
112
- assert.equal(msg.status, 'responded');
113
- assert.equal(msg.response, 'GET /api/projects/:id returns { id, name }');
114
- assert.ok(msg.respondedAt);
154
+ it('enforces the exchange cap and tells the agent to report', async () => {
155
+ for (let i = 0; i < MAX_EXCHANGES; i++) {
156
+ const p = innerchat.ask('a1', 'a2', `q${i}`);
157
+ await tick();
158
+ innerchat.onAgentOutput('a2', result(`a${i}`));
159
+ await p;
160
+ }
161
+ await assert.rejects(
162
+ () => innerchat.ask('a1', 'a2', 'one more'),
163
+ new RegExp(`${MAX_EXCHANGES}-exchange limit`),
164
+ );
115
165
  });
116
166
 
117
- it('should relay response back to sender', async () => {
118
- daemon.processes._loops.add('a1');
119
- daemon.processes._running.add('a1');
120
- await innerchat.send('a1', 'a2', 'What shape?');
121
- daemon.sentMessages.length = 0;
122
-
123
- innerchat.onAgentOutput('a2', { type: 'result', data: 'The answer' });
124
- assert.equal(daemon.sentMessages.length, 1);
125
- assert.equal(daemon.sentMessages[0].id, 'a1');
126
- assert.ok(daemon.sentMessages[0].msg.includes('InnerChat reply from fullstack-14'));
127
- assert.ok(daemon.sentMessages[0].msg.includes('The answer'));
167
+ it('counts exchanges down across a continuing conversation', async () => {
168
+ const p1 = innerchat.ask('a1', 'a2', 'q1');
169
+ await tick(); innerchat.onAgentOutput('a2', result('a1'));
170
+ const r1 = await p1;
171
+
172
+ const p2 = innerchat.ask('a1', 'a2', 'q2');
173
+ await tick(); innerchat.onAgentOutput('a2', result('a2'));
174
+ const r2 = await p2;
175
+
176
+ assert.equal(r1.threadId, r2.threadId, 'same conversation continues');
177
+ assert.equal(r2.exchanges, 2);
178
+ assert.equal(r2.remaining, MAX_EXCHANGES - 2);
128
179
  });
129
180
 
130
- it('should ignore non-result output', async () => {
131
- await innerchat.send('a1', 'a2', 'What shape?');
132
- innerchat.onAgentOutput('a2', { type: 'activity', subtype: 'stream', data: 'partial...' });
133
- const msg = innerchat.getMessage(innerchat.getMessages()[0].id);
134
- assert.equal(msg.status, 'delivered');
135
- assert.equal(msg.response, null);
181
+ it('times out rather than blocking forever', async () => {
182
+ await assert.rejects(
183
+ () => innerchat.ask('a1', 'a2', 'silence', { timeoutMs: 20 }),
184
+ /No answer within/,
185
+ );
186
+ // The slot must be released, or the pair is wedged for good.
187
+ assert.equal(innerchat.getPending('a2'), null);
188
+ assert.equal(innerchat.blockedOn.get('a1'), undefined);
136
189
  });
137
190
 
138
- it('should ignore output from agents with no pending question', () => {
139
- innerchat.onAgentOutput('a2', { type: 'result', data: 'Some output' });
140
- assert.equal(daemon.broadcasts.length, 0);
191
+ it('unblocks the asker when the target dies mid-question', async () => {
192
+ const pending = innerchat.ask('a1', 'a2', 'q');
193
+ await tick();
194
+ innerchat.onAgentGone('a2', 'crashed');
195
+ await assert.rejects(() => pending, /crashed before answering/);
196
+ assert.equal(innerchat.blockedOn.get('a1'), undefined);
141
197
  });
142
198
 
143
- it('should extract text from array-format output', async () => {
144
- await innerchat.send('a1', 'a2', 'Hello');
145
- innerchat.onAgentOutput('a2', {
146
- type: 'result',
147
- data: [
148
- { type: 'text', text: 'Part 1' },
149
- { type: 'tool_use', name: 'Read', input: {} },
150
- { type: 'text', text: 'Part 2' },
151
- ],
152
- });
153
- const msg = innerchat.getMessages()[0];
154
- assert.equal(msg.response, 'Part 1\nPart 2');
199
+ it('rejects unknown, self-addressed and empty asks', async () => {
200
+ await assert.rejects(() => innerchat.ask('nope', 'a2', 'x'), /not found/);
201
+ await assert.rejects(() => innerchat.ask('a1', 'nope', 'x'), /not found/);
202
+ await assert.rejects(() => innerchat.ask('a1', 'a1', 'x'), /cannot ask itself/);
203
+ await assert.rejects(() => innerchat.ask('a1', 'a2', ' '), /message is required/);
155
204
  });
156
205
 
157
- it('should broadcast innerchat:response on response', async () => {
158
- await innerchat.send('a1', 'a2', 'Hello');
159
- innerchat.onAgentOutput('a2', { type: 'result', data: 'Response here' });
160
- const resp = daemon.broadcasts.find(b => b.type === 'innerchat:response');
161
- assert.ok(resp);
162
- assert.equal(resp.data.response, 'Response here');
206
+ it('surfaces a delivery failure as an unreachable error', async () => {
207
+ daemon.processes.sendMessage = async () => { throw new Error('pipe closed'); };
208
+ await assert.rejects(() => innerchat.ask('a1', 'a2', 'x'), /Could not reach fullstack-14: pipe closed/);
209
+ assert.equal(innerchat.blockedOn.get('a1'), undefined);
163
210
  });
164
211
 
165
- it('should list messages filtered by agent', async () => {
166
- daemon.processes._loops.add('a1');
167
- daemon.processes._running.add('a1');
168
- daemon.registry.add({ id: 'a3', name: 'backend-1', role: 'backend' });
169
- daemon.processes._loops.add('a3');
170
- daemon.processes._running.add('a3');
212
+ // ── Stopped targets / id remapping ────────────────────────
213
+
214
+ it('resumes a stopped target and matches the answer on its new id', async () => {
215
+ daemon.processes._loops.delete('a2');
216
+ daemon.processes._running.delete('a2');
171
217
 
172
- await innerchat.send('a1', 'a2', 'Message 1');
173
- await innerchat.send('a1', 'a3', 'Message 2');
218
+ const pending = innerchat.ask('a1', 'a2', 'ping');
219
+ await tick();
174
220
 
175
- assert.equal(innerchat.getMessages().length, 2);
176
- assert.equal(innerchat.getMessages('a2').length, 1);
177
- assert.equal(innerchat.getMessages('a3').length, 1);
178
- assert.equal(innerchat.getMessages('a1').length, 2);
179
- });
221
+ const newId = daemon.resumes.at(-1).newId;
222
+ assert.notEqual(newId, 'a2');
223
+ assert.equal(innerchat.getPending('a2'), null, 'old id no longer tracked');
224
+ assert.ok(innerchat.getPending(newId), 'awaited on the new id');
180
225
 
181
- it('should return pending question for agent', async () => {
182
- assert.equal(innerchat.getPending('a2'), null);
183
- await innerchat.send('a1', 'a2', 'Question?');
184
- const pending = innerchat.getPending('a2');
185
- assert.ok(pending);
186
- assert.equal(pending.message, 'Question?');
226
+ innerchat.onAgentOutput(newId, result('pong'));
227
+ assert.equal((await pending).reply, 'pong');
187
228
  });
188
229
 
189
- it('should clear pending after response', async () => {
190
- await innerchat.send('a1', 'a2', 'Question?');
191
- innerchat.onAgentOutput('a2', { type: 'result', data: 'Answer' });
192
- assert.equal(innerchat.getPending('a2'), null);
230
+ // ── Conversation context ──────────────────────────────────
231
+
232
+ it('replays prior turns so neither side re-explains', async () => {
233
+ const p1 = innerchat.ask('a1', 'a2', 'What shape?');
234
+ await tick(); innerchat.onAgentOutput('a2', result('REST'));
235
+ await p1;
236
+
237
+ const p2 = innerchat.ask('a1', 'a2', 'Versioned?');
238
+ await tick();
239
+ const msg = daemon.sent.at(-1).msg;
240
+ assert.match(msg, /Earlier in this conversation/);
241
+ assert.match(msg, /fullstack-1: What shape\?/);
242
+ assert.match(msg, /fullstack-14: REST/);
243
+ assert.match(msg, /Versioned\?/);
244
+
245
+ innerchat.onAgentOutput('a2', result('v2'));
246
+ await p2;
193
247
  });
194
248
 
195
- it('should audit send and response events', async () => {
196
- await innerchat.send('a1', 'a2', 'Hello');
197
- innerchat.onAgentOutput('a2', { type: 'result', data: 'Reply' });
198
- const sendAudit = daemon.audits.find(a => a.type === 'innerchat.send');
199
- const respAudit = daemon.audits.find(a => a.type === 'innerchat.response');
200
- assert.ok(sendAudit);
201
- assert.ok(respAudit);
249
+ it('broadcasts and audits both directions', async () => {
250
+ const p = innerchat.ask('a1', 'a2', 'q');
251
+ await tick();
252
+ innerchat.onAgentOutput('a2', result('a'));
253
+ await p;
254
+
255
+ const turns = daemon.broadcasts.filter((b) => b.type === 'innerchat:turn');
256
+ assert.equal(turns.length, 2);
257
+ assert.equal(turns[0].data.turn.kind, 'ask');
258
+ assert.equal(turns[1].data.turn.kind, 'answer');
259
+ assert.ok(daemon.audits.some((a) => a.type === 'innerchat.ask'));
260
+ assert.ok(daemon.audits.some((a) => a.type === 'innerchat.answer'));
202
261
  });
203
262
  });
@@ -0,0 +1,108 @@
1
+ // GROOVE — Agent Rename Migration 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, mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from 'fs';
7
+ import { resolve } from 'path';
8
+ import { tmpdir } from 'os';
9
+ import { renameAgent } from '../src/rename.js';
10
+ import { Registry } from '../src/registry.js';
11
+
12
+ describe('renameAgent', () => {
13
+ let root, daemon, agent;
14
+
15
+ beforeEach(() => {
16
+ root = mkdtempSync(resolve(tmpdir(), 'groove-rename-'));
17
+ const grooveDir = resolve(root, '.groove');
18
+ mkdirSync(resolve(grooveDir, 'logs'), { recursive: true });
19
+ mkdirSync(resolve(grooveDir, 'personalities'), { recursive: true });
20
+ mkdirSync(resolve(root, 'agent-files'), { recursive: true });
21
+
22
+ const registry = new Registry({ save() {}, load() { return null; } });
23
+ daemon = {
24
+ registry,
25
+ grooveDir,
26
+ projectDir: root,
27
+ audit: { log() {} },
28
+ };
29
+ agent = registry.add({ role: 'fullstack', name: 'fullstack-1' });
30
+
31
+ writeFileSync(resolve(grooveDir, 'logs', 'fullstack-1.log'), 'session history');
32
+ writeFileSync(resolve(grooveDir, 'personalities', 'fullstack-1.md'), 'be terse');
33
+ mkdirSync(resolve(root, 'agent-files', 'fullstack-1'));
34
+ writeFileSync(resolve(root, 'agent-files', 'fullstack-1', 'notes.md'), 'notes');
35
+ });
36
+
37
+ afterEach(() => rmSync(root, { recursive: true, force: true }));
38
+
39
+ const p = {
40
+ log: (n) => resolve(root, '.groove', 'logs', `${n}.log`),
41
+ personality: (n) => resolve(root, '.groove', 'personalities', `${n}.md`),
42
+ files: (n) => resolve(root, 'agent-files', n),
43
+ };
44
+
45
+ it('migrates the log, personality and agent-files', () => {
46
+ renameAgent(daemon, agent.id, 'senior-dev');
47
+
48
+ assert.equal(daemon.registry.get(agent.id).name, 'senior-dev');
49
+
50
+ assert.ok(!existsSync(p.log('fullstack-1')), 'old log removed');
51
+ assert.equal(readFileSync(p.log('senior-dev'), 'utf8'), 'session history');
52
+
53
+ assert.ok(!existsSync(p.personality('fullstack-1')));
54
+ assert.equal(readFileSync(p.personality('senior-dev'), 'utf8'), 'be terse');
55
+
56
+ assert.ok(!existsSync(p.files('fullstack-1')));
57
+ assert.equal(readFileSync(resolve(p.files('senior-dev'), 'notes.md'), 'utf8'), 'notes');
58
+ });
59
+
60
+ it('rejects a name already taken by another agent', () => {
61
+ daemon.registry.add({ role: 'fullstack', name: 'fullstack-2' });
62
+ assert.throws(() => renameAgent(daemon, agent.id, 'fullstack-2'), /already exists/);
63
+
64
+ // Nothing moved — the original log must survive a rejected rename.
65
+ assert.equal(daemon.registry.get(agent.id).name, 'fullstack-1');
66
+ assert.equal(readFileSync(p.log('fullstack-1'), 'utf8'), 'session history');
67
+ });
68
+
69
+ it('rejects an empty name and no-ops an unchanged one', () => {
70
+ assert.throws(() => renameAgent(daemon, agent.id, ' '), /name is required/);
71
+ const same = renameAgent(daemon, agent.id, 'fullstack-1');
72
+ assert.equal(same.name, 'fullstack-1');
73
+ assert.equal(readFileSync(p.log('fullstack-1'), 'utf8'), 'session history');
74
+ });
75
+
76
+ it('throws for an unknown agent', () => {
77
+ assert.throws(() => renameAgent(daemon, 'nope', 'whatever'), /Agent not found/);
78
+ });
79
+
80
+ it('tolerates missing artifacts', () => {
81
+ rmSync(p.personality('fullstack-1'));
82
+ rmSync(p.files('fullstack-1'), { recursive: true });
83
+
84
+ renameAgent(daemon, agent.id, 'solo');
85
+ assert.equal(readFileSync(p.log('solo'), 'utf8'), 'session history');
86
+ assert.ok(!existsSync(p.personality('solo')));
87
+ });
88
+
89
+ it('rejects names that would escape their directory', () => {
90
+ // These become path segments (agent-files/<name>), so a separator or a
91
+ // dot-segment would write outside the intended directory.
92
+ for (const bad of ['front end/dev', '../escape', '..', 'a/../../b', 'has space']) {
93
+ assert.throws(() => renameAgent(daemon, agent.id, bad), /name may only contain|name must be/);
94
+ }
95
+ assert.equal(daemon.registry.get(agent.id).name, 'fullstack-1');
96
+ assert.ok(existsSync(p.log('fullstack-1')));
97
+ });
98
+
99
+ it('rejects an over-long name', () => {
100
+ assert.throws(() => renameAgent(daemon, agent.id, 'x'.repeat(65)), /64 characters/);
101
+ });
102
+
103
+ it('blocks a bare registry.update from renaming without migration', () => {
104
+ daemon.registry.update(agent.id, { name: 'sneaky' });
105
+ assert.equal(daemon.registry.get(agent.id).name, 'fullstack-1');
106
+ assert.ok(existsSync(p.log('fullstack-1')), 'log was never orphaned');
107
+ });
108
+ });
@@ -185,6 +185,59 @@ describe('Teams', () => {
185
185
  assert.ok(teams2.list().some((t) => t.name === 'Persistent'));
186
186
  });
187
187
 
188
+ describe('reorder', () => {
189
+ it('reorders the list and persists it', () => {
190
+ const a = teams.create('Alpha');
191
+ const b = teams.create('Bravo');
192
+ const c = teams.create('Charlie');
193
+ const def = teams.getDefault();
194
+
195
+ teams.reorder([c.id, a.id, b.id, def.id]);
196
+ assert.deepEqual(teams.list().map((t) => t.name), ['Charlie', 'Alpha', 'Bravo', 'Default']);
197
+
198
+ // Order is the persisted array order, so it survives a reload.
199
+ const reloaded = new Teams(daemon);
200
+ assert.deepEqual(reloaded.list().map((t) => t.name), ['Charlie', 'Alpha', 'Bravo', 'Default']);
201
+ });
202
+
203
+ it('keeps teams missing from the given order rather than dropping them', () => {
204
+ const a = teams.create('Alpha');
205
+ const b = teams.create('Bravo');
206
+ const before = teams.list().length;
207
+
208
+ // A stale client sends only the ids it knew about.
209
+ teams.reorder([b.id, a.id]);
210
+
211
+ assert.equal(teams.list().length, before);
212
+ assert.deepEqual(teams.list().map((t) => t.name), ['Bravo', 'Alpha', 'Default']);
213
+ });
214
+
215
+ it('ignores unknown and duplicated ids', () => {
216
+ const a = teams.create('Alpha');
217
+ const before = teams.list().length;
218
+
219
+ teams.reorder([a.id, 'does-not-exist', a.id]);
220
+
221
+ assert.equal(teams.list().length, before);
222
+ assert.equal(teams.list()[0].name, 'Alpha');
223
+ });
224
+
225
+ it('rejects a non-array', () => {
226
+ assert.throws(() => teams.reorder('nope'), /must be an array/);
227
+ assert.throws(() => teams.reorder(undefined), /must be an array/);
228
+ });
229
+
230
+ it('broadcasts the new order', () => {
231
+ const a = teams.create('Alpha');
232
+ broadcasts.length = 0;
233
+ teams.reorder([a.id]);
234
+
235
+ const evt = broadcasts.find((b) => b.type === 'teams:reordered');
236
+ assert.ok(evt, 'teams:reordered was broadcast');
237
+ assert.equal(evt.teams[0].name, 'Alpha');
238
+ });
239
+ });
240
+
188
241
  it('should provide backward compat stubs', () => {
189
242
  // onAgentChange is a no-op
190
243
  teams.onAgentChange();