groove-dev 0.27.182 → 0.27.183
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.js +213 -62
- package/node_modules/@groove-dev/daemon/src/process.js +1 -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 +12 -9
- package/node_modules/@groove-dev/daemon/src/teams.js +7 -1
- package/node_modules/@groove-dev/daemon/test/innerchat.test.js +197 -111
- package/node_modules/@groove-dev/daemon/test/rename.test.js +108 -0
- package/node_modules/@groove-dev/gui/dist/assets/{index-DTFtRtkx.css → index-CiOy7wVS.css} +1 -1
- package/node_modules/@groove-dev/gui/dist/assets/{index-CTer01Vg.js → index-DPjGBQ5X.js} +225 -225
- 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 +3 -68
- package/node_modules/@groove-dev/gui/src/components/agents/innerchat-relay.jsx +145 -0
- package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +3 -1
- package/node_modules/@groove-dev/gui/src/components/fleet/fleet-pane.jsx +15 -1
- package/node_modules/@groove-dev/gui/src/components/fleet/fleet-sidebar.jsx +33 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +29 -44
- package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +27 -4
- 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.js +213 -62
- package/packages/daemon/src/process.js +1 -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 +12 -9
- package/packages/daemon/src/teams.js +7 -1
- package/packages/gui/dist/assets/{index-DTFtRtkx.css → index-CiOy7wVS.css} +1 -1
- package/packages/gui/dist/assets/{index-CTer01Vg.js → index-DPjGBQ5X.js} +225 -225
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/components/agents/agent-panel.jsx +3 -68
- package/packages/gui/src/components/agents/innerchat-relay.jsx +145 -0
- package/packages/gui/src/components/chat/chat-messages.jsx +3 -1
- package/packages/gui/src/components/fleet/fleet-pane.jsx +15 -1
- package/packages/gui/src/components/fleet/fleet-sidebar.jsx +33 -1
- package/packages/gui/src/stores/groove.js +29 -44
- package/packages/gui/src/stores/slices/agents-slice.js +27 -4
|
@@ -5,6 +5,8 @@ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSy
|
|
|
5
5
|
import { validateAgentConfig, validateReasoningEffort, validateVerbosity } from '../validate.js';
|
|
6
6
|
import { ROLE_INTEGRATIONS, wrapWithRoleReminder } from '../process.js';
|
|
7
7
|
import { getProvider } from '../providers/index.js';
|
|
8
|
+
import { deliverInstruction } from '../deliver.js';
|
|
9
|
+
import { renameAgent } from '../rename.js';
|
|
8
10
|
|
|
9
11
|
export function registerAgentRoutes(app, daemon) {
|
|
10
12
|
// List all agents
|
|
@@ -56,9 +58,21 @@ export function registerAgentRoutes(app, daemon) {
|
|
|
56
58
|
|
|
57
59
|
// Update agent
|
|
58
60
|
app.patch('/api/agents/:id', (req, res) => {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
try {
|
|
62
|
+
const { name, ...rest } = req.body || {};
|
|
63
|
+
|
|
64
|
+
// Renames migrate name-keyed files, so they can't go through the plain
|
|
65
|
+
// field update — registry.update ignores `name` without the opt-in.
|
|
66
|
+
let agent = daemon.registry.get(req.params.id);
|
|
67
|
+
if (!agent) return res.status(404).json({ error: 'Agent not found' });
|
|
68
|
+
|
|
69
|
+
if (Object.keys(rest).length) agent = daemon.registry.update(req.params.id, rest);
|
|
70
|
+
if (typeof name === 'string') agent = renameAgent(daemon, req.params.id, name);
|
|
71
|
+
|
|
72
|
+
res.json(agent);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
res.status(400).json({ error: err.message });
|
|
75
|
+
}
|
|
62
76
|
});
|
|
63
77
|
|
|
64
78
|
// Kill an agent (add ?purge=true to also remove from registry)
|
|
@@ -426,104 +440,12 @@ export function registerAgentRoutes(app, daemon) {
|
|
|
426
440
|
}
|
|
427
441
|
}
|
|
428
442
|
|
|
429
|
-
|
|
430
|
-
if (daemon.journalist) daemon.journalist.recordUserFeedback(agent, finalMessage);
|
|
431
|
-
if (daemon.rotator) daemon.rotator.recordUserMessage(req.params.id);
|
|
432
|
-
|
|
433
|
-
// Agent loop path — send message directly to the running loop
|
|
434
|
-
const wrappedMessage = wrapWithRoleReminder(agent.role, finalMessage);
|
|
435
|
-
if (daemon.processes.hasAgentLoop(req.params.id)) {
|
|
436
|
-
const sent = await daemon.processes.sendMessage(req.params.id, wrappedMessage);
|
|
437
|
-
if (sent) {
|
|
438
|
-
daemon.audit.log('agent.chat', { id: req.params.id });
|
|
439
|
-
return res.json({ id: agent.id, status: 'message_sent' });
|
|
440
|
-
}
|
|
441
|
-
// Loop exists but not running — fall through to resume/rotate
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
// One-shot providers (groove-network): kill any running instance and
|
|
445
|
-
// respawn with the user's message as --prompt. No handoff brief, no
|
|
446
|
-
// session resume, no message queue — each chat message is a fresh spawn.
|
|
447
|
-
const provider = getProvider(agent.provider);
|
|
448
|
-
if (provider?.constructor?.isOneShot) {
|
|
449
|
-
const oldConfig = { ...agent };
|
|
450
|
-
if (daemon.processes.isRunning(req.params.id)) {
|
|
451
|
-
await daemon.processes.kill(req.params.id);
|
|
452
|
-
}
|
|
453
|
-
daemon.registry.remove(req.params.id, { silent: true });
|
|
454
|
-
daemon.locks.release(req.params.id);
|
|
455
|
-
|
|
456
|
-
let newAgent;
|
|
457
|
-
try {
|
|
458
|
-
newAgent = await daemon.processes.spawn({
|
|
459
|
-
role: oldConfig.role,
|
|
460
|
-
scope: oldConfig.scope,
|
|
461
|
-
provider: oldConfig.provider,
|
|
462
|
-
model: oldConfig.model,
|
|
463
|
-
prompt: finalMessage,
|
|
464
|
-
permission: oldConfig.permission || 'full',
|
|
465
|
-
workingDir: oldConfig.workingDir,
|
|
466
|
-
name: oldConfig.name,
|
|
467
|
-
teamId: oldConfig.teamId,
|
|
468
|
-
});
|
|
469
|
-
} catch (spawnErr) {
|
|
470
|
-
daemon.registry.flushPendingRemovals();
|
|
471
|
-
throw spawnErr;
|
|
472
|
-
}
|
|
473
|
-
daemon.audit.log('agent.instruct', { id: req.params.id, newId: newAgent.id, resumed: false });
|
|
474
|
-
return res.json(newAgent);
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
// Non-interactive CLI providers (e.g. Gemini): respawn with the new
|
|
478
|
-
// message as the prompt, preserving original introContext. These providers
|
|
479
|
-
// run one prompt per spawn and cannot resume sessions.
|
|
480
|
-
if (provider?.constructor?.nonInteractive && !daemon.processes.isRunning(req.params.id)) {
|
|
481
|
-
const oldConfig = { ...agent };
|
|
482
|
-
daemon.registry.remove(req.params.id, { silent: true });
|
|
483
|
-
daemon.locks.release(req.params.id);
|
|
484
|
-
|
|
485
|
-
let newAgent;
|
|
486
|
-
try {
|
|
487
|
-
newAgent = await daemon.processes.spawn({
|
|
488
|
-
role: oldConfig.role,
|
|
489
|
-
scope: oldConfig.scope,
|
|
490
|
-
provider: oldConfig.provider,
|
|
491
|
-
model: oldConfig.model,
|
|
492
|
-
prompt: finalMessage,
|
|
493
|
-
introContext: oldConfig.introContext,
|
|
494
|
-
permission: oldConfig.permission || 'full',
|
|
495
|
-
workingDir: oldConfig.workingDir,
|
|
496
|
-
name: oldConfig.name,
|
|
497
|
-
teamId: oldConfig.teamId,
|
|
498
|
-
});
|
|
499
|
-
} catch (spawnErr) {
|
|
500
|
-
daemon.registry.flushPendingRemovals();
|
|
501
|
-
throw spawnErr;
|
|
502
|
-
}
|
|
503
|
-
daemon.audit.log('agent.instruct', { id: req.params.id, newId: newAgent.id, resumed: false });
|
|
504
|
-
return res.json(newAgent);
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
// Running CLI agent (no loop) — queue the message for delivery after
|
|
508
|
-
// the current task completes instead of killing and respawning.
|
|
509
|
-
if (daemon.processes.isRunning(req.params.id)) {
|
|
510
|
-
daemon.processes.queueMessage(req.params.id, wrappedMessage);
|
|
511
|
-
daemon.audit.log('agent.chat.queued', { id: req.params.id });
|
|
512
|
-
return res.json({ id: agent.id, status: 'message_queued' });
|
|
513
|
-
}
|
|
443
|
+
const result = await deliverInstruction(daemon, req.params.id, finalMessage);
|
|
514
444
|
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
const SESSION_RESUME_CEILING = 5_000_000;
|
|
520
|
-
const resumed = !!agent.sessionId && (agent.tokensUsed || 0) < SESSION_RESUME_CEILING;
|
|
521
|
-
const newAgent = resumed
|
|
522
|
-
? await daemon.processes.resume(req.params.id, wrappedMessage)
|
|
523
|
-
: await daemon.rotator.rotate(req.params.id, { additionalPrompt: wrappedMessage });
|
|
524
|
-
|
|
525
|
-
daemon.audit.log('agent.instruct', { id: req.params.id, newId: newAgent.id, resumed });
|
|
526
|
-
res.json(newAgent);
|
|
445
|
+
// Respawn paths return the fresh agent record; in-place delivery just
|
|
446
|
+
// acknowledges with a status the GUI uses to show queued vs sent.
|
|
447
|
+
if (result.agent && result.agentId !== req.params.id) return res.json(result.agent);
|
|
448
|
+
res.json({ id: agent.id, status: result.status });
|
|
527
449
|
} catch (err) {
|
|
528
450
|
res.status(400).json({ error: err.message });
|
|
529
451
|
}
|
|
@@ -1,30 +1,33 @@
|
|
|
1
1
|
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
2
|
|
|
3
3
|
export function registerInnerChatRoutes(app, daemon) {
|
|
4
|
+
// Relay a message from one agent to another. Opens a thread, or continues
|
|
5
|
+
// an existing one when threadId is supplied.
|
|
4
6
|
app.post('/api/innerchat/send', async (req, res) => {
|
|
5
7
|
try {
|
|
6
|
-
const { from, to, message } = req.body;
|
|
8
|
+
const { from, to, message, threadId } = req.body;
|
|
7
9
|
if (!from || typeof from !== 'string') return res.status(400).json({ error: 'from (agent ID) is required' });
|
|
8
10
|
if (!to || typeof to !== 'string') return res.status(400).json({ error: 'to (agent ID) is required' });
|
|
9
11
|
if (!message || typeof message !== 'string' || !message.trim()) return res.status(400).json({ error: 'message is required' });
|
|
10
12
|
if (from === to) return res.status(400).json({ error: 'cannot send a message to yourself' });
|
|
13
|
+
if (threadId && typeof threadId !== 'string') return res.status(400).json({ error: 'threadId must be a string' });
|
|
11
14
|
|
|
12
|
-
const
|
|
13
|
-
res.json(
|
|
15
|
+
const result = await daemon.innerchat.send(from, to, message.trim(), threadId || null);
|
|
16
|
+
res.json(result);
|
|
14
17
|
} catch (err) {
|
|
15
18
|
res.status(400).json({ error: err.message });
|
|
16
19
|
}
|
|
17
20
|
});
|
|
18
21
|
|
|
19
|
-
app.get('/api/innerchat/
|
|
22
|
+
app.get('/api/innerchat/threads', (req, res) => {
|
|
20
23
|
const { agentId } = req.query;
|
|
21
|
-
res.json({
|
|
24
|
+
res.json({ threads: daemon.innerchat.getThreads(agentId || null) });
|
|
22
25
|
});
|
|
23
26
|
|
|
24
|
-
app.get('/api/innerchat/
|
|
25
|
-
const
|
|
26
|
-
if (!
|
|
27
|
-
res.json(
|
|
27
|
+
app.get('/api/innerchat/threads/:id', (req, res) => {
|
|
28
|
+
const thread = daemon.innerchat.getThread(req.params.id);
|
|
29
|
+
if (!thread) return res.status(404).json({ error: 'Thread not found' });
|
|
30
|
+
res.json(thread);
|
|
28
31
|
});
|
|
29
32
|
|
|
30
33
|
app.get('/api/innerchat/pending/:agentId', (req, res) => {
|
|
@@ -148,11 +148,17 @@ export class Teams {
|
|
|
148
148
|
renameSync(oldWorkingDir, newWorkingDir);
|
|
149
149
|
team.workingDir = newWorkingDir;
|
|
150
150
|
|
|
151
|
-
//
|
|
151
|
+
// Repoint every agent under the old tree, not just those sitting at
|
|
152
|
+
// its root — an agent in a subdirectory would otherwise keep a path
|
|
153
|
+
// that no longer exists.
|
|
152
154
|
const agents = this.daemon.registry.getAll().filter((a) => a.teamId === id);
|
|
153
155
|
for (const agent of agents) {
|
|
156
|
+
if (!agent.workingDir) continue;
|
|
154
157
|
if (agent.workingDir === oldWorkingDir) {
|
|
155
158
|
this.daemon.registry.update(agent.id, { workingDir: newWorkingDir });
|
|
159
|
+
} else if (agent.workingDir.startsWith(`${oldWorkingDir}/`)) {
|
|
160
|
+
const suffix = agent.workingDir.slice(oldWorkingDir.length);
|
|
161
|
+
this.daemon.registry.update(agent.id, { workingDir: `${newWorkingDir}${suffix}` });
|
|
156
162
|
}
|
|
157
163
|
}
|
|
158
164
|
} catch (err) {
|
|
@@ -5,22 +5,33 @@ import { describe, it, beforeEach } from 'node:test';
|
|
|
5
5
|
import assert from 'node:assert/strict';
|
|
6
6
|
import { InnerChat } 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
11
|
function makeDaemon() {
|
|
9
12
|
const broadcasts = [];
|
|
10
13
|
const audits = [];
|
|
11
14
|
const sentMessages = [];
|
|
12
15
|
const queuedMessages = [];
|
|
16
|
+
const resumes = [];
|
|
17
|
+
|
|
18
|
+
let idCounter = 0;
|
|
13
19
|
|
|
14
20
|
return {
|
|
15
21
|
broadcasts,
|
|
16
22
|
audits,
|
|
17
23
|
sentMessages,
|
|
18
24
|
queuedMessages,
|
|
25
|
+
resumes,
|
|
19
26
|
registry: {
|
|
20
27
|
_agents: new Map(),
|
|
21
28
|
get(id) { return this._agents.get(id) || null; },
|
|
22
29
|
add(agent) { this._agents.set(agent.id, agent); },
|
|
30
|
+
remove(id) { this._agents.delete(id); },
|
|
31
|
+
flushPendingRemovals() {},
|
|
32
|
+
update() {},
|
|
23
33
|
},
|
|
34
|
+
locks: { release() {} },
|
|
24
35
|
processes: {
|
|
25
36
|
_loops: new Set(),
|
|
26
37
|
_running: new Set(),
|
|
@@ -33,171 +44,246 @@ function makeDaemon() {
|
|
|
33
44
|
queueMessage(id, msg) {
|
|
34
45
|
queuedMessages.push({ id, msg });
|
|
35
46
|
},
|
|
47
|
+
// Resume mints a new agent id, exactly as the real one does.
|
|
48
|
+
async resume(id, msg) {
|
|
49
|
+
const old = this._agentsRef.get(id);
|
|
50
|
+
const fresh = { ...old, id: `${id}-r${++idCounter}` };
|
|
51
|
+
this._agentsRef.delete(id);
|
|
52
|
+
this._agentsRef.set(fresh.id, fresh);
|
|
53
|
+
resumes.push({ id, msg, newId: fresh.id });
|
|
54
|
+
return fresh;
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
rotator: {
|
|
58
|
+
async rotate(id, opts) { return this._daemon.processes.resume(id, opts.additionalPrompt); },
|
|
36
59
|
},
|
|
37
60
|
broadcast(msg) { broadcasts.push(msg); },
|
|
38
61
|
audit: { log(type, data) { audits.push({ type, data }); } },
|
|
39
62
|
};
|
|
40
63
|
}
|
|
41
64
|
|
|
65
|
+
function wire(daemon) {
|
|
66
|
+
daemon.processes._agentsRef = daemon.registry._agents;
|
|
67
|
+
daemon.rotator._daemon = daemon;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const result = (text) => ({ type: 'result', data: text });
|
|
71
|
+
|
|
42
72
|
describe('InnerChat', () => {
|
|
43
73
|
let daemon;
|
|
44
74
|
let innerchat;
|
|
45
75
|
|
|
46
76
|
beforeEach(() => {
|
|
47
77
|
daemon = makeDaemon();
|
|
78
|
+
wire(daemon);
|
|
48
79
|
innerchat = new InnerChat(daemon);
|
|
49
80
|
daemon.innerchat = innerchat;
|
|
50
81
|
|
|
51
|
-
daemon.registry.add({ id: 'a1', name: 'fullstack-1', role: 'fullstack' });
|
|
52
|
-
daemon.registry.add({ id: 'a2', name: 'fullstack-14', role: 'fullstack' });
|
|
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');
|
|
53
86
|
daemon.processes._loops.add('a2');
|
|
54
87
|
daemon.processes._running.add('a2');
|
|
55
88
|
});
|
|
56
89
|
|
|
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
|
-
});
|
|
90
|
+
// ── Sending ───────────────────────────────────────────────
|
|
68
91
|
|
|
69
|
-
it('
|
|
70
|
-
await innerchat.send('a1', 'a2', '
|
|
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
|
-
});
|
|
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?');
|
|
76
94
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
assert.equal(
|
|
81
|
-
assert.equal(
|
|
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');
|
|
101
|
+
|
|
102
|
+
const delivered = daemon.sentMessages.at(-1);
|
|
103
|
+
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\?/);
|
|
82
106
|
});
|
|
83
107
|
|
|
84
|
-
it('
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
await assert.rejects(() => innerchat.send('a1', '
|
|
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/);
|
|
88
113
|
});
|
|
89
114
|
|
|
90
|
-
it('
|
|
91
|
-
await assert.rejects(() => innerchat.send('
|
|
115
|
+
it('rejects an unknown threadId', async () => {
|
|
116
|
+
await assert.rejects(() => innerchat.send('a1', 'a2', 'hi', 'bogus'), /not found/);
|
|
92
117
|
});
|
|
93
118
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
119
|
+
// ── Reply capture and auto-forward ────────────────────────
|
|
120
|
+
|
|
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));
|
|
125
|
+
|
|
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/);
|
|
100
130
|
});
|
|
101
131
|
|
|
102
|
-
it('
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
assert.
|
|
106
|
-
assert.equal(sent.data.message, 'Hello');
|
|
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);
|
|
107
136
|
});
|
|
108
137
|
|
|
109
|
-
it('
|
|
110
|
-
|
|
111
|
-
innerchat.onAgentOutput('a2', { type: '
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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));
|
|
143
|
+
|
|
144
|
+
assert.equal(daemon.sentMessages.at(-1).id, 'a2', 'no forward should have happened');
|
|
145
|
+
assert.ok(innerchat.getPending('a2'), 'relay is still outstanding');
|
|
115
146
|
});
|
|
116
147
|
|
|
117
|
-
it('
|
|
118
|
-
daemon.processes._loops.add('a1');
|
|
119
|
-
daemon.processes._running.add('a1');
|
|
148
|
+
it('captures a reply only once', async () => {
|
|
120
149
|
await innerchat.send('a1', 'a2', 'What shape?');
|
|
121
|
-
|
|
150
|
+
innerchat.onAgentOutput('a2', result('first'));
|
|
151
|
+
innerchat.onAgentOutput('a2', result('second'));
|
|
152
|
+
await new Promise((r) => setImmediate(r));
|
|
122
153
|
|
|
123
|
-
|
|
124
|
-
assert.equal(
|
|
125
|
-
assert.
|
|
126
|
-
assert.ok(daemon.sentMessages[0].msg.includes('InnerChat reply from fullstack-14'));
|
|
127
|
-
assert.ok(daemon.sentMessages[0].msg.includes('The answer'));
|
|
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/);
|
|
128
157
|
});
|
|
129
158
|
|
|
130
|
-
it('
|
|
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');
|
|
131
162
|
await innerchat.send('a1', 'a2', 'What shape?');
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
163
|
+
assert.equal(daemon.queuedMessages.at(-1).id, 'a2');
|
|
164
|
+
|
|
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);
|
|
169
|
+
|
|
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/);
|
|
136
176
|
});
|
|
137
177
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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');
|
|
183
|
+
|
|
184
|
+
const { thread, turn } = await innerchat.send('a1', 'a2', 'ping');
|
|
185
|
+
const newId = daemon.resumes.at(-1).newId;
|
|
186
|
+
|
|
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');
|
|
192
|
+
|
|
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/);
|
|
141
198
|
});
|
|
142
199
|
|
|
143
|
-
it('
|
|
144
|
-
await innerchat.send('a1', 'a2', '
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
assert.equal(msg.response, 'Part 1\nPart 2');
|
|
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));
|
|
207
|
+
|
|
208
|
+
const resume = daemon.resumes.at(-1);
|
|
209
|
+
assert.equal(resume.id, 'a1');
|
|
210
|
+
assert.match(resume.msg, /InnerChat reply from fullstack-14/);
|
|
155
211
|
});
|
|
156
212
|
|
|
157
|
-
it('
|
|
158
|
-
|
|
159
|
-
innerchat.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
assert.equal(
|
|
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/);
|
|
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');
|
|
163
221
|
});
|
|
164
222
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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\?/);
|
|
237
|
+
|
|
238
|
+
assert.equal(innerchat.getThreads().length, 1, 'reused the existing thread');
|
|
239
|
+
assert.equal(first.thread.turns.length, 3);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('keeps concurrent relays to different agents separate', async () => {
|
|
243
|
+
daemon.registry.add({ id: 'a3', name: 'fullstack-9', role: 'fullstack', provider: 'claude-code' });
|
|
169
244
|
daemon.processes._loops.add('a3');
|
|
170
245
|
daemon.processes._running.add('a3');
|
|
171
246
|
|
|
172
|
-
await innerchat.send('a1', 'a2', '
|
|
173
|
-
await innerchat.send('a1', 'a3', '
|
|
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);
|
|
174
250
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
assert.equal(innerchat.getMessages('a3').length, 1);
|
|
178
|
-
assert.equal(innerchat.getMessages('a1').length, 2);
|
|
179
|
-
});
|
|
251
|
+
innerchat.onAgentOutput('a3', result('answer from 9'));
|
|
252
|
+
await new Promise((r) => setImmediate(r));
|
|
180
253
|
|
|
181
|
-
|
|
182
|
-
assert.equal(
|
|
183
|
-
|
|
184
|
-
const pending = innerchat.getPending('a2');
|
|
185
|
-
assert.ok(pending);
|
|
186
|
-
assert.equal(pending.message, 'Question?');
|
|
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');
|
|
187
257
|
});
|
|
188
258
|
|
|
189
|
-
it('
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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');
|
|
263
|
+
|
|
264
|
+
await innerchat.send('a1', 'a2', 'hi');
|
|
265
|
+
await innerchat.send('a1', 'a3', 'hi');
|
|
266
|
+
|
|
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);
|
|
193
272
|
});
|
|
194
273
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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));
|
|
280
|
+
|
|
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');
|
|
285
|
+
|
|
286
|
+
assert.ok(daemon.audits.some((a) => a.type === 'innerchat.send'));
|
|
287
|
+
assert.ok(daemon.audits.some((a) => a.type === 'innerchat.reply'));
|
|
202
288
|
});
|
|
203
289
|
});
|