@hellcoder/companion 0.96.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 (242) hide show
  1. package/bin/cli.ts +168 -0
  2. package/bin/ctl.ts +528 -0
  3. package/bin/generate-token.ts +28 -0
  4. package/dist/apple-touch-icon.png +0 -0
  5. package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
  6. package/dist/assets/CronManager-EGwLJONv.js +1 -0
  7. package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
  8. package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
  9. package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
  10. package/dist/assets/Playground-BV3k0RbV.js +109 -0
  11. package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
  12. package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
  13. package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
  14. package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
  15. package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
  16. package/dist/assets/index-BhUa1e6X.css +1 -0
  17. package/dist/assets/index-DkqeP-R9.js +134 -0
  18. package/dist/assets/sw-register-BibwRdvC.js +1 -0
  19. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  20. package/dist/favicon.svg +8 -0
  21. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  22. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  23. package/dist/icon-192.png +0 -0
  24. package/dist/icon-512.png +0 -0
  25. package/dist/index.html +20 -0
  26. package/dist/logo-codex.svg +14 -0
  27. package/dist/logo-docker.svg +4 -0
  28. package/dist/logo.svg +14 -0
  29. package/dist/manifest.json +24 -0
  30. package/dist/sw.js +2 -0
  31. package/package.json +104 -0
  32. package/server/agent-cron-migrator.test.ts +610 -0
  33. package/server/agent-cron-migrator.ts +85 -0
  34. package/server/agent-executor.test.ts +1108 -0
  35. package/server/agent-executor.ts +346 -0
  36. package/server/agent-store.test.ts +588 -0
  37. package/server/agent-store.ts +185 -0
  38. package/server/agent-types.ts +138 -0
  39. package/server/ai-validation-settings.test.ts +128 -0
  40. package/server/ai-validation-settings.ts +35 -0
  41. package/server/ai-validator.test.ts +387 -0
  42. package/server/ai-validator.ts +271 -0
  43. package/server/auth-manager.test.ts +83 -0
  44. package/server/auth-manager.ts +150 -0
  45. package/server/auto-namer.test.ts +252 -0
  46. package/server/auto-namer.ts +78 -0
  47. package/server/backend-adapter.test.ts +38 -0
  48. package/server/backend-adapter.ts +54 -0
  49. package/server/cache-headers.test.ts +98 -0
  50. package/server/cache-headers.ts +61 -0
  51. package/server/claude-adapter.test.ts +1363 -0
  52. package/server/claude-adapter.ts +889 -0
  53. package/server/claude-container-auth.test.ts +44 -0
  54. package/server/claude-container-auth.ts +30 -0
  55. package/server/claude-protocol-contract.test.ts +71 -0
  56. package/server/claude-protocol-drift.test.ts +78 -0
  57. package/server/claude-session-discovery.test.ts +132 -0
  58. package/server/claude-session-discovery.ts +157 -0
  59. package/server/claude-session-history.test.ts +158 -0
  60. package/server/claude-session-history.ts +410 -0
  61. package/server/cli-launcher.test.ts +1343 -0
  62. package/server/cli-launcher.ts +1298 -0
  63. package/server/cli.test.ts +16 -0
  64. package/server/codex-adapter.test.ts +5545 -0
  65. package/server/codex-adapter.ts +3062 -0
  66. package/server/codex-container-auth.test.ts +50 -0
  67. package/server/codex-container-auth.ts +24 -0
  68. package/server/codex-home.test.ts +61 -0
  69. package/server/codex-home.ts +26 -0
  70. package/server/codex-protocol-contract.test.ts +96 -0
  71. package/server/codex-protocol-drift.test.ts +123 -0
  72. package/server/codex-ws-proxy.cjs +226 -0
  73. package/server/commands-discovery.test.ts +179 -0
  74. package/server/commands-discovery.ts +81 -0
  75. package/server/constants.ts +7 -0
  76. package/server/container-manager.test.ts +1211 -0
  77. package/server/container-manager.ts +1053 -0
  78. package/server/cron-scheduler.test.ts +957 -0
  79. package/server/cron-scheduler.ts +243 -0
  80. package/server/cron-store.test.ts +422 -0
  81. package/server/cron-store.ts +148 -0
  82. package/server/cron-types.ts +63 -0
  83. package/server/env-manager.test.ts +268 -0
  84. package/server/env-manager.ts +161 -0
  85. package/server/event-bus-types.ts +64 -0
  86. package/server/event-bus.test.ts +244 -0
  87. package/server/event-bus.ts +124 -0
  88. package/server/execution-store.test.ts +307 -0
  89. package/server/execution-store.ts +170 -0
  90. package/server/fs-utils.ts +15 -0
  91. package/server/git-utils.test.ts +938 -0
  92. package/server/git-utils.ts +421 -0
  93. package/server/github-pr.test.ts +498 -0
  94. package/server/github-pr.ts +379 -0
  95. package/server/image-pull-manager.test.ts +303 -0
  96. package/server/image-pull-manager.ts +279 -0
  97. package/server/index.ts +396 -0
  98. package/server/linear-agent-bridge.test.ts +1157 -0
  99. package/server/linear-agent-bridge.ts +629 -0
  100. package/server/linear-agent.test.ts +473 -0
  101. package/server/linear-agent.ts +479 -0
  102. package/server/linear-cache.test.ts +136 -0
  103. package/server/linear-cache.ts +113 -0
  104. package/server/linear-connections.test.ts +350 -0
  105. package/server/linear-connections.ts +231 -0
  106. package/server/linear-credential-migration.test.ts +337 -0
  107. package/server/linear-credential-migration.ts +63 -0
  108. package/server/linear-oauth-connections-migration.test.ts +268 -0
  109. package/server/linear-oauth-connections.test.ts +365 -0
  110. package/server/linear-oauth-connections.ts +294 -0
  111. package/server/linear-project-manager.test.ts +162 -0
  112. package/server/linear-project-manager.ts +111 -0
  113. package/server/linear-prompt-builder.test.ts +74 -0
  114. package/server/linear-prompt-builder.ts +61 -0
  115. package/server/linear-staging.test.ts +276 -0
  116. package/server/linear-staging.ts +142 -0
  117. package/server/logger.test.ts +393 -0
  118. package/server/logger.ts +259 -0
  119. package/server/metrics-collector.test.ts +413 -0
  120. package/server/metrics-collector.ts +350 -0
  121. package/server/metrics-types.ts +108 -0
  122. package/server/middleware/managed-auth.test.ts +264 -0
  123. package/server/middleware/managed-auth.ts +195 -0
  124. package/server/novnc-proxy.test.ts +333 -0
  125. package/server/novnc-proxy.ts +99 -0
  126. package/server/path-resolver.test.ts +552 -0
  127. package/server/path-resolver.ts +186 -0
  128. package/server/paths.test.ts +31 -0
  129. package/server/paths.ts +11 -0
  130. package/server/pr-poller.test.ts +191 -0
  131. package/server/pr-poller.ts +162 -0
  132. package/server/prompt-manager.test.ts +211 -0
  133. package/server/prompt-manager.ts +211 -0
  134. package/server/protocol/claude-upstream/README.md +19 -0
  135. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  136. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  137. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  138. package/server/protocol/codex-upstream/README.md +18 -0
  139. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  140. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  141. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  142. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  143. package/server/protocol-monitor.ts +50 -0
  144. package/server/recorder.test.ts +454 -0
  145. package/server/recorder.ts +374 -0
  146. package/server/recording-hub/compat-validator.test.ts +150 -0
  147. package/server/recording-hub/compat-validator.ts +284 -0
  148. package/server/recording-hub/diagnostics.test.ts +140 -0
  149. package/server/recording-hub/diagnostics.ts +299 -0
  150. package/server/recording-hub/hub-config.test.ts +44 -0
  151. package/server/recording-hub/hub-config.ts +19 -0
  152. package/server/recording-hub/hub-routes.test.ts +417 -0
  153. package/server/recording-hub/hub-routes.ts +236 -0
  154. package/server/recording-hub/hub-store.test.ts +262 -0
  155. package/server/recording-hub/hub-store.ts +265 -0
  156. package/server/recording-hub/replay-adapter.test.ts +294 -0
  157. package/server/recording-hub/replay-adapter.ts +207 -0
  158. package/server/relay-client.test.ts +337 -0
  159. package/server/relay-client.ts +320 -0
  160. package/server/replay.test.ts +200 -0
  161. package/server/replay.ts +78 -0
  162. package/server/routes/agent-routes.test.ts +1400 -0
  163. package/server/routes/agent-routes.ts +409 -0
  164. package/server/routes/cron-routes.test.ts +881 -0
  165. package/server/routes/cron-routes.ts +103 -0
  166. package/server/routes/env-routes.test.ts +383 -0
  167. package/server/routes/env-routes.ts +95 -0
  168. package/server/routes/fs-routes.test.ts +1198 -0
  169. package/server/routes/fs-routes.ts +605 -0
  170. package/server/routes/git-routes.test.ts +813 -0
  171. package/server/routes/git-routes.ts +97 -0
  172. package/server/routes/linear-agent-routes.test.ts +721 -0
  173. package/server/routes/linear-agent-routes.ts +304 -0
  174. package/server/routes/linear-connection-routes.test.ts +927 -0
  175. package/server/routes/linear-connection-routes.ts +244 -0
  176. package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
  177. package/server/routes/linear-oauth-connection-routes.ts +129 -0
  178. package/server/routes/linear-routes.test.ts +1510 -0
  179. package/server/routes/linear-routes.ts +953 -0
  180. package/server/routes/metrics-routes.test.ts +103 -0
  181. package/server/routes/metrics-routes.ts +13 -0
  182. package/server/routes/prompt-routes.ts +67 -0
  183. package/server/routes/sandbox-routes.test.ts +513 -0
  184. package/server/routes/sandbox-routes.ts +127 -0
  185. package/server/routes/settings-routes.ts +270 -0
  186. package/server/routes/skills-routes.test.ts +690 -0
  187. package/server/routes/skills-routes.ts +100 -0
  188. package/server/routes/system-routes.test.ts +637 -0
  189. package/server/routes/system-routes.ts +228 -0
  190. package/server/routes/tailscale-routes.test.ts +176 -0
  191. package/server/routes/tailscale-routes.ts +22 -0
  192. package/server/routes.test.ts +4655 -0
  193. package/server/routes.ts +1277 -0
  194. package/server/sandbox-manager.test.ts +378 -0
  195. package/server/sandbox-manager.ts +168 -0
  196. package/server/service.test.ts +1419 -0
  197. package/server/service.ts +718 -0
  198. package/server/session-creation-service.test.ts +661 -0
  199. package/server/session-creation-service.ts +473 -0
  200. package/server/session-git-info.ts +104 -0
  201. package/server/session-linear-issues.test.ts +118 -0
  202. package/server/session-linear-issues.ts +88 -0
  203. package/server/session-names.test.ts +94 -0
  204. package/server/session-names.ts +67 -0
  205. package/server/session-orchestrator.test.ts +1784 -0
  206. package/server/session-orchestrator.ts +973 -0
  207. package/server/session-state-machine.test.ts +606 -0
  208. package/server/session-state-machine.ts +207 -0
  209. package/server/session-store.test.ts +290 -0
  210. package/server/session-store.ts +146 -0
  211. package/server/session-types.ts +509 -0
  212. package/server/settings-manager.test.ts +275 -0
  213. package/server/settings-manager.ts +173 -0
  214. package/server/tailscale-manager.test.ts +553 -0
  215. package/server/tailscale-manager.ts +451 -0
  216. package/server/terminal-manager.ts +240 -0
  217. package/server/update-checker.test.ts +306 -0
  218. package/server/update-checker.ts +197 -0
  219. package/server/usage-limits.test.ts +536 -0
  220. package/server/usage-limits.ts +225 -0
  221. package/server/worktree-tracker.test.ts +243 -0
  222. package/server/worktree-tracker.ts +84 -0
  223. package/server/ws-auth.test.ts +59 -0
  224. package/server/ws-auth.ts +41 -0
  225. package/server/ws-bridge-browser-ingest.test.ts +272 -0
  226. package/server/ws-bridge-browser-ingest.ts +72 -0
  227. package/server/ws-bridge-browser.ts +112 -0
  228. package/server/ws-bridge-cli-ingest.test.ts +302 -0
  229. package/server/ws-bridge-cli-ingest.ts +81 -0
  230. package/server/ws-bridge-codex.test.ts +1837 -0
  231. package/server/ws-bridge-codex.ts +266 -0
  232. package/server/ws-bridge-controls.test.ts +124 -0
  233. package/server/ws-bridge-controls.ts +20 -0
  234. package/server/ws-bridge-persist.test.ts +296 -0
  235. package/server/ws-bridge-persist.ts +66 -0
  236. package/server/ws-bridge-publish.test.ts +234 -0
  237. package/server/ws-bridge-publish.ts +79 -0
  238. package/server/ws-bridge-replay.test.ts +44 -0
  239. package/server/ws-bridge-replay.ts +61 -0
  240. package/server/ws-bridge-types.ts +106 -0
  241. package/server/ws-bridge.test.ts +4777 -0
  242. package/server/ws-bridge.ts +1279 -0
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Compatibility validator for recorded sessions.
3
+ *
4
+ * Compares a recording's browser output messages structurally to detect
5
+ * protocol drift. This catches changes when Claude Code or Codex update
6
+ * their message format.
7
+ */
8
+
9
+ import type { Recording } from "../replay.js";
10
+ import { filterEntries } from "../replay.js";
11
+
12
+ // ─── Types ───────────────────────────────────────────────────────────────────
13
+
14
+ export interface ProtocolDiff {
15
+ entryIndex: number;
16
+ expected: { type: string; [key: string]: unknown };
17
+ actual: { type: string; [key: string]: unknown } | null;
18
+ kind: "missing" | "extra" | "type_mismatch" | "field_mismatch";
19
+ details: string;
20
+ }
21
+
22
+ export interface ValidationResult {
23
+ compatible: boolean;
24
+ backendType: string;
25
+ totalMessages: number;
26
+ diffs: ProtocolDiff[];
27
+ messageTypeBreakdown: Record<string, { count: number; issues: number }>;
28
+ }
29
+
30
+ // Fields to ignore during comparison (they change between runs)
31
+ const IGNORED_FIELDS = new Set([
32
+ "timestamp",
33
+ "ts",
34
+ "created_at",
35
+ "updated_at",
36
+ "session_id",
37
+ "uuid",
38
+ "id",
39
+ "request_id",
40
+ "duration_ms",
41
+ "duration_api_ms",
42
+ "cost_usd",
43
+ "total_cost_usd",
44
+ "api_tokens",
45
+ "input_tokens",
46
+ "output_tokens",
47
+ "cache_read_input_tokens",
48
+ "cache_creation_input_tokens",
49
+ ]);
50
+
51
+ // ─── Validation ──────────────────────────────────────────────────────────────
52
+
53
+ /**
54
+ * Validate a recording's structural consistency.
55
+ *
56
+ * Checks that browser messages have expected types, required fields are present,
57
+ * and message type distribution is reasonable.
58
+ */
59
+ export function validateRecording(recording: Recording): ValidationResult {
60
+ const browserMessages = filterEntries(recording.entries, "out", "browser");
61
+ const diffs: ProtocolDiff[] = [];
62
+ const typeBreakdown: Record<string, { count: number; issues: number }> = {};
63
+
64
+ for (let i = 0; i < browserMessages.length; i++) {
65
+ const entry = browserMessages[i];
66
+ let parsed: Record<string, unknown>;
67
+
68
+ try {
69
+ parsed = JSON.parse(entry.raw);
70
+ } catch {
71
+ diffs.push({
72
+ entryIndex: i,
73
+ expected: { type: "valid_json" },
74
+ actual: null,
75
+ kind: "missing",
76
+ details: `Entry ${i}: unparseable JSON`,
77
+ });
78
+ continue;
79
+ }
80
+
81
+ const msgType = String(parsed.type || "unknown");
82
+
83
+ if (!typeBreakdown[msgType]) {
84
+ typeBreakdown[msgType] = { count: 0, issues: 0 };
85
+ }
86
+ typeBreakdown[msgType].count++;
87
+
88
+ // Validate required fields per message type
89
+ const issues = validateMessageStructure(msgType, parsed, i);
90
+ for (const issue of issues) {
91
+ diffs.push(issue);
92
+ typeBreakdown[msgType].issues++;
93
+ }
94
+ }
95
+
96
+ return {
97
+ compatible: diffs.length === 0,
98
+ backendType: recording.header.backend_type,
99
+ totalMessages: browserMessages.length,
100
+ diffs,
101
+ messageTypeBreakdown: typeBreakdown,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Compare two recordings structurally.
107
+ *
108
+ * Useful for verifying that replaying CLI input through the adapter produces
109
+ * the same browser output. Returns diffs where messages diverge.
110
+ */
111
+ export function compareRecordings(
112
+ expected: Recording,
113
+ actual: { type: string; [key: string]: unknown }[],
114
+ ): ProtocolDiff[] {
115
+ const expectedMsgs = filterEntries(expected.entries, "out", "browser");
116
+ const diffs: ProtocolDiff[] = [];
117
+
118
+ const maxLen = Math.max(expectedMsgs.length, actual.length);
119
+
120
+ for (let i = 0; i < maxLen; i++) {
121
+ if (i >= expectedMsgs.length) {
122
+ diffs.push({
123
+ entryIndex: i,
124
+ expected: { type: "none" },
125
+ actual: actual[i],
126
+ kind: "extra",
127
+ details: `Extra message at index ${i}: type=${actual[i].type}`,
128
+ });
129
+ continue;
130
+ }
131
+
132
+ if (i >= actual.length) {
133
+ let expectedParsed: Record<string, unknown>;
134
+ try {
135
+ expectedParsed = JSON.parse(expectedMsgs[i].raw);
136
+ } catch {
137
+ expectedParsed = { type: "unparseable" };
138
+ }
139
+ diffs.push({
140
+ entryIndex: i,
141
+ expected: expectedParsed as { type: string },
142
+ actual: null,
143
+ kind: "missing",
144
+ details: `Missing message at index ${i}: expected type=${expectedParsed.type}`,
145
+ });
146
+ continue;
147
+ }
148
+
149
+ let expectedParsed: Record<string, unknown>;
150
+ try {
151
+ expectedParsed = JSON.parse(expectedMsgs[i].raw);
152
+ } catch {
153
+ diffs.push({
154
+ entryIndex: i,
155
+ expected: { type: "unparseable" },
156
+ actual: actual[i],
157
+ kind: "field_mismatch",
158
+ details: `Entry ${i}: expected message has unparseable JSON`,
159
+ });
160
+ continue;
161
+ }
162
+
163
+ const actualMsg = actual[i];
164
+
165
+ // Type must match
166
+ if (expectedParsed.type !== actualMsg.type) {
167
+ diffs.push({
168
+ entryIndex: i,
169
+ expected: expectedParsed as { type: string },
170
+ actual: actualMsg as { type: string },
171
+ kind: "type_mismatch",
172
+ details: `Type mismatch at index ${i}: expected=${expectedParsed.type}, actual=${actualMsg.type}`,
173
+ });
174
+ continue;
175
+ }
176
+
177
+ // Check for missing/extra top-level fields (excluding ignored fields)
178
+ const fieldDiffs = compareFields(expectedParsed, actualMsg, i);
179
+ diffs.push(...fieldDiffs);
180
+ }
181
+
182
+ return diffs;
183
+ }
184
+
185
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
186
+
187
+ function validateMessageStructure(
188
+ type: string,
189
+ msg: Record<string, unknown>,
190
+ index: number,
191
+ ): ProtocolDiff[] {
192
+ const issues: ProtocolDiff[] = [];
193
+
194
+ // All messages must have a type field
195
+ if (!msg.type) {
196
+ issues.push({
197
+ entryIndex: index,
198
+ expected: { type: "any" },
199
+ actual: msg as { type: string },
200
+ kind: "field_mismatch",
201
+ details: `Entry ${index}: missing 'type' field`,
202
+ });
203
+ }
204
+
205
+ // Type-specific validation
206
+ switch (type) {
207
+ case "session_init":
208
+ if (!msg.session || typeof msg.session !== "object") {
209
+ issues.push({
210
+ entryIndex: index,
211
+ expected: { type, session: "object" },
212
+ actual: msg as { type: string },
213
+ kind: "field_mismatch",
214
+ details: `Entry ${index}: session_init missing 'session' object`,
215
+ });
216
+ }
217
+ break;
218
+
219
+ case "permission_request":
220
+ if (!msg.tool_name) {
221
+ issues.push({
222
+ entryIndex: index,
223
+ expected: { type, tool_name: "string" },
224
+ actual: msg as { type: string },
225
+ kind: "field_mismatch",
226
+ details: `Entry ${index}: permission_request missing 'tool_name'`,
227
+ });
228
+ }
229
+ break;
230
+
231
+ case "result":
232
+ if (!msg.subtype) {
233
+ issues.push({
234
+ entryIndex: index,
235
+ expected: { type, subtype: "string" },
236
+ actual: msg as { type: string },
237
+ kind: "field_mismatch",
238
+ details: `Entry ${index}: result missing 'subtype'`,
239
+ });
240
+ }
241
+ break;
242
+ }
243
+
244
+ return issues;
245
+ }
246
+
247
+ function compareFields(
248
+ expected: Record<string, unknown>,
249
+ actual: Record<string, unknown>,
250
+ index: number,
251
+ ): ProtocolDiff[] {
252
+ const diffs: ProtocolDiff[] = [];
253
+
254
+ const expectedKeys = Object.keys(expected).filter((k) => !IGNORED_FIELDS.has(k));
255
+ const actualKeys = Object.keys(actual).filter((k) => !IGNORED_FIELDS.has(k));
256
+
257
+ // Check for missing fields in actual
258
+ for (const key of expectedKeys) {
259
+ if (!(key in actual)) {
260
+ diffs.push({
261
+ entryIndex: index,
262
+ expected: expected as { type: string },
263
+ actual: actual as { type: string },
264
+ kind: "field_mismatch",
265
+ details: `Entry ${index}: missing field '${key}' in actual (type=${expected.type})`,
266
+ });
267
+ }
268
+ }
269
+
270
+ // Check for unexpected new fields in actual (informational, not necessarily a break)
271
+ for (const key of actualKeys) {
272
+ if (!(key in expected)) {
273
+ diffs.push({
274
+ entryIndex: index,
275
+ expected: expected as { type: string },
276
+ actual: actual as { type: string },
277
+ kind: "field_mismatch",
278
+ details: `Entry ${index}: new field '${key}' in actual (type=${actual.type})`,
279
+ });
280
+ }
281
+ }
282
+
283
+ return diffs;
284
+ }
@@ -0,0 +1,140 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { analyzeDisconnections, buildTimeline } from "./diagnostics.js";
3
+ import type { Recording } from "../replay.js";
4
+ import type { RecordingEntry } from "../recorder.js";
5
+
6
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
7
+
8
+ function makeRecording(entries: Partial<RecordingEntry>[]): Recording {
9
+ return {
10
+ header: {
11
+ _header: true as const,
12
+ version: 1 as const,
13
+ session_id: "test",
14
+ backend_type: "claude" as const,
15
+ started_at: 0,
16
+ cwd: "/",
17
+ },
18
+ entries: entries.map((e) => ({
19
+ ts: e.ts ?? 0,
20
+ dir: e.dir ?? "in",
21
+ raw: e.raw ?? "",
22
+ ch: e.ch ?? "cli",
23
+ ...(e.event ? { event: e.event } : {}),
24
+ ...(e.meta ? { meta: e.meta } : {}),
25
+ })) as RecordingEntry[],
26
+ };
27
+ }
28
+
29
+ // ─── Tests ───────────────────────────────────────────────────────────────────
30
+
31
+ describe("diagnostics", () => {
32
+ describe("buildTimeline", () => {
33
+ it("extracts explicit lifecycle events", () => {
34
+ const recording = makeRecording([
35
+ { ts: 100, event: "ws_open", ch: "cli" },
36
+ { ts: 200, dir: "in", raw: '{"type":"system"}', ch: "cli" },
37
+ { ts: 500, event: "ws_close", ch: "cli", meta: { code: 1000 } },
38
+ ]);
39
+
40
+ const timeline = buildTimeline(recording);
41
+ expect(timeline).toHaveLength(2); // ws_open and ws_close (data messages aren't timeline events)
42
+ expect(timeline[0].event).toBe("ws_open");
43
+ expect(timeline[1].event).toBe("ws_close");
44
+ expect(timeline[1].detail).toContain("1000");
45
+ });
46
+
47
+ it("extracts cli_connected/disconnected from data messages", () => {
48
+ const recording = makeRecording([
49
+ { ts: 100, dir: "out", raw: JSON.stringify({ type: "cli_connected" }), ch: "browser" },
50
+ { ts: 500, dir: "out", raw: JSON.stringify({ type: "cli_disconnected" }), ch: "browser" },
51
+ ]);
52
+
53
+ const timeline = buildTimeline(recording);
54
+ expect(timeline).toHaveLength(2);
55
+ expect(timeline[0].event).toBe("cli_connected");
56
+ expect(timeline[1].event).toBe("cli_disconnected");
57
+ });
58
+
59
+ it("returns sorted timeline", () => {
60
+ const recording = makeRecording([
61
+ { ts: 500, event: "ws_close", ch: "cli" },
62
+ { ts: 100, event: "ws_open", ch: "cli" },
63
+ ]);
64
+
65
+ const timeline = buildTimeline(recording);
66
+ expect(timeline[0].ts).toBeLessThan(timeline[1].ts);
67
+ });
68
+ });
69
+
70
+ describe("analyzeDisconnections", () => {
71
+ it("reports no disconnections for clean session", () => {
72
+ const recording = makeRecording([
73
+ { ts: 100, dir: "in", raw: '{"type":"system"}', ch: "cli" },
74
+ { ts: 200, dir: "in", raw: '{"type":"assistant"}', ch: "cli" },
75
+ { ts: 300, dir: "in", raw: '{"type":"result"}', ch: "cli" },
76
+ ]);
77
+
78
+ const report = analyzeDisconnections(recording);
79
+ expect(report.totalDisconnections).toBe(0);
80
+ expect(report.dataGaps).toHaveLength(0);
81
+ expect(report.patterns).toContain("No disconnection issues detected in this recording.");
82
+ });
83
+
84
+ it("detects disconnection from lifecycle events", () => {
85
+ const recording = makeRecording([
86
+ { ts: 100, event: "ws_open", ch: "cli" },
87
+ { ts: 1000, event: "ws_close", ch: "cli" },
88
+ { ts: 5000, event: "ws_open", ch: "cli" },
89
+ ]);
90
+
91
+ const report = analyzeDisconnections(recording);
92
+ expect(report.totalDisconnections).toBe(1);
93
+ expect(report.disconnections[0].gapMs).toBe(4000);
94
+ });
95
+
96
+ it("detects data gaps in CLI messages", () => {
97
+ // Gap of 60s between two CLI messages (exceeds 30s threshold)
98
+ const recording = makeRecording([
99
+ { ts: 1000, dir: "in", raw: '{"type":"a"}', ch: "cli" },
100
+ { ts: 61000, dir: "in", raw: '{"type":"b"}', ch: "cli" },
101
+ ]);
102
+
103
+ const report = analyzeDisconnections(recording);
104
+ expect(report.dataGaps).toHaveLength(1);
105
+ expect(report.dataGaps[0].gapMs).toBe(60000);
106
+ });
107
+
108
+ it("reports asymmetric disconnection pattern (CLI-only)", () => {
109
+ const recording = makeRecording([
110
+ { ts: 100, dir: "out", raw: JSON.stringify({ type: "cli_disconnected" }), ch: "browser" },
111
+ { ts: 5000, dir: "out", raw: JSON.stringify({ type: "cli_connected" }), ch: "browser" },
112
+ ]);
113
+
114
+ const report = analyzeDisconnections(recording);
115
+ expect(report.patterns.some((p) => p.includes("CLI-side"))).toBe(true);
116
+ });
117
+
118
+ it("detects rapid reconnect cycling", () => {
119
+ // 3 rapid disconnect/reconnect cycles (< 5s gaps), spaced >20s apart so dedup doesn't merge them
120
+ const recording = makeRecording([
121
+ { ts: 1000, dir: "out", raw: JSON.stringify({ type: "cli_disconnected" }), ch: "browser" },
122
+ { ts: 2000, dir: "out", raw: JSON.stringify({ type: "cli_connected" }), ch: "browser" },
123
+ { ts: 25000, dir: "out", raw: JSON.stringify({ type: "cli_disconnected" }), ch: "browser" },
124
+ { ts: 26000, dir: "out", raw: JSON.stringify({ type: "cli_connected" }), ch: "browser" },
125
+ { ts: 50000, dir: "out", raw: JSON.stringify({ type: "cli_disconnected" }), ch: "browser" },
126
+ { ts: 51000, dir: "out", raw: JSON.stringify({ type: "cli_connected" }), ch: "browser" },
127
+ ]);
128
+
129
+ const report = analyzeDisconnections(recording);
130
+ expect(report.patterns.some((p) => p.includes("rapid reconnections"))).toBe(true);
131
+ });
132
+
133
+ it("includes session metadata in report", () => {
134
+ const recording = makeRecording([{ ts: 100, dir: "in", raw: "{}", ch: "cli" }]);
135
+ const report = analyzeDisconnections(recording);
136
+ expect(report.sessionId).toBe("test");
137
+ expect(report.backendType).toBe("claude");
138
+ });
139
+ });
140
+ });