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.
- package/.watch-test-flag-1784767927904 +1 -0
- package/.watch-test-flag-1784768055729 +1 -0
- package/.watch-test-flag-1784768066364 +1 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +2 -0
- package/node_modules/@groove-dev/daemon/src/index.js +17 -0
- package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +96 -0
- package/node_modules/@groove-dev/daemon/src/innerchat.js +265 -96
- package/node_modules/@groove-dev/daemon/src/introducer.js +31 -1
- package/node_modules/@groove-dev/daemon/src/process.js +36 -6
- package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +109 -13
- package/node_modules/@groove-dev/daemon/src/routes/teams.js +11 -0
- package/node_modules/@groove-dev/daemon/src/routes/watch.js +42 -0
- package/node_modules/@groove-dev/daemon/src/teams.js +28 -0
- package/node_modules/@groove-dev/daemon/src/watcher.js +258 -0
- package/node_modules/@groove-dev/daemon/test/innerchat.test.js +222 -179
- package/node_modules/@groove-dev/daemon/test/teams.test.js +53 -0
- package/node_modules/@groove-dev/daemon/test/watcher.test.js +158 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-CU8L_r5f.css +1 -0
- package/{packages/gui/dist/assets/index-DPjGBQ5X.js → node_modules/@groove-dev/gui/dist/assets/index-DEZwM4Hb.js} +233 -228
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/app.css +1 -0
- package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +60 -4
- package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +1 -29
- 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-pane.jsx +1 -15
- package/node_modules/@groove-dev/gui/src/components/fleet/fleet-sidebar.jsx +29 -11
- package/node_modules/@groove-dev/gui/src/stores/groove.js +36 -16
- package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +6 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/teams-slice.js +22 -0
- package/node_modules/@groove-dev/gui/src/views/memory.jsx +56 -31
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +2 -0
- package/packages/daemon/src/index.js +17 -0
- package/packages/daemon/src/innerchat-docs.js +96 -0
- package/packages/daemon/src/innerchat.js +265 -96
- package/packages/daemon/src/introducer.js +31 -1
- package/packages/daemon/src/process.js +36 -6
- package/packages/daemon/src/routes/innerchat.js +109 -13
- package/packages/daemon/src/routes/teams.js +11 -0
- package/packages/daemon/src/routes/watch.js +42 -0
- package/packages/daemon/src/teams.js +28 -0
- package/packages/daemon/src/watcher.js +258 -0
- package/packages/gui/dist/assets/index-CU8L_r5f.css +1 -0
- package/{node_modules/@groove-dev/gui/dist/assets/index-DPjGBQ5X.js → packages/gui/dist/assets/index-DEZwM4Hb.js} +233 -228
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/app.css +1 -0
- package/packages/gui/src/components/agents/agent-feed.jsx +60 -4
- package/packages/gui/src/components/agents/agent-panel.jsx +1 -29
- package/packages/gui/src/components/fleet/fleet-agent-row.jsx +18 -2
- package/packages/gui/src/components/fleet/fleet-pane.jsx +1 -15
- package/packages/gui/src/components/fleet/fleet-sidebar.jsx +29 -11
- package/packages/gui/src/stores/groove.js +36 -16
- package/packages/gui/src/stores/slices/agents-slice.js +6 -0
- package/packages/gui/src/stores/slices/teams-slice.js +22 -0
- package/packages/gui/src/views/memory.jsx +56 -31
- package/node_modules/@groove-dev/gui/dist/assets/index-CiOy7wVS.css +0 -1
- package/node_modules/@groove-dev/gui/src/components/agents/innerchat-relay.jsx +0 -145
- package/packages/gui/dist/assets/index-CiOy7wVS.css +0 -1
- 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.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
|
15
|
-
const
|
|
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
|
-
|
|
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,
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
//
|
|
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('
|
|
93
|
-
const
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
assert.equal(
|
|
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.
|
|
88
|
+
const delivered = daemon.sent.at(-1);
|
|
103
89
|
assert.equal(delivered.id, 'a2');
|
|
104
|
-
assert.match(delivered.msg, /
|
|
105
|
-
assert.match(delivered.msg, /
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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('
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
await
|
|
123
|
-
innerchat.
|
|
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
|
-
|
|
127
|
-
assert.equal(
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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('
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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('
|
|
149
|
-
|
|
150
|
-
innerchat.onAgentOutput('a2', result('
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
assert.equal(
|
|
156
|
-
assert.
|
|
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('
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
innerchat.
|
|
167
|
-
await
|
|
168
|
-
|
|
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
|
-
|
|
171
|
-
innerchat.
|
|
172
|
-
await
|
|
173
|
-
|
|
174
|
-
assert.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
185
|
-
|
|
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), '
|
|
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
|
|
197
|
-
assert.match(daemon.sentMessages.at(-1).msg, /pong/);
|
|
230
|
+
assert.equal((await pending).reply, 'pong');
|
|
198
231
|
});
|
|
199
232
|
|
|
200
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
239
|
-
assert.equal(
|
|
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
|
-
|
|
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
|
-
|
|
252
|
-
await
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
assert.
|
|
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('
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
daemon.processes.
|
|
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
|
-
|
|
265
|
-
await
|
|
286
|
+
innerchat.onAgentOutput('a2', result('confirmed: {code, message}'));
|
|
287
|
+
await tick();
|
|
266
288
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
assert.equal(
|
|
270
|
-
assert.
|
|
271
|
-
assert.
|
|
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
|
-
|
|
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('
|
|
277
|
-
|
|
278
|
-
innerchat.
|
|
279
|
-
await
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
assert.equal(
|
|
284
|
-
|
|
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
|
-
|
|
287
|
-
|
|
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();
|