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.
- package/CLAUDE.md +0 -7
- 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/deliver.js +130 -0
- package/node_modules/@groove-dev/daemon/src/index.js +7 -3
- package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +41 -0
- package/node_modules/@groove-dev/daemon/src/innerchat.js +267 -60
- package/node_modules/@groove-dev/daemon/src/introducer.js +30 -1
- package/node_modules/@groove-dev/daemon/src/process.js +22 -1
- package/node_modules/@groove-dev/daemon/src/registry.js +5 -1
- package/node_modules/@groove-dev/daemon/src/rename.js +72 -0
- package/node_modules/@groove-dev/daemon/src/routes/agents.js +22 -100
- package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +63 -16
- package/node_modules/@groove-dev/daemon/src/routes/teams.js +11 -0
- package/node_modules/@groove-dev/daemon/src/teams.js +35 -1
- package/node_modules/@groove-dev/daemon/test/innerchat.test.js +193 -134
- package/node_modules/@groove-dev/daemon/test/rename.test.js +108 -0
- package/node_modules/@groove-dev/daemon/test/teams.test.js +53 -0
- package/node_modules/@groove-dev/gui/dist/assets/{index-CTer01Vg.js → index-BpOyN6Zf.js} +227 -227
- package/node_modules/@groove-dev/gui/dist/assets/index-DiXB7yry.css +1 -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/components/agents/agent-panel.jsx +2 -95
- package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +3 -1
- package/node_modules/@groove-dev/gui/src/components/fleet/fleet-agent-row.jsx +18 -2
- package/node_modules/@groove-dev/gui/src/components/fleet/fleet-sidebar.jsx +56 -6
- package/node_modules/@groove-dev/gui/src/stores/groove.js +35 -44
- package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +27 -4
- package/node_modules/@groove-dev/gui/src/stores/slices/teams-slice.js +22 -0
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/deliver.js +130 -0
- package/packages/daemon/src/index.js +7 -3
- package/packages/daemon/src/innerchat-docs.js +41 -0
- package/packages/daemon/src/innerchat.js +267 -60
- package/packages/daemon/src/introducer.js +30 -1
- package/packages/daemon/src/process.js +22 -1
- package/packages/daemon/src/registry.js +5 -1
- package/packages/daemon/src/rename.js +72 -0
- package/packages/daemon/src/routes/agents.js +22 -100
- package/packages/daemon/src/routes/innerchat.js +63 -16
- package/packages/daemon/src/routes/teams.js +11 -0
- package/packages/daemon/src/teams.js +35 -1
- package/packages/gui/dist/assets/{index-CTer01Vg.js → index-BpOyN6Zf.js} +227 -227
- package/packages/gui/dist/assets/index-DiXB7yry.css +1 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/components/agents/agent-panel.jsx +2 -95
- package/packages/gui/src/components/chat/chat-messages.jsx +3 -1
- package/packages/gui/src/components/fleet/fleet-agent-row.jsx +18 -2
- package/packages/gui/src/components/fleet/fleet-sidebar.jsx +56 -6
- package/packages/gui/src/stores/groove.js +35 -44
- package/packages/gui/src/stores/slices/agents-slice.js +27 -4
- package/packages/gui/src/stores/slices/teams-slice.js +22 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-DTFtRtkx.css +0 -1
- 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
|
|
12
|
-
const
|
|
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
|
-
|
|
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,
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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('
|
|
70
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
await
|
|
80
|
-
assert.equal(
|
|
81
|
-
|
|
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('
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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('
|
|
91
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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('
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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('
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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('
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
innerchat.onAgentOutput('a2',
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
assert.
|
|
127
|
-
assert.
|
|
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('
|
|
131
|
-
await
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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('
|
|
139
|
-
innerchat.
|
|
140
|
-
|
|
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('
|
|
144
|
-
await innerchat.
|
|
145
|
-
innerchat.
|
|
146
|
-
|
|
147
|
-
|
|
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('
|
|
158
|
-
|
|
159
|
-
innerchat.
|
|
160
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
daemon.
|
|
169
|
-
daemon.processes.
|
|
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
|
-
|
|
173
|
-
await
|
|
218
|
+
const pending = innerchat.ask('a1', 'a2', 'ping');
|
|
219
|
+
await tick();
|
|
174
220
|
|
|
175
|
-
|
|
176
|
-
assert.
|
|
177
|
-
assert.equal(innerchat.
|
|
178
|
-
assert.
|
|
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
|
-
|
|
182
|
-
assert.equal(
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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('
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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();
|