@vibelet/cli 0.1.38 → 1.0.0

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.
Files changed (323) hide show
  1. package/README.md +80 -0
  2. package/bin/cloudflared-quick-tunnel.mjs +11 -0
  3. package/bin/cloudflared-resolver.mjs +171 -0
  4. package/bin/vibelet-runtime-policy.mjs +36 -0
  5. package/bin/vibelet.cjs +12 -0
  6. package/bin/vibelet.mjs +1062 -0
  7. package/dist/index.cjs +126 -0
  8. package/package.json +24 -22
  9. package/app.json +0 -5
  10. package/dist/advertised-hosts.d.ts +0 -34
  11. package/dist/advertised-hosts.d.ts.map +0 -1
  12. package/dist/advertised-hosts.js +0 -176
  13. package/dist/advertised-hosts.js.map +0 -1
  14. package/dist/advertised-hosts.test.d.ts +0 -2
  15. package/dist/advertised-hosts.test.d.ts.map +0 -1
  16. package/dist/advertised-hosts.test.js +0 -96
  17. package/dist/advertised-hosts.test.js.map +0 -1
  18. package/dist/audit.d.ts +0 -30
  19. package/dist/audit.d.ts.map +0 -1
  20. package/dist/audit.js +0 -73
  21. package/dist/audit.js.map +0 -1
  22. package/dist/audit.test.d.ts +0 -2
  23. package/dist/audit.test.d.ts.map +0 -1
  24. package/dist/audit.test.js +0 -33
  25. package/dist/audit.test.js.map +0 -1
  26. package/dist/auth.d.ts +0 -6
  27. package/dist/auth.d.ts.map +0 -1
  28. package/dist/auth.js +0 -27
  29. package/dist/auth.js.map +0 -1
  30. package/dist/claude-hooks.d.ts +0 -58
  31. package/dist/claude-hooks.d.ts.map +0 -1
  32. package/dist/claude-hooks.js +0 -129
  33. package/dist/claude-hooks.js.map +0 -1
  34. package/dist/cli-version.d.ts +0 -3
  35. package/dist/cli-version.d.ts.map +0 -1
  36. package/dist/cli-version.js +0 -35
  37. package/dist/cli-version.js.map +0 -1
  38. package/dist/cli-version.test.d.ts +0 -2
  39. package/dist/cli-version.test.d.ts.map +0 -1
  40. package/dist/cli-version.test.js +0 -38
  41. package/dist/cli-version.test.js.map +0 -1
  42. package/dist/config.d.ts +0 -30
  43. package/dist/config.d.ts.map +0 -1
  44. package/dist/config.js +0 -327
  45. package/dist/config.js.map +0 -1
  46. package/dist/config.test.d.ts +0 -2
  47. package/dist/config.test.d.ts.map +0 -1
  48. package/dist/config.test.js +0 -184
  49. package/dist/config.test.js.map +0 -1
  50. package/dist/dev-auth.test.d.ts +0 -2
  51. package/dist/dev-auth.test.d.ts.map +0 -1
  52. package/dist/dev-auth.test.js +0 -154
  53. package/dist/dev-auth.test.js.map +0 -1
  54. package/dist/dev-script.test.d.ts +0 -2
  55. package/dist/dev-script.test.d.ts.map +0 -1
  56. package/dist/dev-script.test.js +0 -412
  57. package/dist/dev-script.test.js.map +0 -1
  58. package/dist/drivers/claude.d.ts +0 -34
  59. package/dist/drivers/claude.d.ts.map +0 -1
  60. package/dist/drivers/claude.js +0 -413
  61. package/dist/drivers/claude.js.map +0 -1
  62. package/dist/drivers/claude.test.d.ts +0 -2
  63. package/dist/drivers/claude.test.d.ts.map +0 -1
  64. package/dist/drivers/claude.test.js +0 -951
  65. package/dist/drivers/claude.test.js.map +0 -1
  66. package/dist/drivers/codex.d.ts +0 -38
  67. package/dist/drivers/codex.d.ts.map +0 -1
  68. package/dist/drivers/codex.js +0 -771
  69. package/dist/drivers/codex.js.map +0 -1
  70. package/dist/drivers/codex.test.d.ts +0 -2
  71. package/dist/drivers/codex.test.d.ts.map +0 -1
  72. package/dist/drivers/codex.test.js +0 -939
  73. package/dist/drivers/codex.test.js.map +0 -1
  74. package/dist/drivers/types.d.ts +0 -14
  75. package/dist/drivers/types.d.ts.map +0 -1
  76. package/dist/drivers/types.js +0 -2
  77. package/dist/drivers/types.js.map +0 -1
  78. package/dist/e2e.test.d.ts +0 -2
  79. package/dist/e2e.test.d.ts.map +0 -1
  80. package/dist/e2e.test.js +0 -111
  81. package/dist/e2e.test.js.map +0 -1
  82. package/dist/identity.d.ts +0 -10
  83. package/dist/identity.d.ts.map +0 -1
  84. package/dist/identity.js +0 -66
  85. package/dist/identity.js.map +0 -1
  86. package/dist/identity.test.d.ts +0 -2
  87. package/dist/identity.test.d.ts.map +0 -1
  88. package/dist/identity.test.js +0 -25
  89. package/dist/identity.test.js.map +0 -1
  90. package/dist/index-entry.test.d.ts +0 -2
  91. package/dist/index-entry.test.d.ts.map +0 -1
  92. package/dist/index-entry.test.js +0 -272
  93. package/dist/index-entry.test.js.map +0 -1
  94. package/dist/index.d.ts +0 -2
  95. package/dist/index.d.ts.map +0 -1
  96. package/dist/index.js +0 -707
  97. package/dist/index.js.map +0 -1
  98. package/dist/logger.d.ts +0 -31
  99. package/dist/logger.d.ts.map +0 -1
  100. package/dist/logger.js +0 -75
  101. package/dist/logger.js.map +0 -1
  102. package/dist/metrics.d.ts +0 -52
  103. package/dist/metrics.d.ts.map +0 -1
  104. package/dist/metrics.js +0 -89
  105. package/dist/metrics.js.map +0 -1
  106. package/dist/pairing-store.d.ts +0 -29
  107. package/dist/pairing-store.d.ts.map +0 -1
  108. package/dist/pairing-store.js +0 -131
  109. package/dist/pairing-store.js.map +0 -1
  110. package/dist/pairing-store.test.d.ts +0 -2
  111. package/dist/pairing-store.test.d.ts.map +0 -1
  112. package/dist/pairing-store.test.js +0 -47
  113. package/dist/pairing-store.test.js.map +0 -1
  114. package/dist/paths.d.ts +0 -16
  115. package/dist/paths.d.ts.map +0 -1
  116. package/dist/paths.js +0 -18
  117. package/dist/paths.js.map +0 -1
  118. package/dist/perf-compare.d.ts +0 -13
  119. package/dist/perf-compare.d.ts.map +0 -1
  120. package/dist/perf-compare.js +0 -125
  121. package/dist/perf-compare.js.map +0 -1
  122. package/dist/port-conflict.d.ts +0 -9
  123. package/dist/port-conflict.d.ts.map +0 -1
  124. package/dist/port-conflict.js +0 -33
  125. package/dist/port-conflict.js.map +0 -1
  126. package/dist/port-conflict.test.d.ts +0 -2
  127. package/dist/port-conflict.test.d.ts.map +0 -1
  128. package/dist/port-conflict.test.js +0 -38
  129. package/dist/port-conflict.test.js.map +0 -1
  130. package/dist/process-scanner.d.ts +0 -43
  131. package/dist/process-scanner.d.ts.map +0 -1
  132. package/dist/process-scanner.js +0 -453
  133. package/dist/process-scanner.js.map +0 -1
  134. package/dist/process-scanner.perf.test.d.ts +0 -2
  135. package/dist/process-scanner.perf.test.d.ts.map +0 -1
  136. package/dist/process-scanner.perf.test.js +0 -186
  137. package/dist/process-scanner.perf.test.js.map +0 -1
  138. package/dist/process-scanner.test.d.ts +0 -2
  139. package/dist/process-scanner.test.d.ts.map +0 -1
  140. package/dist/process-scanner.test.js +0 -399
  141. package/dist/process-scanner.test.js.map +0 -1
  142. package/dist/push-protocol.d.ts +0 -15
  143. package/dist/push-protocol.d.ts.map +0 -1
  144. package/dist/push-protocol.js +0 -23
  145. package/dist/push-protocol.js.map +0 -1
  146. package/dist/push-protocol.test.d.ts +0 -2
  147. package/dist/push-protocol.test.d.ts.map +0 -1
  148. package/dist/push-protocol.test.js +0 -57
  149. package/dist/push-protocol.test.js.map +0 -1
  150. package/dist/push-store.d.ts +0 -22
  151. package/dist/push-store.d.ts.map +0 -1
  152. package/dist/push-store.js +0 -103
  153. package/dist/push-store.js.map +0 -1
  154. package/dist/push-store.test.d.ts +0 -2
  155. package/dist/push-store.test.d.ts.map +0 -1
  156. package/dist/push-store.test.js +0 -79
  157. package/dist/push-store.test.js.map +0 -1
  158. package/dist/push.d.ts +0 -65
  159. package/dist/push.d.ts.map +0 -1
  160. package/dist/push.js +0 -202
  161. package/dist/push.js.map +0 -1
  162. package/dist/push.test.d.ts +0 -2
  163. package/dist/push.test.d.ts.map +0 -1
  164. package/dist/push.test.js +0 -199
  165. package/dist/push.test.js.map +0 -1
  166. package/dist/safe-stdio.d.ts +0 -3
  167. package/dist/safe-stdio.d.ts.map +0 -1
  168. package/dist/safe-stdio.js +0 -46
  169. package/dist/safe-stdio.js.map +0 -1
  170. package/dist/scanner.d.ts +0 -30
  171. package/dist/scanner.d.ts.map +0 -1
  172. package/dist/scanner.js +0 -859
  173. package/dist/scanner.js.map +0 -1
  174. package/dist/scanner.perf.test.d.ts +0 -2
  175. package/dist/scanner.perf.test.d.ts.map +0 -1
  176. package/dist/scanner.perf.test.js +0 -320
  177. package/dist/scanner.perf.test.js.map +0 -1
  178. package/dist/scanner.test.d.ts +0 -2
  179. package/dist/scanner.test.d.ts.map +0 -1
  180. package/dist/scanner.test.js +0 -948
  181. package/dist/scanner.test.js.map +0 -1
  182. package/dist/session-inventory.d.ts +0 -63
  183. package/dist/session-inventory.d.ts.map +0 -1
  184. package/dist/session-inventory.js +0 -525
  185. package/dist/session-inventory.js.map +0 -1
  186. package/dist/session-inventory.perf.test.d.ts +0 -2
  187. package/dist/session-inventory.perf.test.d.ts.map +0 -1
  188. package/dist/session-inventory.perf.test.js +0 -220
  189. package/dist/session-inventory.perf.test.js.map +0 -1
  190. package/dist/session-inventory.test.d.ts +0 -2
  191. package/dist/session-inventory.test.d.ts.map +0 -1
  192. package/dist/session-inventory.test.js +0 -712
  193. package/dist/session-inventory.test.js.map +0 -1
  194. package/dist/session-manager.d.ts +0 -75
  195. package/dist/session-manager.d.ts.map +0 -1
  196. package/dist/session-manager.js +0 -1515
  197. package/dist/session-manager.js.map +0 -1
  198. package/dist/session-manager.test.d.ts +0 -2
  199. package/dist/session-manager.test.d.ts.map +0 -1
  200. package/dist/session-manager.test.js +0 -2861
  201. package/dist/session-manager.test.js.map +0 -1
  202. package/dist/session-store.d.ts +0 -42
  203. package/dist/session-store.d.ts.map +0 -1
  204. package/dist/session-store.js +0 -163
  205. package/dist/session-store.js.map +0 -1
  206. package/dist/session-store.test.d.ts +0 -2
  207. package/dist/session-store.test.d.ts.map +0 -1
  208. package/dist/session-store.test.js +0 -236
  209. package/dist/session-store.test.js.map +0 -1
  210. package/dist/session-title.d.ts +0 -6
  211. package/dist/session-title.d.ts.map +0 -1
  212. package/dist/session-title.js +0 -105
  213. package/dist/session-title.js.map +0 -1
  214. package/dist/session-title.perf.test.d.ts +0 -2
  215. package/dist/session-title.perf.test.d.ts.map +0 -1
  216. package/dist/session-title.perf.test.js +0 -99
  217. package/dist/session-title.perf.test.js.map +0 -1
  218. package/dist/session-title.test.d.ts +0 -2
  219. package/dist/session-title.test.d.ts.map +0 -1
  220. package/dist/session-title.test.js +0 -199
  221. package/dist/session-title.test.js.map +0 -1
  222. package/dist/shutdown-endpoint.test.d.ts +0 -2
  223. package/dist/shutdown-endpoint.test.d.ts.map +0 -1
  224. package/dist/shutdown-endpoint.test.js +0 -93
  225. package/dist/shutdown-endpoint.test.js.map +0 -1
  226. package/dist/storage-housekeeping.d.ts +0 -28
  227. package/dist/storage-housekeeping.d.ts.map +0 -1
  228. package/dist/storage-housekeeping.js +0 -76
  229. package/dist/storage-housekeeping.js.map +0 -1
  230. package/dist/storage-housekeeping.test.d.ts +0 -2
  231. package/dist/storage-housekeeping.test.d.ts.map +0 -1
  232. package/dist/storage-housekeeping.test.js +0 -65
  233. package/dist/storage-housekeeping.test.js.map +0 -1
  234. package/dist/test-daemon-harness.d.ts +0 -31
  235. package/dist/test-daemon-harness.d.ts.map +0 -1
  236. package/dist/test-daemon-harness.js +0 -337
  237. package/dist/test-daemon-harness.js.map +0 -1
  238. package/dist/token-auth.test.d.ts +0 -2
  239. package/dist/token-auth.test.d.ts.map +0 -1
  240. package/dist/token-auth.test.js +0 -52
  241. package/dist/token-auth.test.js.map +0 -1
  242. package/dist/utils.d.ts +0 -4
  243. package/dist/utils.d.ts.map +0 -1
  244. package/dist/utils.js +0 -40
  245. package/dist/utils.js.map +0 -1
  246. package/dist/utils.test.d.ts +0 -2
  247. package/dist/utils.test.d.ts.map +0 -1
  248. package/dist/utils.test.js +0 -54
  249. package/dist/utils.test.js.map +0 -1
  250. package/dist/ws-data.d.ts +0 -4
  251. package/dist/ws-data.d.ts.map +0 -1
  252. package/dist/ws-data.js +0 -20
  253. package/dist/ws-data.js.map +0 -1
  254. package/dist/ws-data.test.d.ts +0 -2
  255. package/dist/ws-data.test.d.ts.map +0 -1
  256. package/dist/ws-data.test.js +0 -17
  257. package/dist/ws-data.test.js.map +0 -1
  258. package/perf-reporter.mjs +0 -138
  259. package/scripts/build-release.mjs +0 -41
  260. package/scripts/dev.mjs +0 -537
  261. package/src/advertised-hosts.test.ts +0 -125
  262. package/src/advertised-hosts.ts +0 -225
  263. package/src/audit.test.ts +0 -38
  264. package/src/audit.ts +0 -117
  265. package/src/auth.ts +0 -31
  266. package/src/claude-hooks.ts +0 -195
  267. package/src/cli-version.test.ts +0 -36
  268. package/src/cli-version.ts +0 -46
  269. package/src/config.test.ts +0 -254
  270. package/src/config.ts +0 -324
  271. package/src/dev-auth.test.ts +0 -183
  272. package/src/dev-script.test.ts +0 -511
  273. package/src/drivers/claude.test.ts +0 -1186
  274. package/src/drivers/claude.ts +0 -443
  275. package/src/drivers/codex.test.ts +0 -1096
  276. package/src/drivers/codex.ts +0 -879
  277. package/src/drivers/types.ts +0 -15
  278. package/src/e2e.test.ts +0 -139
  279. package/src/identity.test.ts +0 -26
  280. package/src/identity.ts +0 -82
  281. package/src/index-entry.test.ts +0 -336
  282. package/src/index.ts +0 -781
  283. package/src/logger.ts +0 -112
  284. package/src/metrics.ts +0 -117
  285. package/src/pairing-store.test.ts +0 -53
  286. package/src/pairing-store.ts +0 -154
  287. package/src/paths.ts +0 -19
  288. package/src/perf-compare.ts +0 -164
  289. package/src/port-conflict.test.ts +0 -45
  290. package/src/port-conflict.ts +0 -44
  291. package/src/process-scanner.perf.test.ts +0 -222
  292. package/src/process-scanner.test.ts +0 -575
  293. package/src/process-scanner.ts +0 -514
  294. package/src/push-protocol.test.ts +0 -74
  295. package/src/push-protocol.ts +0 -36
  296. package/src/push-store.test.ts +0 -89
  297. package/src/push-store.ts +0 -126
  298. package/src/push.test.ts +0 -234
  299. package/src/push.ts +0 -318
  300. package/src/safe-stdio.ts +0 -51
  301. package/src/scanner.perf.test.ts +0 -359
  302. package/src/scanner.test.ts +0 -1045
  303. package/src/scanner.ts +0 -924
  304. package/src/session-inventory.perf.test.ts +0 -250
  305. package/src/session-inventory.test.ts +0 -1002
  306. package/src/session-inventory.ts +0 -721
  307. package/src/session-manager.test.ts +0 -3430
  308. package/src/session-manager.ts +0 -1775
  309. package/src/session-store.test.ts +0 -276
  310. package/src/session-store.ts +0 -202
  311. package/src/session-title.perf.test.ts +0 -118
  312. package/src/session-title.test.ts +0 -286
  313. package/src/session-title.ts +0 -108
  314. package/src/shutdown-endpoint.test.ts +0 -95
  315. package/src/storage-housekeeping.test.ts +0 -78
  316. package/src/storage-housekeeping.ts +0 -111
  317. package/src/test-daemon-harness.ts +0 -410
  318. package/src/token-auth.test.ts +0 -67
  319. package/src/utils.test.ts +0 -65
  320. package/src/utils.ts +0 -47
  321. package/src/ws-data.test.ts +0 -20
  322. package/src/ws-data.ts +0 -26
  323. package/tsconfig.json +0 -12
@@ -1,2861 +0,0 @@
1
- import test from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { mkdtemp, rm, writeFile, mkdir } from 'fs/promises';
4
- import { join } from 'path';
5
- import { tmpdir } from 'os';
6
- import { SessionManager } from './session-manager.js';
7
- import { config } from './config.js';
8
- import { __emitExternalInventoryBackfillForTests, __resetExternalInventoryStateForTests, } from './session-inventory.js';
9
- // ── Mock helpers ──────────────────────────────────────────────────────
10
- function mockWs(open = true) {
11
- const sent = [];
12
- return {
13
- readyState: open ? 1 : 3, // 1 = OPEN, 3 = CLOSED
14
- send(data) { sent.push(data); },
15
- sent,
16
- };
17
- }
18
- function mockDriver() {
19
- const calls = {
20
- start: [],
21
- sendPrompt: [],
22
- respondApproval: [],
23
- interrupt: [],
24
- stop: [],
25
- setApprovalMode: [],
26
- };
27
- let messageHandler = null;
28
- let exitHandler = null;
29
- return {
30
- async start(cwd, resumeSessionId, approvalMode) {
31
- calls.start.push({ cwd, resumeSessionId, approvalMode });
32
- return 'mock-session-id';
33
- },
34
- sendPrompt(text) { calls.sendPrompt.push(text); },
35
- respondApproval(requestId, approved) {
36
- calls.respondApproval.push({ requestId, approved });
37
- return true;
38
- },
39
- interrupt() { calls.interrupt.push(true); },
40
- stop() { calls.stop.push(true); },
41
- setApprovalMode(mode) { calls.setApprovalMode.push(mode); },
42
- onMessage(handler) { messageHandler = handler; },
43
- onExit(handler) { exitHandler = handler; },
44
- calls,
45
- _emitMessage(msg) { messageHandler?.(msg); },
46
- _emitExit(code) { exitHandler?.(code); },
47
- };
48
- }
49
- function createDeferred() {
50
- let resolve;
51
- let reject;
52
- const promise = new Promise((res, rej) => {
53
- resolve = res;
54
- reject = rej;
55
- });
56
- return { promise, resolve, reject };
57
- }
58
- async function flushAsyncWork() {
59
- await Promise.resolve();
60
- await new Promise((resolve) => setTimeout(resolve, 0));
61
- }
62
- function createManager(pushSender) {
63
- const manager = new SessionManager(pushSender ?? (() => { }));
64
- // Avoid reading/writing from disk by replacing store internals
65
- const store = manager.store;
66
- store.records = [];
67
- store.deletedSessionIds = new Set();
68
- store.scheduleSave = () => { }; // no-op to prevent disk writes
69
- store.saveToDisk = () => { }; // no-op to prevent disk writes
70
- return manager;
71
- }
72
- function injectSession(manager, overrides) {
73
- const session = {
74
- agent: 'codex',
75
- cwd: '/tmp',
76
- driver: mockDriver(),
77
- clients: new Set(),
78
- title: 'Test session',
79
- createdAt: '2026-03-20T00:00:00.000Z',
80
- lastActivityAt: '2026-03-20T00:00:00.000Z',
81
- active: true,
82
- lastActivityTs: Date.now(),
83
- isResponding: false,
84
- currentReplyText: '',
85
- acceptedClientMessageIds: [],
86
- syntheticApprovalRetries: {},
87
- startupInProgress: false,
88
- bufferedPrompts: [],
89
- startupToken: 0,
90
- pendingClaudeHookApprovals: new Map(),
91
- ...overrides,
92
- };
93
- manager.sessions.set(session.sessionId, session);
94
- return session;
95
- }
96
- function parseReply(ws, index = 0) {
97
- return JSON.parse(ws.sent[index]);
98
- }
99
- test.beforeEach(() => {
100
- __resetExternalInventoryStateForTests();
101
- });
102
- // ── broadcast ─────────────────────────────────────────────────────────
103
- test('broadcast: sends to all open clients', () => {
104
- const manager = createManager();
105
- const ws1 = mockWs();
106
- const ws2 = mockWs();
107
- injectSession(manager, {
108
- sessionId: 's1',
109
- clients: new Set([ws1, ws2]),
110
- });
111
- const msg = { type: 'text.delta', sessionId: 's1', content: 'hello' };
112
- manager.broadcast('s1', msg);
113
- assert.equal(ws1.sent.length, 1);
114
- assert.equal(ws2.sent.length, 1);
115
- assert.deepEqual(JSON.parse(ws1.sent[0]), msg);
116
- assert.deepEqual(JSON.parse(ws2.sent[0]), msg);
117
- });
118
- test('broadcast: skips clients with readyState !== 1', () => {
119
- const manager = createManager();
120
- const wsOpen = mockWs(true);
121
- const wsClosed = mockWs(false);
122
- injectSession(manager, {
123
- sessionId: 's1',
124
- clients: new Set([wsOpen, wsClosed]),
125
- });
126
- manager.broadcast('s1', { type: 'text.delta', sessionId: 's1', content: 'x' });
127
- assert.equal(wsOpen.sent.length, 1);
128
- assert.equal(wsClosed.sent.length, 0);
129
- });
130
- test('broadcast: does nothing when session not found', () => {
131
- const manager = createManager();
132
- // Should not throw
133
- manager.broadcast('nonexistent', { type: 'text.delta', sessionId: 'nonexistent', content: 'x' });
134
- });
135
- test('broadcast: session.done sends push with the accumulated assistant reply preview', () => {
136
- const pushCalls = [];
137
- const manager = createManager((title, body, data) => {
138
- pushCalls.push({ title, body, data });
139
- });
140
- injectSession(manager, {
141
- sessionId: 's1',
142
- title: 'Fix login flow',
143
- });
144
- manager.broadcast('s1', { type: 'text.delta', sessionId: 's1', content: 'First line.\n' });
145
- manager.broadcast('s1', { type: 'text.delta', sessionId: 's1', content: 'Second line.' });
146
- manager.broadcast('s1', { type: 'session.done', sessionId: 's1' });
147
- assert.deepEqual(pushCalls, [{
148
- title: 'Fix login flow',
149
- body: 'First line. Second line.',
150
- data: { sessionId: 's1', agent: 'codex', eventType: 'reply_ready' },
151
- }]);
152
- });
153
- test('broadcast: session.done falls back to a generic push body when the turn has no assistant text', () => {
154
- const pushCalls = [];
155
- const manager = createManager((title, body, data) => {
156
- pushCalls.push({ title, body, data });
157
- });
158
- injectSession(manager, {
159
- sessionId: 's1',
160
- title: 'Tool-only session',
161
- });
162
- manager.broadcast('s1', { type: 'session.done', sessionId: 's1' });
163
- assert.deepEqual(pushCalls, [{
164
- title: 'Tool-only session',
165
- body: 'Done.',
166
- data: { sessionId: 's1', agent: 'codex', eventType: 'reply_ready' },
167
- }]);
168
- });
169
- test('broadcast: approval.request sends to all globalClients, not just session clients', () => {
170
- const manager = createManager();
171
- const sessionClient = mockWs(); // subscribed to session s1
172
- const globalOnly = mockWs(); // NOT subscribed to s1, but is a global client
173
- injectSession(manager, {
174
- sessionId: 's1',
175
- clients: new Set([sessionClient]),
176
- });
177
- // Register both as global clients
178
- manager.addGlobalClient(sessionClient);
179
- manager.addGlobalClient(globalOnly);
180
- // A normal text.delta should only go to session clients
181
- manager.broadcast('s1', { type: 'text.delta', sessionId: 's1', content: 'x' });
182
- assert.equal(sessionClient.sent.length, 1);
183
- assert.equal(globalOnly.sent.length, 0, 'text.delta should not reach non-session clients');
184
- // An approval.request should go to ALL global clients
185
- const approvalMsg = {
186
- type: 'approval.request',
187
- sessionId: 's1',
188
- requestId: 'req-1',
189
- toolName: 'Write',
190
- input: { file_path: '/tmp/test.ts' },
191
- description: 'Write to /tmp/test.ts',
192
- };
193
- manager.broadcast('s1', approvalMsg);
194
- assert.equal(sessionClient.sent.length, 2, 'session client should receive approval');
195
- assert.equal(globalOnly.sent.length, 1, 'global-only client should also receive approval');
196
- assert.deepEqual(JSON.parse(globalOnly.sent[0]), approvalMsg);
197
- });
198
- test('broadcast: approval.request skips closed globalClients', () => {
199
- const manager = createManager();
200
- const wsOpen = mockWs(true);
201
- const wsClosed = mockWs(false);
202
- injectSession(manager, { sessionId: 's1' });
203
- manager.addGlobalClient(wsOpen);
204
- manager.addGlobalClient(wsClosed);
205
- manager.broadcast('s1', {
206
- type: 'approval.request',
207
- sessionId: 's1',
208
- requestId: 'req-1',
209
- toolName: 'Bash',
210
- input: {},
211
- description: 'Run command',
212
- });
213
- assert.equal(wsOpen.sent.length, 1);
214
- assert.equal(wsClosed.sent.length, 0);
215
- });
216
- test('session.delete broadcasts a sessions.changed invalidation hint to global clients', async () => {
217
- const manager = createManager();
218
- const ws = mockWs();
219
- manager.addGlobalClient(ws);
220
- injectSession(manager, {
221
- sessionId: 'delete-broadcast',
222
- driver: mockDriver(),
223
- });
224
- await manager.handle(ws, {
225
- action: 'session.delete',
226
- id: 'req-delete-broadcast',
227
- sessionId: 'delete-broadcast',
228
- });
229
- const messages = ws.sent.map((entry) => JSON.parse(entry));
230
- assert.deepEqual(messages[0], {
231
- type: 'sessions.changed',
232
- version: 1,
233
- reason: 'session_deleted',
234
- });
235
- assert.equal(messages[1].type, 'response');
236
- assert.equal(messages[1].id, 'req-delete-broadcast');
237
- assert.equal(messages[1].ok, true);
238
- });
239
- test('inventory backfill broadcasts a sessions.changed invalidation hint to global clients', () => {
240
- const manager = createManager();
241
- const ws = mockWs();
242
- manager.addGlobalClient(ws);
243
- __emitExternalInventoryBackfillForTests();
244
- assert.deepEqual(JSON.parse(ws.sent[0]), {
245
- type: 'sessions.changed',
246
- version: 1,
247
- reason: 'inventory_backfilled',
248
- });
249
- });
250
- // ── removeClient ──────────────────────────────────────────────────────
251
- test('removeClient: removes ws from all sessions and globalClients', () => {
252
- const manager = createManager();
253
- const ws = mockWs();
254
- const s1 = injectSession(manager, { sessionId: 's1', clients: new Set([ws]) });
255
- const s2 = injectSession(manager, { sessionId: 's2', clients: new Set([ws]) });
256
- manager.addGlobalClient(ws);
257
- manager.removeClient(ws);
258
- assert.equal(s1.clients.size, 0);
259
- assert.equal(s2.clients.size, 0);
260
- // Verify globalClients is also cleaned up: broadcast approval should not reach removed client
261
- injectSession(manager, { sessionId: 's3' });
262
- manager.broadcast('s3', {
263
- type: 'approval.request', sessionId: 's3', requestId: 'r', toolName: 'X', input: {}, description: '',
264
- });
265
- assert.equal(ws.sent.length, 0, 'removed client should not receive broadcasts');
266
- });
267
- test('removeClient: does nothing if ws not in any session', () => {
268
- const manager = createManager();
269
- const ws = mockWs();
270
- injectSession(manager, { sessionId: 's1' });
271
- // Should not throw
272
- manager.removeClient(ws);
273
- });
274
- // ── shutdown ──────────────────────────────────────────────────────────
275
- test('shutdown: stops all drivers and sets sessions inactive', () => {
276
- const manager = createManager();
277
- const driver1 = mockDriver();
278
- const driver2 = mockDriver();
279
- const ws = mockWs();
280
- const s1 = injectSession(manager, { sessionId: 's1', driver: driver1, clients: new Set([ws]) });
281
- const s2 = injectSession(manager, { sessionId: 's2', driver: driver2, clients: new Set([ws]) });
282
- manager.shutdown();
283
- assert.equal(driver1.calls.stop.length, 1);
284
- assert.equal(driver2.calls.stop.length, 1);
285
- assert.equal(s1.active, false);
286
- assert.equal(s2.active, false);
287
- assert.equal(s1.driver, null);
288
- assert.equal(s2.driver, null);
289
- assert.equal(s1.clients.size, 0);
290
- assert.equal(s2.clients.size, 0);
291
- });
292
- test('shutdown: handles driver.stop() throwing without crashing', () => {
293
- const manager = createManager();
294
- const throwingDriver = mockDriver();
295
- throwingDriver.stop = () => { throw new Error('boom'); };
296
- injectSession(manager, { sessionId: 's1', driver: throwingDriver });
297
- // Should not throw
298
- manager.shutdown();
299
- });
300
- // ── handle: session.approve ───────────────────────────────────────────
301
- test('session.approve: calls driver.respondApproval and replies ok', async () => {
302
- const manager = createManager();
303
- const ws = mockWs();
304
- const driver = mockDriver();
305
- injectSession(manager, { sessionId: 's1', driver });
306
- await manager.handle(ws, {
307
- action: 'session.approve',
308
- id: 'req1',
309
- sessionId: 's1',
310
- requestId: 'tool-1',
311
- approved: true,
312
- });
313
- assert.deepEqual(driver.calls.respondApproval, [{ requestId: 'tool-1', approved: true }]);
314
- const reply = parseReply(ws);
315
- assert.equal(reply.ok, true);
316
- assert.equal(reply.id, 'req1');
317
- });
318
- test('session.approve: retries Claude synthetic approval with one-turn acceptEdits even when driver is null', async () => {
319
- const manager = createManager();
320
- const ws = mockWs();
321
- const driver = mockDriver();
322
- manager.createDriver = () => driver;
323
- const session = injectSession(manager, {
324
- sessionId: 's1',
325
- agent: 'claude',
326
- driver: null,
327
- active: false,
328
- lastUserMessage: 'Please make the requested edit.',
329
- syntheticApprovalRetries: {
330
- 'claude-permission-denial:toolu_1': { message: 'Please make the requested edit.', toolName: 'Write' },
331
- },
332
- });
333
- await manager.handle(ws, {
334
- action: 'session.approve',
335
- id: 'req1',
336
- sessionId: 's1',
337
- requestId: 'claude-permission-denial:toolu_1',
338
- approved: true,
339
- });
340
- const reply = parseReply(ws);
341
- assert.equal(reply.ok, true);
342
- assert.deepEqual(driver.calls.respondApproval, []);
343
- assert.deepEqual(driver.calls.start, [{
344
- cwd: '/tmp',
345
- resumeSessionId: 's1',
346
- approvalMode: 'acceptEdits',
347
- }]);
348
- assert.deepEqual(driver.calls.sendPrompt, ['Please make the requested edit.']);
349
- assert.equal(session.driver, driver);
350
- assert.equal(session.active, true);
351
- assert.equal(session.isResponding, true);
352
- assert.deepEqual(session.syntheticApprovalRetries, {});
353
- });
354
- test('session.approve: resolves Claude hook approval without driver.respondApproval', async () => {
355
- const manager = createManager();
356
- const ws = mockWs();
357
- const driver = mockDriver();
358
- const session = injectSession(manager, {
359
- sessionId: 'hook-approve-1',
360
- agent: 'claude',
361
- driver,
362
- claudeHookSecret: 'secret-1',
363
- });
364
- manager.claudeHookSessions.set('secret-1', session);
365
- const hookPromise = manager.handleClaudePermissionHook('secret-1', {
366
- tool_name: 'Bash',
367
- tool_input: { command: 'pwd' },
368
- tool_use_id: 'toolu_1',
369
- });
370
- assert.deepEqual(session.pendingApproval, {
371
- requestId: 'claude-hook:toolu_1',
372
- toolName: 'Bash',
373
- input: { command: 'pwd' },
374
- description: 'Claude requested permissions to use Bash.',
375
- });
376
- await manager.handle(ws, {
377
- action: 'session.approve',
378
- id: 'req-hook-approve',
379
- sessionId: 'hook-approve-1',
380
- requestId: 'claude-hook:toolu_1',
381
- approved: true,
382
- });
383
- const reply = parseReply(ws);
384
- assert.equal(reply.ok, true);
385
- assert.deepEqual(driver.calls.respondApproval, []);
386
- assert.equal(session.pendingApproval, undefined);
387
- assert.equal(session.isResponding, true);
388
- assert.deepEqual(await hookPromise, {
389
- continue: true,
390
- suppressOutput: true,
391
- hookSpecificOutput: {
392
- hookEventName: 'PreToolUse',
393
- permissionDecision: 'allow',
394
- },
395
- });
396
- });
397
- test('session.approve: Claude hook approval still resolves after switching to autoApprove during pending approval', async () => {
398
- const manager = createManager();
399
- const ws = mockWs();
400
- const driver = mockDriver();
401
- const session = injectSession(manager, {
402
- sessionId: 'hook-approve-yolo-1',
403
- agent: 'claude',
404
- approvalMode: 'normal',
405
- driver,
406
- claudeHookSecret: 'secret-yolo-1',
407
- });
408
- manager.claudeHookSessions.set('secret-yolo-1', session);
409
- const hookPromise = manager.handleClaudePermissionHook('secret-yolo-1', {
410
- tool_name: 'Bash',
411
- tool_input: { command: 'pwd' },
412
- tool_use_id: 'toolu_yolo_1',
413
- });
414
- await manager.handle(ws, {
415
- action: 'session.setApprovalMode',
416
- id: 'req-hook-mode',
417
- sessionId: 'hook-approve-yolo-1',
418
- approvalMode: 'autoApprove',
419
- });
420
- await manager.handle(ws, {
421
- action: 'session.approve',
422
- id: 'req-hook-approve-yolo',
423
- sessionId: 'hook-approve-yolo-1',
424
- requestId: 'claude-hook:toolu_yolo_1',
425
- approved: true,
426
- });
427
- const modeReply = parseReply(ws, 0);
428
- const approveReply = parseReply(ws, 1);
429
- assert.equal(modeReply.ok, true);
430
- assert.equal(approveReply.ok, true);
431
- assert.equal(session.approvalMode, 'autoApprove');
432
- assert.equal(session.driver, driver);
433
- assert.equal(driver.calls.stop.length, 0);
434
- assert.deepEqual(driver.calls.setApprovalMode, ['autoApprove']);
435
- assert.deepEqual(driver.calls.respondApproval, []);
436
- assert.equal(session.pendingApproval, undefined);
437
- assert.equal(session.isResponding, true);
438
- assert.deepEqual(await hookPromise, {
439
- continue: true,
440
- suppressOutput: true,
441
- hookSpecificOutput: {
442
- hookEventName: 'PreToolUse',
443
- permissionDecision: 'allow',
444
- },
445
- });
446
- });
447
- test('handleClaudePermissionHook auto-approves autoApprove sessions', async () => {
448
- const manager = createManager();
449
- const session = injectSession(manager, {
450
- sessionId: 'hook-auto-1',
451
- agent: 'claude',
452
- approvalMode: 'autoApprove',
453
- claudeHookSecret: 'secret-auto',
454
- });
455
- manager.claudeHookSessions.set('secret-auto', session);
456
- const response = await manager.handleClaudePermissionHook('secret-auto', {
457
- tool_name: 'Write',
458
- tool_input: { file_path: '/tmp/a.txt' },
459
- tool_use_id: 'toolu_auto',
460
- });
461
- assert.deepEqual(response, {
462
- continue: true,
463
- suppressOutput: true,
464
- hookSpecificOutput: {
465
- hookEventName: 'PreToolUse',
466
- permissionDecision: 'allow',
467
- },
468
- });
469
- assert.equal(session.pendingApproval, undefined);
470
- assert.equal(session.pendingClaudeHookApprovals.size, 0);
471
- });
472
- test('session.approve: returns error if session not found', async () => {
473
- const manager = createManager();
474
- const ws = mockWs();
475
- await manager.handle(ws, {
476
- action: 'session.approve',
477
- id: 'req1',
478
- sessionId: 'nonexistent',
479
- requestId: 'tool-1',
480
- approved: true,
481
- });
482
- const reply = parseReply(ws);
483
- assert.equal(reply.ok, false);
484
- assert.equal(reply.error, 'Session not found or inactive');
485
- });
486
- test('session.approve: returns error if driver is null', async () => {
487
- const manager = createManager();
488
- const ws = mockWs();
489
- injectSession(manager, { sessionId: 's1', driver: null });
490
- await manager.handle(ws, {
491
- action: 'session.approve',
492
- id: 'req1',
493
- sessionId: 's1',
494
- requestId: 'tool-1',
495
- approved: false,
496
- });
497
- const reply = parseReply(ws);
498
- assert.equal(reply.ok, false);
499
- assert.equal(reply.error, 'Session not found or inactive');
500
- });
501
- test('session.approve: returns error when respondApproval fails (stdin not writable)', async () => {
502
- const manager = createManager();
503
- const ws = mockWs();
504
- const driver = mockDriver();
505
- // Override respondApproval to return false (simulates stdin not writable)
506
- driver.respondApproval = (requestId, approved) => {
507
- driver.calls.respondApproval.push({ requestId, approved });
508
- return false;
509
- };
510
- const session = injectSession(manager, {
511
- sessionId: 's1',
512
- driver,
513
- pendingApproval: {
514
- requestId: 'tool-1',
515
- toolName: 'Bash',
516
- input: {},
517
- description: 'Need approval',
518
- },
519
- });
520
- await manager.handle(ws, {
521
- action: 'session.approve',
522
- id: 'req1',
523
- sessionId: 's1',
524
- requestId: 'tool-1',
525
- approved: true,
526
- });
527
- const reply = parseReply(ws);
528
- assert.equal(reply.ok, false);
529
- assert.match(reply.error, /not running/i);
530
- assert.deepEqual(session.pendingApproval, {
531
- requestId: 'tool-1',
532
- toolName: 'Bash',
533
- input: {},
534
- description: 'Need approval',
535
- });
536
- });
537
- // ── handle: session.interrupt ─────────────────────────────────────────
538
- test('session.interrupt: calls driver.interrupt and replies ok', async () => {
539
- const manager = createManager();
540
- const ws = mockWs();
541
- const driver = mockDriver();
542
- injectSession(manager, { sessionId: 's1', driver });
543
- await manager.handle(ws, {
544
- action: 'session.interrupt',
545
- id: 'req1',
546
- sessionId: 's1',
547
- });
548
- assert.equal(driver.calls.interrupt.length, 1);
549
- const reply = parseReply(ws);
550
- assert.equal(reply.ok, true);
551
- });
552
- test('session.interrupt: returns error if session not found', async () => {
553
- const manager = createManager();
554
- const ws = mockWs();
555
- await manager.handle(ws, {
556
- action: 'session.interrupt',
557
- id: 'req1',
558
- sessionId: 'nonexistent',
559
- });
560
- const reply = parseReply(ws);
561
- assert.equal(reply.ok, false);
562
- assert.equal(reply.error, 'Session not found or inactive');
563
- });
564
- // ── handle: session.stop ──────────────────────────────────────────────
565
- test('session.stop: stops driver, sets active=false and driver=null', async () => {
566
- const manager = createManager();
567
- const ws = mockWs();
568
- const driver = mockDriver();
569
- const session = injectSession(manager, { sessionId: 's1', driver });
570
- await manager.handle(ws, {
571
- action: 'session.stop',
572
- id: 'req1',
573
- sessionId: 's1',
574
- });
575
- assert.equal(driver.calls.stop.length, 1);
576
- assert.equal(session.active, false);
577
- assert.equal(session.driver, null);
578
- const reply = parseReply(ws);
579
- assert.equal(reply.ok, true);
580
- });
581
- test('session.stop: returns error if session not found', async () => {
582
- const manager = createManager();
583
- const ws = mockWs();
584
- await manager.handle(ws, {
585
- action: 'session.stop',
586
- id: 'req1',
587
- sessionId: 'nonexistent',
588
- });
589
- const reply = parseReply(ws);
590
- assert.equal(reply.ok, false);
591
- assert.equal(reply.error, 'Session not found');
592
- });
593
- test('session.stop: works when driver is already null', async () => {
594
- const manager = createManager();
595
- const ws = mockWs();
596
- const session = injectSession(manager, { sessionId: 's1', driver: null });
597
- await manager.handle(ws, {
598
- action: 'session.stop',
599
- id: 'req1',
600
- sessionId: 's1',
601
- });
602
- assert.equal(session.active, false);
603
- const reply = parseReply(ws);
604
- assert.equal(reply.ok, true);
605
- });
606
- // ── handle: session.delete ────────────────────────────────────────────
607
- test('session.delete: stops driver and removes session from map', async () => {
608
- const manager = createManager();
609
- const ws = mockWs();
610
- const driver = mockDriver();
611
- injectSession(manager, { sessionId: 's1', driver });
612
- await manager.handle(ws, {
613
- action: 'session.delete',
614
- id: 'req1',
615
- sessionId: 's1',
616
- });
617
- assert.equal(driver.calls.stop.length, 1);
618
- assert.equal(manager.sessions.has('s1'), false);
619
- const reply = parseReply(ws);
620
- assert.equal(reply.ok, true);
621
- });
622
- test('session.delete: replies ok even if session does not exist', async () => {
623
- const manager = createManager();
624
- const ws = mockWs();
625
- await manager.handle(ws, {
626
- action: 'session.delete',
627
- id: 'req1',
628
- sessionId: 'nonexistent',
629
- });
630
- const reply = parseReply(ws);
631
- assert.equal(reply.ok, true);
632
- });
633
- test('session.delete: removes record from sessionRecords', async () => {
634
- const manager = createManager();
635
- const ws = mockWs();
636
- manager.store.records = [
637
- { sessionId: 's1', agent: 'codex', cwd: '/tmp', title: 'T', createdAt: '', lastActivityAt: '' },
638
- { sessionId: 's2', agent: 'codex', cwd: '/tmp', title: 'T', createdAt: '', lastActivityAt: '' },
639
- ];
640
- injectSession(manager, { sessionId: 's1' });
641
- await manager.handle(ws, {
642
- action: 'session.delete',
643
- id: 'req1',
644
- sessionId: 's1',
645
- });
646
- const records = manager.store.records;
647
- assert.equal(records.length, 1);
648
- assert.equal(records[0].sessionId, 's2');
649
- assert.equal(manager.store.isDeleted('s1'), true);
650
- });
651
- // ── sendHistory ───────────────────────────────────────────────────────
652
- test('sendHistory: adds ws to session.clients for reconnection', async () => {
653
- const manager = createManager();
654
- const ws = mockWs();
655
- const session = injectSession(manager, { sessionId: 's1' });
656
- assert.equal(session.clients.has(ws), false);
657
- await manager.handle(ws, {
658
- action: 'session.history',
659
- id: 'req1',
660
- sessionId: 's1',
661
- agent: 'codex',
662
- });
663
- assert.equal(session.clients.has(ws), true);
664
- });
665
- test('sendHistory: includes the current partial reply for active responding sessions', async () => {
666
- const manager = createManager();
667
- const ws = mockWs();
668
- injectSession(manager, {
669
- sessionId: 's1',
670
- isResponding: true,
671
- currentReplyText: 'still streaming',
672
- acceptedClientMessageIds: ['cm-streaming'],
673
- });
674
- await manager.handle(ws, {
675
- action: 'session.history',
676
- id: 'req-partial',
677
- sessionId: 's1',
678
- agent: 'codex',
679
- });
680
- const historyMsg = JSON.parse(ws.sent[0]);
681
- assert.equal(historyMsg.type, 'session.history');
682
- assert.equal(historyMsg.partialReplyText, 'still streaming');
683
- assert.deepEqual(historyMsg.acceptedClientMessageIds, ['cm-streaming']);
684
- });
685
- test('sendHistory: infers responding state for external Claude sessions from transcript history', async () => {
686
- const manager = createManager();
687
- const ws = mockWs();
688
- const tempHome = await mkdtemp(join(tmpdir(), 'vibelet-manager-home-'));
689
- const projectDir = join(tempHome, '.claude', 'projects', '-test');
690
- await mkdir(projectDir, { recursive: true });
691
- await writeFile(join(projectDir, 'external-claude.jsonl'), [
692
- JSON.stringify({ type: 'user', cwd: '/test', sessionId: 'external-claude', message: { role: 'user', content: 'Check types' } }),
693
- JSON.stringify({
694
- type: 'assistant',
695
- message: {
696
- role: 'assistant',
697
- stop_reason: null,
698
- content: [{ type: 'text', text: 'Typecheck is still running' }],
699
- },
700
- }),
701
- ].join('\n') + '\n', 'utf-8');
702
- const originalHome = process.env.HOME;
703
- process.env.HOME = tempHome;
704
- try {
705
- manager.store.upsert({
706
- sessionId: 'external-claude',
707
- agent: 'claude',
708
- cwd: '/test',
709
- title: 'External Claude',
710
- createdAt: '2026-03-20T00:00:00.000Z',
711
- lastActivityAt: '2026-03-20T00:00:00.000Z',
712
- });
713
- await manager.handle(ws, {
714
- action: 'session.history',
715
- id: 'req-external-history',
716
- sessionId: 'external-claude',
717
- agent: 'claude',
718
- });
719
- const historyMsg = JSON.parse(ws.sent[0]);
720
- assert.equal(historyMsg.type, 'session.history');
721
- assert.equal(historyMsg.isResponding, true);
722
- assert.equal(historyMsg.partialReplyText, 'Typecheck is still running');
723
- }
724
- finally {
725
- process.env.HOME = originalHome;
726
- await rm(tempHome, { recursive: true, force: true });
727
- }
728
- });
729
- test('reconnect.snapshot: returns active session state for the current session', async () => {
730
- const manager = createManager();
731
- const ws = mockWs();
732
- injectSession(manager, {
733
- sessionId: 'snap-1',
734
- agent: 'codex',
735
- approvalMode: 'autoApprove',
736
- isResponding: true,
737
- currentReplyText: 'half reply',
738
- acceptedClientMessageIds: ['cm-snap-1'],
739
- pendingApproval: {
740
- requestId: 'approval-1',
741
- toolName: 'Bash',
742
- input: { command: 'pwd' },
743
- description: 'Run pwd',
744
- },
745
- });
746
- await manager.handle(ws, {
747
- action: 'reconnect.snapshot',
748
- id: 'snap-req',
749
- activeSessionId: 'snap-1',
750
- activeAgent: 'codex',
751
- });
752
- const reply = parseReply(ws);
753
- assert.equal(reply.ok, true);
754
- assert.ok(reply.data.snapshot);
755
- assert.equal(reply.data.snapshot.activeSession.sessionId, 'snap-1');
756
- assert.equal(reply.data.snapshot.activeSession.agent, 'codex');
757
- assert.equal(reply.data.snapshot.activeSession.approvalMode, 'autoApprove');
758
- assert.equal(reply.data.snapshot.activeSession.isResponding, true);
759
- assert.equal(reply.data.snapshot.activeSession.partialReplyText, 'half reply');
760
- assert.deepEqual(reply.data.snapshot.activeSession.acceptedClientMessageIds, ['cm-snap-1']);
761
- assert.equal(reply.data.snapshot.activeSession.pendingApproval.requestId, 'approval-1');
762
- assert.equal(reply.data.snapshot.pendingApprovals[0].requestId, 'approval-1');
763
- assert.equal(reply.data.snapshot.inventoryVersion, 0);
764
- });
765
- test('reconnect.snapshot: restores external Claude running state from transcript history', async () => {
766
- const manager = createManager();
767
- const ws = mockWs();
768
- const tempHome = await mkdtemp(join(tmpdir(), 'vibelet-manager-home-'));
769
- const projectDir = join(tempHome, '.claude', 'projects', '-test');
770
- await mkdir(projectDir, { recursive: true });
771
- await writeFile(join(projectDir, 'snapshot-claude.jsonl'), [
772
- JSON.stringify({ type: 'user', cwd: '/test', sessionId: 'snapshot-claude', message: { role: 'user', content: 'Inspect upload flow' } }),
773
- JSON.stringify({
774
- type: 'assistant',
775
- message: {
776
- role: 'assistant',
777
- stop_reason: null,
778
- content: [{ type: 'text', text: 'Still inspecting upload flow' }],
779
- },
780
- }),
781
- ].join('\n') + '\n', 'utf-8');
782
- const originalHome = process.env.HOME;
783
- process.env.HOME = tempHome;
784
- try {
785
- manager.store.upsert({
786
- sessionId: 'snapshot-claude',
787
- agent: 'claude',
788
- cwd: '/test',
789
- title: 'Snapshot Claude',
790
- createdAt: '2026-03-20T00:00:00.000Z',
791
- lastActivityAt: '2026-03-20T00:00:00.000Z',
792
- });
793
- await manager.handle(ws, {
794
- action: 'reconnect.snapshot',
795
- id: 'snap-external',
796
- activeSessionId: 'snapshot-claude',
797
- activeAgent: 'claude',
798
- });
799
- const reply = parseReply(ws);
800
- assert.equal(reply.ok, true);
801
- assert.equal(reply.data.snapshot.activeSession.sessionId, 'snapshot-claude');
802
- assert.equal(reply.data.snapshot.activeSession.isResponding, true);
803
- assert.equal(reply.data.snapshot.activeSession.partialReplyText, 'Still inspecting upload flow');
804
- }
805
- finally {
806
- process.env.HOME = originalHome;
807
- await rm(tempHome, { recursive: true, force: true });
808
- }
809
- });
810
- test('reconnect.snapshot: returns pending approvals for non-active and persisted sessions', async () => {
811
- const manager = createManager();
812
- const ws = mockWs();
813
- injectSession(manager, {
814
- sessionId: 'live-approval',
815
- agent: 'claude',
816
- pendingApproval: {
817
- requestId: 'req-live',
818
- toolName: 'Bash',
819
- input: { command: 'pwd' },
820
- description: 'Run pwd',
821
- },
822
- });
823
- manager.store.upsert({
824
- sessionId: 'stored-approval',
825
- agent: 'codex',
826
- cwd: '/tmp',
827
- title: 'Stored session',
828
- createdAt: '2026-03-20T00:00:00.000Z',
829
- lastActivityAt: '2026-03-20T00:00:00.000Z',
830
- pendingApproval: {
831
- requestId: 'req-stored',
832
- toolName: 'Bash',
833
- input: { command: 'ls' },
834
- description: 'Run ls',
835
- approvalContext: {
836
- provider: 'codex',
837
- kind: 'command-execution',
838
- rpcId: 77,
839
- },
840
- },
841
- });
842
- await manager.handle(ws, {
843
- action: 'reconnect.snapshot',
844
- id: 'snap-all',
845
- });
846
- const reply = parseReply(ws);
847
- assert.equal(reply.ok, true);
848
- const pendingApprovals = reply.data.snapshot.pendingApprovals;
849
- assert.ok(Array.isArray(pendingApprovals));
850
- assert.ok(pendingApprovals.some((approval) => approval.requestId === 'req-live' && approval.sessionId === 'live-approval'));
851
- assert.ok(pendingApprovals.some((approval) => approval.requestId === 'req-stored' && approval.sessionId === 'stored-approval'));
852
- });
853
- test('reconnect.snapshot: omits deleted activeSessionId', async () => {
854
- const manager = createManager();
855
- const ws = mockWs();
856
- manager.store.remove('deleted-active');
857
- await manager.handle(ws, {
858
- action: 'reconnect.snapshot',
859
- id: 'snap-deleted',
860
- activeSessionId: 'deleted-active',
861
- activeAgent: 'codex',
862
- });
863
- const reply = parseReply(ws);
864
- assert.equal(reply.ok, true);
865
- assert.ok(reply.data.snapshot);
866
- assert.equal(reply.data.snapshot.activeSession, undefined);
867
- });
868
- test('reconnect.snapshot: subscribes ws to active session broadcasts', async () => {
869
- const manager = createManager();
870
- const ws = mockWs();
871
- const session = injectSession(manager, {
872
- sessionId: 'snap-sub',
873
- agent: 'claude',
874
- isResponding: true,
875
- currentReplyText: 'streaming...',
876
- });
877
- // ws should NOT be in clients before snapshot
878
- assert.equal(session.clients.has(ws), false);
879
- await manager.handle(ws, {
880
- action: 'reconnect.snapshot',
881
- id: 'snap-sub-req',
882
- activeSessionId: 'snap-sub',
883
- activeAgent: 'claude',
884
- });
885
- // After snapshot, ws should be subscribed to the session's broadcast list
886
- assert.equal(session.clients.has(ws), true);
887
- // Verify broadcasts now reach the reconnected ws
888
- ws.sent.length = 0; // clear snapshot reply
889
- const delta = { type: 'text.delta', sessionId: 'snap-sub', content: 'more text' };
890
- manager.broadcast('snap-sub', delta);
891
- assert.equal(ws.sent.length, 1);
892
- assert.deepEqual(JSON.parse(ws.sent[0]), delta);
893
- });
894
- test('sendHistory: works when session does not exist in sessions map', async () => {
895
- const manager = createManager();
896
- const ws = mockWs();
897
- // Should not throw; will just send empty history + response
898
- await manager.handle(ws, {
899
- action: 'session.history',
900
- id: 'req1',
901
- sessionId: 'nonexistent',
902
- agent: 'codex',
903
- });
904
- // Should have sent something (history msg + reply)
905
- assert.ok(ws.sent.length >= 1);
906
- });
907
- test('sendHistory: rejects deleted sessions that are no longer active', async () => {
908
- const manager = createManager();
909
- const ws = mockWs();
910
- manager.store.remove('deleted-history');
911
- await manager.handle(ws, {
912
- action: 'session.history',
913
- id: 'req-deleted-history',
914
- sessionId: 'deleted-history',
915
- agent: 'codex',
916
- });
917
- const reply = parseReply(ws);
918
- assert.equal(reply.ok, false);
919
- assert.equal(reply.error, 'Session not found');
920
- });
921
- // ── activeSessionSnapshots ────────────────────────────────────────────
922
- test('activeSessionSnapshots: excludes pending_ sessions', () => {
923
- const manager = createManager();
924
- injectSession(manager, { sessionId: 'pending_abc', title: 'Pending' });
925
- injectSession(manager, { sessionId: 'real-session', title: 'Real' });
926
- const snapshots = manager.activeSessionSnapshots();
927
- assert.equal(snapshots.length, 1);
928
- assert.equal(snapshots[0].sessionId, 'real-session');
929
- });
930
- test('activeSessionSnapshots: returns correct snapshot fields', () => {
931
- const manager = createManager();
932
- injectSession(manager, {
933
- sessionId: 'sess-1',
934
- agent: 'claude',
935
- cwd: '/home/user/project',
936
- title: 'My session',
937
- createdAt: '2026-03-20T01:00:00.000Z',
938
- lastActivityAt: '2026-03-20T02:00:00.000Z',
939
- });
940
- const snapshots = manager.activeSessionSnapshots();
941
- assert.equal(snapshots.length, 1);
942
- assert.deepEqual(snapshots[0], {
943
- sessionId: 'sess-1',
944
- agent: 'claude',
945
- cwd: '/home/user/project',
946
- approvalMode: undefined,
947
- title: 'My session',
948
- createdAt: '2026-03-20T01:00:00.000Z',
949
- lastActivityAt: '2026-03-20T02:00:00.000Z',
950
- managed: undefined,
951
- });
952
- });
953
- // ── reply ─────────────────────────────────────────────────────────────
954
- test('reply: does not send if ws is closed', () => {
955
- const manager = createManager();
956
- const ws = mockWs(false); // readyState = 3
957
- manager.reply(ws, 'req1', true, { some: 'data' });
958
- assert.equal(ws.sent.length, 0);
959
- });
960
- test('reply: sends correct response structure', () => {
961
- const manager = createManager();
962
- const ws = mockWs();
963
- manager.reply(ws, 'req1', false, undefined, 'oops');
964
- const msg = parseReply(ws);
965
- assert.equal(msg.type, 'response');
966
- assert.equal(msg.id, 'req1');
967
- assert.equal(msg.ok, false);
968
- assert.equal(msg.error, 'oops');
969
- });
970
- // ── bindDriverLifecycle ───────────────────────────────────────────────
971
- test('bindDriverLifecycle: onExit sets session inactive and driver null', () => {
972
- const manager = createManager();
973
- const driver = mockDriver();
974
- const session = injectSession(manager, { sessionId: 's1', driver });
975
- manager.bindDriverLifecycle(session, 'codex', '');
976
- driver._emitExit(0);
977
- assert.equal(session.active, false);
978
- assert.equal(session.driver, null);
979
- });
980
- test('bindDriverLifecycle: onMessage broadcasts to clients', () => {
981
- const manager = createManager();
982
- const driver = mockDriver();
983
- const ws = mockWs();
984
- const session = injectSession(manager, {
985
- sessionId: 's1',
986
- driver,
987
- clients: new Set([ws]),
988
- });
989
- manager.bindDriverLifecycle(session, 'codex', '');
990
- const msg = { type: 'text.delta', sessionId: 's1', content: 'hi' };
991
- driver._emitMessage(msg);
992
- assert.equal(ws.sent.length, 1);
993
- assert.deepEqual(JSON.parse(ws.sent[0]), msg);
994
- });
995
- test('bindDriverLifecycle: remaps session ID when driver reports a new one', () => {
996
- const manager = createManager();
997
- const driver = mockDriver();
998
- const ws = mockWs();
999
- const session = injectSession(manager, {
1000
- sessionId: 'old-id',
1001
- driver,
1002
- clients: new Set([ws]),
1003
- });
1004
- manager.bindDriverLifecycle(session, 'codex', '', ws);
1005
- // Driver reports a message with a different sessionId
1006
- driver._emitMessage({ type: 'session.done', sessionId: 'new-id' });
1007
- // Session should be remapped
1008
- assert.equal(manager.sessions.has('old-id'), false);
1009
- assert.equal(manager.sessions.has('new-id'), true);
1010
- assert.equal(session.sessionId, 'new-id');
1011
- });
1012
- test('bindDriverLifecycle: does not remap if new sessionId already exists', () => {
1013
- const manager = createManager();
1014
- const driver = mockDriver();
1015
- injectSession(manager, { sessionId: 'existing-id' });
1016
- const session = injectSession(manager, { sessionId: 'current-id', driver });
1017
- manager.bindDriverLifecycle(session, 'codex', '');
1018
- // Driver reports sessionId that already exists as another session
1019
- driver._emitMessage({ type: 'session.done', sessionId: 'existing-id' });
1020
- // Should NOT remap because existing-id already exists
1021
- assert.equal(manager.sessions.has('current-id'), true);
1022
- assert.equal(session.sessionId, 'current-id');
1023
- });
1024
- test('bindDriverLifecycle: does not remap for response type messages', () => {
1025
- const manager = createManager();
1026
- const driver = mockDriver();
1027
- const session = injectSession(manager, { sessionId: 'my-id', driver });
1028
- manager.bindDriverLifecycle(session, 'codex', '');
1029
- // response messages should not trigger remapping
1030
- driver._emitMessage({ type: 'response', id: 'r1', ok: true, sessionId: 'different-id' });
1031
- assert.equal(session.sessionId, 'my-id');
1032
- });
1033
- // ── sendMessage (direct, active session) ─────────────────────────────
1034
- test('sendMessage: sends prompt to active session driver', async () => {
1035
- const manager = createManager();
1036
- const ws = mockWs();
1037
- const driver = mockDriver();
1038
- injectSession(manager, { sessionId: 's1', driver });
1039
- await manager.handle(ws, {
1040
- action: 'session.send',
1041
- id: 'req1',
1042
- sessionId: 's1',
1043
- message: 'hello',
1044
- });
1045
- assert.deepEqual(driver.calls.sendPrompt, ['hello']);
1046
- const reply = parseReply(ws);
1047
- assert.equal(reply.ok, true);
1048
- });
1049
- test('sendMessage: appends attached image paths to the prompt', async () => {
1050
- const manager = createManager();
1051
- const ws = mockWs();
1052
- const driver = mockDriver();
1053
- injectSession(manager, { sessionId: 's-images', driver });
1054
- await manager.handle(ws, {
1055
- action: 'session.send',
1056
- id: 'req-images',
1057
- sessionId: 's-images',
1058
- message: 'Please review these screenshots',
1059
- images: ['/tmp/one.heic', '/tmp/two.png'],
1060
- });
1061
- assert.deepEqual(driver.calls.sendPrompt, [
1062
- 'Please review these screenshots\n\n[Attached image: /tmp/one.heic]\n[Attached image: /tmp/two.png]',
1063
- ]);
1064
- const reply = parseReply(ws);
1065
- assert.equal(reply.ok, true);
1066
- });
1067
- test('sendMessage: buffers follow-up prompts while the session is already responding', async () => {
1068
- const manager = createManager();
1069
- const ws = mockWs();
1070
- const driver = mockDriver();
1071
- const session = injectSession(manager, {
1072
- sessionId: 's-buffered',
1073
- driver,
1074
- isResponding: true,
1075
- currentReplyText: 'current reply',
1076
- lastUserMessage: 'current prompt',
1077
- });
1078
- manager.bindDriverLifecycle(session, 'codex', '');
1079
- await manager.handle(ws, {
1080
- action: 'session.send',
1081
- id: 'req-buffered',
1082
- sessionId: 's-buffered',
1083
- message: 'follow-up prompt',
1084
- clientMessageId: 'cm-buffered',
1085
- });
1086
- assert.deepEqual(driver.calls.sendPrompt, []);
1087
- assert.deepEqual(session.bufferedPrompts, ['follow-up prompt']);
1088
- assert.equal(session.currentReplyText, 'current reply');
1089
- assert.equal(session.lastUserMessage, 'current prompt');
1090
- assert.deepEqual(session.acceptedClientMessageIds, ['cm-buffered']);
1091
- driver._emitMessage({ type: 'session.done', sessionId: 's-buffered' });
1092
- assert.deepEqual(driver.calls.sendPrompt, ['follow-up prompt']);
1093
- assert.equal(session.isResponding, true);
1094
- assert.equal(session.currentReplyText, '');
1095
- assert.equal(session.lastUserMessage, 'follow-up prompt');
1096
- const reply = parseReply(ws);
1097
- assert.equal(reply.ok, true);
1098
- assert.deepEqual(reply.data, {
1099
- sessionId: 's-buffered',
1100
- clientMessageId: 'cm-buffered',
1101
- });
1102
- });
1103
- test('sendMessage: adds ws to session clients', async () => {
1104
- const manager = createManager();
1105
- const ws = mockWs();
1106
- const driver = mockDriver();
1107
- const session = injectSession(manager, { sessionId: 's1', driver });
1108
- assert.equal(session.clients.has(ws), false);
1109
- await manager.handle(ws, {
1110
- action: 'session.send',
1111
- id: 'req1',
1112
- sessionId: 's1',
1113
- message: 'hello',
1114
- });
1115
- assert.equal(session.clients.has(ws), true);
1116
- });
1117
- test('sendMessage: updates fallback title from first message', async () => {
1118
- const manager = createManager();
1119
- const ws = mockWs();
1120
- const driver = mockDriver();
1121
- const session = injectSession(manager, {
1122
- sessionId: 's1',
1123
- driver,
1124
- title: 'New session', // fallback title
1125
- });
1126
- await manager.handle(ws, {
1127
- action: 'session.send',
1128
- id: 'req1',
1129
- sessionId: 's1',
1130
- message: 'Fix the login bug in auth module',
1131
- });
1132
- assert.equal(session.title, 'Fix the login bug in auth module');
1133
- });
1134
- test('sendMessage: does not overwrite real title', async () => {
1135
- const manager = createManager();
1136
- const ws = mockWs();
1137
- const driver = mockDriver();
1138
- const session = injectSession(manager, {
1139
- sessionId: 's1',
1140
- driver,
1141
- title: 'My real title',
1142
- });
1143
- await manager.handle(ws, {
1144
- action: 'session.send',
1145
- id: 'req1',
1146
- sessionId: 's1',
1147
- message: 'Some new message',
1148
- });
1149
- assert.equal(session.title, 'My real title');
1150
- });
1151
- test('sendMessage: returns error when session not found and no agent provided', async () => {
1152
- const manager = createManager();
1153
- const ws = mockWs();
1154
- await manager.handle(ws, {
1155
- action: 'session.send',
1156
- id: 'req1',
1157
- sessionId: 'nonexistent',
1158
- message: 'hello',
1159
- });
1160
- const reply = parseReply(ws);
1161
- assert.equal(reply.ok, false);
1162
- assert.match(reply.error, /not found/i);
1163
- });
1164
- test('sendMessage: reconnects from sessionRecords when session not in memory', async () => {
1165
- const manager = createManager();
1166
- const ws = mockWs();
1167
- // Put a record in sessionRecords
1168
- manager.store.records = [
1169
- {
1170
- sessionId: 'rec-1',
1171
- agent: 'codex',
1172
- cwd: '/tmp',
1173
- title: 'Recorded',
1174
- createdAt: '2026-03-20T00:00:00.000Z',
1175
- lastActivityAt: '2026-03-20T00:00:00.000Z',
1176
- },
1177
- ];
1178
- // Mock createDriver to return a mock driver
1179
- const driver = mockDriver();
1180
- manager.createDriver = () => driver;
1181
- await manager.handle(ws, {
1182
- action: 'session.send',
1183
- id: 'req1',
1184
- sessionId: 'rec-1',
1185
- message: 'hello from reconnect',
1186
- });
1187
- assert.deepEqual(driver.calls.sendPrompt, ['hello from reconnect']);
1188
- const reply = parseReply(ws);
1189
- assert.equal(reply.ok, true);
1190
- // Session should now be in memory
1191
- assert.equal(manager.sessions.has('rec-1'), true);
1192
- });
1193
- test('sendMessage: restores accepted client message ids from session records during auto-reconnect', async () => {
1194
- const manager = createManager();
1195
- const ws = mockWs();
1196
- const driver = mockDriver();
1197
- manager.store.records = [
1198
- {
1199
- sessionId: 'rec-dup',
1200
- agent: 'codex',
1201
- cwd: '/tmp',
1202
- title: 'Recorded',
1203
- createdAt: '2026-03-20T00:00:00.000Z',
1204
- lastActivityAt: '2026-03-20T00:00:00.000Z',
1205
- acceptedClientMessageIds: ['cm_saved'],
1206
- },
1207
- ];
1208
- manager.createDriver = () => driver;
1209
- await manager.handle(ws, {
1210
- action: 'session.send',
1211
- id: 'req1',
1212
- sessionId: 'rec-dup',
1213
- message: 'hello from reconnect',
1214
- clientMessageId: 'cm_saved',
1215
- });
1216
- assert.deepEqual(driver.calls.sendPrompt, []);
1217
- const reply = parseReply(ws);
1218
- assert.equal(reply.ok, true);
1219
- assert.equal(reply.data.duplicate, true);
1220
- });
1221
- test('sendMessage: dedupes replayed clientMessageId values', async () => {
1222
- const manager = createManager();
1223
- const ws = mockWs();
1224
- const driver = mockDriver();
1225
- const session = injectSession(manager, {
1226
- sessionId: 's1',
1227
- driver,
1228
- acceptedClientMessageIds: ['cm_1'],
1229
- });
1230
- await manager.handle(ws, {
1231
- action: 'session.send',
1232
- id: 'req1',
1233
- sessionId: 's1',
1234
- message: 'hello',
1235
- clientMessageId: 'cm_1',
1236
- });
1237
- assert.deepEqual(driver.calls.sendPrompt, []);
1238
- assert.equal(session.acceptedClientMessageIds.length, 1);
1239
- const reply = parseReply(ws);
1240
- assert.equal(reply.ok, true);
1241
- assert.equal(reply.data.clientMessageId, 'cm_1');
1242
- assert.equal(reply.data.duplicate, true);
1243
- });
1244
- // ── touchSession ─────────────────────────────────────────────────────
1245
- test('touchSession: updates lastActivityAt on active session', () => {
1246
- const manager = createManager();
1247
- const session = injectSession(manager, {
1248
- sessionId: 's1',
1249
- lastActivityAt: '2026-03-01T00:00:00.000Z',
1250
- });
1251
- const before = session.lastActivityAt;
1252
- manager.touchSession('s1');
1253
- assert.notEqual(session.lastActivityAt, before);
1254
- });
1255
- test('touchSession: updates lastActivityAt on session record', () => {
1256
- const manager = createManager();
1257
- manager.store.records = [
1258
- {
1259
- sessionId: 'rec-1',
1260
- agent: 'codex',
1261
- cwd: '/tmp',
1262
- title: 'T',
1263
- createdAt: '2026-03-01T00:00:00.000Z',
1264
- lastActivityAt: '2026-03-01T00:00:00.000Z',
1265
- },
1266
- ];
1267
- manager.touchSession('rec-1');
1268
- const updated = manager.store.find('rec-1');
1269
- assert.notEqual(updated.lastActivityAt, '2026-03-01T00:00:00.000Z');
1270
- });
1271
- // ── persistRecord / removeRecord ─────────────────────────────────────
1272
- test('store.upsert: adds new record to front of store', () => {
1273
- const manager = createManager();
1274
- const store = manager.store;
1275
- store.upsert({
1276
- sessionId: 's-new',
1277
- agent: 'claude',
1278
- cwd: '/project',
1279
- title: 'New Session',
1280
- createdAt: '2026-03-20T00:00:00.000Z',
1281
- lastActivityAt: '2026-03-20T00:00:00.000Z',
1282
- });
1283
- const records = store.getAll();
1284
- assert.equal(records.length, 1);
1285
- assert.equal(records[0].sessionId, 's-new');
1286
- assert.equal(records[0].agent, 'claude');
1287
- });
1288
- test('store.upsert: updates existing record in place', () => {
1289
- const manager = createManager();
1290
- const store = manager.store;
1291
- store.records = [
1292
- { sessionId: 's1', agent: 'codex', cwd: '/old', title: 'Old', createdAt: '', lastActivityAt: '' },
1293
- ];
1294
- store.upsert({
1295
- sessionId: 's1',
1296
- agent: 'codex',
1297
- cwd: '/new',
1298
- title: 'Updated',
1299
- createdAt: '',
1300
- lastActivityAt: '',
1301
- });
1302
- const records = store.getAll();
1303
- assert.equal(records.length, 1);
1304
- assert.equal(records[0].cwd, '/new');
1305
- assert.equal(records[0].title, 'Updated');
1306
- });
1307
- test('store.remove: removes matching record', () => {
1308
- const manager = createManager();
1309
- const store = manager.store;
1310
- store.records = [
1311
- { sessionId: 's1', agent: 'codex', cwd: '/tmp', title: 'T1', createdAt: '', lastActivityAt: '' },
1312
- { sessionId: 's2', agent: 'codex', cwd: '/tmp', title: 'T2', createdAt: '', lastActivityAt: '' },
1313
- ];
1314
- store.remove('s1');
1315
- const records = store.getAll();
1316
- assert.equal(records.length, 1);
1317
- assert.equal(records[0].sessionId, 's2');
1318
- });
1319
- // ── createSession ────────────────────────────────────────────────────
1320
- test('createSession: codex returns a pending session immediately', async () => {
1321
- const manager = createManager();
1322
- const ws = mockWs();
1323
- const deferred = createDeferred();
1324
- const driver = mockDriver();
1325
- driver.start = async (cwd, resumeSessionId, approvalMode) => {
1326
- driver.calls.start.push({ cwd, resumeSessionId, approvalMode });
1327
- return deferred.promise;
1328
- };
1329
- manager.createDriver = () => driver;
1330
- await manager.handle(ws, {
1331
- action: 'session.create',
1332
- id: 'req1',
1333
- agent: 'codex',
1334
- cwd: '/tmp',
1335
- });
1336
- const reply = parseReply(ws);
1337
- assert.equal(reply.ok, true);
1338
- assert.match(reply.data.sessionId, /^pending_/);
1339
- const session = manager.sessions.get(reply.data.sessionId);
1340
- assert.ok(session, 'session should be stored');
1341
- assert.equal(session.startupInProgress, true);
1342
- assert.deepEqual(driver.calls.start[0], { cwd: '/tmp', resumeSessionId: undefined, approvalMode: undefined });
1343
- });
1344
- test('createSession: stores approval mode', async () => {
1345
- const manager = createManager();
1346
- const ws = mockWs();
1347
- const driver = mockDriver();
1348
- manager.createDriver = () => driver;
1349
- await manager.handle(ws, {
1350
- action: 'session.create',
1351
- id: 'req1',
1352
- agent: 'claude',
1353
- cwd: '/tmp',
1354
- approvalMode: 'autoApprove',
1355
- });
1356
- const reply = parseReply(ws);
1357
- const session = manager.sessions.get(reply.data.sessionId);
1358
- assert.equal(session.approvalMode, 'autoApprove');
1359
- });
1360
- test('createSession: codex startup failure is reported inside the pending session', async () => {
1361
- const manager = createManager();
1362
- const ws = mockWs();
1363
- const driver = mockDriver();
1364
- driver.start = async () => { throw new Error('spawn failed'); };
1365
- manager.createDriver = () => driver;
1366
- await manager.handle(ws, {
1367
- action: 'session.create',
1368
- id: 'req1',
1369
- agent: 'codex',
1370
- cwd: '/tmp',
1371
- });
1372
- const reply = parseReply(ws);
1373
- assert.equal(reply.ok, true);
1374
- assert.match(reply.data.sessionId, /^pending_/);
1375
- await flushAsyncWork();
1376
- assert.equal(ws.sent.length, 2);
1377
- const errorMsg = JSON.parse(ws.sent[1]);
1378
- assert.equal(errorMsg.type, 'error');
1379
- assert.equal(errorMsg.sessionId, reply.data.sessionId);
1380
- assert.match(errorMsg.message, /spawn failed/);
1381
- const session = manager.sessions.get(reply.data.sessionId);
1382
- assert.ok(session, 'pending session should remain addressable');
1383
- assert.equal(session.active, false);
1384
- assert.equal(session.driver, null);
1385
- assert.equal(session.startupInProgress, false);
1386
- });
1387
- test('createSession: adds ws to session clients', async () => {
1388
- const manager = createManager();
1389
- const ws = mockWs();
1390
- const deferred = createDeferred();
1391
- const driver = mockDriver();
1392
- driver.start = async (cwd, resumeSessionId, approvalMode) => {
1393
- driver.calls.start.push({ cwd, resumeSessionId, approvalMode });
1394
- return deferred.promise;
1395
- };
1396
- manager.createDriver = () => driver;
1397
- await manager.handle(ws, {
1398
- action: 'session.create',
1399
- id: 'req1',
1400
- agent: 'codex',
1401
- cwd: '/tmp',
1402
- });
1403
- const reply = parseReply(ws);
1404
- const session = manager.sessions.get(reply.data.sessionId);
1405
- assert.equal(session.clients.has(ws), true);
1406
- });
1407
- test('createSession: codex buffers the first prompt until startup completes, then remaps the session id', async () => {
1408
- const manager = createManager();
1409
- const ws = mockWs();
1410
- const deferred = createDeferred();
1411
- const driver = mockDriver();
1412
- driver.start = async (cwd, resumeSessionId, approvalMode) => {
1413
- driver.calls.start.push({ cwd, resumeSessionId, approvalMode });
1414
- return deferred.promise;
1415
- };
1416
- manager.createDriver = () => driver;
1417
- await manager.handle(ws, {
1418
- action: 'session.create',
1419
- id: 'req-create',
1420
- agent: 'codex',
1421
- cwd: '/tmp',
1422
- });
1423
- const createReply = parseReply(ws);
1424
- const pendingSessionId = createReply.data.sessionId;
1425
- await manager.handle(ws, {
1426
- action: 'session.send',
1427
- id: 'req-send',
1428
- sessionId: pendingSessionId,
1429
- message: 'hello buffered codex',
1430
- clientMessageId: 'cm-buffered',
1431
- });
1432
- const sendReply = parseReply(ws, 1);
1433
- assert.equal(sendReply.ok, true);
1434
- assert.equal(sendReply.data.sessionId, pendingSessionId);
1435
- assert.deepEqual(driver.calls.sendPrompt, []);
1436
- const pendingSession = manager.sessions.get(pendingSessionId);
1437
- assert.deepEqual(pendingSession.bufferedPrompts, ['hello buffered codex']);
1438
- deferred.resolve('codex-thread-1');
1439
- await flushAsyncWork();
1440
- assert.equal(manager.sessions.has(pendingSessionId), false);
1441
- const liveSession = manager.sessions.get('codex-thread-1');
1442
- assert.ok(liveSession, 'resolved session should be stored under the real thread id');
1443
- assert.equal(liveSession.startupInProgress, false);
1444
- assert.deepEqual(driver.calls.sendPrompt, ['hello buffered codex']);
1445
- const idUpdate = JSON.parse(ws.sent[2]);
1446
- assert.equal(idUpdate.type, 'response');
1447
- assert.equal(idUpdate.ok, true);
1448
- assert.deepEqual(idUpdate.data, {
1449
- sessionId: 'codex-thread-1',
1450
- oldSessionId: pendingSessionId,
1451
- });
1452
- });
1453
- test('interrupt: clears buffered prompt while codex startup is still pending', async () => {
1454
- const manager = createManager();
1455
- const ws = mockWs();
1456
- const deferred = createDeferred();
1457
- const driver = mockDriver();
1458
- driver.start = async (cwd, resumeSessionId, approvalMode) => {
1459
- driver.calls.start.push({ cwd, resumeSessionId, approvalMode });
1460
- return deferred.promise;
1461
- };
1462
- manager.createDriver = () => driver;
1463
- await manager.handle(ws, {
1464
- action: 'session.create',
1465
- id: 'req-create',
1466
- agent: 'codex',
1467
- cwd: '/tmp',
1468
- });
1469
- const createReply = parseReply(ws);
1470
- const pendingSessionId = createReply.data.sessionId;
1471
- await manager.handle(ws, {
1472
- action: 'session.send',
1473
- id: 'req-send',
1474
- sessionId: pendingSessionId,
1475
- message: 'stop me before startup finishes',
1476
- clientMessageId: 'cm-stop',
1477
- });
1478
- await manager.handle(ws, {
1479
- action: 'session.interrupt',
1480
- id: 'req-interrupt',
1481
- sessionId: pendingSessionId,
1482
- });
1483
- const pendingSession = manager.sessions.get(pendingSessionId);
1484
- assert.ok(pendingSession, 'pending session should still exist before startup resolves');
1485
- assert.deepEqual(pendingSession.bufferedPrompts, []);
1486
- assert.equal(pendingSession.isResponding, false);
1487
- assert.equal(pendingSession.currentReplyText, '');
1488
- assert.equal(pendingSession.lastUserMessage, undefined);
1489
- assert.deepEqual(driver.calls.interrupt, []);
1490
- const interruptReply = parseReply(ws, 2);
1491
- assert.equal(interruptReply.ok, true);
1492
- const interruptedEvent = parseReply(ws, 3);
1493
- assert.deepEqual(interruptedEvent, {
1494
- type: 'session.interrupted',
1495
- sessionId: pendingSessionId,
1496
- });
1497
- deferred.resolve('codex-thread-2');
1498
- await flushAsyncWork();
1499
- assert.deepEqual(driver.calls.sendPrompt, []);
1500
- assert.equal(manager.sessions.has(pendingSessionId), false);
1501
- const liveSession = manager.sessions.get('codex-thread-2');
1502
- assert.ok(liveSession, 'resolved session should still remap to the real thread id');
1503
- const idUpdate = parseReply(ws, 4);
1504
- assert.equal(idUpdate.type, 'response');
1505
- assert.equal(idUpdate.ok, true);
1506
- assert.deepEqual(idUpdate.data, {
1507
- sessionId: 'codex-thread-2',
1508
- oldSessionId: pendingSessionId,
1509
- });
1510
- });
1511
- test('createSession: continueSession=true creates new session when no previous sessions exist', async () => {
1512
- const manager = createManager();
1513
- const ws = mockWs();
1514
- const driver = mockDriver();
1515
- manager.createDriver = () => driver;
1516
- await manager.handle(ws, {
1517
- action: 'session.create',
1518
- id: 'req1',
1519
- agent: 'codex',
1520
- cwd: '/tmp',
1521
- continueSession: true,
1522
- });
1523
- const reply = parseReply(ws);
1524
- assert.equal(reply.ok, true);
1525
- assert.ok(reply.data.sessionId);
1526
- });
1527
- test('createSession: continueSession=true skips deleted recent sessions', async () => {
1528
- const manager = createManager();
1529
- const ws = mockWs();
1530
- const driver = mockDriver();
1531
- manager.createDriver = () => driver;
1532
- manager.listRecentSessionsForContinue = async () => [
1533
- {
1534
- sessionId: 'deleted-recent',
1535
- agent: 'codex',
1536
- cwd: '/tmp',
1537
- title: 'Deleted recent',
1538
- createdAt: '2026-03-20T00:00:00.000Z',
1539
- lastActivityAt: '2026-03-21T00:00:00.000Z',
1540
- sources: ['scanner'],
1541
- runtime: { state: 'idle', confidence: 'high', resumeMode: 'resumeSession' },
1542
- },
1543
- {
1544
- sessionId: 'kept-recent',
1545
- agent: 'codex',
1546
- cwd: '/tmp',
1547
- title: 'Kept recent',
1548
- createdAt: '2026-03-19T00:00:00.000Z',
1549
- lastActivityAt: '2026-03-20T00:00:00.000Z',
1550
- sources: ['scanner'],
1551
- runtime: { state: 'idle', confidence: 'high', resumeMode: 'resumeSession' },
1552
- },
1553
- ];
1554
- manager.store.remove('deleted-recent');
1555
- await manager.handle(ws, {
1556
- action: 'session.create',
1557
- id: 'req-skip-deleted',
1558
- agent: 'codex',
1559
- cwd: '/tmp',
1560
- continueSession: true,
1561
- });
1562
- assert.deepEqual(driver.calls.start[0], {
1563
- cwd: '/tmp',
1564
- resumeSessionId: 'kept-recent',
1565
- approvalMode: undefined,
1566
- });
1567
- });
1568
- test('createSession: expands tilde in cwd', async () => {
1569
- const manager = createManager();
1570
- const ws = mockWs();
1571
- const driver = mockDriver();
1572
- let capturedCwd = '';
1573
- driver.start = async (cwd) => { capturedCwd = cwd; return 'sess-1'; };
1574
- manager.createDriver = () => driver;
1575
- await manager.handle(ws, {
1576
- action: 'session.create',
1577
- id: 'req1',
1578
- agent: 'codex',
1579
- cwd: '~',
1580
- });
1581
- // cwd should be expanded from ~ to absolute home directory path
1582
- assert.ok(!capturedCwd.startsWith('~'));
1583
- assert.ok(capturedCwd.startsWith('/'));
1584
- });
1585
- test('createSession: returns error for nonexistent cwd', async () => {
1586
- const manager = createManager();
1587
- const ws = mockWs();
1588
- const driver = mockDriver();
1589
- manager.createDriver = () => driver;
1590
- await manager.handle(ws, {
1591
- action: 'session.create',
1592
- id: 'req1',
1593
- agent: 'claude',
1594
- cwd: '/nonexistent/path/that/does/not/exist',
1595
- });
1596
- const reply = parseReply(ws);
1597
- assert.equal(reply.ok, false);
1598
- assert.match(reply.error, /does not exist/i);
1599
- assert.equal(driver.calls.start.length, 0, 'driver should not be started');
1600
- });
1601
- test('createSession: returns error when cwd is a file, not a directory', async () => {
1602
- const manager = createManager();
1603
- const ws = mockWs();
1604
- const driver = mockDriver();
1605
- manager.createDriver = () => driver;
1606
- // Use a known file path
1607
- await manager.handle(ws, {
1608
- action: 'session.create',
1609
- id: 'req1',
1610
- agent: 'claude',
1611
- cwd: '/etc/hosts',
1612
- });
1613
- const reply = parseReply(ws);
1614
- assert.equal(reply.ok, false);
1615
- assert.match(reply.error, /not a directory/i);
1616
- assert.equal(driver.calls.start.length, 0, 'driver should not be started');
1617
- });
1618
- // ── resumeSession ────────────────────────────────────────────────────
1619
- test('resumeSession: attaches to existing active session without creating new driver', async () => {
1620
- const manager = createManager();
1621
- const ws = mockWs();
1622
- const driver = mockDriver();
1623
- const session = injectSession(manager, { sessionId: 's1', driver, active: true });
1624
- let driverCreated = false;
1625
- manager.createDriver = () => { driverCreated = true; return mockDriver(); };
1626
- await manager.handle(ws, {
1627
- action: 'session.resume',
1628
- id: 'req1',
1629
- sessionId: 's1',
1630
- agent: 'codex',
1631
- });
1632
- assert.equal(driverCreated, false);
1633
- assert.equal(session.clients.has(ws), true);
1634
- const reply = parseReply(ws);
1635
- assert.equal(reply.ok, true);
1636
- });
1637
- test('resumeSession: creates new driver for inactive session', async () => {
1638
- const manager = createManager();
1639
- const ws = mockWs();
1640
- const driver = mockDriver();
1641
- manager.createDriver = () => driver;
1642
- await manager.handle(ws, {
1643
- action: 'session.resume',
1644
- id: 'req1',
1645
- sessionId: 'new-session',
1646
- agent: 'claude',
1647
- });
1648
- const reply = parseReply(ws);
1649
- assert.equal(reply.ok, true);
1650
- assert.equal(manager.sessions.has(reply.data.sessionId), true);
1651
- });
1652
- test('resumeSession: replies error when driver.start fails', async () => {
1653
- const manager = createManager();
1654
- const ws = mockWs();
1655
- manager.createDriver = () => ({
1656
- ...mockDriver(),
1657
- start: async () => { throw new Error('resume failed'); },
1658
- });
1659
- await manager.handle(ws, {
1660
- action: 'session.resume',
1661
- id: 'req1',
1662
- sessionId: 'fail-session',
1663
- agent: 'codex',
1664
- });
1665
- const reply = parseReply(ws);
1666
- assert.equal(reply.ok, false);
1667
- assert.match(reply.error, /resume failed/);
1668
- });
1669
- // listDirs tests removed — dirs.list is now handled directly in index.ts
1670
- // ── Additional coverage tests ─────────────────────────────────────────
1671
- test('createSession: expands fullwidth tilde ~ in cwd', async () => {
1672
- const manager = createManager();
1673
- const ws = mockWs();
1674
- const driver = mockDriver();
1675
- let capturedCwd = '';
1676
- driver.start = async (cwd) => { capturedCwd = cwd; return 'sess-1'; };
1677
- manager.createDriver = () => driver;
1678
- await manager.handle(ws, {
1679
- action: 'session.create',
1680
- id: 'req1',
1681
- agent: 'codex',
1682
- cwd: '~', // fullwidth tilde
1683
- });
1684
- assert.ok(!capturedCwd.startsWith('~'));
1685
- assert.ok(!capturedCwd.startsWith('~'));
1686
- assert.ok(capturedCwd.startsWith('/'));
1687
- });
1688
- test('touchSession: updates record when session is not in active sessions map', () => {
1689
- const manager = createManager();
1690
- manager.store.records = [
1691
- {
1692
- sessionId: 'rec-only',
1693
- agent: 'codex',
1694
- cwd: '/tmp',
1695
- title: 'T',
1696
- createdAt: '2026-03-01T00:00:00.000Z',
1697
- lastActivityAt: '2026-03-01T00:00:00.000Z',
1698
- },
1699
- ];
1700
- // No active session for 'rec-only'
1701
- manager.touchSession('rec-only');
1702
- const updated = manager.store.find('rec-only');
1703
- assert.notEqual(updated.lastActivityAt, '2026-03-01T00:00:00.000Z');
1704
- });
1705
- test('touchSession: does nothing when session not found anywhere', () => {
1706
- const manager = createManager();
1707
- manager.store.records = [];
1708
- // Should not throw
1709
- manager.touchSession('nonexistent');
1710
- });
1711
- test('sendMessage: auto-reconnect uses agent from client message as last resort', async () => {
1712
- const manager = createManager();
1713
- const ws = mockWs();
1714
- const driver = mockDriver();
1715
- manager.createDriver = () => driver;
1716
- // No sessionRecords, no scanner results, but agent is provided
1717
- manager.store.records = [];
1718
- await manager.handle(ws, {
1719
- action: 'session.send',
1720
- id: 'req1',
1721
- sessionId: 'unknown-session',
1722
- message: 'hello',
1723
- agent: 'codex',
1724
- });
1725
- // Should have attempted to reconnect using the provided agent
1726
- const reply = parseReply(ws);
1727
- // It might fail at driver.start() but it should try
1728
- // mock driver.start returns 'mock-session-id', so it should succeed
1729
- assert.equal(reply.ok, true);
1730
- assert.equal(manager.sessions.has('unknown-session'), true);
1731
- });
1732
- test('resumeSession: uses cwd and approvalMode from persisted record', async () => {
1733
- const manager = createManager();
1734
- const ws = mockWs();
1735
- const driver = mockDriver();
1736
- let startCwd = '';
1737
- let startApproval = '';
1738
- driver.start = async (cwd, _id, approval) => {
1739
- startCwd = cwd;
1740
- startApproval = approval ?? '';
1741
- return 'resumed-id';
1742
- };
1743
- manager.createDriver = () => driver;
1744
- manager.store.records = [
1745
- {
1746
- sessionId: 'persisted-1',
1747
- agent: 'claude',
1748
- cwd: '/saved-cwd',
1749
- approvalMode: 'acceptEdits',
1750
- title: 'Saved',
1751
- createdAt: '2026-03-01T00:00:00.000Z',
1752
- lastActivityAt: '2026-03-20T00:00:00.000Z',
1753
- },
1754
- ];
1755
- await manager.handle(ws, {
1756
- action: 'session.resume',
1757
- id: 'req1',
1758
- sessionId: 'persisted-1',
1759
- agent: 'claude',
1760
- });
1761
- assert.equal(startCwd, '/saved-cwd');
1762
- assert.equal(startApproval, 'acceptEdits');
1763
- const reply = parseReply(ws);
1764
- assert.equal(reply.ok, true);
1765
- });
1766
- // ── setApprovalMode ──────────────────────────────────────────────────
1767
- test('setApprovalMode: updates session approvalMode and persists', async () => {
1768
- const manager = createManager();
1769
- const ws = mockWs();
1770
- const driver = mockDriver();
1771
- driver.setApprovalMode = (mode) => {
1772
- driver.calls.setApprovalMode = driver.calls.setApprovalMode || [];
1773
- driver.calls.setApprovalMode.push(mode);
1774
- };
1775
- injectSession(manager, {
1776
- sessionId: 'am-1',
1777
- agent: 'claude',
1778
- approvalMode: 'normal',
1779
- driver,
1780
- });
1781
- await manager.handle(ws, {
1782
- action: 'session.setApprovalMode',
1783
- id: 'req1',
1784
- sessionId: 'am-1',
1785
- approvalMode: 'autoApprove',
1786
- });
1787
- const reply = parseReply(ws);
1788
- assert.equal(reply.ok, true);
1789
- assert.equal(reply.data.approvalMode, 'autoApprove');
1790
- const session = manager.sessions.get('am-1');
1791
- assert.equal(session.approvalMode, 'autoApprove');
1792
- assert.deepEqual(driver.calls.setApprovalMode, ['autoApprove']);
1793
- });
1794
- test('setApprovalMode: succeeds for session not in memory and not in store (untracked)', async () => {
1795
- const manager = createManager();
1796
- const ws = mockWs();
1797
- await manager.handle(ws, {
1798
- action: 'session.setApprovalMode',
1799
- id: 'req1',
1800
- sessionId: 'nonexistent',
1801
- approvalMode: 'autoApprove',
1802
- });
1803
- const reply = parseReply(ws);
1804
- assert.equal(reply.ok, true);
1805
- assert.equal(reply.data.approvalMode, 'autoApprove');
1806
- });
1807
- test('setApprovalMode: updates store record for session not in memory but in store', async () => {
1808
- const manager = createManager();
1809
- const ws = mockWs();
1810
- const store = manager.store;
1811
- store.records.push({
1812
- sessionId: 'stored-only',
1813
- agent: 'claude',
1814
- cwd: '/tmp',
1815
- title: 'Stored session',
1816
- createdAt: '2026-03-20T00:00:00.000Z',
1817
- lastActivityAt: '2026-03-20T00:00:00.000Z',
1818
- approvalMode: 'normal',
1819
- });
1820
- await manager.handle(ws, {
1821
- action: 'session.setApprovalMode',
1822
- id: 'req1',
1823
- sessionId: 'stored-only',
1824
- approvalMode: 'autoApprove',
1825
- });
1826
- const reply = parseReply(ws);
1827
- assert.equal(reply.ok, true);
1828
- assert.equal(reply.data.approvalMode, 'autoApprove');
1829
- const record = store.records.find((r) => r.sessionId === 'stored-only');
1830
- assert.equal(record.approvalMode, 'autoApprove');
1831
- });
1832
- test('setApprovalMode: updates active codex driver without restarting it', async () => {
1833
- const manager = createManager();
1834
- const ws = mockWs();
1835
- const driver = mockDriver();
1836
- injectSession(manager, {
1837
- sessionId: 'am-codex-1',
1838
- agent: 'codex',
1839
- approvalMode: 'normal',
1840
- driver,
1841
- });
1842
- await manager.handle(ws, {
1843
- action: 'session.setApprovalMode',
1844
- id: 'req1',
1845
- sessionId: 'am-codex-1',
1846
- approvalMode: 'autoApprove',
1847
- });
1848
- const reply = parseReply(ws);
1849
- assert.equal(reply.ok, true);
1850
- assert.equal(driver.calls.stop.length, 0, 'driver should not be restarted');
1851
- assert.deepEqual(driver.calls.setApprovalMode, ['autoApprove']);
1852
- });
1853
- test('setApprovalMode: does not restart codex if mode unchanged', async () => {
1854
- const manager = createManager();
1855
- const ws = mockWs();
1856
- const driver = mockDriver();
1857
- driver.setApprovalMode = () => { };
1858
- injectSession(manager, {
1859
- sessionId: 'am-codex-2',
1860
- agent: 'codex',
1861
- approvalMode: 'autoApprove',
1862
- driver,
1863
- });
1864
- await manager.handle(ws, {
1865
- action: 'session.setApprovalMode',
1866
- id: 'req1',
1867
- sessionId: 'am-codex-2',
1868
- approvalMode: 'autoApprove',
1869
- });
1870
- assert.equal(driver.calls.stop.length, 0, 'driver should not be restarted');
1871
- });
1872
- // ── pendingApproval resend ───────────────────────────────────────────
1873
- test('pending approval is tracked and resent on resume attach', async () => {
1874
- const manager = createManager();
1875
- const ws1 = mockWs();
1876
- const driver = mockDriver();
1877
- const session = injectSession(manager, {
1878
- sessionId: 'pa-1',
1879
- agent: 'claude',
1880
- driver,
1881
- clients: new Set([ws1]),
1882
- isResponding: true,
1883
- });
1884
- // Simulate driver emitting an approval.request
1885
- manager.bindDriverLifecycle(session, 'claude', '');
1886
- driver._emitMessage({
1887
- type: 'approval.request',
1888
- sessionId: 'pa-1',
1889
- requestId: 'req-abc',
1890
- toolName: 'Bash',
1891
- input: { command: 'rm -rf /' },
1892
- description: 'Run dangerous command',
1893
- });
1894
- // Verify pendingApproval is tracked
1895
- assert.ok(session.pendingApproval);
1896
- assert.equal(session.pendingApproval.requestId, 'req-abc');
1897
- assert.equal(session.isResponding, false, 'approval.request should clear isResponding');
1898
- // New client reconnects via resumeSession (attach to active)
1899
- const ws2 = mockWs();
1900
- await manager.handle(ws2, {
1901
- action: 'session.resume',
1902
- id: 'resume-1',
1903
- sessionId: 'pa-1',
1904
- agent: 'claude',
1905
- });
1906
- // ws2 should receive the pending approval inside session.history
1907
- const historyMsg = ws2.sent
1908
- .map((s) => JSON.parse(s))
1909
- .find((m) => m.type === 'session.history');
1910
- assert.ok(historyMsg, 'reconnected client should receive session.history');
1911
- assert.equal(historyMsg.pendingApproval?.requestId, 'req-abc');
1912
- assert.equal(historyMsg.pendingApproval?.toolName, 'Bash');
1913
- });
1914
- test('autoApprove mode immediately resolves follow-up codex approvals without surfacing another sheet', async () => {
1915
- const manager = createManager();
1916
- const ws = mockWs();
1917
- const driver = mockDriver();
1918
- const session = injectSession(manager, {
1919
- sessionId: 'codex-auto-approval',
1920
- agent: 'codex',
1921
- driver,
1922
- approvalMode: 'autoApprove',
1923
- clients: new Set([ws]),
1924
- isResponding: true,
1925
- });
1926
- manager.bindDriverLifecycle(session, 'codex', '');
1927
- driver._emitMessage({
1928
- type: 'approval.request',
1929
- sessionId: 'codex-auto-approval',
1930
- requestId: 'req-auto',
1931
- toolName: 'Bash',
1932
- input: { command: 'git status' },
1933
- description: 'Run git status',
1934
- });
1935
- assert.deepEqual(driver.calls.respondApproval, [{ requestId: 'req-auto', approved: true }]);
1936
- assert.equal(session.pendingApproval, undefined);
1937
- assert.equal(session.isResponding, true);
1938
- assert.equal(ws.sent.length, 0, 'approval request should not be broadcast when the session is already autoApprove');
1939
- });
1940
- test('approval.request sends a push notification with session routing data', async () => {
1941
- const pushCalls = [];
1942
- const manager = createManager((title, body, data) => {
1943
- pushCalls.push({ title, body, data });
1944
- });
1945
- const ws = mockWs();
1946
- const driver = mockDriver();
1947
- const session = injectSession(manager, {
1948
- sessionId: 'push-approval',
1949
- agent: 'codex',
1950
- title: 'Deploy context',
1951
- driver,
1952
- clients: new Set([ws]),
1953
- });
1954
- manager.bindDriverLifecycle(session, 'codex', '');
1955
- driver._emitMessage({
1956
- type: 'approval.request',
1957
- sessionId: 'push-approval',
1958
- requestId: 'req-push',
1959
- toolName: 'Bash',
1960
- input: { command: 'ps -Ao pid,ppid,etime,command' },
1961
- description: 'Inspect the build process',
1962
- });
1963
- assert.deepEqual(pushCalls, [{
1964
- title: 'Approval required',
1965
- body: 'Deploy context: Inspect the build process',
1966
- data: {
1967
- sessionId: 'push-approval',
1968
- agent: 'codex',
1969
- eventType: 'approval_request',
1970
- requestId: 'req-push',
1971
- },
1972
- }]);
1973
- });
1974
- test('pending approval is cleared after user responds', async () => {
1975
- const manager = createManager();
1976
- const ws = mockWs();
1977
- const driver = mockDriver();
1978
- const session = injectSession(manager, {
1979
- sessionId: 'pa-2',
1980
- agent: 'codex',
1981
- driver,
1982
- clients: new Set([ws]),
1983
- });
1984
- manager.bindDriverLifecycle(session, 'codex', '');
1985
- driver._emitMessage({
1986
- type: 'approval.request',
1987
- sessionId: 'pa-2',
1988
- requestId: 'req-xyz',
1989
- toolName: 'Write',
1990
- input: {},
1991
- description: 'Write file',
1992
- });
1993
- assert.ok(session.pendingApproval);
1994
- await manager.handle(ws, {
1995
- action: 'session.approve',
1996
- id: 'approve-1',
1997
- sessionId: 'pa-2',
1998
- requestId: 'req-xyz',
1999
- approved: true,
2000
- });
2001
- assert.equal(session.pendingApproval, undefined, 'pendingApproval should be cleared after response');
2002
- });
2003
- test('session.approve: revives an inactive codex session before sending the approval response', async () => {
2004
- const manager = createManager();
2005
- const ws = mockWs();
2006
- const revivedDriver = mockDriver();
2007
- injectSession(manager, {
2008
- sessionId: 'pa-revive',
2009
- agent: 'codex',
2010
- driver: null,
2011
- active: false,
2012
- pendingApproval: {
2013
- requestId: 'req-revive',
2014
- toolName: 'Bash',
2015
- input: { command: 'ps -Ao pid,ppid,etime,command' },
2016
- description: 'Inspect process list',
2017
- },
2018
- });
2019
- manager.createDriver = () => revivedDriver;
2020
- await manager.handle(ws, {
2021
- action: 'session.approve',
2022
- id: 'approve-revive',
2023
- sessionId: 'pa-revive',
2024
- requestId: 'req-revive',
2025
- approved: true,
2026
- });
2027
- const reply = parseReply(ws);
2028
- const session = manager.sessions.get('pa-revive');
2029
- assert.equal(reply.ok, true);
2030
- assert.equal(revivedDriver.calls.start.length, 1);
2031
- assert.deepEqual(revivedDriver.calls.respondApproval, [{ requestId: 'req-revive', approved: true }]);
2032
- assert.equal(session.pendingApproval, undefined);
2033
- assert.equal(session.driver, revivedDriver);
2034
- });
2035
- test('pending approval is cleared on session.done', async () => {
2036
- const manager = createManager();
2037
- const ws = mockWs();
2038
- const driver = mockDriver();
2039
- const session = injectSession(manager, {
2040
- sessionId: 'pa-3',
2041
- agent: 'claude',
2042
- driver,
2043
- clients: new Set([ws]),
2044
- });
2045
- manager.bindDriverLifecycle(session, 'claude', '');
2046
- driver._emitMessage({
2047
- type: 'approval.request',
2048
- sessionId: 'pa-3',
2049
- requestId: 'req-done',
2050
- toolName: 'Edit',
2051
- input: {},
2052
- description: 'Edit file',
2053
- });
2054
- assert.ok(session.pendingApproval);
2055
- driver._emitMessage({
2056
- type: 'session.done',
2057
- sessionId: 'pa-3',
2058
- });
2059
- assert.equal(session.pendingApproval, undefined, 'pendingApproval should be cleared on session.done');
2060
- });
2061
- test('synthetic pending approval survives session.done', async () => {
2062
- const manager = createManager();
2063
- const ws = mockWs();
2064
- const driver = mockDriver();
2065
- const session = injectSession(manager, {
2066
- sessionId: 'pa-3-synth',
2067
- agent: 'claude',
2068
- driver,
2069
- clients: new Set([ws]),
2070
- });
2071
- manager.bindDriverLifecycle(session, 'claude', '');
2072
- driver._emitMessage({
2073
- type: 'approval.request',
2074
- sessionId: 'pa-3-synth',
2075
- requestId: 'claude-permission-denial:tu-1',
2076
- toolName: 'Edit',
2077
- input: {},
2078
- description: 'Edit file',
2079
- });
2080
- assert.ok(session.pendingApproval);
2081
- driver._emitMessage({
2082
- type: 'session.done',
2083
- sessionId: 'pa-3-synth',
2084
- });
2085
- assert.ok(session.pendingApproval, 'synthetic pendingApproval should survive session.done');
2086
- });
2087
- test('pending approval is cleared on driver exit', async () => {
2088
- const manager = createManager();
2089
- const ws = mockWs();
2090
- const driver = mockDriver();
2091
- const session = injectSession(manager, {
2092
- sessionId: 'pa-4',
2093
- agent: 'claude',
2094
- driver,
2095
- clients: new Set([ws]),
2096
- });
2097
- manager.bindDriverLifecycle(session, 'claude', '');
2098
- driver._emitMessage({
2099
- type: 'approval.request',
2100
- sessionId: 'pa-4',
2101
- requestId: 'req-exit',
2102
- toolName: 'Bash',
2103
- input: {},
2104
- description: 'Run command',
2105
- });
2106
- assert.ok(session.pendingApproval);
2107
- driver._emitExit(0);
2108
- assert.equal(session.pendingApproval, undefined, 'pendingApproval should be cleared on driver exit');
2109
- });
2110
- test('synthetic pending approval survives a clean driver exit', async () => {
2111
- const manager = createManager();
2112
- const ws = mockWs();
2113
- const driver = mockDriver();
2114
- const session = injectSession(manager, {
2115
- sessionId: 'pa-4-synth',
2116
- agent: 'claude',
2117
- driver,
2118
- clients: new Set([ws]),
2119
- });
2120
- manager.bindDriverLifecycle(session, 'claude', '');
2121
- driver._emitMessage({
2122
- type: 'approval.request',
2123
- sessionId: 'pa-4-synth',
2124
- requestId: 'claude-permission-denial:tu-2',
2125
- toolName: 'Bash',
2126
- input: {},
2127
- description: 'Run command',
2128
- });
2129
- assert.ok(session.pendingApproval);
2130
- driver._emitExit(0);
2131
- assert.ok(session.pendingApproval, 'synthetic pendingApproval should survive a clean driver exit');
2132
- });
2133
- // ── auto-reconnect approvalMode recovery ─────────────────────────────
2134
- test('auto-reconnect recovers approvalMode from existing in-memory session', async () => {
2135
- const manager = createManager();
2136
- const ws = mockWs();
2137
- let startApproval = '';
2138
- const driver = mockDriver();
2139
- driver.start = async (_cwd, _id, approval) => {
2140
- startApproval = approval ?? '';
2141
- return 'reconnect-1';
2142
- };
2143
- // Create a session that's inactive (driver null) but still in memory
2144
- injectSession(manager, {
2145
- sessionId: 'reconnect-1',
2146
- agent: 'claude',
2147
- approvalMode: 'autoApprove',
2148
- driver: null,
2149
- active: false,
2150
- });
2151
- manager.createDriver = () => driver;
2152
- await manager.handle(ws, {
2153
- action: 'session.send',
2154
- id: 'req1',
2155
- sessionId: 'reconnect-1',
2156
- message: 'hello',
2157
- agent: 'claude',
2158
- });
2159
- assert.equal(startApproval, 'autoApprove', 'auto-reconnect should recover approvalMode from in-memory session');
2160
- });
2161
- // ── additional edge cases ────────────────────────────────────────────
2162
- test('setApprovalMode: works on session with no active driver', async () => {
2163
- const manager = createManager();
2164
- const ws = mockWs();
2165
- injectSession(manager, {
2166
- sessionId: 'am-nodriver',
2167
- agent: 'claude',
2168
- approvalMode: 'normal',
2169
- driver: null,
2170
- active: false,
2171
- });
2172
- await manager.handle(ws, {
2173
- action: 'session.setApprovalMode',
2174
- id: 'req1',
2175
- sessionId: 'am-nodriver',
2176
- approvalMode: 'autoApprove',
2177
- });
2178
- const reply = parseReply(ws);
2179
- assert.equal(reply.ok, true);
2180
- const session = manager.sessions.get('am-nodriver');
2181
- assert.equal(session.approvalMode, 'autoApprove');
2182
- });
2183
- test('setApprovalMode: persists to session store', async () => {
2184
- const manager = createManager();
2185
- const ws = mockWs();
2186
- injectSession(manager, {
2187
- sessionId: 'am-persist',
2188
- agent: 'claude',
2189
- approvalMode: 'normal',
2190
- driver: null,
2191
- active: false,
2192
- });
2193
- await manager.handle(ws, {
2194
- action: 'session.setApprovalMode',
2195
- id: 'req1',
2196
- sessionId: 'am-persist',
2197
- approvalMode: 'acceptEdits',
2198
- });
2199
- const record = manager.store.find('am-persist');
2200
- assert.ok(record, 'store should have the session record');
2201
- assert.equal(record.approvalMode, 'acceptEdits', 'store record should reflect updated mode');
2202
- });
2203
- test('pending approval resent via sendHistory', async () => {
2204
- const manager = createManager();
2205
- const ws = mockWs();
2206
- const driver = mockDriver();
2207
- const session = injectSession(manager, {
2208
- sessionId: 'pa-history',
2209
- agent: 'claude',
2210
- driver,
2211
- clients: new Set([ws]),
2212
- pendingApproval: {
2213
- requestId: 'req-hist',
2214
- toolName: 'Grep',
2215
- input: { pattern: 'foo' },
2216
- description: 'Search for foo',
2217
- },
2218
- });
2219
- const ws2 = mockWs();
2220
- await manager.handle(ws2, {
2221
- action: 'session.history',
2222
- id: 'hist-1',
2223
- sessionId: 'pa-history',
2224
- agent: 'claude',
2225
- });
2226
- const historyMsg = ws2.sent
2227
- .map((s) => JSON.parse(s))
2228
- .find((m) => m.type === 'session.history');
2229
- assert.ok(historyMsg, 'sendHistory should return session.history');
2230
- assert.equal(historyMsg.pendingApproval?.requestId, 'req-hist');
2231
- assert.equal(historyMsg.pendingApproval?.toolName, 'Grep');
2232
- });
2233
- test('sendHistory skips closed WebSocket clients', async () => {
2234
- const manager = createManager();
2235
- const ws = mockWs();
2236
- const driver = mockDriver();
2237
- injectSession(manager, {
2238
- sessionId: 'pa-closed',
2239
- agent: 'claude',
2240
- driver,
2241
- clients: new Set([ws]),
2242
- pendingApproval: {
2243
- requestId: 'req-closed',
2244
- toolName: 'Bash',
2245
- input: {},
2246
- description: 'Run command',
2247
- },
2248
- });
2249
- const closedWs = mockWs(false);
2250
- await manager.handle(closedWs, {
2251
- action: 'session.history',
2252
- id: 'hist-closed',
2253
- sessionId: 'pa-closed',
2254
- agent: 'claude',
2255
- });
2256
- assert.equal(closedWs.sent.length, 0, 'should not send to closed WebSocket');
2257
- });
2258
- test('later approval.request overwrites previous pendingApproval', async () => {
2259
- const manager = createManager();
2260
- const ws = mockWs();
2261
- const driver = mockDriver();
2262
- const session = injectSession(manager, {
2263
- sessionId: 'pa-overwrite',
2264
- agent: 'claude',
2265
- driver,
2266
- clients: new Set([ws]),
2267
- });
2268
- manager.bindDriverLifecycle(session, 'claude', '');
2269
- driver._emitMessage({
2270
- type: 'approval.request',
2271
- sessionId: 'pa-overwrite',
2272
- requestId: 'req-first',
2273
- toolName: 'Bash',
2274
- input: {},
2275
- description: 'First request',
2276
- });
2277
- assert.equal(session.pendingApproval.requestId, 'req-first');
2278
- driver._emitMessage({
2279
- type: 'approval.request',
2280
- sessionId: 'pa-overwrite',
2281
- requestId: 'req-second',
2282
- toolName: 'Write',
2283
- input: {},
2284
- description: 'Second request',
2285
- });
2286
- assert.equal(session.pendingApproval.requestId, 'req-second', 'should track only the latest approval');
2287
- });
2288
- test('pending approval cleared on session.interrupted', async () => {
2289
- const manager = createManager();
2290
- const ws = mockWs();
2291
- const driver = mockDriver();
2292
- const session = injectSession(manager, {
2293
- sessionId: 'pa-interrupt',
2294
- agent: 'claude',
2295
- driver,
2296
- clients: new Set([ws]),
2297
- });
2298
- manager.bindDriverLifecycle(session, 'claude', '');
2299
- driver._emitMessage({
2300
- type: 'approval.request',
2301
- sessionId: 'pa-interrupt',
2302
- requestId: 'req-int',
2303
- toolName: 'Bash',
2304
- input: {},
2305
- description: 'Run command',
2306
- });
2307
- assert.ok(session.pendingApproval);
2308
- driver._emitMessage({
2309
- type: 'session.interrupted',
2310
- sessionId: 'pa-interrupt',
2311
- });
2312
- assert.equal(session.pendingApproval, undefined, 'pendingApproval should be cleared on session.interrupted');
2313
- });
2314
- test('setApprovalMode: codex mode change keeps the active session intact', async () => {
2315
- const manager = createManager();
2316
- const ws = mockWs();
2317
- const driver = mockDriver();
2318
- injectSession(manager, {
2319
- sessionId: 'am-fail',
2320
- agent: 'codex',
2321
- approvalMode: 'normal',
2322
- driver,
2323
- });
2324
- manager.createDriver = () => {
2325
- throw new Error('createDriver should not be called when switching codex approval mode');
2326
- };
2327
- await manager.handle(ws, {
2328
- action: 'session.setApprovalMode',
2329
- id: 'req1',
2330
- sessionId: 'am-fail',
2331
- approvalMode: 'autoApprove',
2332
- });
2333
- const reply = parseReply(ws);
2334
- assert.equal(reply.ok, true);
2335
- const session = manager.sessions.get('am-fail');
2336
- assert.equal(session.active, true);
2337
- assert.equal(session.driver, driver);
2338
- });
2339
- // ── createSession via handle() ───────────────────────────────────────
2340
- test('createSession: full flow via handle() creates session and replies', async () => {
2341
- const manager = createManager();
2342
- const ws = mockWs();
2343
- const driver = mockDriver();
2344
- manager.createDriver = () => driver;
2345
- await manager.handle(ws, {
2346
- action: 'session.create',
2347
- id: 'req1',
2348
- agent: 'claude',
2349
- cwd: '/tmp',
2350
- });
2351
- const reply = parseReply(ws);
2352
- assert.equal(reply.ok, true);
2353
- assert.equal(reply.data.sessionId, 'mock-session-id');
2354
- const session = manager.sessions.get('mock-session-id');
2355
- assert.ok(session, 'session should be stored');
2356
- assert.equal(session.agent, 'claude');
2357
- assert.equal(session.cwd, '/tmp');
2358
- assert.equal(session.active, true);
2359
- });
2360
- test('createSession: passes approvalMode to driver.start', async () => {
2361
- const manager = createManager();
2362
- const ws = mockWs();
2363
- const driver = mockDriver();
2364
- manager.createDriver = () => driver;
2365
- await manager.handle(ws, {
2366
- action: 'session.create',
2367
- id: 'req1',
2368
- agent: 'claude',
2369
- cwd: '/tmp',
2370
- approvalMode: 'autoApprove',
2371
- });
2372
- assert.deepEqual(driver.calls.start[0], { cwd: '/tmp', resumeSessionId: undefined, approvalMode: 'autoApprove' });
2373
- });
2374
- test('createSession: driver.start failure returns error reply', async () => {
2375
- const manager = createManager();
2376
- const ws = mockWs();
2377
- const driver = mockDriver();
2378
- driver.start = async () => { throw new Error('spawn failed'); };
2379
- manager.createDriver = () => driver;
2380
- await manager.handle(ws, {
2381
- action: 'session.create',
2382
- id: 'req1',
2383
- agent: 'claude',
2384
- cwd: '/tmp',
2385
- });
2386
- const reply = parseReply(ws);
2387
- assert.equal(reply.ok, false);
2388
- assert.match(reply.error, /spawn failed/);
2389
- });
2390
- // ── sendMessage via handle() ─────────────────────────────────────────
2391
- test('sendMessage: sends prompt to active driver', async () => {
2392
- const manager = createManager();
2393
- const ws = mockWs();
2394
- const driver = mockDriver();
2395
- injectSession(manager, {
2396
- sessionId: 'sm-1',
2397
- agent: 'claude',
2398
- driver,
2399
- clients: new Set([ws]),
2400
- });
2401
- await manager.handle(ws, {
2402
- action: 'session.send',
2403
- id: 'req1',
2404
- sessionId: 'sm-1',
2405
- message: 'hello world',
2406
- });
2407
- const reply = parseReply(ws);
2408
- assert.equal(reply.ok, true);
2409
- assert.deepEqual(driver.calls.sendPrompt, ['hello world']);
2410
- });
2411
- test('sendMessage: sets isResponding and lastUserMessage', async () => {
2412
- const manager = createManager();
2413
- const ws = mockWs();
2414
- const driver = mockDriver();
2415
- injectSession(manager, {
2416
- sessionId: 'sm-2',
2417
- agent: 'claude',
2418
- driver,
2419
- clients: new Set([ws]),
2420
- });
2421
- await manager.handle(ws, {
2422
- action: 'session.send',
2423
- id: 'req1',
2424
- sessionId: 'sm-2',
2425
- message: 'test prompt',
2426
- });
2427
- const session = manager.sessions.get('sm-2');
2428
- assert.equal(session.isResponding, true);
2429
- assert.equal(session.lastUserMessage, 'test prompt');
2430
- });
2431
- test('sendMessage: deduplicates by clientMessageId', async () => {
2432
- const manager = createManager();
2433
- const ws = mockWs();
2434
- const driver = mockDriver();
2435
- injectSession(manager, {
2436
- sessionId: 'sm-dedup',
2437
- agent: 'claude',
2438
- driver,
2439
- clients: new Set([ws]),
2440
- });
2441
- await manager.handle(ws, {
2442
- action: 'session.send',
2443
- id: 'req1',
2444
- sessionId: 'sm-dedup',
2445
- message: 'hello',
2446
- clientMessageId: 'cmid-1',
2447
- });
2448
- await manager.handle(ws, {
2449
- action: 'session.send',
2450
- id: 'req2',
2451
- sessionId: 'sm-dedup',
2452
- message: 'hello',
2453
- clientMessageId: 'cmid-1',
2454
- });
2455
- assert.equal(driver.calls.sendPrompt.length, 1, 'duplicate message should not be sent');
2456
- const reply2 = parseReply(ws, 1);
2457
- assert.equal(reply2.data.duplicate, true);
2458
- });
2459
- test('sendMessage: rejects deleted sessions before reconnecting', async () => {
2460
- const manager = createManager();
2461
- const ws = mockWs();
2462
- manager.store.remove('deleted-send');
2463
- await manager.handle(ws, {
2464
- action: 'session.send',
2465
- id: 'req-deleted-send',
2466
- sessionId: 'deleted-send',
2467
- agent: 'codex',
2468
- message: 'hello',
2469
- });
2470
- const reply = parseReply(ws);
2471
- assert.equal(reply.ok, false);
2472
- assert.equal(reply.error, 'Session not found');
2473
- });
2474
- test('sessions.list: filters deleted sessions at manager layer', async () => {
2475
- const manager = createManager();
2476
- const ws = mockWs();
2477
- const cwd = '/tmp/session-list-test';
2478
- manager.store.records = [
2479
- {
2480
- sessionId: 'deleted-list',
2481
- agent: 'codex',
2482
- cwd,
2483
- title: 'Deleted list',
2484
- createdAt: '2026-03-20T00:00:00.000Z',
2485
- lastActivityAt: '2026-03-20T00:00:00.000Z',
2486
- },
2487
- {
2488
- sessionId: 'kept-list',
2489
- agent: 'codex',
2490
- cwd,
2491
- title: 'Kept list',
2492
- createdAt: '2026-03-19T00:00:00.000Z',
2493
- lastActivityAt: '2026-03-19T00:00:00.000Z',
2494
- },
2495
- ];
2496
- manager.store.deletedSessionIds.add('deleted-list');
2497
- await manager.handle(ws, {
2498
- action: 'sessions.list',
2499
- id: 'req-list',
2500
- cwd,
2501
- limit: 20,
2502
- });
2503
- const reply = parseReply(ws);
2504
- assert.equal(reply.ok, true);
2505
- const ids = reply.data.sessions.map((session) => session.sessionId);
2506
- assert.ok(ids.includes('kept-list'));
2507
- assert.ok(!ids.includes('deleted-list'));
2508
- assert.equal(reply.data.inventoryVersion, 0);
2509
- });
2510
- // ── stopSession ──────────────────────────────────────────────────────
2511
- test('stopSession: stops driver and marks session inactive', async () => {
2512
- const manager = createManager();
2513
- const ws = mockWs();
2514
- const driver = mockDriver();
2515
- injectSession(manager, {
2516
- sessionId: 'stop-1',
2517
- agent: 'codex',
2518
- driver,
2519
- });
2520
- await manager.handle(ws, {
2521
- action: 'session.stop',
2522
- id: 'req1',
2523
- sessionId: 'stop-1',
2524
- });
2525
- const reply = parseReply(ws);
2526
- assert.equal(reply.ok, true);
2527
- assert.equal(driver.calls.stop.length, 1);
2528
- const session = manager.sessions.get('stop-1');
2529
- assert.equal(session.active, false);
2530
- assert.equal(session.driver, null);
2531
- });
2532
- test('stopSession: returns error for unknown session', async () => {
2533
- const manager = createManager();
2534
- const ws = mockWs();
2535
- await manager.handle(ws, {
2536
- action: 'session.stop',
2537
- id: 'req1',
2538
- sessionId: 'nonexistent',
2539
- });
2540
- const reply = parseReply(ws);
2541
- assert.equal(reply.ok, false);
2542
- assert.match(reply.error, /not found/i);
2543
- });
2544
- // ── deleteSession ────────────────────────────────────────────────────
2545
- test('deleteSession: removes session from map and store', async () => {
2546
- const manager = createManager();
2547
- const ws = mockWs();
2548
- const driver = mockDriver();
2549
- injectSession(manager, {
2550
- sessionId: 'del-1',
2551
- agent: 'claude',
2552
- driver,
2553
- });
2554
- await manager.handle(ws, {
2555
- action: 'session.delete',
2556
- id: 'req1',
2557
- sessionId: 'del-1',
2558
- });
2559
- const reply = parseReply(ws);
2560
- assert.equal(reply.ok, true);
2561
- assert.equal(driver.calls.stop.length, 1);
2562
- assert.equal(manager.sessions.has('del-1'), false, 'session should be removed from map');
2563
- });
2564
- test('deleteSession: succeeds even for unknown session', async () => {
2565
- const manager = createManager();
2566
- const ws = mockWs();
2567
- await manager.handle(ws, {
2568
- action: 'session.delete',
2569
- id: 'req1',
2570
- sessionId: 'nonexistent',
2571
- });
2572
- const reply = parseReply(ws);
2573
- assert.equal(reply.ok, true);
2574
- });
2575
- test('resumeSession: rejects deleted sessions', async () => {
2576
- const manager = createManager();
2577
- const ws = mockWs();
2578
- manager.store.remove('deleted-resume');
2579
- await manager.handle(ws, {
2580
- action: 'session.resume',
2581
- id: 'req1',
2582
- sessionId: 'deleted-resume',
2583
- agent: 'codex',
2584
- });
2585
- const reply = parseReply(ws);
2586
- assert.equal(reply.ok, false);
2587
- assert.equal(reply.error, 'Session not found');
2588
- });
2589
- // ── interrupt ────────────────────────────────────────────────────────
2590
- test('interrupt: calls driver.interrupt()', async () => {
2591
- const manager = createManager();
2592
- const ws = mockWs();
2593
- const driver = mockDriver();
2594
- injectSession(manager, {
2595
- sessionId: 'int-1',
2596
- agent: 'claude',
2597
- driver,
2598
- });
2599
- await manager.handle(ws, {
2600
- action: 'session.interrupt',
2601
- id: 'req1',
2602
- sessionId: 'int-1',
2603
- });
2604
- const reply = parseReply(ws);
2605
- assert.equal(reply.ok, true);
2606
- assert.equal(driver.calls.interrupt.length, 1);
2607
- });
2608
- test('interrupt: returns error for session without driver', async () => {
2609
- const manager = createManager();
2610
- const ws = mockWs();
2611
- injectSession(manager, {
2612
- sessionId: 'int-2',
2613
- agent: 'claude',
2614
- driver: null,
2615
- active: false,
2616
- });
2617
- await manager.handle(ws, {
2618
- action: 'session.interrupt',
2619
- id: 'req1',
2620
- sessionId: 'int-2',
2621
- });
2622
- const reply = parseReply(ws);
2623
- assert.equal(reply.ok, false);
2624
- });
2625
- // ── sweepIdleSessions ────────────────────────────────────────────────
2626
- test('sweepIdleSessions: stops idle drivers beyond timeout', () => {
2627
- const manager = createManager();
2628
- const driver = mockDriver();
2629
- injectSession(manager, {
2630
- sessionId: 'idle-1',
2631
- agent: 'claude',
2632
- driver,
2633
- lastActivityTs: Date.now() - 60 * 60 * 1000, // 1 hour ago
2634
- isResponding: false,
2635
- });
2636
- manager.sweepIdleSessions();
2637
- assert.equal(driver.calls.stop.length, 1, 'idle driver should be stopped');
2638
- const session = manager.sessions.get('idle-1');
2639
- assert.equal(session.active, false);
2640
- assert.equal(session.driver, null);
2641
- });
2642
- test('sweepIdleSessions: does not stop sessions within timeout', () => {
2643
- const manager = createManager();
2644
- const driver = mockDriver();
2645
- injectSession(manager, {
2646
- sessionId: 'active-1',
2647
- agent: 'claude',
2648
- driver,
2649
- lastActivityTs: Date.now() - 1000, // 1 second ago
2650
- isResponding: false,
2651
- });
2652
- manager.sweepIdleSessions();
2653
- assert.equal(driver.calls.stop.length, 0, 'recent session should not be stopped');
2654
- });
2655
- test('sweepIdleSessions: stops stalled responding sessions beyond timeout', () => {
2656
- const manager = createManager();
2657
- const driver = mockDriver();
2658
- const ws = mockWs();
2659
- injectSession(manager, {
2660
- sessionId: 'responding-stalled-1',
2661
- agent: 'claude',
2662
- driver,
2663
- clients: new Set([ws]),
2664
- lastActivityTs: Date.now() - 60 * 60 * 1000, // 1 hour ago
2665
- isResponding: true,
2666
- });
2667
- manager.sweepIdleSessions();
2668
- assert.equal(driver.calls.stop.length, 1, 'stalled responding session should be stopped');
2669
- const session = manager.sessions.get('responding-stalled-1');
2670
- assert.equal(session.active, false);
2671
- assert.equal(session.driver, null);
2672
- const errorMsg = ws.sent
2673
- .map((entry) => JSON.parse(entry))
2674
- .find((msg) => msg.type === 'error');
2675
- assert.ok(errorMsg, 'stalled turn should broadcast an error');
2676
- assert.match(errorMsg.message, /stopped responding/i);
2677
- });
2678
- test('sweepIdleSessions: does not stop responding sessions waiting for approval', () => {
2679
- const manager = createManager();
2680
- const driver = mockDriver();
2681
- injectSession(manager, {
2682
- sessionId: 'responding-approval-1',
2683
- agent: 'claude',
2684
- driver,
2685
- lastActivityTs: Date.now() - 60 * 60 * 1000, // 1 hour ago
2686
- isResponding: true,
2687
- pendingApproval: {
2688
- requestId: 'req-pending',
2689
- toolName: 'Write',
2690
- input: {},
2691
- description: 'Write file',
2692
- },
2693
- });
2694
- manager.sweepIdleSessions();
2695
- assert.equal(driver.calls.stop.length, 0, 'pending approval should not be treated as a stalled turn');
2696
- });
2697
- test('sweepIdleSessions: does not stop idle sessions waiting for approval', () => {
2698
- const manager = createManager();
2699
- const driver = mockDriver();
2700
- injectSession(manager, {
2701
- sessionId: 'idle-approval-1',
2702
- agent: 'codex',
2703
- driver,
2704
- lastActivityTs: Date.now() - 60 * 60 * 1000,
2705
- isResponding: false,
2706
- pendingApproval: {
2707
- requestId: 'req-idle',
2708
- toolName: 'Bash',
2709
- input: { command: 'pwd' },
2710
- description: 'Run pwd',
2711
- approvalContext: {
2712
- provider: 'codex',
2713
- kind: 'command-execution',
2714
- rpcId: 88,
2715
- },
2716
- },
2717
- });
2718
- manager.sweepIdleSessions();
2719
- assert.equal(driver.calls.stop.length, 0, 'pending approval should bypass idle timeout');
2720
- const session = manager.sessions.get('idle-approval-1');
2721
- assert.equal(session.active, true);
2722
- });
2723
- test('sweepIdleSessions: honors disabled stall timeout', () => {
2724
- const originalTurnStallTimeoutMs = config.turnStallTimeoutMs;
2725
- config.turnStallTimeoutMs = 0;
2726
- try {
2727
- const manager = createManager();
2728
- const driver = mockDriver();
2729
- injectSession(manager, {
2730
- sessionId: 'responding-disabled-1',
2731
- agent: 'claude',
2732
- driver,
2733
- lastActivityTs: Date.now() - 60 * 60 * 1000, // 1 hour ago
2734
- isResponding: true,
2735
- });
2736
- manager.sweepIdleSessions();
2737
- assert.equal(driver.calls.stop.length, 0, 'disabled stall timeout should skip responding sessions');
2738
- }
2739
- finally {
2740
- config.turnStallTimeoutMs = originalTurnStallTimeoutMs;
2741
- }
2742
- });
2743
- test('sweepIdleSessions: skips sessions without driver', () => {
2744
- const manager = createManager();
2745
- injectSession(manager, {
2746
- sessionId: 'nodriver-1',
2747
- agent: 'claude',
2748
- driver: null,
2749
- active: false,
2750
- lastActivityTs: Date.now() - 60 * 60 * 1000,
2751
- });
2752
- // Should not throw
2753
- manager.sweepIdleSessions();
2754
- });
2755
- test('sweepIdleSessions: removes inactive sessions from map after 1 hour', () => {
2756
- const manager = createManager();
2757
- injectSession(manager, {
2758
- sessionId: 'inactive-1',
2759
- agent: 'claude',
2760
- active: false,
2761
- driver: null,
2762
- lastActivityTs: Date.now() - 2 * 60 * 60 * 1000, // 2 hours ago
2763
- clients: new Set(),
2764
- });
2765
- assert.ok(manager.sessions.has('inactive-1'), 'session should exist before sweep');
2766
- manager.sweepIdleSessions();
2767
- assert.ok(!manager.sessions.has('inactive-1'), 'inactive session should be removed from map');
2768
- });
2769
- test('sweepIdleSessions: does not remove inactive sessions with connected clients', () => {
2770
- const manager = createManager();
2771
- const ws = mockWs();
2772
- injectSession(manager, {
2773
- sessionId: 'inactive-with-client',
2774
- agent: 'claude',
2775
- active: false,
2776
- driver: null,
2777
- lastActivityTs: Date.now() - 2 * 60 * 60 * 1000, // 2 hours ago
2778
- clients: new Set([ws]),
2779
- });
2780
- manager.sweepIdleSessions();
2781
- assert.ok(manager.sessions.has('inactive-with-client'), 'inactive session with connected clients should NOT be removed');
2782
- });
2783
- test('sweepIdleSessions: stalled driver resolves pending claude hook approvals', () => {
2784
- const manager = createManager();
2785
- const driver = mockDriver();
2786
- const ws = mockWs();
2787
- const deferred = createDeferred();
2788
- const session = injectSession(manager, {
2789
- sessionId: 'stall-hooks-1',
2790
- agent: 'claude',
2791
- driver,
2792
- clients: new Set([ws]),
2793
- lastActivityTs: Date.now() - 60 * 60 * 1000,
2794
- isResponding: true,
2795
- pendingClaudeHookApprovals: new Map([
2796
- ['hook-req-1', { requestId: 'hook-req-1', promise: deferred.promise, resolve: deferred.resolve }],
2797
- ]),
2798
- });
2799
- manager.sweepIdleSessions();
2800
- assert.equal(driver.calls.stop.length, 1, 'stalled driver should be stopped');
2801
- assert.equal(session.pendingClaudeHookApprovals.size, 0, 'pending hook approvals should be resolved');
2802
- assert.equal(session.active, false);
2803
- assert.equal(session.driver, null);
2804
- assert.equal(session.isResponding, false);
2805
- });
2806
- test('sweepIdleSessions: stalled session stays in map for reconnect', () => {
2807
- const manager = createManager();
2808
- const driver = mockDriver();
2809
- injectSession(manager, {
2810
- sessionId: 'stall-reconnect-1',
2811
- agent: 'claude',
2812
- driver,
2813
- lastActivityTs: Date.now() - 60 * 60 * 1000,
2814
- isResponding: true,
2815
- });
2816
- manager.sweepIdleSessions();
2817
- assert.ok(manager.sessions.has('stall-reconnect-1'), 'stalled session should remain in map so next message can auto-reconnect');
2818
- const session = manager.sessions.get('stall-reconnect-1');
2819
- assert.equal(session.active, false);
2820
- assert.equal(session.driver, null);
2821
- });
2822
- // ── buildPushBody ────────────────────────────────────────────────────
2823
- test('buildPushBody: returns default for empty text', () => {
2824
- const manager = createManager();
2825
- assert.equal(manager.buildPushBody(''), 'Done.');
2826
- assert.equal(manager.buildPushBody(' '), 'Done.');
2827
- });
2828
- test('buildPushBody: returns short text as-is', () => {
2829
- const manager = createManager();
2830
- assert.equal(manager.buildPushBody('Hello world'), 'Hello world');
2831
- });
2832
- test('buildPushBody: collapses whitespace', () => {
2833
- const manager = createManager();
2834
- assert.equal(manager.buildPushBody('hello\n world\t!'), 'hello world !');
2835
- });
2836
- test('buildPushBody: truncates long text with ellipsis', () => {
2837
- const manager = createManager();
2838
- const longText = 'a'.repeat(300);
2839
- const result = manager.buildPushBody(longText);
2840
- assert.ok(result.endsWith('...'), 'should end with ellipsis');
2841
- assert.ok(result.length <= 183, `should be at most 183 chars, got ${result.length}`);
2842
- });
2843
- // ── getActiveSessionCount / getDriverCounts ──────────────────────────
2844
- test('getActiveSessionCount: counts only active sessions', () => {
2845
- const manager = createManager();
2846
- injectSession(manager, { sessionId: 'a1', active: true });
2847
- injectSession(manager, { sessionId: 'a2', active: true });
2848
- injectSession(manager, { sessionId: 'a3', active: false, driver: null });
2849
- assert.equal(manager.getActiveSessionCount(), 2);
2850
- });
2851
- test('getDriverCounts: groups by agent type', () => {
2852
- const manager = createManager();
2853
- injectSession(manager, { sessionId: 'c1', agent: 'claude', active: true });
2854
- injectSession(manager, { sessionId: 'c2', agent: 'claude', active: true });
2855
- injectSession(manager, { sessionId: 'x1', agent: 'codex', active: true });
2856
- injectSession(manager, { sessionId: 'x2', agent: 'codex', active: false, driver: null });
2857
- const counts = manager.getDriverCounts();
2858
- assert.equal(counts.claude, 2);
2859
- assert.equal(counts.codex, 1);
2860
- });
2861
- //# sourceMappingURL=session-manager.test.js.map