groove-dev 0.27.183 → 0.27.185

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 (65) hide show
  1. package/.watch-test-flag-1784767927904 +1 -0
  2. package/.watch-test-flag-1784768055729 +1 -0
  3. package/.watch-test-flag-1784768066364 +1 -0
  4. package/node_modules/@groove-dev/cli/package.json +1 -1
  5. package/node_modules/@groove-dev/daemon/package.json +1 -1
  6. package/node_modules/@groove-dev/daemon/src/api.js +2 -0
  7. package/node_modules/@groove-dev/daemon/src/index.js +17 -0
  8. package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +96 -0
  9. package/node_modules/@groove-dev/daemon/src/innerchat.js +265 -96
  10. package/node_modules/@groove-dev/daemon/src/introducer.js +31 -1
  11. package/node_modules/@groove-dev/daemon/src/process.js +36 -6
  12. package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +109 -13
  13. package/node_modules/@groove-dev/daemon/src/routes/teams.js +11 -0
  14. package/node_modules/@groove-dev/daemon/src/routes/watch.js +42 -0
  15. package/node_modules/@groove-dev/daemon/src/teams.js +28 -0
  16. package/node_modules/@groove-dev/daemon/src/watcher.js +258 -0
  17. package/node_modules/@groove-dev/daemon/test/innerchat.test.js +222 -179
  18. package/node_modules/@groove-dev/daemon/test/teams.test.js +53 -0
  19. package/node_modules/@groove-dev/daemon/test/watcher.test.js +158 -0
  20. package/node_modules/@groove-dev/gui/dist/assets/index-CU8L_r5f.css +1 -0
  21. package/{packages/gui/dist/assets/index-DPjGBQ5X.js → node_modules/@groove-dev/gui/dist/assets/index-DEZwM4Hb.js} +233 -228
  22. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  23. package/node_modules/@groove-dev/gui/package.json +1 -1
  24. package/node_modules/@groove-dev/gui/src/app.css +1 -0
  25. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +60 -4
  26. package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +1 -29
  27. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-agent-row.jsx +18 -2
  28. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-pane.jsx +1 -15
  29. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-sidebar.jsx +29 -11
  30. package/node_modules/@groove-dev/gui/src/stores/groove.js +36 -16
  31. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +6 -0
  32. package/node_modules/@groove-dev/gui/src/stores/slices/teams-slice.js +22 -0
  33. package/node_modules/@groove-dev/gui/src/views/memory.jsx +56 -31
  34. package/package.json +1 -1
  35. package/packages/cli/package.json +1 -1
  36. package/packages/daemon/package.json +1 -1
  37. package/packages/daemon/src/api.js +2 -0
  38. package/packages/daemon/src/index.js +17 -0
  39. package/packages/daemon/src/innerchat-docs.js +96 -0
  40. package/packages/daemon/src/innerchat.js +265 -96
  41. package/packages/daemon/src/introducer.js +31 -1
  42. package/packages/daemon/src/process.js +36 -6
  43. package/packages/daemon/src/routes/innerchat.js +109 -13
  44. package/packages/daemon/src/routes/teams.js +11 -0
  45. package/packages/daemon/src/routes/watch.js +42 -0
  46. package/packages/daemon/src/teams.js +28 -0
  47. package/packages/daemon/src/watcher.js +258 -0
  48. package/packages/gui/dist/assets/index-CU8L_r5f.css +1 -0
  49. package/{node_modules/@groove-dev/gui/dist/assets/index-DPjGBQ5X.js → packages/gui/dist/assets/index-DEZwM4Hb.js} +233 -228
  50. package/packages/gui/dist/index.html +2 -2
  51. package/packages/gui/package.json +1 -1
  52. package/packages/gui/src/app.css +1 -0
  53. package/packages/gui/src/components/agents/agent-feed.jsx +60 -4
  54. package/packages/gui/src/components/agents/agent-panel.jsx +1 -29
  55. package/packages/gui/src/components/fleet/fleet-agent-row.jsx +18 -2
  56. package/packages/gui/src/components/fleet/fleet-pane.jsx +1 -15
  57. package/packages/gui/src/components/fleet/fleet-sidebar.jsx +29 -11
  58. package/packages/gui/src/stores/groove.js +36 -16
  59. package/packages/gui/src/stores/slices/agents-slice.js +6 -0
  60. package/packages/gui/src/stores/slices/teams-slice.js +22 -0
  61. package/packages/gui/src/views/memory.jsx +56 -31
  62. package/node_modules/@groove-dev/gui/dist/assets/index-CiOy7wVS.css +0 -1
  63. package/node_modules/@groove-dev/gui/src/components/agents/innerchat-relay.jsx +0 -145
  64. package/packages/gui/dist/assets/index-CiOy7wVS.css +0 -1
  65. package/packages/gui/src/components/agents/innerchat-relay.jsx +0 -145
@@ -1,32 +1,28 @@
1
1
  // GROOVE — InnerChat Tests
2
2
  // FSL-1.1-Apache-2.0 — see LICENSE
3
3
 
4
- import { describe, it, beforeEach } from 'node:test';
4
+ import { describe, it, beforeEach, afterEach } 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,281 @@ 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
+ // Clear any un-awaited ask/tell timers so the runner can exit.
76
+ afterEach(() => innerchat.stop());
77
+
78
+ // ── The blocking round trip ───────────────────────────────
91
79
 
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?');
80
+ it('blocks until the target answers, then resolves with the reply', async () => {
81
+ const pending = innerchat.ask('a1', 'a2', 'What endpoint shape?');
94
82
 
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');
83
+ let settled = false;
84
+ pending.then(() => { settled = true; });
85
+ await tick();
86
+ assert.equal(settled, false, 'must not resolve before an answer arrives');
101
87
 
102
- const delivered = daemon.sentMessages.at(-1);
88
+ const delivered = daemon.sent.at(-1);
103
89
  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
- });
90
+ assert.match(delivered.msg, /fullstack-1 \(fullstack\) is asking you a question/);
91
+ assert.match(delivered.msg, /BLOCKED waiting on your answer/);
92
+
93
+ innerchat.onAgentOutput('a2', result('REST, /api/v2/orders'));
94
+ const res = await pending;
107
95
 
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/);
96
+ assert.equal(res.reply, 'REST, /api/v2/orders');
97
+ assert.equal(res.exchanges, 1);
98
+ assert.equal(res.remaining, MAX_EXCHANGES - 1);
113
99
  });
114
100
 
115
- it('rejects an unknown threadId', async () => {
116
- await assert.rejects(() => innerchat.send('a1', 'a2', 'hi', 'bogus'), /not found/);
101
+ it('ignores non-result and empty output while waiting', async () => {
102
+ const pending = innerchat.ask('a1', 'a2', 'What shape?');
103
+ await tick();
104
+
105
+ innerchat.onAgentOutput('a2', { type: 'assistant', data: 'thinking…' });
106
+ innerchat.onAgentOutput('a2', result(' '));
107
+ await tick();
108
+
109
+ assert.ok(innerchat.getPending('a2'), 'still waiting');
110
+ innerchat.onAgentOutput('a2', result('done'));
111
+ assert.equal((await pending).reply, 'done');
117
112
  });
118
113
 
119
- // ── Reply capture and auto-forward ────────────────────────
114
+ it('skips the in-flight result when the question queued behind live work', async () => {
115
+ daemon.processes._loops.delete('a2'); // running, no loop → queued
116
+ const pending = innerchat.ask('a1', 'a2', 'What shape?');
117
+ await tick();
118
+ assert.equal(daemon.queued.at(-1).id, 'a2');
120
119
 
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));
120
+ innerchat.onAgentOutput('a2', result('finished the PREVIOUS task'));
121
+ await tick();
122
+ assert.ok(innerchat.getPending('a2'), 'prior task result must not be taken as the answer');
125
123
 
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/);
124
+ innerchat.onAgentOutput('a2', result('the actual answer'));
125
+ assert.equal((await pending).reply, 'the actual answer');
130
126
  });
131
127
 
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);
128
+ // ── Guardrails ────────────────────────────────────────────
129
+
130
+ it('refuses a call that would deadlock and says how to resolve it', async () => {
131
+ const outbound = innerchat.ask('a1', 'a2', 'question for 14');
132
+ await tick();
133
+
134
+ // a2 tries to ask a1 back while a1 is blocked on a2.
135
+ await assert.rejects(
136
+ () => innerchat.ask('a2', 'a1', 'counter-question'),
137
+ /waiting for YOUR answer/,
138
+ );
139
+
140
+ innerchat.onAgentOutput('a2', result('fine'));
141
+ await outbound;
136
142
  });
137
143
 
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));
144
+ it('refuses a second concurrent question to a busy target', async () => {
145
+ daemon.registry.add({ id: 'a3', name: 'fullstack-9', role: 'fullstack', provider: 'claude-code' });
146
+ daemon.processes._loops.add('a3');
147
+ daemon.processes._running.add('a3');
143
148
 
144
- assert.equal(daemon.sentMessages.at(-1).id, 'a2', 'no forward should have happened');
145
- assert.ok(innerchat.getPending('a2'), 'relay is still outstanding');
149
+ const first = innerchat.ask('a1', 'a2', 'q1');
150
+ await tick();
151
+ await assert.rejects(() => innerchat.ask('a3', 'a2', 'q2'), /already answering/);
152
+
153
+ innerchat.onAgentOutput('a2', result('a'));
154
+ await first;
155
+ });
156
+
157
+ it('enforces the exchange cap and tells the agent to report', async () => {
158
+ for (let i = 0; i < MAX_EXCHANGES; i++) {
159
+ const p = innerchat.ask('a1', 'a2', `q${i}`);
160
+ await tick();
161
+ innerchat.onAgentOutput('a2', result(`a${i}`));
162
+ await p;
163
+ }
164
+ await assert.rejects(
165
+ () => innerchat.ask('a1', 'a2', 'one more'),
166
+ new RegExp(`${MAX_EXCHANGES}-exchange limit`),
167
+ );
146
168
  });
147
169
 
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));
170
+ it('counts exchanges down across a continuing conversation', async () => {
171
+ const p1 = innerchat.ask('a1', 'a2', 'q1');
172
+ await tick(); innerchat.onAgentOutput('a2', result('a1'));
173
+ const r1 = await p1;
174
+
175
+ const p2 = innerchat.ask('a1', 'a2', 'q2');
176
+ await tick(); innerchat.onAgentOutput('a2', result('a2'));
177
+ const r2 = await p2;
153
178
 
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/);
179
+ assert.equal(r1.threadId, r2.threadId, 'same conversation continues');
180
+ assert.equal(r2.exchanges, 2);
181
+ assert.equal(r2.remaining, MAX_EXCHANGES - 2);
157
182
  });
158
183
 
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');
184
+ it('times out rather than blocking forever', async () => {
185
+ await assert.rejects(
186
+ () => innerchat.ask('a1', 'a2', 'silence', { timeoutMs: 20 }),
187
+ /No answer within/,
188
+ );
189
+ // The slot must be released, or the pair is wedged for good.
190
+ assert.equal(innerchat.getPending('a2'), null);
191
+ assert.equal(innerchat.blockedOn.get('a1'), undefined);
192
+ });
164
193
 
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);
194
+ it('unblocks the asker when the target dies mid-question', async () => {
195
+ const pending = innerchat.ask('a1', 'a2', 'q');
196
+ await tick();
197
+ innerchat.onAgentGone('a2', 'crashed');
198
+ await assert.rejects(() => pending, /crashed before answering/);
199
+ assert.equal(innerchat.blockedOn.get('a1'), undefined);
200
+ });
169
201
 
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/);
202
+ it('rejects unknown, self-addressed and empty asks', async () => {
203
+ await assert.rejects(() => innerchat.ask('nope', 'a2', 'x'), /not found/);
204
+ await assert.rejects(() => innerchat.ask('a1', 'nope', 'x'), /not found/);
205
+ await assert.rejects(() => innerchat.ask('a1', 'a1', 'x'), /cannot ask itself/);
206
+ await assert.rejects(() => innerchat.ask('a1', 'a2', ' '), /message is required/);
176
207
  });
177
208
 
178
- // ── Stopped agents / id remapping ─────────────────────────
209
+ it('surfaces a delivery failure as an unreachable error', async () => {
210
+ daemon.processes.sendMessage = async () => { throw new Error('pipe closed'); };
211
+ await assert.rejects(() => innerchat.ask('a1', 'a2', 'x'), /Could not reach fullstack-14: pipe closed/);
212
+ assert.equal(innerchat.blockedOn.get('a1'), undefined);
213
+ });
179
214
 
180
- it('resumes a stopped target and tracks it under its new id', async () => {
215
+ // ── Stopped targets / id remapping ────────────────────────
216
+
217
+ it('resumes a stopped target and matches the answer on its new id', async () => {
181
218
  daemon.processes._loops.delete('a2');
182
219
  daemon.processes._running.delete('a2');
183
220
 
184
- const { thread, turn } = await innerchat.send('a1', 'a2', 'ping');
185
- const newId = daemon.resumes.at(-1).newId;
221
+ const pending = innerchat.ask('a1', 'a2', 'ping');
222
+ await tick();
186
223
 
224
+ const newId = daemon.resumes.at(-1).newId;
187
225
  assert.notEqual(newId, 'a2');
188
- assert.equal(turn.to.id, newId);
189
- assert.ok(thread.participants.some((p) => p.id === newId));
190
226
  assert.equal(innerchat.getPending('a2'), null, 'old id no longer tracked');
191
- assert.ok(innerchat.getPending(newId), 'reply is awaited on the new id');
227
+ assert.ok(innerchat.getPending(newId), 'awaited on the new id');
192
228
 
193
- // The reply arrives on the new id and still forwards correctly.
194
- daemon.processes._loops.add(newId);
195
229
  innerchat.onAgentOutput(newId, result('pong'));
196
- await new Promise((r) => setImmediate(r));
197
- assert.match(daemon.sentMessages.at(-1).msg, /pong/);
230
+ assert.equal((await pending).reply, 'pong');
198
231
  });
199
232
 
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');
204
-
205
- innerchat.onAgentOutput('a2', result('REST'));
206
- await new Promise((r) => setImmediate(r));
233
+ // ── Conversation context ──────────────────────────────────
207
234
 
208
- const resume = daemon.resumes.at(-1);
209
- assert.equal(resume.id, 'a1');
210
- assert.match(resume.msg, /InnerChat reply from fullstack-14/);
211
- });
235
+ it('replays prior turns so neither side re-explains', async () => {
236
+ const p1 = innerchat.ask('a1', 'a2', 'What shape?');
237
+ await tick(); innerchat.onAgentOutput('a2', result('REST'));
238
+ await p1;
212
239
 
213
- it('marks the turn failed when delivery throws', async () => {
214
- daemon.processes.sendMessage = async () => { throw new Error('pipe closed'); };
215
- await assert.rejects(() => innerchat.send('a1', 'a2', 'ping'), /pipe closed/);
240
+ const p2 = innerchat.ask('a1', 'a2', 'Versioned?');
241
+ await tick();
242
+ const msg = daemon.sent.at(-1).msg;
243
+ assert.match(msg, /Earlier in this conversation/);
244
+ assert.match(msg, /fullstack-1: What shape\?/);
245
+ assert.match(msg, /fullstack-14: REST/);
246
+ assert.match(msg, /Versioned\?/);
216
247
 
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');
248
+ innerchat.onAgentOutput('a2', result('v2'));
249
+ await p2;
221
250
  });
222
251
 
223
- // ── Threads ───────────────────────────────────────────────
224
-
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));
229
-
230
- await innerchat.send('a1', 'a2', 'Versioned?', first.thread.id);
231
-
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\?/);
252
+ it('broadcasts and audits both directions', async () => {
253
+ const p = innerchat.ask('a1', 'a2', 'q');
254
+ await tick();
255
+ innerchat.onAgentOutput('a2', result('a'));
256
+ await p;
237
257
 
238
- assert.equal(innerchat.getThreads().length, 1, 'reused the existing thread');
239
- assert.equal(first.thread.turns.length, 3);
258
+ const turns = daemon.broadcasts.filter((b) => b.type === 'innerchat:turn');
259
+ assert.equal(turns.length, 2);
260
+ assert.equal(turns[0].data.turn.kind, 'ask');
261
+ assert.equal(turns[1].data.turn.kind, 'answer');
262
+ assert.ok(daemon.audits.some((a) => a.type === 'innerchat.ask'));
263
+ assert.ok(daemon.audits.some((a) => a.type === 'innerchat.answer'));
240
264
  });
241
265
 
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);
266
+ // ── tell: non-blocking send ───────────────────────────────
250
267
 
251
- innerchat.onAgentOutput('a3', result('answer from 9'));
252
- await new Promise((r) => setImmediate(r));
268
+ it('tell returns immediately without waiting for a reply', async () => {
269
+ const res = await innerchat.tell('a1', 'a2', 'heads up, spec is on disk');
270
+ assert.equal(res.delivered, true);
271
+ assert.ok(res.threadId);
253
272
 
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');
273
+ // Delivered, framed as non-blocking.
274
+ const msg = daemon.sent.at(-1);
275
+ assert.equal(msg.id, 'a2');
276
+ assert.match(msg.msg, /sent you a message/);
277
+ assert.match(msg.msg, /NOT blocked/);
257
278
  });
258
279
 
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');
280
+ it('routes an async reply back to the sender, resuming it if needed', async () => {
281
+ await innerchat.tell('a1', 'a2', 'when you get a sec, confirm the error shape');
282
+ // a1 ended its turn: stopped, no loop.
283
+ daemon.processes._loops.delete('a1');
284
+ daemon.processes._running.delete('a1');
263
285
 
264
- await innerchat.send('a1', 'a2', 'hi');
265
- await innerchat.send('a1', 'a3', 'hi');
286
+ innerchat.onAgentOutput('a2', result('confirmed: {code, message}'));
287
+ await tick();
266
288
 
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);
289
+ // Reply delivered to a1 — via resume, since it had ended.
290
+ const resume = daemon.resumes.at(-1);
291
+ assert.equal(resume.id, 'a1');
292
+ assert.match(resume.msg, /InnerChat reply from fullstack-14/);
293
+ assert.match(resume.msg, /confirmed: \{code, message\}/);
294
+ assert.match(resume.msg, /you were not blocking/);
272
295
  });
273
296
 
274
- // ── Broadcast & audit ─────────────────────────────────────
297
+ it('does not block the sender or set blockedOn for a tell', async () => {
298
+ await innerchat.tell('a1', 'a2', 'fyi');
299
+ assert.equal(innerchat.blockedOn.get('a1'), undefined);
300
+ assert.ok(innerchat.getPending('a2'), 'still capturing the eventual reply');
301
+ });
275
302
 
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));
303
+ it('a tell to a target already awaited is refused', async () => {
304
+ // Left blocking; afterEach stop() rejects it — swallow so it isn't unhandled.
305
+ innerchat.ask('a1', 'a2', 'blocking q').catch(() => {});
306
+ await tick();
307
+ await assert.rejects(() => innerchat.tell('a1', 'a2', 'second'), /unanswered message/);
308
+ });
280
309
 
281
- const turns = daemon.broadcasts.filter((b) => b.type === 'innerchat:turn');
282
- assert.equal(turns.length, 2);
283
- assert.equal(turns[0].data.turn.kind, 'relay');
284
- assert.equal(turns[1].data.turn.kind, 'reply');
310
+ it('ask and tell share the exchange budget', async () => {
311
+ const r1 = await innerchat.tell('a1', 'a2', 'one');
312
+ assert.equal(r1.exchanges, 1);
313
+ // a2 replies to the tell, freeing the slot.
314
+ innerchat.onAgentOutput('a2', result('ok'));
315
+ await tick();
316
+
317
+ const p = innerchat.ask('a1', 'a2', 'two');
318
+ await tick();
319
+ innerchat.onAgentOutput('a2', result('answer'));
320
+ const r2 = await p;
321
+ assert.equal(r2.exchanges, 2, 'tell + ask both count');
322
+ });
285
323
 
286
- assert.ok(daemon.audits.some((a) => a.type === 'innerchat.send'));
287
- assert.ok(daemon.audits.some((a) => a.type === 'innerchat.reply'));
324
+ it('an async sender that vanishes is dropped quietly', async () => {
325
+ await innerchat.tell('a1', 'a2', 'x');
326
+ daemon.registry._agents.delete('a1'); // sender gone entirely
327
+ // Reply arrives with nobody to route to — must not throw.
328
+ innerchat.onAgentOutput('a2', result('late answer'));
329
+ await tick();
330
+ assert.equal(innerchat.getPending('a2'), null);
288
331
  });
289
332
  });
@@ -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();