claude-code-swarm 0.3.11 → 0.3.16

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.
@@ -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
+ });
@@ -0,0 +1,270 @@
1
+ /**
2
+ * E2E test: sessionlog lifecycle dispatch through cc-swarm plugin flow.
3
+ *
4
+ * Creates a real temp git repo with real config files, enables sessionlog,
5
+ * then dispatches lifecycle events via dispatchSessionlogHook() and verifies
6
+ * session state files on disk.
7
+ *
8
+ * Mocked:
9
+ * - resolvePackage("sessionlog") — returns the real sessionlog module from
10
+ * references/ (in production this resolves from global node_modules)
11
+ * - paths.mjs GLOBAL_CONFIG_PATH — points to tmp dir to avoid reading
12
+ * the user's real ~/.claude-swarm/config.json
13
+ * - process.cwd() — points to tmp git repo
14
+ *
15
+ * Real:
16
+ * - Git repo (git init + commit in tmp dir)
17
+ * - sessionlog enable() — creates .sessionlog/, .git/sessionlog-sessions/, git hooks
18
+ * - .swarm/claude-swarm/config.json — written to tmp dir, read by real readConfig()
19
+ * - readConfig() — real config resolution (reads from tmp dir)
20
+ * - hasStandaloneHooks() — real file read of .claude/settings.json
21
+ * - dispatchSessionlogHook() — real dispatch through sessionlog lifecycle handler
22
+ * - Session state files — real files at .git/sessionlog-sessions/<id>.json
23
+ */
24
+
25
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
26
+ import fs from "fs";
27
+ import path from "path";
28
+ import os from "os";
29
+ import { execSync } from "child_process";
30
+
31
+ // Resolve the real sessionlog module from the monorepo references dir
32
+ const SESSIONLOG_PATH = path.resolve(
33
+ import.meta.dirname, "..", "..", "..", "sessionlog"
34
+ );
35
+
36
+ let _tmpDir;
37
+ let _sessionlogMod;
38
+
39
+ // Mock resolvePackage — the only mock needed for the core dispatch.
40
+ // In production, sessionlog is resolved from global node_modules via swarmkit.
41
+ // Here we return the real module from references/.
42
+ vi.mock("../swarmkit-resolver.mjs", () => ({
43
+ resolvePackage: vi.fn(async (name) => {
44
+ if (name === "sessionlog") return _sessionlogMod;
45
+ return null;
46
+ }),
47
+ }));
48
+
49
+ // Mock GLOBAL_CONFIG_PATH to a tmp path so we don't read the user's real
50
+ // ~/.claude-swarm/config.json. CONFIG_PATH stays relative (".swarm/claude-swarm/config.json")
51
+ // and is resolved by readConfig() via path.resolve(process.cwd(), configPath).
52
+ vi.mock("../paths.mjs", async (importOriginal) => {
53
+ const actual = await importOriginal();
54
+ const { mkdtempSync } = await import("fs");
55
+ const { join } = await import("path");
56
+ const { tmpdir } = await import("os");
57
+ const globalTmp = mkdtempSync(join(tmpdir(), "swarm-global-"));
58
+ return {
59
+ ...actual,
60
+ GLOBAL_CONFIG_PATH: join(globalTmp, "config.json"),
61
+ };
62
+ });
63
+
64
+ // Import the function under test AFTER mocks are set up
65
+ const { dispatchSessionlogHook, hasStandaloneHooks } = await import("../sessionlog.mjs");
66
+
67
+ // ── Helpers ──────────────────────────────────────────────────────────────────
68
+
69
+ function initGitRepo(dir) {
70
+ execSync("git init", { cwd: dir, stdio: "pipe" });
71
+ execSync('git config user.email "test@test.com"', { cwd: dir, stdio: "pipe" });
72
+ execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" });
73
+ fs.writeFileSync(path.join(dir, "README.md"), "# Test");
74
+ execSync("git add . && git commit -m initial", { cwd: dir, stdio: "pipe" });
75
+ }
76
+
77
+ function writeConfig(dir, config) {
78
+ const configDir = path.join(dir, ".swarm", "claude-swarm");
79
+ fs.mkdirSync(configDir, { recursive: true });
80
+ fs.writeFileSync(
81
+ path.join(configDir, "config.json"),
82
+ JSON.stringify(config, null, 2)
83
+ );
84
+ }
85
+
86
+ function writeClaudeSettings(dir, settings) {
87
+ const claudeDir = path.join(dir, ".claude");
88
+ fs.mkdirSync(claudeDir, { recursive: true });
89
+ fs.writeFileSync(
90
+ path.join(claudeDir, "settings.json"),
91
+ JSON.stringify(settings, null, 2)
92
+ );
93
+ }
94
+
95
+ function readSessionState(dir, sessionId) {
96
+ const stateFile = path.join(dir, ".git", "sessionlog-sessions", `${sessionId}.json`);
97
+ if (!fs.existsSync(stateFile)) return null;
98
+ return JSON.parse(fs.readFileSync(stateFile, "utf-8"));
99
+ }
100
+
101
+ // ── Tests ────────────────────────────────────────────────────────────────────
102
+
103
+ describe("sessionlog e2e: plugin dispatch lifecycle", () => {
104
+ beforeEach(async () => {
105
+ _tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sessionlog-e2e-"));
106
+ initGitRepo(_tmpDir);
107
+
108
+ // Load the real sessionlog module
109
+ _sessionlogMod = await import(SESSIONLOG_PATH + "/src/index.ts");
110
+
111
+ // Enable sessionlog in the temp repo (dirs + git hooks, no agent hooks)
112
+ const result = await _sessionlogMod.enable({
113
+ cwd: _tmpDir,
114
+ agent: "claude-code",
115
+ skipAgentHooks: true,
116
+ });
117
+ if (!result.enabled) {
118
+ throw new Error(`sessionlog enable failed: ${result.errors.join(", ")}`);
119
+ }
120
+
121
+ // Point process.cwd() to the tmp dir — this makes:
122
+ // - readConfig() read .swarm/claude-swarm/config.json from tmp dir
123
+ // - hasStandaloneHooks() read .claude/settings.json from tmp dir
124
+ // - sessionlog stores resolve .git/sessionlog-sessions/ from tmp dir
125
+ vi.spyOn(process, "cwd").mockReturnValue(_tmpDir);
126
+ });
127
+
128
+ afterEach(() => {
129
+ vi.restoreAllMocks();
130
+ try {
131
+ fs.rmSync(_tmpDir, { recursive: true, force: true });
132
+ } catch {
133
+ // ignore
134
+ }
135
+ });
136
+
137
+ it("hasStandaloneHooks returns false when skipAgentHooks was used", () => {
138
+ expect(hasStandaloneHooks()).toBe(false);
139
+ });
140
+
141
+ it("dispatches session-start with real config (mode: plugin)", async () => {
142
+ writeConfig(_tmpDir, {
143
+ sessionlog: { enabled: true, sync: "off", mode: "plugin" },
144
+ });
145
+
146
+ await dispatchSessionlogHook("session-start", {
147
+ session_id: "e2e-plugin-session",
148
+ transcript_path: path.join(_tmpDir, "transcript.jsonl"),
149
+ });
150
+
151
+ const state = readSessionState(_tmpDir, "e2e-plugin-session");
152
+ expect(state).not.toBeNull();
153
+ expect(state.sessionID).toBe("e2e-plugin-session");
154
+ expect(state.phase).toBe("idle");
155
+ });
156
+
157
+ it("full lifecycle: start → prompt → stop → end", async () => {
158
+ writeConfig(_tmpDir, {
159
+ sessionlog: { enabled: true, sync: "off", mode: "plugin" },
160
+ });
161
+
162
+ const sessionId = "e2e-full-lifecycle";
163
+ const transcriptPath = path.join(_tmpDir, "transcript.jsonl");
164
+ fs.writeFileSync(transcriptPath, "");
165
+
166
+ // SessionStart
167
+ await dispatchSessionlogHook("session-start", {
168
+ session_id: sessionId,
169
+ transcript_path: transcriptPath,
170
+ });
171
+ expect(readSessionState(_tmpDir, sessionId).phase).toBe("idle");
172
+
173
+ // UserPromptSubmit (TurnStart)
174
+ await dispatchSessionlogHook("user-prompt-submit", {
175
+ session_id: sessionId,
176
+ transcript_path: transcriptPath,
177
+ prompt: "implement feature X",
178
+ });
179
+ let state = readSessionState(_tmpDir, sessionId);
180
+ expect(state.phase).toBe("active");
181
+ expect(state.firstPrompt).toBe("implement feature X");
182
+
183
+ // Stop (TurnEnd)
184
+ await dispatchSessionlogHook("stop", {
185
+ session_id: sessionId,
186
+ transcript_path: transcriptPath,
187
+ });
188
+ expect(readSessionState(_tmpDir, sessionId).phase).toBe("idle");
189
+
190
+ // SessionEnd
191
+ await dispatchSessionlogHook("session-end", {
192
+ session_id: sessionId,
193
+ transcript_path: transcriptPath,
194
+ });
195
+ state = readSessionState(_tmpDir, sessionId);
196
+ expect(state.phase).toBe("ended");
197
+ expect(state.endedAt).toBeDefined();
198
+ });
199
+
200
+ it("standalone mode skips dispatch (real config)", async () => {
201
+ writeConfig(_tmpDir, {
202
+ sessionlog: { enabled: true, sync: "off", mode: "standalone" },
203
+ });
204
+
205
+ await dispatchSessionlogHook("session-start", {
206
+ session_id: "should-not-exist",
207
+ transcript_path: "/tmp/transcript.jsonl",
208
+ });
209
+
210
+ expect(readSessionState(_tmpDir, "should-not-exist")).toBeNull();
211
+ });
212
+
213
+ it("auto mode defers when standalone hooks exist in .claude/settings.json", async () => {
214
+ writeConfig(_tmpDir, {
215
+ sessionlog: { enabled: true, sync: "off", mode: "auto" },
216
+ });
217
+
218
+ // Write standalone sessionlog hooks to .claude/settings.json
219
+ writeClaudeSettings(_tmpDir, {
220
+ hooks: {
221
+ SessionStart: [
222
+ {
223
+ matcher: "",
224
+ hooks: [{ type: "command", command: "sessionlog hooks claude-code session-start" }],
225
+ },
226
+ ],
227
+ },
228
+ });
229
+
230
+ await dispatchSessionlogHook("session-start", {
231
+ session_id: "should-not-exist-auto",
232
+ transcript_path: "/tmp/transcript.jsonl",
233
+ });
234
+
235
+ expect(readSessionState(_tmpDir, "should-not-exist-auto")).toBeNull();
236
+ });
237
+
238
+ it("auto mode dispatches when no standalone hooks exist", async () => {
239
+ writeConfig(_tmpDir, {
240
+ sessionlog: { enabled: true, sync: "off", mode: "auto" },
241
+ });
242
+
243
+ // No .claude/settings.json with sessionlog hooks — auto should dispatch
244
+ await dispatchSessionlogHook("session-start", {
245
+ session_id: "e2e-auto-no-standalone",
246
+ transcript_path: path.join(_tmpDir, "transcript.jsonl"),
247
+ });
248
+
249
+ const state = readSessionState(_tmpDir, "e2e-auto-no-standalone");
250
+ expect(state).not.toBeNull();
251
+ expect(state.phase).toBe("idle");
252
+ });
253
+
254
+ it("mode defaults to auto when not specified in config", async () => {
255
+ // Config with no mode field — should default to "auto"
256
+ writeConfig(_tmpDir, {
257
+ sessionlog: { enabled: true, sync: "off" },
258
+ });
259
+
260
+ await dispatchSessionlogHook("session-start", {
261
+ session_id: "e2e-default-mode",
262
+ transcript_path: path.join(_tmpDir, "transcript.jsonl"),
263
+ });
264
+
265
+ // No standalone hooks → auto dispatches
266
+ const state = readSessionState(_tmpDir, "e2e-default-mode");
267
+ expect(state).not.toBeNull();
268
+ expect(state.phase).toBe("idle");
269
+ });
270
+ });