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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +3 -7
- package/hooks/hooks.json +97 -10
- package/package.json +3 -3
- package/scripts/map-hook.mjs +12 -1
- package/scripts/map-sidecar.mjs +106 -0
- package/src/__tests__/bootstrap.test.mjs +34 -5
- package/src/__tests__/helpers.mjs +1 -0
- package/src/__tests__/opentasks-connector.test.mjs +216 -0
- package/src/__tests__/sessionlog-e2e.test.mjs +270 -0
- package/src/__tests__/sessionlog.test.mjs +256 -13
- package/src/__tests__/sidecar-server.test.mjs +2 -2
- package/src/bootstrap.mjs +17 -4
- package/src/config.mjs +6 -2
- package/src/content-provider.mjs +176 -0
- package/src/index.mjs +3 -0
- package/src/map-connection.mjs +6 -2
- package/src/opentasks-connector.mjs +86 -0
- package/src/sessionlog.mjs +163 -12
- package/src/sidecar-server.mjs +16 -7
|
@@ -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
|
+
});
|