groove-dev 0.27.183 → 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 (45) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/daemon/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +41 -0
  4. package/node_modules/@groove-dev/daemon/src/innerchat.js +147 -91
  5. package/node_modules/@groove-dev/daemon/src/introducer.js +30 -1
  6. package/node_modules/@groove-dev/daemon/src/process.js +21 -0
  7. package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +57 -13
  8. package/node_modules/@groove-dev/daemon/src/routes/teams.js +11 -0
  9. package/node_modules/@groove-dev/daemon/src/teams.js +28 -0
  10. package/node_modules/@groove-dev/daemon/test/innerchat.test.js +157 -184
  11. package/node_modules/@groove-dev/daemon/test/teams.test.js +53 -0
  12. package/{packages/gui/dist/assets/index-DPjGBQ5X.js → node_modules/@groove-dev/gui/dist/assets/index-BpOyN6Zf.js} +227 -227
  13. package/node_modules/@groove-dev/gui/dist/assets/index-DiXB7yry.css +1 -0
  14. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  15. package/node_modules/@groove-dev/gui/package.json +1 -1
  16. package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +1 -29
  17. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-agent-row.jsx +18 -2
  18. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-pane.jsx +1 -15
  19. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-sidebar.jsx +29 -11
  20. package/node_modules/@groove-dev/gui/src/stores/groove.js +6 -0
  21. package/node_modules/@groove-dev/gui/src/stores/slices/teams-slice.js +22 -0
  22. package/package.json +1 -1
  23. package/packages/cli/package.json +1 -1
  24. package/packages/daemon/package.json +1 -1
  25. package/packages/daemon/src/innerchat-docs.js +41 -0
  26. package/packages/daemon/src/innerchat.js +147 -91
  27. package/packages/daemon/src/introducer.js +30 -1
  28. package/packages/daemon/src/process.js +21 -0
  29. package/packages/daemon/src/routes/innerchat.js +57 -13
  30. package/packages/daemon/src/routes/teams.js +11 -0
  31. package/packages/daemon/src/teams.js +28 -0
  32. package/{node_modules/@groove-dev/gui/dist/assets/index-DPjGBQ5X.js → packages/gui/dist/assets/index-BpOyN6Zf.js} +227 -227
  33. package/packages/gui/dist/assets/index-DiXB7yry.css +1 -0
  34. package/packages/gui/dist/index.html +2 -2
  35. package/packages/gui/package.json +1 -1
  36. package/packages/gui/src/components/agents/agent-panel.jsx +1 -29
  37. package/packages/gui/src/components/fleet/fleet-agent-row.jsx +18 -2
  38. package/packages/gui/src/components/fleet/fleet-pane.jsx +1 -15
  39. package/packages/gui/src/components/fleet/fleet-sidebar.jsx +29 -11
  40. package/packages/gui/src/stores/groove.js +6 -0
  41. package/packages/gui/src/stores/slices/teams-slice.js +22 -0
  42. package/node_modules/@groove-dev/gui/dist/assets/index-CiOy7wVS.css +0 -1
  43. package/node_modules/@groove-dev/gui/src/components/agents/innerchat-relay.jsx +0 -145
  44. package/packages/gui/dist/assets/index-CiOy7wVS.css +0 -1
  45. package/packages/gui/src/components/agents/innerchat-relay.jsx +0 -145
@@ -3,30 +3,26 @@
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. Agents live in the
9
- // registry; `_loops` marks an interactive loop, `_running` a busy CLI agent,
10
- // and anything in neither is stopped (resume path, which mints a new id).
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).
11
11
  function makeDaemon() {
12
12
  const broadcasts = [];
13
13
  const audits = [];
14
- const sentMessages = [];
15
- const queuedMessages = [];
14
+ const sent = [];
15
+ const queued = [];
16
16
  const resumes = [];
17
-
18
17
  let idCounter = 0;
19
18
 
20
19
  return {
21
- broadcasts,
22
- audits,
23
- sentMessages,
24
- queuedMessages,
25
- resumes,
20
+ broadcasts, audits, sent, queued, resumes,
26
21
  registry: {
27
22
  _agents: new Map(),
28
23
  get(id) { return this._agents.get(id) || null; },
29
- 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; },
30
26
  remove(id) { this._agents.delete(id); },
31
27
  flushPendingRemovals() {},
32
28
  update() {},
@@ -37,14 +33,8 @@ function makeDaemon() {
37
33
  _running: new Set(),
38
34
  hasAgentLoop(id) { return this._loops.has(id); },
39
35
  isRunning(id) { return this._running.has(id); },
40
- async sendMessage(id, msg, source) {
41
- sentMessages.push({ id, msg, source });
42
- return true;
43
- },
44
- queueMessage(id, msg) {
45
- queuedMessages.push({ id, msg });
46
- },
47
- // Resume mints a new agent id, exactly as the real one does.
36
+ async sendMessage(id, msg) { sent.push({ id, msg }); return true; },
37
+ queueMessage(id, msg) { queued.push({ id, msg }); },
48
38
  async resume(id, msg) {
49
39
  const old = this._agentsRef.get(id);
50
40
  const fresh = { ...old, id: `${id}-r${++idCounter}` };
@@ -62,228 +52,211 @@ function makeDaemon() {
62
52
  };
63
53
  }
64
54
 
65
- function wire(daemon) {
66
- daemon.processes._agentsRef = daemon.registry._agents;
67
- daemon.rotator._daemon = daemon;
68
- }
69
-
70
55
  const result = (text) => ({ type: 'result', data: text });
56
+ const tick = () => new Promise((r) => setImmediate(r));
71
57
 
72
58
  describe('InnerChat', () => {
73
- let daemon;
74
- let innerchat;
59
+ let daemon, innerchat;
75
60
 
76
61
  beforeEach(() => {
77
62
  daemon = makeDaemon();
78
- wire(daemon);
63
+ daemon.processes._agentsRef = daemon.registry._agents;
64
+ daemon.rotator._daemon = daemon;
79
65
  innerchat = new InnerChat(daemon);
80
66
  daemon.innerchat = innerchat;
81
67
 
82
- daemon.registry.add({ id: 'a1', name: 'fullstack-1', role: 'fullstack', provider: 'claude-code' });
83
- daemon.registry.add({ id: 'a2', name: 'fullstack-14', role: 'fullstack', provider: 'claude-code' });
84
- daemon.processes._loops.add('a1');
85
- daemon.processes._running.add('a1');
86
- daemon.processes._loops.add('a2');
87
- 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
+ }
88
73
  });
89
74
 
90
- // ── Sending ───────────────────────────────────────────────
75
+ // ── The blocking round trip ───────────────────────────────
91
76
 
92
- it('opens a thread and delivers the relay to the target', async () => {
93
- const { thread, turn } = await innerchat.send('a1', 'a2', 'What endpoint shape are you using?');
77
+ it('blocks until the target answers, then resolves with the reply', async () => {
78
+ const pending = innerchat.ask('a1', 'a2', 'What endpoint shape?');
94
79
 
95
- assert.ok(thread.id);
96
- assert.equal(thread.status, 'awaiting_reply');
97
- assert.equal(thread.turns.length, 1);
98
- assert.equal(turn.kind, 'relay');
99
- assert.equal(turn.from.id, 'a1');
100
- assert.equal(turn.to.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');
101
84
 
102
- const delivered = daemon.sentMessages.at(-1);
85
+ const delivered = daemon.sent.at(-1);
103
86
  assert.equal(delivered.id, 'a2');
104
- assert.match(delivered.msg, /InnerChat from fullstack-1/);
105
- assert.match(delivered.msg, /What endpoint shape are you using\?/);
106
- });
87
+ assert.match(delivered.msg, /fullstack-1 \(fullstack\) is asking you a question/);
88
+ assert.match(delivered.msg, /BLOCKED waiting on your answer/);
107
89
 
108
- it('rejects unknown, self-addressed, and empty relays', async () => {
109
- await assert.rejects(() => innerchat.send('nope', 'a2', 'hi'), /not found/);
110
- await assert.rejects(() => innerchat.send('a1', 'nope', 'hi'), /not found/);
111
- await assert.rejects(() => innerchat.send('a1', 'a1', 'hi'), /itself/);
112
- await assert.rejects(() => innerchat.send('a1', 'a2', ' '), /message is required/);
113
- });
90
+ innerchat.onAgentOutput('a2', result('REST, /api/v2/orders'));
91
+ const res = await pending;
114
92
 
115
- it('rejects an unknown threadId', async () => {
116
- await assert.rejects(() => innerchat.send('a1', 'a2', 'hi', 'bogus'), /not found/);
93
+ assert.equal(res.reply, 'REST, /api/v2/orders');
94
+ assert.equal(res.exchanges, 1);
95
+ assert.equal(res.remaining, MAX_EXCHANGES - 1);
117
96
  });
118
97
 
119
- // ── Reply capture and auto-forward ────────────────────────
98
+ it('ignores non-result and empty output while waiting', async () => {
99
+ const pending = innerchat.ask('a1', 'a2', 'What shape?');
100
+ await tick();
120
101
 
121
- it('forwards the reply back to the asker automatically', async () => {
122
- await innerchat.send('a1', 'a2', 'What shape?');
123
- innerchat.onAgentOutput('a2', result('REST, /api/v2/orders'));
124
- await new Promise((r) => setImmediate(r));
102
+ innerchat.onAgentOutput('a2', { type: 'assistant', data: 'thinking…' });
103
+ innerchat.onAgentOutput('a2', result(' '));
104
+ await tick();
125
105
 
126
- const forwarded = daemon.sentMessages.at(-1);
127
- assert.equal(forwarded.id, 'a1');
128
- assert.match(forwarded.msg, /InnerChat reply from fullstack-14/);
129
- assert.match(forwarded.msg, /REST, \/api\/v2\/orders/);
106
+ assert.ok(innerchat.getPending('a2'), 'still waiting');
107
+ innerchat.onAgentOutput('a2', result('done'));
108
+ assert.equal((await pending).reply, 'done');
130
109
  });
131
110
 
132
- it('ignores output from agents with no relay outstanding', () => {
133
- const before = daemon.sentMessages.length;
134
- innerchat.onAgentOutput('a2', result('unrelated work'));
135
- assert.equal(daemon.sentMessages.length, before);
136
- });
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');
137
116
 
138
- it('ignores non-result and empty output', async () => {
139
- await innerchat.send('a1', 'a2', 'What shape?');
140
- innerchat.onAgentOutput('a2', { type: 'assistant', data: 'thinking...' });
141
- innerchat.onAgentOutput('a2', result(' '));
142
- await new Promise((r) => setImmediate(r));
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');
143
120
 
144
- assert.equal(daemon.sentMessages.at(-1).id, 'a2', 'no forward should have happened');
145
- assert.ok(innerchat.getPending('a2'), 'relay is still outstanding');
121
+ innerchat.onAgentOutput('a2', result('the actual answer'));
122
+ assert.equal((await pending).reply, 'the actual answer');
146
123
  });
147
124
 
148
- it('captures a reply only once', async () => {
149
- await innerchat.send('a1', 'a2', 'What shape?');
150
- innerchat.onAgentOutput('a2', result('first'));
151
- innerchat.onAgentOutput('a2', result('second'));
152
- await new Promise((r) => setImmediate(r));
125
+ // ── Guardrails ────────────────────────────────────────────
153
126
 
154
- const forwards = daemon.sentMessages.filter((m) => /InnerChat reply/.test(m.msg));
155
- assert.equal(forwards.length, 1);
156
- assert.match(forwards[0].msg, /first/);
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;
157
139
  });
158
140
 
159
- it('skips the in-flight result when the relay was queued behind live work', async () => {
160
- // No loop, but running the relay queues behind the current task.
161
- daemon.processes._loops.delete('a2');
162
- await innerchat.send('a1', 'a2', 'What shape?');
163
- assert.equal(daemon.queuedMessages.at(-1).id, 'a2');
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');
164
145
 
165
- // This result belongs to the task that was already underway.
166
- innerchat.onAgentOutput('a2', result('finished the previous task'));
167
- await new Promise((r) => setImmediate(r));
168
- assert.equal(daemon.sentMessages.filter((m) => /InnerChat reply/.test(m.msg)).length, 0);
146
+ const first = innerchat.ask('a1', 'a2', 'q1');
147
+ await tick();
148
+ await assert.rejects(() => innerchat.ask('a3', 'a2', 'q2'), /already answering/);
169
149
 
170
- // This one is the actual answer.
171
- innerchat.onAgentOutput('a2', result('REST, /api/v2/orders'));
172
- await new Promise((r) => setImmediate(r));
173
- const forwarded = daemon.sentMessages.at(-1);
174
- assert.equal(forwarded.id, 'a1');
175
- assert.match(forwarded.msg, /REST, \/api\/v2\/orders/);
150
+ innerchat.onAgentOutput('a2', result('a'));
151
+ await first;
176
152
  });
177
153
 
178
- // ── Stopped agents / id remapping ─────────────────────────
179
-
180
- it('resumes a stopped target and tracks it under its new id', async () => {
181
- daemon.processes._loops.delete('a2');
182
- daemon.processes._running.delete('a2');
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
+ );
165
+ });
183
166
 
184
- const { thread, turn } = await innerchat.send('a1', 'a2', 'ping');
185
- const newId = daemon.resumes.at(-1).newId;
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;
186
171
 
187
- assert.notEqual(newId, 'a2');
188
- assert.equal(turn.to.id, newId);
189
- assert.ok(thread.participants.some((p) => p.id === newId));
190
- assert.equal(innerchat.getPending('a2'), null, 'old id no longer tracked');
191
- assert.ok(innerchat.getPending(newId), 'reply is awaited on the new id');
172
+ const p2 = innerchat.ask('a1', 'a2', 'q2');
173
+ await tick(); innerchat.onAgentOutput('a2', result('a2'));
174
+ const r2 = await p2;
192
175
 
193
- // The reply arrives on the new id and still forwards correctly.
194
- daemon.processes._loops.add(newId);
195
- innerchat.onAgentOutput(newId, result('pong'));
196
- await new Promise((r) => setImmediate(r));
197
- assert.match(daemon.sentMessages.at(-1).msg, /pong/);
176
+ assert.equal(r1.threadId, r2.threadId, 'same conversation continues');
177
+ assert.equal(r2.exchanges, 2);
178
+ assert.equal(r2.remaining, MAX_EXCHANGES - 2);
198
179
  });
199
180
 
200
- it('resumes a stopped asker to deliver the reply', async () => {
201
- await innerchat.send('a1', 'a2', 'What shape?');
202
- daemon.processes._loops.delete('a1');
203
- daemon.processes._running.delete('a1');
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);
189
+ });
204
190
 
205
- innerchat.onAgentOutput('a2', result('REST'));
206
- await new Promise((r) => setImmediate(r));
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);
197
+ });
207
198
 
208
- const resume = daemon.resumes.at(-1);
209
- assert.equal(resume.id, 'a1');
210
- assert.match(resume.msg, /InnerChat reply from fullstack-14/);
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/);
211
204
  });
212
205
 
213
- it('marks the turn failed when delivery throws', async () => {
206
+ it('surfaces a delivery failure as an unreachable error', async () => {
214
207
  daemon.processes.sendMessage = async () => { throw new Error('pipe closed'); };
215
- await assert.rejects(() => innerchat.send('a1', 'a2', 'ping'), /pipe closed/);
216
-
217
- const thread = innerchat.getThreads('a1')[0];
218
- assert.equal(thread.status, 'failed');
219
- assert.equal(thread.turns[0].status, 'failed');
220
- assert.equal(thread.turns[0].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);
221
210
  });
222
211
 
223
- // ── Threads ───────────────────────────────────────────────
212
+ // ── Stopped targets / id remapping ────────────────────────
224
213
 
225
- it('continues a thread and replays prior turns as context', async () => {
226
- const first = await innerchat.send('a1', 'a2', 'What shape?');
227
- innerchat.onAgentOutput('a2', result('REST'));
228
- await new Promise((r) => setImmediate(r));
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');
229
217
 
230
- await innerchat.send('a1', 'a2', 'Versioned?', first.thread.id);
218
+ const pending = innerchat.ask('a1', 'a2', 'ping');
219
+ await tick();
231
220
 
232
- const relay = daemon.sentMessages.filter((m) => /InnerChat from/.test(m.msg)).at(-1);
233
- assert.match(relay.msg, /Earlier in this conversation/);
234
- assert.match(relay.msg, /fullstack-1: What shape\?/);
235
- assert.match(relay.msg, /fullstack-14: REST/);
236
- assert.match(relay.msg, /Versioned\?/);
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');
237
225
 
238
- assert.equal(innerchat.getThreads().length, 1, 'reused the existing thread');
239
- assert.equal(first.thread.turns.length, 3);
226
+ innerchat.onAgentOutput(newId, result('pong'));
227
+ assert.equal((await pending).reply, 'pong');
240
228
  });
241
229
 
242
- it('keeps concurrent relays to different agents separate', async () => {
243
- daemon.registry.add({ id: 'a3', name: 'fullstack-9', role: 'fullstack', provider: 'claude-code' });
244
- daemon.processes._loops.add('a3');
245
- daemon.processes._running.add('a3');
246
-
247
- const one = await innerchat.send('a1', 'a2', 'question for 14');
248
- const two = await innerchat.send('a1', 'a3', 'question for 9');
249
- assert.notEqual(one.thread.id, two.thread.id);
250
-
251
- innerchat.onAgentOutput('a3', result('answer from 9'));
252
- await new Promise((r) => setImmediate(r));
253
-
254
- assert.equal(two.thread.turns.at(-1).text, 'answer from 9');
255
- assert.equal(one.thread.turns.length, 1, 'the other thread is untouched');
256
- assert.ok(innerchat.getPending('a2'), 'still awaiting the first reply');
257
- });
230
+ // ── Conversation context ──────────────────────────────────
258
231
 
259
- it('filters threads by participant', async () => {
260
- daemon.registry.add({ id: 'a3', name: 'fullstack-9', role: 'fullstack', provider: 'claude-code' });
261
- daemon.processes._loops.add('a3');
262
- daemon.processes._running.add('a3');
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;
263
236
 
264
- await innerchat.send('a1', 'a2', 'hi');
265
- await innerchat.send('a1', 'a3', 'hi');
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\?/);
266
244
 
267
- assert.equal(innerchat.getThreads().length, 2);
268
- assert.equal(innerchat.getThreads('a1').length, 2);
269
- assert.equal(innerchat.getThreads('a2').length, 1);
270
- assert.equal(innerchat.getThreads('a3').length, 1);
271
- assert.equal(innerchat.getThreads('nobody').length, 0);
245
+ innerchat.onAgentOutput('a2', result('v2'));
246
+ await p2;
272
247
  });
273
248
 
274
- // ── Broadcast & audit ─────────────────────────────────────
275
-
276
- it('broadcasts and audits both hops', async () => {
277
- await innerchat.send('a1', 'a2', 'What shape?');
278
- innerchat.onAgentOutput('a2', result('REST'));
279
- await new Promise((r) => setImmediate(r));
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;
280
254
 
281
255
  const turns = daemon.broadcasts.filter((b) => b.type === 'innerchat:turn');
282
256
  assert.equal(turns.length, 2);
283
- assert.equal(turns[0].data.turn.kind, 'relay');
284
- assert.equal(turns[1].data.turn.kind, 'reply');
285
-
286
- assert.ok(daemon.audits.some((a) => a.type === 'innerchat.send'));
287
- assert.ok(daemon.audits.some((a) => a.type === 'innerchat.reply'));
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'));
288
261
  });
289
262
  });
@@ -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();