claude-code-swarm 0.3.10 → 0.3.12
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 +176 -0
- package/.claude-plugin/plugin.json +18 -13
- package/CLAUDE.md +10 -0
- package/hooks/hooks.json +87 -0
- package/package.json +1 -4
- package/scripts/map-hook.mjs +12 -1
- package/scripts/map-sidecar.mjs +4 -2
- package/src/__tests__/bootstrap.test.mjs +80 -5
- package/src/__tests__/e2e-main-agent-registration.test.mjs +229 -0
- package/src/__tests__/e2e-reconnection.test.mjs +5 -5
- package/src/__tests__/helpers.mjs +1 -0
- package/src/__tests__/sessionlog-e2e.test.mjs +270 -0
- package/src/__tests__/sessionlog.test.mjs +217 -14
- package/src/__tests__/sidecar-server.test.mjs +4 -4
- package/src/__tests__/swarmkit-resolver.test.mjs +168 -0
- package/src/bootstrap.mjs +31 -4
- package/src/config.mjs +6 -2
- package/src/index.mjs +3 -0
- package/src/map-connection.mjs +10 -3
- package/src/mesh-connection.mjs +10 -3
- package/src/sessionlog.mjs +148 -13
- package/src/sidecar-server.mjs +63 -25
- package/src/skilltree-client.mjs +7 -31
- package/src/swarmkit-resolver.mjs +48 -0
- package/.claude-plugin/run-agent-inbox-mcp.sh +0 -95
- package/.claude-plugin/run-minimem-mcp.sh +0 -98
- package/.claude-plugin/run-opentasks-mcp.sh +0 -65
- package/scripts/dev-link.mjs +0 -179
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E test: Main agent registration on bootstrap
|
|
3
|
+
*
|
|
4
|
+
* Verifies that when the sidecar starts successfully, the bootstrap
|
|
5
|
+
* sends a spawn command to register the main Claude Code session agent.
|
|
6
|
+
*
|
|
7
|
+
* Uses a real TestServer + AgentConnection (no mocks on MAP layer).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
11
|
+
import net from "net";
|
|
12
|
+
import path from "path";
|
|
13
|
+
import { createStreamPair, AgentConnection } from "@multi-agent-protocol/sdk";
|
|
14
|
+
import { TestServer } from "@multi-agent-protocol/sdk/testing";
|
|
15
|
+
import { createSocketServer, createCommandHandler } from "../sidecar-server.mjs";
|
|
16
|
+
import { makeTmpDir, cleanupTmpDir } from "./helpers.mjs";
|
|
17
|
+
|
|
18
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function sendSocketCommand(socketPath, command) {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const client = net.createConnection(socketPath);
|
|
23
|
+
let data = "";
|
|
24
|
+
client.on("connect", () => {
|
|
25
|
+
client.write(JSON.stringify(command) + "\n");
|
|
26
|
+
});
|
|
27
|
+
client.on("data", (chunk) => {
|
|
28
|
+
data += chunk.toString();
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(data.trim().split("\n").pop());
|
|
31
|
+
client.destroy();
|
|
32
|
+
resolve(parsed);
|
|
33
|
+
} catch {
|
|
34
|
+
// wait for more data
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
client.on("error", reject);
|
|
38
|
+
setTimeout(() => {
|
|
39
|
+
client.destroy();
|
|
40
|
+
try {
|
|
41
|
+
resolve(JSON.parse(data.trim().split("\n").pop()));
|
|
42
|
+
} catch {
|
|
43
|
+
reject(new Error("Timeout waiting for response"));
|
|
44
|
+
}
|
|
45
|
+
}, 3000);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function createLiveMapConnection(agentName = "sidecar-agent") {
|
|
50
|
+
const server = new TestServer({ name: "test-map-server" });
|
|
51
|
+
const [clientStream, serverStream] = createStreamPair();
|
|
52
|
+
server.acceptConnection(serverStream);
|
|
53
|
+
|
|
54
|
+
const conn = new AgentConnection(clientStream, {
|
|
55
|
+
name: agentName,
|
|
56
|
+
role: "sidecar",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await conn.connect();
|
|
60
|
+
|
|
61
|
+
return { server, conn };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
describe("Main agent registration via spawn", () => {
|
|
67
|
+
let tmpDir;
|
|
68
|
+
let socketPath;
|
|
69
|
+
let socketServer;
|
|
70
|
+
let mapServer;
|
|
71
|
+
let conn;
|
|
72
|
+
let registeredAgents;
|
|
73
|
+
|
|
74
|
+
const SCOPE = "swarm:test-team";
|
|
75
|
+
const SESSION_ID = "test-session-abc-123";
|
|
76
|
+
|
|
77
|
+
beforeEach(async () => {
|
|
78
|
+
tmpDir = makeTmpDir("e2e-main-agent-");
|
|
79
|
+
socketPath = path.join(tmpDir, "sidecar.sock");
|
|
80
|
+
|
|
81
|
+
const live = await createLiveMapConnection("test-team-sidecar");
|
|
82
|
+
mapServer = live.server;
|
|
83
|
+
conn = live.conn;
|
|
84
|
+
|
|
85
|
+
registeredAgents = new Map();
|
|
86
|
+
const handler = createCommandHandler(conn, SCOPE, registeredAgents);
|
|
87
|
+
socketServer = createSocketServer(socketPath, handler);
|
|
88
|
+
await new Promise((resolve) => socketServer.on("listening", resolve));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterEach(async () => {
|
|
92
|
+
if (socketServer) {
|
|
93
|
+
await new Promise((resolve) => socketServer.close(resolve));
|
|
94
|
+
socketServer = null;
|
|
95
|
+
}
|
|
96
|
+
if (conn && conn.isConnected) {
|
|
97
|
+
try { await conn.disconnect(); } catch { /* ignore */ }
|
|
98
|
+
}
|
|
99
|
+
cleanupTmpDir(tmpDir);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("registers main agent with sessionId as agentId", async () => {
|
|
103
|
+
// This is the exact command bootstrap sends after sidecar starts
|
|
104
|
+
const resp = await sendSocketCommand(socketPath, {
|
|
105
|
+
action: "spawn",
|
|
106
|
+
agent: {
|
|
107
|
+
agentId: SESSION_ID,
|
|
108
|
+
name: "test-team-main",
|
|
109
|
+
role: "orchestrator",
|
|
110
|
+
scopes: [SCOPE],
|
|
111
|
+
metadata: { isMain: true, sessionId: SESSION_ID },
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(resp.ok).toBe(true);
|
|
116
|
+
expect(resp.agent?.agent?.id).toBe(SESSION_ID);
|
|
117
|
+
|
|
118
|
+
// Verify in TestServer
|
|
119
|
+
const mainAgent = mapServer.agents.get(SESSION_ID);
|
|
120
|
+
expect(mainAgent).toBeDefined();
|
|
121
|
+
expect(mainAgent.name).toBe("test-team-main");
|
|
122
|
+
expect(mainAgent.role).toBe("orchestrator");
|
|
123
|
+
|
|
124
|
+
// Verify in local tracking
|
|
125
|
+
expect(registeredAgents.has(SESSION_ID)).toBe(true);
|
|
126
|
+
expect(registeredAgents.get(SESSION_ID).role).toBe("orchestrator");
|
|
127
|
+
expect(registeredAgents.get(SESSION_ID).metadata.isMain).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("main agent coexists with spawned subagents", async () => {
|
|
131
|
+
// Register main agent
|
|
132
|
+
await sendSocketCommand(socketPath, {
|
|
133
|
+
action: "spawn",
|
|
134
|
+
agent: {
|
|
135
|
+
agentId: SESSION_ID,
|
|
136
|
+
name: "test-team-main",
|
|
137
|
+
role: "orchestrator",
|
|
138
|
+
scopes: [SCOPE],
|
|
139
|
+
metadata: { isMain: true, sessionId: SESSION_ID },
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Spawn a subagent (like SubagentStart hook would)
|
|
144
|
+
const subResp = await sendSocketCommand(socketPath, {
|
|
145
|
+
action: "spawn",
|
|
146
|
+
agent: {
|
|
147
|
+
agentId: "subagent-xyz-456",
|
|
148
|
+
name: "Explore",
|
|
149
|
+
role: "subagent",
|
|
150
|
+
scopes: [SCOPE],
|
|
151
|
+
metadata: { agentType: "Explore", sessionId: "subagent-xyz-456" },
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(subResp.ok).toBe(true);
|
|
156
|
+
|
|
157
|
+
// Both should exist in TestServer
|
|
158
|
+
expect(mapServer.agents.has(SESSION_ID)).toBe(true);
|
|
159
|
+
expect(mapServer.agents.has("subagent-xyz-456")).toBe(true);
|
|
160
|
+
|
|
161
|
+
// Both in local tracking
|
|
162
|
+
expect(registeredAgents.size).toBe(2);
|
|
163
|
+
expect(registeredAgents.get(SESSION_ID).role).toBe("orchestrator");
|
|
164
|
+
expect(registeredAgents.get("subagent-xyz-456").role).toBe("subagent");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("main agent survives subagent lifecycle", async () => {
|
|
168
|
+
// Register main
|
|
169
|
+
await sendSocketCommand(socketPath, {
|
|
170
|
+
action: "spawn",
|
|
171
|
+
agent: {
|
|
172
|
+
agentId: SESSION_ID,
|
|
173
|
+
name: "test-team-main",
|
|
174
|
+
role: "orchestrator",
|
|
175
|
+
scopes: [SCOPE],
|
|
176
|
+
metadata: { isMain: true, sessionId: SESSION_ID },
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Spawn and kill a subagent
|
|
181
|
+
await sendSocketCommand(socketPath, {
|
|
182
|
+
action: "spawn",
|
|
183
|
+
agent: {
|
|
184
|
+
agentId: "ephemeral-sub",
|
|
185
|
+
name: "Explore",
|
|
186
|
+
role: "subagent",
|
|
187
|
+
scopes: [SCOPE],
|
|
188
|
+
metadata: {},
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
await sendSocketCommand(socketPath, {
|
|
193
|
+
action: "done",
|
|
194
|
+
agentId: "ephemeral-sub",
|
|
195
|
+
reason: "completed",
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Main agent should still be registered
|
|
199
|
+
expect(registeredAgents.has(SESSION_ID)).toBe(true);
|
|
200
|
+
expect(registeredAgents.has("ephemeral-sub")).toBe(false);
|
|
201
|
+
expect(mapServer.agents.has(SESSION_ID)).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("main agent appears in TestServer event history as agent_registered", async () => {
|
|
205
|
+
await sendSocketCommand(socketPath, {
|
|
206
|
+
action: "spawn",
|
|
207
|
+
agent: {
|
|
208
|
+
agentId: SESSION_ID,
|
|
209
|
+
name: "test-team-main",
|
|
210
|
+
role: "orchestrator",
|
|
211
|
+
scopes: [SCOPE],
|
|
212
|
+
metadata: { isMain: true, sessionId: SESSION_ID },
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const registeredEvents = mapServer.eventHistory.filter(
|
|
217
|
+
(e) => e.event.type === "agent_registered"
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
// At least sidecar + main agent
|
|
221
|
+
expect(registeredEvents.length).toBeGreaterThanOrEqual(2);
|
|
222
|
+
|
|
223
|
+
const mainEvent = registeredEvents.find(
|
|
224
|
+
(e) => e.event.data?.name === "test-team-main" ||
|
|
225
|
+
e.event.data?.agentId === SESSION_ID
|
|
226
|
+
);
|
|
227
|
+
expect(mainEvent).toBeDefined();
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -102,7 +102,7 @@ describe("E2E: sidecar reconnection after MAP connection loss", () => {
|
|
|
102
102
|
socketPath = path.join(tmpDir, "sidecar.sock");
|
|
103
103
|
mockConn = createMockMapConnection();
|
|
104
104
|
registeredAgents = new Map();
|
|
105
|
-
handler = createCommandHandler(mockConn, SCOPE, registeredAgents);
|
|
105
|
+
handler = createCommandHandler(mockConn, SCOPE, registeredAgents, { connWaitTimeoutMs: 500 });
|
|
106
106
|
server = createSocketServer(socketPath, handler);
|
|
107
107
|
await new Promise((resolve) => server.on("listening", resolve));
|
|
108
108
|
});
|
|
@@ -191,7 +191,7 @@ describe("E2E: sidecar reconnection after MAP connection loss", () => {
|
|
|
191
191
|
expect(mockConn.send).not.toHaveBeenCalled();
|
|
192
192
|
});
|
|
193
193
|
|
|
194
|
-
it("spawn responds ok:false with error when connection is null", async () => {
|
|
194
|
+
it("spawn responds ok:false with error when connection is null (after wait timeout)", async () => {
|
|
195
195
|
handler.setConnection(null);
|
|
196
196
|
|
|
197
197
|
const resp = await sendSocketCommand(socketPath, {
|
|
@@ -206,10 +206,10 @@ describe("E2E: sidecar reconnection after MAP connection loss", () => {
|
|
|
206
206
|
});
|
|
207
207
|
|
|
208
208
|
expect(resp.ok).toBe(false);
|
|
209
|
-
expect(resp.error).
|
|
209
|
+
expect(resp.error).toContain("timed out waiting");
|
|
210
210
|
});
|
|
211
211
|
|
|
212
|
-
it("trajectory-checkpoint responds ok:false when connection is null", async () => {
|
|
212
|
+
it("trajectory-checkpoint responds ok:false when connection is null (after wait timeout)", async () => {
|
|
213
213
|
handler.setConnection(null);
|
|
214
214
|
|
|
215
215
|
const resp = await sendSocketCommand(socketPath, {
|
|
@@ -218,7 +218,7 @@ describe("E2E: sidecar reconnection after MAP connection loss", () => {
|
|
|
218
218
|
});
|
|
219
219
|
|
|
220
220
|
expect(resp.ok).toBe(false);
|
|
221
|
-
expect(resp.error).
|
|
221
|
+
expect(resp.error).toContain("timed out waiting");
|
|
222
222
|
});
|
|
223
223
|
|
|
224
224
|
it("ping still works with null connection", async () => {
|
|
@@ -32,6 +32,7 @@ export function makeConfig(overrides = {}) {
|
|
|
32
32
|
sessionlog: {
|
|
33
33
|
enabled: overrides.sessionlogEnabled ?? false,
|
|
34
34
|
sync: overrides.sessionlogSync ?? "off",
|
|
35
|
+
mode: overrides.sessionlogMode ?? "auto",
|
|
35
36
|
},
|
|
36
37
|
opentasks: {
|
|
37
38
|
enabled: overrides.opentasksEnabled ?? false,
|
|
@@ -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
|
+
});
|