groove-dev 0.27.181 → 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/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/scripts/repair-tokens.mjs +78 -0
- package/node_modules/@groove-dev/daemon/src/api.js +1 -1
- package/node_modules/@groove-dev/daemon/src/deliver.js +130 -0
- package/node_modules/@groove-dev/daemon/src/index.js +8 -3
- package/node_modules/@groove-dev/daemon/src/innerchat.js +213 -62
- package/node_modules/@groove-dev/daemon/src/journalist.js +15 -8
- package/node_modules/@groove-dev/daemon/src/process.js +59 -34
- package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +9 -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/rotator.js +62 -5
- 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/src/tokentracker.js +46 -6
- package/node_modules/@groove-dev/daemon/test/claude-code-provider.test.js +52 -0
- package/node_modules/@groove-dev/daemon/test/innerchat.test.js +197 -111
- package/node_modules/@groove-dev/daemon/test/journalist.test.js +4 -2
- package/node_modules/@groove-dev/daemon/test/rename.test.js +108 -0
- package/node_modules/@groove-dev/daemon/test/rotator.test.js +131 -0
- package/node_modules/@groove-dev/daemon/test/tokentracker.test.js +4 -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/scripts/repair-tokens.mjs +78 -0
- package/packages/daemon/src/api.js +1 -1
- package/packages/daemon/src/deliver.js +130 -0
- package/packages/daemon/src/index.js +8 -3
- package/packages/daemon/src/innerchat.js +213 -62
- package/packages/daemon/src/journalist.js +15 -8
- package/packages/daemon/src/process.js +59 -34
- package/packages/daemon/src/providers/claude-code.js +9 -1
- package/packages/daemon/src/registry.js +5 -1
- package/packages/daemon/src/rename.js +72 -0
- package/packages/daemon/src/rotator.js +62 -5
- 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/daemon/src/tokentracker.js +46 -6
- 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
|
@@ -12,6 +12,14 @@ const COLD_START_PER_FILE = 15;
|
|
|
12
12
|
const COLD_START_PER_DIR = 40;
|
|
13
13
|
// Estimated tokens wasted per file conflict (agent discovers, backs off, retries)
|
|
14
14
|
const CONFLICT_OVERHEAD = 500;
|
|
15
|
+
// Max per-agent session records kept. Aggregates are cumulative and unaffected;
|
|
16
|
+
// windowed queries (getVelocity/getTokensInWindow) only need the recent tail,
|
|
17
|
+
// which 500 records covers by orders of magnitude. Uncapped, tokens.json grew
|
|
18
|
+
// to 13.8MB and was rewritten on every assistant stream event.
|
|
19
|
+
const SESSION_CAP = 500;
|
|
20
|
+
// Debounce window for persisting to disk. record() fires per assistant event —
|
|
21
|
+
// writing the full ledger each time is a hot-path full-file rewrite.
|
|
22
|
+
const SAVE_DEBOUNCE_MS = 5_000;
|
|
15
23
|
|
|
16
24
|
export class TokenTracker {
|
|
17
25
|
constructor(grooveDir) {
|
|
@@ -23,6 +31,7 @@ export class TokenTracker {
|
|
|
23
31
|
this.coldStartsSkipped = 0;
|
|
24
32
|
this.projectFiles = 0; // Set from indexer stats
|
|
25
33
|
this.projectDirs = 0;
|
|
34
|
+
this._saveTimer = null;
|
|
26
35
|
this.load();
|
|
27
36
|
}
|
|
28
37
|
|
|
@@ -47,6 +56,7 @@ export class TokenTracker {
|
|
|
47
56
|
if (!entry.totalDurationMs) entry.totalDurationMs = 0;
|
|
48
57
|
if (!entry.totalTurns) entry.totalTurns = 0;
|
|
49
58
|
if (!entry.modelDistribution) entry.modelDistribution = {};
|
|
59
|
+
this._capSessions(entry);
|
|
50
60
|
}
|
|
51
61
|
} catch {
|
|
52
62
|
this.usage = {};
|
|
@@ -55,6 +65,10 @@ export class TokenTracker {
|
|
|
55
65
|
}
|
|
56
66
|
|
|
57
67
|
save() {
|
|
68
|
+
if (this._saveTimer) {
|
|
69
|
+
clearTimeout(this._saveTimer);
|
|
70
|
+
this._saveTimer = null;
|
|
71
|
+
}
|
|
58
72
|
writeFileSync(this.path, JSON.stringify({
|
|
59
73
|
usage: this.usage,
|
|
60
74
|
rotationSavings: this.rotationSavings,
|
|
@@ -64,6 +78,30 @@ export class TokenTracker {
|
|
|
64
78
|
}, null, 2));
|
|
65
79
|
}
|
|
66
80
|
|
|
81
|
+
// Debounced persistence for hot-path recording. In-memory state is always
|
|
82
|
+
// current; at most SAVE_DEBOUNCE_MS of records are at risk on a crash.
|
|
83
|
+
_scheduleSave() {
|
|
84
|
+
if (this._saveTimer) return;
|
|
85
|
+
this._saveTimer = setTimeout(() => {
|
|
86
|
+
this._saveTimer = null;
|
|
87
|
+
try { this.save(); } catch { /* best-effort */ }
|
|
88
|
+
}, SAVE_DEBOUNCE_MS);
|
|
89
|
+
this._saveTimer.unref?.();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Write any pending debounced state to disk. Call on daemon shutdown.
|
|
93
|
+
flush() {
|
|
94
|
+
if (this._saveTimer) {
|
|
95
|
+
try { this.save(); } catch { /* best-effort */ }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
_capSessions(entry) {
|
|
100
|
+
if (Array.isArray(entry.sessions) && entry.sessions.length > SESSION_CAP) {
|
|
101
|
+
entry.sessions.splice(0, entry.sessions.length - SESSION_CAP);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
67
105
|
_initAgent(agentId) {
|
|
68
106
|
if (!this.usage[agentId]) {
|
|
69
107
|
this.usage[agentId] = {
|
|
@@ -89,7 +127,8 @@ export class TokenTracker {
|
|
|
89
127
|
if (typeof detail === 'number') {
|
|
90
128
|
entry.total += detail;
|
|
91
129
|
entry.sessions.push({ tokens: detail, timestamp: new Date().toISOString() });
|
|
92
|
-
this.
|
|
130
|
+
this._capSessions(entry);
|
|
131
|
+
this._scheduleSave();
|
|
93
132
|
return;
|
|
94
133
|
}
|
|
95
134
|
|
|
@@ -121,8 +160,9 @@ export class TokenTracker {
|
|
|
121
160
|
projectRoot: detail.projectRoot || null,
|
|
122
161
|
timestamp: new Date().toISOString(),
|
|
123
162
|
});
|
|
163
|
+
this._capSessions(entry);
|
|
124
164
|
|
|
125
|
-
this.
|
|
165
|
+
this._scheduleSave();
|
|
126
166
|
}
|
|
127
167
|
|
|
128
168
|
// Sum tokens recorded for an agent since a given timestamp.
|
|
@@ -152,25 +192,25 @@ export class TokenTracker {
|
|
|
152
192
|
if (costUsd) entry.totalCostUsd += costUsd;
|
|
153
193
|
if (durationMs) entry.totalDurationMs += durationMs;
|
|
154
194
|
if (turns) entry.totalTurns += turns;
|
|
155
|
-
this.
|
|
195
|
+
this._scheduleSave();
|
|
156
196
|
}
|
|
157
197
|
|
|
158
198
|
// Record that a rotation saved context tokens
|
|
159
199
|
recordRotation(agentId, tokensBefore) {
|
|
160
200
|
this.rotationSavings += Math.round(tokensBefore * 0.3); // ~30% of context was degraded
|
|
161
|
-
this.
|
|
201
|
+
this._scheduleSave();
|
|
162
202
|
}
|
|
163
203
|
|
|
164
204
|
// Record that a conflict was prevented (scope enforcement)
|
|
165
205
|
recordConflictPrevented() {
|
|
166
206
|
this.conflictsPrevented++;
|
|
167
|
-
this.
|
|
207
|
+
this._scheduleSave();
|
|
168
208
|
}
|
|
169
209
|
|
|
170
210
|
// Record that a cold-start was skipped (Journalist provided context)
|
|
171
211
|
recordColdStartSkipped() {
|
|
172
212
|
this.coldStartsSkipped++;
|
|
173
|
-
this.
|
|
213
|
+
this._scheduleSave();
|
|
174
214
|
}
|
|
175
215
|
|
|
176
216
|
// Set project size from indexer for dynamic cold-start estimation
|
|
@@ -69,6 +69,58 @@ describe('ClaudeCodeProvider result parsing', () => {
|
|
|
69
69
|
});
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
+
describe('ClaudeCodeProvider resume handshake (CLI >= 2.1.212)', () => {
|
|
73
|
+
// The 0-turn no-op result the CLI emits ~180ms into --resume reconciliation.
|
|
74
|
+
const HANDSHAKE = JSON.stringify({
|
|
75
|
+
type: 'result', subtype: 'success', is_error: false,
|
|
76
|
+
duration_ms: 186, duration_api_ms: 0, num_turns: 0, total_cost_usd: 0,
|
|
77
|
+
});
|
|
78
|
+
const ABORT = JSON.stringify({
|
|
79
|
+
type: 'result', subtype: 'error_during_execution', is_error: true,
|
|
80
|
+
duration_ms: 5126, duration_api_ms: 0, num_turns: 2, total_cost_usd: 0,
|
|
81
|
+
terminal_reason: 'aborted_streaming',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// This is the gate the daemon uses to decide whether a result means "the
|
|
85
|
+
// turn completed". Treating the handshake as a completion arms a 5s
|
|
86
|
+
// force-kill that SIGTERMs the process mid-turn.
|
|
87
|
+
const reachedModel = (o) => o.apiDurationMs === undefined || o.apiDurationMs > 0;
|
|
88
|
+
|
|
89
|
+
it('does not count the resume handshake as a completed turn', () => {
|
|
90
|
+
const out = provider.parseOutput(HANDSHAKE);
|
|
91
|
+
assert.equal(out.isError, false);
|
|
92
|
+
assert.equal(reachedModel(out), false,
|
|
93
|
+
'handshake must not arm the force-kill or clear the queued message');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('counts a real turn as completed', () => {
|
|
97
|
+
const out = provider.parseOutput(JSON.stringify({
|
|
98
|
+
type: 'result', subtype: 'success', is_error: false,
|
|
99
|
+
duration_ms: 17850, duration_api_ms: 17672, num_turns: 3, total_cost_usd: 0.42,
|
|
100
|
+
}));
|
|
101
|
+
assert.equal(reachedModel(out), true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('providers without an API duration still count as completed', () => {
|
|
105
|
+
// codex / gemini / ollama don't report duration_api_ms — they must be
|
|
106
|
+
// unaffected by the guard.
|
|
107
|
+
const out = provider.parseOutput(JSON.stringify({
|
|
108
|
+
type: 'result', subtype: 'success', duration_ms: 1200, num_turns: 1,
|
|
109
|
+
}));
|
|
110
|
+
assert.equal(out.apiDurationMs, undefined);
|
|
111
|
+
assert.equal(reachedModel(out), true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Regression: taking the first result in a chunk let the handshake mask a
|
|
115
|
+
// subsequent abort, so the failure looked like a clean completion.
|
|
116
|
+
it('surfaces the abort when a chunk carries handshake + abort together', () => {
|
|
117
|
+
const out = provider.parseOutput(`${HANDSHAKE}\n${ABORT}`);
|
|
118
|
+
assert.equal(out.isError, true, 'abort must win over the no-op success result');
|
|
119
|
+
assert.equal(out.terminalReason, 'aborted_streaming');
|
|
120
|
+
assert.equal(reachedModel(out), false);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
72
124
|
describe('ClaudeCodeProvider models', () => {
|
|
73
125
|
it('offers Fable 5', () => {
|
|
74
126
|
assert.ok(ClaudeCodeProvider.models.some((m) => m.id === 'claude-fable-5'));
|
|
@@ -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
|
});
|
|
@@ -174,13 +174,15 @@ describe('Journalist', () => {
|
|
|
174
174
|
const { daemon, grooveDir } = createMockDaemon();
|
|
175
175
|
const journalist = new Journalist(daemon);
|
|
176
176
|
|
|
177
|
-
// Create a mock log — filename must match agent.
|
|
177
|
+
// Create a mock log — filename must match agent.name ('backend-1'),
|
|
178
|
+
// mirroring the writer in process.js (logs are keyed by sanitized name
|
|
179
|
+
// so rotated agents keep appending to the same file)
|
|
178
180
|
const logLines = [
|
|
179
181
|
JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Write', input: { file_path: 'src/api/auth.js' } }] } }),
|
|
180
182
|
JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Edit', input: { file_path: 'src/api/users.js', old_string: 'old', new_string: 'new' } }] } }),
|
|
181
183
|
JSON.stringify({ type: 'user', message: { content: 'Add JWT middleware to the auth route' } }),
|
|
182
184
|
].join('\n');
|
|
183
|
-
writeFileSync(join(grooveDir, 'logs', '
|
|
185
|
+
writeFileSync(join(grooveDir, 'logs', 'backend-1.log'), logLines);
|
|
184
186
|
|
|
185
187
|
const agent = {
|
|
186
188
|
id: 'a1', name: 'backend-1', role: 'backend',
|