claude-code-swarm 0.3.12 → 0.3.17
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-plugin/marketplace.json +1 -1
- package/.claude-plugin/mcp-launcher.mjs +17 -4
- package/.claude-plugin/plugin.json +1 -1
- package/e2e/tier7-memory-sync.test.mjs +259 -0
- package/hooks/hooks.json +31 -22
- package/package.json +4 -4
- package/scripts/map-hook.mjs +14 -0
- package/scripts/map-sidecar.mjs +132 -0
- package/src/__tests__/memory-sync.test.mjs +132 -0
- package/src/__tests__/memory-watcher.test.mjs +104 -0
- package/src/__tests__/opentasks-connector.test.mjs +216 -0
- package/src/__tests__/sessionlog.test.mjs +40 -0
- package/src/__tests__/sidecar-server.test.mjs +2 -2
- package/src/bootstrap.mjs +27 -2
- package/src/content-provider.mjs +176 -0
- package/src/map-connection.mjs +6 -2
- package/src/map-events.mjs +30 -0
- package/src/memory-watcher.mjs +73 -0
- package/src/opentasks-connector.mjs +86 -0
- package/src/sessionlog.mjs +19 -0
- package/src/sidecar-server.mjs +54 -7
package/scripts/map-sidecar.mjs
CHANGED
|
@@ -24,6 +24,8 @@ import { SOCKET_PATH, PID_PATH, INBOX_SOCKET_PATH, sessionPaths, pluginDir } fro
|
|
|
24
24
|
import { connectToMAP } from "../src/map-connection.mjs";
|
|
25
25
|
import { createMeshPeer, createMeshInbox } from "../src/mesh-connection.mjs";
|
|
26
26
|
import { createSocketServer, createCommandHandler } from "../src/sidecar-server.mjs";
|
|
27
|
+
import { createContentProvider } from "../src/content-provider.mjs";
|
|
28
|
+
import { startMemoryWatcher } from "../src/memory-watcher.mjs";
|
|
27
29
|
import { readConfig } from "../src/config.mjs";
|
|
28
30
|
import { createLogger, init as initLog } from "../src/log.mjs";
|
|
29
31
|
import { configureNodePath, resolvePackage } from "../src/swarmkit-resolver.mjs";
|
|
@@ -50,6 +52,38 @@ const RECONNECT_INTERVAL_MS = parseInt(getArg("reconnect-interval", ""), 10) ||
|
|
|
50
52
|
// Auth credential for server-driven auth negotiation (opaque — type determined by server)
|
|
51
53
|
const AUTH_CREDENTIAL = getArg("credential", "");
|
|
52
54
|
|
|
55
|
+
// Project context for swarm identification (sent as agent metadata)
|
|
56
|
+
import { execSync } from "child_process";
|
|
57
|
+
function getProjectContext() {
|
|
58
|
+
const context = {};
|
|
59
|
+
try { context.project = path.basename(process.cwd()); } catch {}
|
|
60
|
+
try {
|
|
61
|
+
context.branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
62
|
+
encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"],
|
|
63
|
+
}).trim();
|
|
64
|
+
} catch {}
|
|
65
|
+
try {
|
|
66
|
+
const config = readConfig();
|
|
67
|
+
if (config.template) context.template = config.template;
|
|
68
|
+
|
|
69
|
+
// Include task_graph metadata when opentasks is enabled
|
|
70
|
+
if (config.opentasks?.enabled) {
|
|
71
|
+
try {
|
|
72
|
+
const opentasksDir = path.resolve(".opentasks");
|
|
73
|
+
const configPath = path.join(opentasksDir, "config.json");
|
|
74
|
+
const taskGraph = { path: opentasksDir };
|
|
75
|
+
if (fs.existsSync(configPath)) {
|
|
76
|
+
const otConfig = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
77
|
+
if (otConfig.location?.hash) taskGraph.location_hash = otConfig.location.hash;
|
|
78
|
+
}
|
|
79
|
+
context.task_graph = taskGraph;
|
|
80
|
+
} catch { /* opentasks config not available */ }
|
|
81
|
+
}
|
|
82
|
+
} catch {}
|
|
83
|
+
return context;
|
|
84
|
+
}
|
|
85
|
+
const PROJECT_CONTEXT = getProjectContext();
|
|
86
|
+
|
|
53
87
|
// Configure NODE_PATH so dynamic imports of globally-installed packages
|
|
54
88
|
// (@multi-agent-protocol/sdk, agent-inbox, agentic-mesh) resolve correctly.
|
|
55
89
|
// Must happen before any dynamic import() calls.
|
|
@@ -186,6 +220,7 @@ function startSlowReconnectLoop() {
|
|
|
186
220
|
scope: MAP_SCOPE,
|
|
187
221
|
systemId: SYSTEM_ID,
|
|
188
222
|
credential: AUTH_CREDENTIAL || undefined,
|
|
223
|
+
projectContext: PROJECT_CONTEXT,
|
|
189
224
|
onMessage: () => resetInactivityTimer(),
|
|
190
225
|
});
|
|
191
226
|
|
|
@@ -300,15 +335,77 @@ async function tryMeshTransport() {
|
|
|
300
335
|
return true;
|
|
301
336
|
}
|
|
302
337
|
|
|
338
|
+
/**
|
|
339
|
+
* Register opentasks notification handlers on a MAP connection.
|
|
340
|
+
* Delegates to the extracted opentasks-connector module for testability.
|
|
341
|
+
*/
|
|
342
|
+
async function registerOpenTasksHandler(conn) {
|
|
343
|
+
const { registerOpenTasksHandler: _register } = await import("../src/opentasks-connector.mjs");
|
|
344
|
+
return _register(conn, {
|
|
345
|
+
scope: MAP_SCOPE,
|
|
346
|
+
onActivity: resetInactivityTimer,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
303
350
|
/**
|
|
304
351
|
* Start with direct MAP SDK WebSocket transport (fallback).
|
|
305
352
|
*/
|
|
353
|
+
/**
|
|
354
|
+
* Register the trajectory/content.request notification handler on a connection.
|
|
355
|
+
* When the hub sends a content request, the sidecar reads the transcript
|
|
356
|
+
* from sessionlog and responds with a trajectory/content.response notification.
|
|
357
|
+
*/
|
|
358
|
+
function registerContentHandler(conn) {
|
|
359
|
+
if (!conn || typeof conn.onNotification !== "function") return;
|
|
360
|
+
|
|
361
|
+
const contentProvider = createContentProvider();
|
|
362
|
+
|
|
363
|
+
conn.onNotification("trajectory/content.request", async (params) => {
|
|
364
|
+
const requestId = params?.request_id;
|
|
365
|
+
const checkpointId = params?.checkpoint_id;
|
|
366
|
+
if (!requestId) return;
|
|
367
|
+
|
|
368
|
+
log.info("content request received", { requestId, checkpointId });
|
|
369
|
+
resetInactivityTimer();
|
|
370
|
+
|
|
371
|
+
try {
|
|
372
|
+
const content = checkpointId ? await contentProvider(checkpointId) : null;
|
|
373
|
+
|
|
374
|
+
if (content) {
|
|
375
|
+
conn.sendNotification("trajectory/content.response", {
|
|
376
|
+
request_id: requestId,
|
|
377
|
+
transcript: content.transcript,
|
|
378
|
+
metadata: content.metadata,
|
|
379
|
+
prompts: content.prompts,
|
|
380
|
+
context: content.context,
|
|
381
|
+
});
|
|
382
|
+
log.info("content response sent", { requestId, size: content.transcript.length });
|
|
383
|
+
} else {
|
|
384
|
+
conn.sendNotification("trajectory/content.response", {
|
|
385
|
+
request_id: requestId,
|
|
386
|
+
error: "Content not found",
|
|
387
|
+
});
|
|
388
|
+
log.warn("content not found", { requestId, checkpointId });
|
|
389
|
+
}
|
|
390
|
+
} catch (err) {
|
|
391
|
+
log.error("content provider error", { requestId, error: err.message });
|
|
392
|
+
try {
|
|
393
|
+
conn.sendNotification("trajectory/content.response", {
|
|
394
|
+
request_id: requestId,
|
|
395
|
+
error: err.message,
|
|
396
|
+
});
|
|
397
|
+
} catch { /* ignore */ }
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
306
402
|
async function startWebSocketTransport() {
|
|
307
403
|
connection = await connectToMAP({
|
|
308
404
|
server: MAP_SERVER,
|
|
309
405
|
scope: MAP_SCOPE,
|
|
310
406
|
systemId: SYSTEM_ID,
|
|
311
407
|
credential: AUTH_CREDENTIAL || undefined,
|
|
408
|
+
projectContext: PROJECT_CONTEXT,
|
|
312
409
|
onMessage: () => {
|
|
313
410
|
resetInactivityTimer();
|
|
314
411
|
},
|
|
@@ -316,6 +413,16 @@ async function startWebSocketTransport() {
|
|
|
316
413
|
|
|
317
414
|
transportMode = "websocket";
|
|
318
415
|
|
|
416
|
+
// Register trajectory content handler for on-demand transcript serving
|
|
417
|
+
if (connection) {
|
|
418
|
+
registerContentHandler(connection);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Register opentasks connector for remote graph queries (only when opentasks is enabled)
|
|
422
|
+
if (connection && PROJECT_CONTEXT.task_graph) {
|
|
423
|
+
await registerOpenTasksHandler(connection);
|
|
424
|
+
}
|
|
425
|
+
|
|
319
426
|
// Start agent-inbox with MAP connection (legacy mode)
|
|
320
427
|
if (INBOX_CONFIG && connection) {
|
|
321
428
|
inboxInstance = await startLegacyAgentInbox(connection);
|
|
@@ -411,6 +518,31 @@ async function main() {
|
|
|
411
518
|
return commandHandler(command, client);
|
|
412
519
|
});
|
|
413
520
|
|
|
521
|
+
// Start memory file watcher if minimem is enabled
|
|
522
|
+
const sidecarConfig = readConfig();
|
|
523
|
+
if (sidecarConfig.minimem?.enabled) {
|
|
524
|
+
const minimemDir = sidecarConfig.minimem?.dir || ".swarm/minimem";
|
|
525
|
+
const memWatcher = startMemoryWatcher(minimemDir, (_event) => {
|
|
526
|
+
// Send bridge-memory-sync through the command handler
|
|
527
|
+
// This reuses the same callExtension path as the PostToolUse hook
|
|
528
|
+
const fakeClient = {
|
|
529
|
+
write: () => {},
|
|
530
|
+
writable: true,
|
|
531
|
+
};
|
|
532
|
+
commandHandler({
|
|
533
|
+
action: "bridge-memory-sync",
|
|
534
|
+
agentId: SESSION_ID || "minimem",
|
|
535
|
+
timestamp: new Date().toISOString(),
|
|
536
|
+
}, fakeClient);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// Clean up watcher on exit
|
|
540
|
+
if (memWatcher) {
|
|
541
|
+
process.on("exit", () => memWatcher.close());
|
|
542
|
+
process.on("SIGTERM", () => memWatcher.close());
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
414
546
|
// Start inactivity timer
|
|
415
547
|
resetInactivityTimer();
|
|
416
548
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for minimem → MAP sync bridge
|
|
3
|
+
*
|
|
4
|
+
* Tests the bridge command builder (map-events.mjs) that converts
|
|
5
|
+
* minimem MCP tool usage into MAP sync commands.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect } from "vitest";
|
|
9
|
+
import { buildMinimemBridgeCommand } from "../map-events.mjs";
|
|
10
|
+
|
|
11
|
+
describe("buildMinimemBridgeCommand", () => {
|
|
12
|
+
describe("write operations (should emit)", () => {
|
|
13
|
+
it("emits for memory_append tool", () => {
|
|
14
|
+
const cmd = buildMinimemBridgeCommand({
|
|
15
|
+
tool_name: "minimem__memory_append",
|
|
16
|
+
tool_input: { text: "Decided to use Redis" },
|
|
17
|
+
tool_output: '{"content":[{"text":"Appended to memory/2026-03-27.md"}]}',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
expect(cmd).not.toBeNull();
|
|
21
|
+
expect(cmd.action).toBe("bridge-memory-sync");
|
|
22
|
+
expect(cmd.timestamp).toBeDefined();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("emits for memory_upsert tool", () => {
|
|
26
|
+
const cmd = buildMinimemBridgeCommand({
|
|
27
|
+
tool_name: "minimem__memory_upsert",
|
|
28
|
+
tool_input: { path: "memory/decision.md", content: "# Decision" },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(cmd).not.toBeNull();
|
|
32
|
+
expect(cmd.action).toBe("bridge-memory-sync");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("emits for tool names containing 'append'", () => {
|
|
36
|
+
const cmd = buildMinimemBridgeCommand({
|
|
37
|
+
tool_name: "minimem__appendToday",
|
|
38
|
+
tool_input: { text: "some note" },
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(cmd).not.toBeNull();
|
|
42
|
+
expect(cmd.action).toBe("bridge-memory-sync");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("emits for tool names containing 'upsert'", () => {
|
|
46
|
+
const cmd = buildMinimemBridgeCommand({
|
|
47
|
+
tool_name: "minimem__upsert_file",
|
|
48
|
+
tool_input: { path: "memory/test.md" },
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
expect(cmd).not.toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("includes session_id as agentId", () => {
|
|
55
|
+
const cmd = buildMinimemBridgeCommand({
|
|
56
|
+
tool_name: "minimem__memory_append",
|
|
57
|
+
tool_input: { text: "test" },
|
|
58
|
+
session_id: "sess-abc-123",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(cmd.agentId).toBe("sess-abc-123");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("defaults agentId to 'minimem' when no session_id", () => {
|
|
65
|
+
const cmd = buildMinimemBridgeCommand({
|
|
66
|
+
tool_name: "minimem__memory_append",
|
|
67
|
+
tool_input: { text: "test" },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(cmd.agentId).toBe("minimem");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("read operations (should NOT emit)", () => {
|
|
75
|
+
it("does not emit for memory_search", () => {
|
|
76
|
+
const cmd = buildMinimemBridgeCommand({
|
|
77
|
+
tool_name: "minimem__memory_search",
|
|
78
|
+
tool_input: { query: "redis caching" },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(cmd).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("does not emit for memory_get_details", () => {
|
|
85
|
+
const cmd = buildMinimemBridgeCommand({
|
|
86
|
+
tool_name: "minimem__memory_get_details",
|
|
87
|
+
tool_input: { results: [] },
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(cmd).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("does not emit for knowledge_search", () => {
|
|
94
|
+
const cmd = buildMinimemBridgeCommand({
|
|
95
|
+
tool_name: "minimem__knowledge_search",
|
|
96
|
+
tool_input: { query: "database" },
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(cmd).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("does not emit for knowledge_graph", () => {
|
|
103
|
+
const cmd = buildMinimemBridgeCommand({
|
|
104
|
+
tool_name: "minimem__knowledge_graph",
|
|
105
|
+
tool_input: { nodeId: "k-test" },
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(cmd).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("does not emit for knowledge_path", () => {
|
|
112
|
+
const cmd = buildMinimemBridgeCommand({
|
|
113
|
+
tool_name: "minimem__knowledge_path",
|
|
114
|
+
tool_input: { fromId: "k-a", toId: "k-b" },
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(cmd).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("edge cases", () => {
|
|
122
|
+
it("handles missing tool_name", () => {
|
|
123
|
+
const cmd = buildMinimemBridgeCommand({});
|
|
124
|
+
expect(cmd).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("handles empty tool_name", () => {
|
|
128
|
+
const cmd = buildMinimemBridgeCommand({ tool_name: "" });
|
|
129
|
+
expect(cmd).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for memory file watcher
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
6
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
import { tmpdir } from "os";
|
|
9
|
+
import { startMemoryWatcher } from "../memory-watcher.mjs";
|
|
10
|
+
|
|
11
|
+
describe("startMemoryWatcher", () => {
|
|
12
|
+
let tmpDir;
|
|
13
|
+
let watcher;
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
if (watcher) {
|
|
17
|
+
watcher.close();
|
|
18
|
+
watcher = null;
|
|
19
|
+
}
|
|
20
|
+
if (tmpDir) {
|
|
21
|
+
try { rmSync(tmpDir, { recursive: true }); } catch {}
|
|
22
|
+
tmpDir = null;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns null for non-existent directory", () => {
|
|
27
|
+
const result = startMemoryWatcher("/nonexistent/path", () => {});
|
|
28
|
+
expect(result).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns null for empty/undefined dir", () => {
|
|
32
|
+
expect(startMemoryWatcher("", () => {})).toBeNull();
|
|
33
|
+
expect(startMemoryWatcher(undefined, () => {})).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns a watcher handle with close method", () => {
|
|
37
|
+
tmpDir = mkdtempSync(join(tmpdir(), "mem-watch-"));
|
|
38
|
+
mkdirSync(join(tmpDir, "memory"), { recursive: true });
|
|
39
|
+
|
|
40
|
+
watcher = startMemoryWatcher(tmpDir, () => {});
|
|
41
|
+
expect(watcher).not.toBeNull();
|
|
42
|
+
expect(typeof watcher.close).toBe("function");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("detects new .md file and calls onSync", async () => {
|
|
46
|
+
tmpDir = mkdtempSync(join(tmpdir(), "mem-watch-"));
|
|
47
|
+
mkdirSync(join(tmpDir, "memory"), { recursive: true });
|
|
48
|
+
|
|
49
|
+
const onSync = vi.fn();
|
|
50
|
+
watcher = startMemoryWatcher(tmpDir, onSync);
|
|
51
|
+
|
|
52
|
+
// Wait for watcher to be ready
|
|
53
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
54
|
+
|
|
55
|
+
// Write a new .md file
|
|
56
|
+
writeFileSync(join(tmpDir, "memory", "test-note.md"), "# Test Note\nContent here.");
|
|
57
|
+
|
|
58
|
+
// Wait for debounce (2s) + buffer
|
|
59
|
+
await new Promise((r) => setTimeout(r, 3500));
|
|
60
|
+
|
|
61
|
+
expect(onSync).toHaveBeenCalled();
|
|
62
|
+
const call = onSync.mock.calls[0][0];
|
|
63
|
+
expect(call.type).toBe("add");
|
|
64
|
+
expect(call.path).toContain("test-note.md");
|
|
65
|
+
}, 10_000);
|
|
66
|
+
|
|
67
|
+
it("ignores non-.md files", async () => {
|
|
68
|
+
tmpDir = mkdtempSync(join(tmpdir(), "mem-watch-"));
|
|
69
|
+
|
|
70
|
+
const onSync = vi.fn();
|
|
71
|
+
watcher = startMemoryWatcher(tmpDir, onSync);
|
|
72
|
+
|
|
73
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
74
|
+
|
|
75
|
+
// Write non-.md files
|
|
76
|
+
writeFileSync(join(tmpDir, "index.db"), "binary data");
|
|
77
|
+
writeFileSync(join(tmpDir, "config.json"), "{}");
|
|
78
|
+
|
|
79
|
+
await new Promise((r) => setTimeout(r, 3500));
|
|
80
|
+
|
|
81
|
+
expect(onSync).not.toHaveBeenCalled();
|
|
82
|
+
}, 10_000);
|
|
83
|
+
|
|
84
|
+
it("debounces rapid changes", async () => {
|
|
85
|
+
tmpDir = mkdtempSync(join(tmpdir(), "mem-watch-"));
|
|
86
|
+
mkdirSync(join(tmpDir, "memory"), { recursive: true });
|
|
87
|
+
|
|
88
|
+
const onSync = vi.fn();
|
|
89
|
+
watcher = startMemoryWatcher(tmpDir, onSync);
|
|
90
|
+
|
|
91
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
92
|
+
|
|
93
|
+
// Write multiple files rapidly
|
|
94
|
+
writeFileSync(join(tmpDir, "memory", "note1.md"), "# Note 1");
|
|
95
|
+
writeFileSync(join(tmpDir, "memory", "note2.md"), "# Note 2");
|
|
96
|
+
writeFileSync(join(tmpDir, "memory", "note3.md"), "# Note 3");
|
|
97
|
+
|
|
98
|
+
// Wait for debounce
|
|
99
|
+
await new Promise((r) => setTimeout(r, 3500));
|
|
100
|
+
|
|
101
|
+
// Should only fire once (debounced)
|
|
102
|
+
expect(onSync).toHaveBeenCalledTimes(1);
|
|
103
|
+
}, 10_000);
|
|
104
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { registerOpenTasksHandler } from "../opentasks-connector.mjs";
|
|
3
|
+
|
|
4
|
+
// ── Mock factories ──────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
const MOCK_METHODS = {
|
|
7
|
+
QUERY_REQUEST: "opentasks/query.request",
|
|
8
|
+
LINK_REQUEST: "opentasks/link.request",
|
|
9
|
+
ANNOTATE_REQUEST: "opentasks/annotate.request",
|
|
10
|
+
TASK_REQUEST: "opentasks/task.request",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function createMockConnection() {
|
|
14
|
+
const handlers = new Map();
|
|
15
|
+
return {
|
|
16
|
+
onNotification: vi.fn((method, handler) => {
|
|
17
|
+
handlers.set(method, handler);
|
|
18
|
+
}),
|
|
19
|
+
sendNotification: vi.fn(),
|
|
20
|
+
// Test helper: fire a notification as if the hub sent it
|
|
21
|
+
_fireNotification(method, params) {
|
|
22
|
+
const handler = handlers.get(method);
|
|
23
|
+
if (handler) return handler(params);
|
|
24
|
+
},
|
|
25
|
+
_handlers: handlers,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createMockOpentasks() {
|
|
30
|
+
const connector = {
|
|
31
|
+
handleNotification: vi.fn(),
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
createClient: vi.fn(() => ({ /* mock client */ })),
|
|
35
|
+
createMAPConnector: vi.fn(() => connector),
|
|
36
|
+
MAP_CONNECTOR_METHODS: { ...MOCK_METHODS },
|
|
37
|
+
_connector: connector,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function createMockOpentasksClient(socketPath = "/tmp/opentasks/daemon.sock") {
|
|
42
|
+
return {
|
|
43
|
+
findSocketPath: vi.fn(() => socketPath),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
describe("registerOpenTasksHandler", () => {
|
|
50
|
+
let mockConn;
|
|
51
|
+
let mockOpentasks;
|
|
52
|
+
let mockOtClient;
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
mockConn = createMockConnection();
|
|
56
|
+
mockOpentasks = createMockOpentasks();
|
|
57
|
+
mockOtClient = createMockOpentasksClient();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const callRegister = (connOverride, optsOverride = {}) =>
|
|
61
|
+
registerOpenTasksHandler(connOverride ?? mockConn, {
|
|
62
|
+
scope: "swarm:test",
|
|
63
|
+
importOpentasks: async () => mockOpentasks,
|
|
64
|
+
importOpentasksClient: async () => mockOtClient,
|
|
65
|
+
...optsOverride,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("creates a client with the socket path from findSocketPath", async () => {
|
|
69
|
+
await callRegister();
|
|
70
|
+
|
|
71
|
+
expect(mockOtClient.findSocketPath).toHaveBeenCalled();
|
|
72
|
+
expect(mockOpentasks.createClient).toHaveBeenCalledWith({
|
|
73
|
+
socketPath: "/tmp/opentasks/daemon.sock",
|
|
74
|
+
autoConnect: true,
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("creates a MAP connector with the client and a send function", async () => {
|
|
79
|
+
await callRegister();
|
|
80
|
+
|
|
81
|
+
expect(mockOpentasks.createMAPConnector).toHaveBeenCalledTimes(1);
|
|
82
|
+
const callArgs = mockOpentasks.createMAPConnector.mock.calls[0][0];
|
|
83
|
+
|
|
84
|
+
// Should pass the client returned by createClient
|
|
85
|
+
expect(callArgs.client).toBeDefined();
|
|
86
|
+
// Should pass a send function
|
|
87
|
+
expect(typeof callArgs.send).toBe("function");
|
|
88
|
+
// Should pass agentId derived from scope
|
|
89
|
+
expect(callArgs.agentId).toBe("swarm:test-sidecar");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("registers onNotification for all 4 request methods", async () => {
|
|
93
|
+
await callRegister();
|
|
94
|
+
|
|
95
|
+
expect(mockConn.onNotification).toHaveBeenCalledTimes(4);
|
|
96
|
+
|
|
97
|
+
const registeredMethods = mockConn.onNotification.mock.calls.map((c) => c[0]);
|
|
98
|
+
expect(registeredMethods).toContain(MOCK_METHODS.QUERY_REQUEST);
|
|
99
|
+
expect(registeredMethods).toContain(MOCK_METHODS.LINK_REQUEST);
|
|
100
|
+
expect(registeredMethods).toContain(MOCK_METHODS.ANNOTATE_REQUEST);
|
|
101
|
+
expect(registeredMethods).toContain(MOCK_METHODS.TASK_REQUEST);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("forwards notifications to connector.handleNotification", async () => {
|
|
105
|
+
await callRegister();
|
|
106
|
+
|
|
107
|
+
const params = { request_id: "req-1", query: "status:open" };
|
|
108
|
+
await mockConn._fireNotification(MOCK_METHODS.QUERY_REQUEST, params);
|
|
109
|
+
|
|
110
|
+
expect(mockOpentasks._connector.handleNotification).toHaveBeenCalledWith(
|
|
111
|
+
MOCK_METHODS.QUERY_REQUEST,
|
|
112
|
+
params,
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("passes empty object when notification params are missing", async () => {
|
|
117
|
+
await callRegister();
|
|
118
|
+
|
|
119
|
+
await mockConn._fireNotification(MOCK_METHODS.TASK_REQUEST, undefined);
|
|
120
|
+
|
|
121
|
+
expect(mockOpentasks._connector.handleNotification).toHaveBeenCalledWith(
|
|
122
|
+
MOCK_METHODS.TASK_REQUEST,
|
|
123
|
+
{},
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("calls the send function on the connector via sendNotification", async () => {
|
|
128
|
+
await callRegister();
|
|
129
|
+
|
|
130
|
+
// Get the send function that was passed to createMAPConnector
|
|
131
|
+
const sendFn = mockOpentasks.createMAPConnector.mock.calls[0][0].send;
|
|
132
|
+
sendFn("opentasks/query.response", { data: "result" });
|
|
133
|
+
|
|
134
|
+
expect(mockConn.sendNotification).toHaveBeenCalledWith(
|
|
135
|
+
"opentasks/query.response",
|
|
136
|
+
{ data: "result" },
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("does not throw when sendNotification fails in the send callback", async () => {
|
|
141
|
+
mockConn.sendNotification.mockImplementation(() => {
|
|
142
|
+
throw new Error("connection closed");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await callRegister();
|
|
146
|
+
|
|
147
|
+
const sendFn = mockOpentasks.createMAPConnector.mock.calls[0][0].send;
|
|
148
|
+
// Should not throw
|
|
149
|
+
expect(() => sendFn("method", {})).not.toThrow();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("calls onActivity callback when a notification fires", async () => {
|
|
153
|
+
const onActivity = vi.fn();
|
|
154
|
+
await callRegister(undefined, { onActivity });
|
|
155
|
+
|
|
156
|
+
await mockConn._fireNotification(MOCK_METHODS.LINK_REQUEST, { request_id: "r1" });
|
|
157
|
+
|
|
158
|
+
expect(onActivity).toHaveBeenCalledTimes(1);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("does nothing when conn is null", async () => {
|
|
162
|
+
// Should not throw
|
|
163
|
+
await registerOpenTasksHandler(null, {
|
|
164
|
+
scope: "swarm:test",
|
|
165
|
+
importOpentasks: async () => mockOpentasks,
|
|
166
|
+
importOpentasksClient: async () => mockOtClient,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(mockOpentasks.createClient).not.toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("does nothing when conn lacks onNotification", async () => {
|
|
173
|
+
await registerOpenTasksHandler({ sendNotification: vi.fn() }, {
|
|
174
|
+
scope: "swarm:test",
|
|
175
|
+
importOpentasks: async () => mockOpentasks,
|
|
176
|
+
importOpentasksClient: async () => mockOtClient,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(mockOpentasks.createClient).not.toHaveBeenCalled();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("does nothing when opentasks module is missing createMAPConnector", async () => {
|
|
183
|
+
const brokenModule = { createClient: vi.fn() }; // no createMAPConnector
|
|
184
|
+
|
|
185
|
+
await registerOpenTasksHandler(mockConn, {
|
|
186
|
+
scope: "swarm:test",
|
|
187
|
+
importOpentasks: async () => brokenModule,
|
|
188
|
+
importOpentasksClient: async () => mockOtClient,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(mockConn.onNotification).not.toHaveBeenCalled();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("does nothing when opentasks import throws", async () => {
|
|
195
|
+
await registerOpenTasksHandler(mockConn, {
|
|
196
|
+
scope: "swarm:test",
|
|
197
|
+
importOpentasks: async () => { throw new Error("module not found"); },
|
|
198
|
+
importOpentasksClient: async () => mockOtClient,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
expect(mockConn.onNotification).not.toHaveBeenCalled();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("uses custom socket path from findSocketPath", async () => {
|
|
205
|
+
const customClient = createMockOpentasksClient("/custom/path/daemon.sock");
|
|
206
|
+
|
|
207
|
+
await callRegister(undefined, {
|
|
208
|
+
importOpentasksClient: async () => customClient,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(mockOpentasks.createClient).toHaveBeenCalledWith({
|
|
212
|
+
socketPath: "/custom/path/daemon.sock",
|
|
213
|
+
autoConnect: true,
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -188,6 +188,46 @@ describe("sessionlog", () => {
|
|
|
188
188
|
expect(cp.token_usage.input_tokens).toBe(800);
|
|
189
189
|
expect(cp.token_usage.output_tokens).toBe(400);
|
|
190
190
|
});
|
|
191
|
+
|
|
192
|
+
it("includes project name from cwd in metadata", () => {
|
|
193
|
+
const cp = buildTrajectoryCheckpoint(baseState, "lifecycle", makeConfig());
|
|
194
|
+
expect(cp.metadata.project).toBeDefined();
|
|
195
|
+
expect(typeof cp.metadata.project).toBe("string");
|
|
196
|
+
expect(cp.metadata.project.length).toBeGreaterThan(0);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("includes git branch as top-level wire format field", () => {
|
|
200
|
+
const cp = buildTrajectoryCheckpoint(baseState, "lifecycle", makeConfig());
|
|
201
|
+
// branch may be null in CI/non-git environments, but should be defined
|
|
202
|
+
expect("branch" in cp).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("includes firstPrompt from session state when available", () => {
|
|
206
|
+
const state = { ...baseState, firstPrompt: "fix the bug in server.ts" };
|
|
207
|
+
const cp = buildTrajectoryCheckpoint(state, "lifecycle", makeConfig());
|
|
208
|
+
expect(cp.metadata.firstPrompt).toBe("fix the bug in server.ts");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("truncates long firstPrompt to 200 chars", () => {
|
|
212
|
+
const state = { ...baseState, firstPrompt: "x".repeat(300) };
|
|
213
|
+
const cp = buildTrajectoryCheckpoint(state, "lifecycle", makeConfig());
|
|
214
|
+
expect(cp.metadata.firstPrompt.length).toBe(200);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("omits firstPrompt when not in session state", () => {
|
|
218
|
+
const cp = buildTrajectoryCheckpoint(baseState, "lifecycle", makeConfig());
|
|
219
|
+
expect(cp.metadata.firstPrompt).toBeUndefined();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("includes template from config when configured", () => {
|
|
223
|
+
const cp = buildTrajectoryCheckpoint(baseState, "lifecycle", makeConfig({ template: "gsd" }));
|
|
224
|
+
expect(cp.metadata.template).toBe("gsd");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("omits template when not configured", () => {
|
|
228
|
+
const cp = buildTrajectoryCheckpoint(baseState, "lifecycle", makeConfig({ template: "" }));
|
|
229
|
+
expect(cp.metadata.template).toBeUndefined();
|
|
230
|
+
});
|
|
191
231
|
});
|
|
192
232
|
|
|
193
233
|
describe("ensureSessionlogEnabled", () => {
|
|
@@ -229,13 +229,13 @@ describe("sidecar-server", () => {
|
|
|
229
229
|
|
|
230
230
|
it("falls back to broadcast with trajectory.checkpoint payload when callExtension throws", async () => {
|
|
231
231
|
mockConnection.callExtension.mockRejectedValueOnce(new Error("not supported"));
|
|
232
|
-
const cp = { id: "cp1",
|
|
232
|
+
const cp = { id: "cp1", agent: "a", session_id: "s", files_touched: [], token_usage: null, metadata: { phase: "active" } };
|
|
233
233
|
await handler({ action: "trajectory-checkpoint", checkpoint: cp }, mockClient);
|
|
234
234
|
expect(mockConnection.send).toHaveBeenCalled();
|
|
235
235
|
const [, payload] = mockConnection.send.mock.calls[0];
|
|
236
236
|
expect(payload.type).toBe("trajectory.checkpoint");
|
|
237
237
|
expect(payload.checkpoint.id).toBe("cp1");
|
|
238
|
-
expect(payload.checkpoint.
|
|
238
|
+
expect(payload.checkpoint.agent).toBe("a");
|
|
239
239
|
expect(payload.checkpoint.metadata).toEqual({ phase: "active" });
|
|
240
240
|
});
|
|
241
241
|
|