@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,302 @@
1
+ import { describe, it, expect } from "vitest";
2
+
3
+ // Stub Bun.hash for vitest (runs under Node, not Bun).
4
+ if (typeof globalThis.Bun === "undefined") {
5
+ (globalThis as any).Bun = {
6
+ hash(input: string | Uint8Array): number {
7
+ const s = typeof input === "string" ? input : new TextDecoder().decode(input);
8
+ let h = 0;
9
+ for (let i = 0; i < s.length; i++) {
10
+ h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
11
+ }
12
+ return h >>> 0;
13
+ },
14
+ };
15
+ }
16
+
17
+ import { parseNDJSON, isDuplicateCLIMessage, type CLIDedupState } from "./ws-bridge-cli-ingest.js";
18
+ import type { CLIMessage } from "./session-types.js";
19
+
20
+ function makeDedupState(): CLIDedupState {
21
+ return {
22
+ recentCLIMessageHashes: [],
23
+ recentCLIMessageHashSet: new Set(),
24
+ };
25
+ }
26
+
27
+ // ─── parseNDJSON ──────────────────────────────────────────────────────────────
28
+
29
+ describe("parseNDJSON", () => {
30
+ it("returns empty array for empty string", () => {
31
+ expect(parseNDJSON("")).toEqual([]);
32
+ });
33
+
34
+ it("returns empty array for whitespace-only input", () => {
35
+ expect(parseNDJSON(" \n \n ")).toEqual([]);
36
+ });
37
+
38
+ it("parses a single JSON line", () => {
39
+ const line = '{"type":"system","subtype":"init"}';
40
+ expect(parseNDJSON(line)).toEqual([line]);
41
+ });
42
+
43
+ it("parses multiple JSON lines separated by newlines", () => {
44
+ const line1 = '{"type":"assistant","message":{}}';
45
+ const line2 = '{"type":"result","data":{}}';
46
+ const input = `${line1}\n${line2}`;
47
+ expect(parseNDJSON(input)).toEqual([line1, line2]);
48
+ });
49
+
50
+ it("filters blank lines between valid JSON", () => {
51
+ const line1 = '{"type":"system"}';
52
+ const line2 = '{"type":"result"}';
53
+ const input = `${line1}\n\n\n${line2}\n`;
54
+ expect(parseNDJSON(input)).toEqual([line1, line2]);
55
+ });
56
+
57
+ it("handles Buffer input", () => {
58
+ const line = '{"type":"assistant"}';
59
+ const buffer = Buffer.from(line, "utf-8");
60
+ expect(parseNDJSON(buffer)).toEqual([line]);
61
+ });
62
+
63
+ it("handles multi-line NDJSON with trailing newline", () => {
64
+ const input = '{"a":1}\n{"b":2}\n';
65
+ expect(parseNDJSON(input)).toEqual(['{"a":1}', '{"b":2}']);
66
+ });
67
+ });
68
+
69
+ // ─── isDuplicateCLIMessage ────────────────────────────────────────────────────
70
+
71
+ describe("isDuplicateCLIMessage", () => {
72
+ describe("assistant/result/system messages (hash-based dedup)", () => {
73
+ it("returns false for first occurrence", () => {
74
+ const state = makeDedupState();
75
+ const line = '{"type":"assistant","message":{}}';
76
+ const msg: CLIMessage = { type: "assistant" } as any;
77
+ expect(isDuplicateCLIMessage(msg, line, state, 100)).toBe(false);
78
+ });
79
+
80
+ it("returns true for duplicate assistant message", () => {
81
+ const state = makeDedupState();
82
+ const line = '{"type":"assistant","message":{"id":"m1"}}';
83
+ const msg: CLIMessage = { type: "assistant" } as any;
84
+
85
+ // First occurrence — not duplicate
86
+ expect(isDuplicateCLIMessage(msg, line, state, 100)).toBe(false);
87
+ // Same content again — duplicate
88
+ expect(isDuplicateCLIMessage(msg, line, state, 100)).toBe(true);
89
+ });
90
+
91
+ it("returns true for duplicate result message", () => {
92
+ const state = makeDedupState();
93
+ const line = '{"type":"result","num_turns":3}';
94
+ const msg: CLIMessage = { type: "result" } as any;
95
+
96
+ expect(isDuplicateCLIMessage(msg, line, state, 100)).toBe(false);
97
+ expect(isDuplicateCLIMessage(msg, line, state, 100)).toBe(true);
98
+ });
99
+
100
+ it("returns true for duplicate system message", () => {
101
+ const state = makeDedupState();
102
+ const line = '{"type":"system","subtype":"init","model":"claude"}';
103
+ const msg: CLIMessage = { type: "system", subtype: "init" } as any;
104
+
105
+ expect(isDuplicateCLIMessage(msg, line, state, 100)).toBe(false);
106
+ expect(isDuplicateCLIMessage(msg, line, state, 100)).toBe(true);
107
+ });
108
+
109
+ it("different content is not a duplicate", () => {
110
+ const state = makeDedupState();
111
+ const msg: CLIMessage = { type: "assistant" } as any;
112
+
113
+ expect(isDuplicateCLIMessage(msg, '{"type":"assistant","id":"1"}', state, 100)).toBe(false);
114
+ expect(isDuplicateCLIMessage(msg, '{"type":"assistant","id":"2"}', state, 100)).toBe(false);
115
+ });
116
+ });
117
+
118
+ describe("stream_event messages (uuid-based dedup)", () => {
119
+ it("returns false for first occurrence with uuid", () => {
120
+ const state = makeDedupState();
121
+ const line = '{"type":"stream_event","uuid":"evt-1"}';
122
+ const msg = { type: "stream_event", uuid: "evt-1" } as CLIMessage;
123
+
124
+ expect(isDuplicateCLIMessage(msg, line, state, 100)).toBe(false);
125
+ });
126
+
127
+ it("returns true for duplicate stream_event with same uuid", () => {
128
+ const state = makeDedupState();
129
+ const line = '{"type":"stream_event","uuid":"evt-1"}';
130
+ const msg = { type: "stream_event", uuid: "evt-1" } as CLIMessage;
131
+
132
+ expect(isDuplicateCLIMessage(msg, line, state, 100)).toBe(false);
133
+ expect(isDuplicateCLIMessage(msg, line, state, 100)).toBe(true);
134
+ });
135
+
136
+ it("different uuids are not duplicates", () => {
137
+ const state = makeDedupState();
138
+ const msg1 = { type: "stream_event", uuid: "evt-1" } as CLIMessage;
139
+ const msg2 = { type: "stream_event", uuid: "evt-2" } as CLIMessage;
140
+
141
+ expect(isDuplicateCLIMessage(msg1, "", state, 100)).toBe(false);
142
+ expect(isDuplicateCLIMessage(msg2, "", state, 100)).toBe(false);
143
+ });
144
+
145
+ it("stream_event without uuid is never considered a duplicate", () => {
146
+ const state = makeDedupState();
147
+ const msg = { type: "stream_event" } as CLIMessage;
148
+
149
+ // Same message twice without uuid — both pass through
150
+ expect(isDuplicateCLIMessage(msg, '{"type":"stream_event"}', state, 100)).toBe(false);
151
+ expect(isDuplicateCLIMessage(msg, '{"type":"stream_event"}', state, 100)).toBe(false);
152
+ });
153
+ });
154
+
155
+ describe("types that skip dedup", () => {
156
+ it.each([
157
+ "keep_alive",
158
+ "control_request",
159
+ "control_response",
160
+ "tool_progress",
161
+ "tool_use_summary",
162
+ "auth_status",
163
+ ])("'%s' messages are never deduplicated", (type) => {
164
+ const state = makeDedupState();
165
+ const msg = { type } as CLIMessage;
166
+ const line = JSON.stringify(msg);
167
+
168
+ // Same message twice — both pass through
169
+ expect(isDuplicateCLIMessage(msg, line, state, 100)).toBe(false);
170
+ expect(isDuplicateCLIMessage(msg, line, state, 100)).toBe(false);
171
+ });
172
+ });
173
+
174
+ describe("window eviction", () => {
175
+ it("evicts oldest hash when window size is exceeded", () => {
176
+ const state = makeDedupState();
177
+ const windowSize = 3;
178
+
179
+ // Fill the window with 3 messages
180
+ const lines = ["msg-A", "msg-B", "msg-C"];
181
+ for (const line of lines) {
182
+ isDuplicateCLIMessage({ type: "assistant" } as any, line, state, windowSize);
183
+ }
184
+ expect(state.recentCLIMessageHashes).toHaveLength(3);
185
+
186
+ // Add a 4th — should evict "msg-A"
187
+ isDuplicateCLIMessage({ type: "assistant" } as any, "msg-D", state, windowSize);
188
+ expect(state.recentCLIMessageHashes).toHaveLength(3);
189
+
190
+ // "msg-A" should no longer be considered a duplicate (evicted from window)
191
+ expect(isDuplicateCLIMessage({ type: "assistant" } as any, "msg-A", state, windowSize)).toBe(false);
192
+ // "msg-B" should still be a duplicate (still in window... unless evicted by adding msg-A back)
193
+ // After adding msg-A back, window is [msg-C, msg-D, msg-A], so msg-B is evicted
194
+ });
195
+
196
+ it("maintains correct window size under heavy traffic", () => {
197
+ const state = makeDedupState();
198
+ const windowSize = 10;
199
+
200
+ // Send 50 unique messages
201
+ for (let i = 0; i < 50; i++) {
202
+ isDuplicateCLIMessage(
203
+ { type: "assistant" } as any,
204
+ `message-${i}`,
205
+ state,
206
+ windowSize,
207
+ );
208
+ }
209
+
210
+ // Window should cap at windowSize
211
+ expect(state.recentCLIMessageHashes).toHaveLength(windowSize);
212
+ expect(state.recentCLIMessageHashSet.size).toBe(windowSize);
213
+ });
214
+ });
215
+
216
+ describe("reconnect scenarios", () => {
217
+ it("filters all replayed messages after CLI reconnect", () => {
218
+ // Simulate: CLI sends 10 messages, then reconnects and replays all 10.
219
+ // All replayed messages should be filtered as duplicates.
220
+ const state = makeDedupState();
221
+ const messages = Array.from({ length: 10 }, (_, i) => ({
222
+ line: `{"type":"assistant","id":"${i}"}`,
223
+ msg: { type: "assistant" } as CLIMessage,
224
+ }));
225
+
226
+ // First send: all unique
227
+ for (const { line, msg } of messages) {
228
+ expect(isDuplicateCLIMessage(msg, line, state, 100)).toBe(false);
229
+ }
230
+
231
+ // Replay (reconnect): all duplicates
232
+ for (const { line, msg } of messages) {
233
+ expect(isDuplicateCLIMessage(msg, line, state, 100)).toBe(true);
234
+ }
235
+ });
236
+
237
+ it("filters replayed messages but passes new ones after partial overlap", () => {
238
+ // Simulate: CLI sends messages 0-9, reconnects, replays messages 5-9
239
+ // plus sends new messages 10-14. Messages 5-9 should be filtered,
240
+ // messages 10-14 should pass through.
241
+ const state = makeDedupState();
242
+ const allLines = Array.from({ length: 15 }, (_, i) =>
243
+ `{"type":"assistant","content":"msg-${i}"}`,
244
+ );
245
+
246
+ // Original send: messages 0-9
247
+ for (let i = 0; i < 10; i++) {
248
+ isDuplicateCLIMessage({ type: "assistant" } as CLIMessage, allLines[i], state, 100);
249
+ }
250
+
251
+ // Reconnect: replay 5-9 (duplicate) + new 10-14 (unique)
252
+ const replayResults: boolean[] = [];
253
+ for (let i = 5; i < 15; i++) {
254
+ replayResults.push(
255
+ isDuplicateCLIMessage({ type: "assistant" } as CLIMessage, allLines[i], state, 100),
256
+ );
257
+ }
258
+
259
+ // First 5 (indices 5-9) should be duplicates
260
+ expect(replayResults.slice(0, 5)).toEqual([true, true, true, true, true]);
261
+ // Last 5 (indices 10-14) should be new
262
+ expect(replayResults.slice(5, 10)).toEqual([false, false, false, false, false]);
263
+ });
264
+
265
+ it("filters replayed stream_events by uuid after reconnect", () => {
266
+ const state = makeDedupState();
267
+
268
+ // Original: 5 stream_events
269
+ for (let i = 0; i < 5; i++) {
270
+ const msg = { type: "stream_event", uuid: `uuid-${i}` } as CLIMessage;
271
+ expect(isDuplicateCLIMessage(msg, "", state, 100)).toBe(false);
272
+ }
273
+
274
+ // Reconnect replay: same 5 stream_events + 3 new ones
275
+ for (let i = 0; i < 5; i++) {
276
+ const msg = { type: "stream_event", uuid: `uuid-${i}` } as CLIMessage;
277
+ expect(isDuplicateCLIMessage(msg, "", state, 100)).toBe(true);
278
+ }
279
+ for (let i = 5; i < 8; i++) {
280
+ const msg = { type: "stream_event", uuid: `uuid-${i}` } as CLIMessage;
281
+ expect(isDuplicateCLIMessage(msg, "", state, 100)).toBe(false);
282
+ }
283
+ });
284
+
285
+ it("shares dedup window between assistant messages and stream_events", () => {
286
+ // The dedup state is shared — stream_event uuids and message hashes
287
+ // live in the same rolling window. Verify they don't interfere.
288
+ const state = makeDedupState();
289
+
290
+ // Mix of types
291
+ const assistantLine = '{"type":"assistant","content":"hello"}';
292
+ isDuplicateCLIMessage({ type: "assistant" } as CLIMessage, assistantLine, state, 100);
293
+
294
+ const streamMsg = { type: "stream_event", uuid: "evt-1" } as CLIMessage;
295
+ isDuplicateCLIMessage(streamMsg, "", state, 100);
296
+
297
+ // Both should be deduplicated on replay
298
+ expect(isDuplicateCLIMessage({ type: "assistant" } as CLIMessage, assistantLine, state, 100)).toBe(true);
299
+ expect(isDuplicateCLIMessage(streamMsg, "", state, 100)).toBe(true);
300
+ });
301
+ });
302
+ });
@@ -0,0 +1,81 @@
1
+ import type { CLIMessage } from "./session-types.js";
2
+
3
+ // ─── CLI Ingest Pipeline ────────────────────────────────────────────────────
4
+ // Pure functions for parsing and deduplicating CLI (NDJSON) messages.
5
+ // Extracted from WsBridge.handleCLIMessage to enable isolated testing
6
+ // of reconnect/replay deduplication scenarios.
7
+
8
+ /** State needed for CLI message deduplication. Matches a subset of Session. */
9
+ export interface CLIDedupState {
10
+ recentCLIMessageHashes: string[];
11
+ recentCLIMessageHashSet: Set<string>;
12
+ }
13
+
14
+ /**
15
+ * Parse raw NDJSON data into individual line strings.
16
+ * Splits on newlines and filters blank lines.
17
+ */
18
+ export function parseNDJSON(raw: string | Buffer): string[] {
19
+ const data = typeof raw === "string" ? raw : raw.toString("utf-8");
20
+ return data.split("\n").filter((l) => l.trim());
21
+ }
22
+
23
+ /**
24
+ * Check if a CLI message is a duplicate based on a rolling hash window.
25
+ * On WS reconnect, the CLI replays in-flight messages; this dedup prevents
26
+ * duplicates from reaching downstream handlers.
27
+ *
28
+ * - `assistant`, `result`, `system` messages: deduped by content hash (Bun.hash)
29
+ * - `stream_event` messages: deduped by their stable `uuid` field
30
+ * - All other types (keep_alive, control_request, tool_progress, etc.): never deduped
31
+ *
32
+ * Returns true if the message is a duplicate and should be skipped.
33
+ * Mutates the dedupState window as a side effect.
34
+ */
35
+ export function isDuplicateCLIMessage(
36
+ msg: CLIMessage,
37
+ rawLine: string,
38
+ state: CLIDedupState,
39
+ windowSize: number,
40
+ ): boolean {
41
+ if (msg.type === "assistant" || msg.type === "result" || msg.type === "system") {
42
+ // Namespace with "h:" prefix to prevent collisions with uuid-based keys
43
+ const key = `h:${Bun.hash(rawLine).toString(36)}`;
44
+ if (state.recentCLIMessageHashSet.has(key)) {
45
+ return true;
46
+ }
47
+ state.recentCLIMessageHashes.push(key);
48
+ state.recentCLIMessageHashSet.add(key);
49
+ while (state.recentCLIMessageHashes.length > windowSize) {
50
+ const old = state.recentCLIMessageHashes.shift()!;
51
+ state.recentCLIMessageHashSet.delete(old);
52
+ }
53
+ return false;
54
+ }
55
+
56
+ if (msg.type === "stream_event" && (msg as { uuid?: string }).uuid) {
57
+ // Namespace with "u:" prefix to prevent collisions with hash-based keys.
58
+ // Current CLI versions (1.0+) always provide UUIDs on stream_event messages.
59
+ // UUID-less stream_events from older protocol versions fall through to no-dedup below.
60
+ const key = `u:${(msg as { uuid: string }).uuid}`;
61
+ if (state.recentCLIMessageHashSet.has(key)) {
62
+ return true;
63
+ }
64
+ state.recentCLIMessageHashes.push(key);
65
+ state.recentCLIMessageHashSet.add(key);
66
+ while (state.recentCLIMessageHashes.length > windowSize) {
67
+ const old = state.recentCLIMessageHashes.shift()!;
68
+ state.recentCLIMessageHashSet.delete(old);
69
+ }
70
+ return false;
71
+ }
72
+
73
+ // All other message types (keep_alive, control_request, tool_progress, etc.)
74
+ // are never considered duplicates — they're either stateless or handled by
75
+ // separate mechanisms. stream_event without uuid also falls through here;
76
+ // current CLI versions (1.0+) always provide UUIDs, but older protocol
77
+ // versions may not. In that case, reconnect replay could produce duplicate
78
+ // stream content in the UI — acceptable since stream_events are transient
79
+ // and the final assistant message is always deduplicated.
80
+ return false;
81
+ }