@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,272 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import {
3
+ parseBrowserMessage,
4
+ deduplicateBrowserMessage,
5
+ IDEMPOTENT_BROWSER_MESSAGE_TYPES,
6
+ } from "./ws-bridge-browser-ingest.js";
7
+ import type { Session } from "./ws-bridge-types.js";
8
+ import { SessionStateMachine } from "./session-state-machine.js";
9
+
10
+ function makeDedupSession(): Session {
11
+ return {
12
+ id: "test-session",
13
+ backendType: "claude",
14
+ backendAdapter: null,
15
+ browserSockets: new Set(),
16
+ state: {} as any,
17
+ pendingPermissions: new Map(),
18
+ messageHistory: [],
19
+ pendingMessages: [],
20
+ nextEventSeq: 1,
21
+ eventBuffer: [],
22
+ lastAckSeq: 0,
23
+ processedClientMessageIds: [],
24
+ processedClientMessageIdSet: new Set(),
25
+ lastCliActivityTs: Date.now(),
26
+ stateMachine: new SessionStateMachine("test-session"),
27
+ };
28
+ }
29
+
30
+ // ─── parseBrowserMessage ──────────────────────────────────────────────────────
31
+
32
+ describe("parseBrowserMessage", () => {
33
+ it("parses valid JSON into BrowserOutgoingMessage", () => {
34
+ const raw = '{"type":"user_message","content":"hello"}';
35
+ const msg = parseBrowserMessage(raw);
36
+ expect(msg).toEqual({ type: "user_message", content: "hello" });
37
+ });
38
+
39
+ it("returns null for malformed JSON", () => {
40
+ // Suppress the console.warn from parseBrowserMessage
41
+ vi.spyOn(console, "warn").mockImplementation(() => {});
42
+ expect(parseBrowserMessage("{invalid")).toBeNull();
43
+ vi.restoreAllMocks();
44
+ });
45
+
46
+ it("returns null for empty string", () => {
47
+ vi.spyOn(console, "warn").mockImplementation(() => {});
48
+ expect(parseBrowserMessage("")).toBeNull();
49
+ vi.restoreAllMocks();
50
+ });
51
+
52
+ it("handles Buffer input", () => {
53
+ const raw = Buffer.from('{"type":"interrupt"}', "utf-8");
54
+ const msg = parseBrowserMessage(raw);
55
+ expect(msg).toEqual({ type: "interrupt" });
56
+ });
57
+
58
+ it("handles complex message types", () => {
59
+ const raw = JSON.stringify({
60
+ type: "permission_response",
61
+ request_id: "req-1",
62
+ behavior: "allow",
63
+ client_msg_id: "cmid-1",
64
+ });
65
+ const msg = parseBrowserMessage(raw);
66
+ expect(msg).toEqual({
67
+ type: "permission_response",
68
+ request_id: "req-1",
69
+ behavior: "allow",
70
+ client_msg_id: "cmid-1",
71
+ });
72
+ });
73
+ });
74
+
75
+ // ─── deduplicateBrowserMessage ────────────────────────────────────────────────
76
+
77
+ describe("deduplicateBrowserMessage", () => {
78
+ let session: Session;
79
+ let persistFn: ReturnType<typeof vi.fn<(session: Session) => void>>;
80
+
81
+ beforeEach(() => {
82
+ session = makeDedupSession();
83
+ persistFn = vi.fn<(session: Session) => void>();
84
+ });
85
+
86
+ it("returns false for first occurrence of a message with client_msg_id", () => {
87
+ const msg = { type: "user_message" as const, content: "hello", client_msg_id: "id-1" };
88
+ const result = deduplicateBrowserMessage(
89
+ msg, IDEMPOTENT_BROWSER_MESSAGE_TYPES, session, 100, persistFn,
90
+ );
91
+ expect(result).toBe(false);
92
+ });
93
+
94
+ it("returns true for duplicate message with same client_msg_id", () => {
95
+ const msg = { type: "user_message" as const, content: "hello", client_msg_id: "id-1" };
96
+
97
+ // First call: not a duplicate
98
+ deduplicateBrowserMessage(msg, IDEMPOTENT_BROWSER_MESSAGE_TYPES, session, 100, persistFn);
99
+ // Second call: duplicate
100
+ const result = deduplicateBrowserMessage(
101
+ msg, IDEMPOTENT_BROWSER_MESSAGE_TYPES, session, 100, persistFn,
102
+ );
103
+ expect(result).toBe(true);
104
+ });
105
+
106
+ it("returns false for messages without client_msg_id", () => {
107
+ const msg = { type: "user_message" as const, content: "hello" };
108
+
109
+ // No client_msg_id — never considered duplicate
110
+ expect(deduplicateBrowserMessage(msg, IDEMPOTENT_BROWSER_MESSAGE_TYPES, session, 100, persistFn)).toBe(false);
111
+ expect(deduplicateBrowserMessage(msg, IDEMPOTENT_BROWSER_MESSAGE_TYPES, session, 100, persistFn)).toBe(false);
112
+ });
113
+
114
+ it("returns false for messages with empty client_msg_id", () => {
115
+ const msg = { type: "user_message" as const, content: "hello", client_msg_id: "" };
116
+
117
+ expect(deduplicateBrowserMessage(msg, IDEMPOTENT_BROWSER_MESSAGE_TYPES, session, 100, persistFn)).toBe(false);
118
+ expect(deduplicateBrowserMessage(msg, IDEMPOTENT_BROWSER_MESSAGE_TYPES, session, 100, persistFn)).toBe(false);
119
+ });
120
+
121
+ it("returns false for non-idempotent message types even with client_msg_id", () => {
122
+ // session_subscribe and session_ack are not in IDEMPOTENT_BROWSER_MESSAGE_TYPES
123
+ const msg = { type: "session_subscribe" as const, last_seq: 0, client_msg_id: "id-1" } as any;
124
+
125
+ expect(deduplicateBrowserMessage(msg, IDEMPOTENT_BROWSER_MESSAGE_TYPES, session, 100, persistFn)).toBe(false);
126
+ // Same message again — still not deduplicated because type is not idempotent
127
+ expect(deduplicateBrowserMessage(msg, IDEMPOTENT_BROWSER_MESSAGE_TYPES, session, 100, persistFn)).toBe(false);
128
+ });
129
+
130
+ it("calls persistFn when remembering a new client_msg_id", () => {
131
+ const msg = { type: "interrupt" as const, client_msg_id: "id-1" };
132
+ deduplicateBrowserMessage(msg, IDEMPOTENT_BROWSER_MESSAGE_TYPES, session, 100, persistFn);
133
+
134
+ expect(persistFn).toHaveBeenCalledWith(session);
135
+ });
136
+
137
+ it("does not call persistFn for duplicate messages", () => {
138
+ const msg = { type: "interrupt" as const, client_msg_id: "id-1" };
139
+ deduplicateBrowserMessage(msg, IDEMPOTENT_BROWSER_MESSAGE_TYPES, session, 100, persistFn);
140
+ persistFn.mockClear();
141
+
142
+ // Second call — duplicate, should not persist
143
+ deduplicateBrowserMessage(msg, IDEMPOTENT_BROWSER_MESSAGE_TYPES, session, 100, persistFn);
144
+ expect(persistFn).not.toHaveBeenCalled();
145
+ });
146
+
147
+ it("deduplicates within each idempotent message type", () => {
148
+ // Verify each idempotent type is individually deduped by client_msg_id
149
+ const types = Array.from(IDEMPOTENT_BROWSER_MESSAGE_TYPES);
150
+ for (const type of types) {
151
+ const msg = { type, client_msg_id: `${type}-id` } as any;
152
+ expect(deduplicateBrowserMessage(msg, IDEMPOTENT_BROWSER_MESSAGE_TYPES, session, 100, persistFn)).toBe(false);
153
+ expect(deduplicateBrowserMessage(msg, IDEMPOTENT_BROWSER_MESSAGE_TYPES, session, 100, persistFn)).toBe(true);
154
+ }
155
+ });
156
+
157
+ it("deduplicates across different idempotent message types with same client_msg_id", () => {
158
+ // A shared client_msg_id should be deduplicated regardless of which
159
+ // idempotent type sends it — the dedup namespace is type-agnostic.
160
+ const sharedId = "shared-cross-type-id";
161
+ const msg1 = { type: "user_message" as const, content: "hello", client_msg_id: sharedId };
162
+ expect(deduplicateBrowserMessage(msg1, IDEMPOTENT_BROWSER_MESSAGE_TYPES, session, 100, persistFn)).toBe(false);
163
+
164
+ // Same client_msg_id from a different idempotent type — should be filtered
165
+ const msg2 = { type: "interrupt" as const, client_msg_id: sharedId };
166
+ expect(deduplicateBrowserMessage(msg2, IDEMPOTENT_BROWSER_MESSAGE_TYPES, session, 100, persistFn)).toBe(true);
167
+ });
168
+
169
+ it("enforces window cap by evicting oldest client_msg_ids", () => {
170
+ const windowSize = 3;
171
+
172
+ // Fill window with 3 IDs
173
+ for (let i = 0; i < 3; i++) {
174
+ const msg = { type: "user_message" as const, content: "", client_msg_id: `id-${i}` };
175
+ deduplicateBrowserMessage(msg, IDEMPOTENT_BROWSER_MESSAGE_TYPES, session, windowSize, persistFn);
176
+ }
177
+
178
+ // Add a 4th — should evict id-0
179
+ const msg4 = { type: "user_message" as const, content: "", client_msg_id: "id-3" };
180
+ deduplicateBrowserMessage(msg4, IDEMPOTENT_BROWSER_MESSAGE_TYPES, session, windowSize, persistFn);
181
+
182
+ // id-0 should no longer be considered a duplicate (evicted)
183
+ const msg0 = { type: "user_message" as const, content: "", client_msg_id: "id-0" };
184
+ expect(deduplicateBrowserMessage(msg0, IDEMPOTENT_BROWSER_MESSAGE_TYPES, session, windowSize, persistFn)).toBe(false);
185
+
186
+ // id-1 should still be a duplicate...
187
+ // But adding id-0 back means window is now [id-2, id-3, id-0], so id-1 is evicted
188
+ });
189
+
190
+ describe("reconnect scenarios", () => {
191
+ it("filters resent user_message after browser reconnect", () => {
192
+ // Browser sends user_message with client_msg_id, disconnects, reconnects,
193
+ // and resends the same message. Should be filtered.
194
+ const msg = { type: "user_message" as const, content: "hello", client_msg_id: "msg-1" };
195
+
196
+ // Original send
197
+ expect(deduplicateBrowserMessage(msg, IDEMPOTENT_BROWSER_MESSAGE_TYPES, session, 100, persistFn)).toBe(false);
198
+
199
+ // After reconnect: same message resent
200
+ expect(deduplicateBrowserMessage(msg, IDEMPOTENT_BROWSER_MESSAGE_TYPES, session, 100, persistFn)).toBe(true);
201
+ });
202
+
203
+ it("filters resent permission_response after browser reconnect", () => {
204
+ const msg = {
205
+ type: "permission_response" as const,
206
+ request_id: "req-1",
207
+ behavior: "allow" as const,
208
+ client_msg_id: "perm-1",
209
+ };
210
+
211
+ expect(deduplicateBrowserMessage(msg, IDEMPOTENT_BROWSER_MESSAGE_TYPES, session, 100, persistFn)).toBe(false);
212
+ expect(deduplicateBrowserMessage(msg, IDEMPOTENT_BROWSER_MESSAGE_TYPES, session, 100, persistFn)).toBe(true);
213
+ });
214
+
215
+ it("two browsers with same client_msg_id — second is filtered", () => {
216
+ // If two browsers somehow send the same client_msg_id (e.g., copied tab),
217
+ // the second should be filtered to ensure idempotency.
218
+ const msg = { type: "user_message" as const, content: "hello", client_msg_id: "shared-id" };
219
+
220
+ // Browser A sends
221
+ expect(deduplicateBrowserMessage(msg, IDEMPOTENT_BROWSER_MESSAGE_TYPES, session, 100, persistFn)).toBe(false);
222
+ // Browser B sends same client_msg_id
223
+ expect(deduplicateBrowserMessage(msg, IDEMPOTENT_BROWSER_MESSAGE_TYPES, session, 100, persistFn)).toBe(true);
224
+ });
225
+
226
+ it("dedup survives server restart via processedClientMessageIds persistence", () => {
227
+ // This tests the critical path: browser sends message → server persists
228
+ // processedClientMessageIds → server restarts → session restored from disk
229
+ // → browser retransmits same message → dedup fires.
230
+ //
231
+ // Step 1: Process a message (simulates pre-restart state)
232
+ const msg = { type: "user_message" as const, content: "hello", client_msg_id: "restart-id-1" };
233
+ expect(deduplicateBrowserMessage(msg, IDEMPOTENT_BROWSER_MESSAGE_TYPES, session, 100, persistFn)).toBe(false);
234
+
235
+ // Step 2: Simulate server restart — create a new session restored from disk.
236
+ // restoreFromDisk reconstructs processedClientMessageIdSet from the persisted
237
+ // processedClientMessageIds array (see WsBridge.restoreFromDisk).
238
+ const restoredSession = makeDedupSession();
239
+ restoredSession.processedClientMessageIds = [...session.processedClientMessageIds];
240
+ restoredSession.processedClientMessageIdSet = new Set(session.processedClientMessageIds);
241
+
242
+ // Step 3: Browser retransmits the same message after reconnecting
243
+ const result = deduplicateBrowserMessage(msg, IDEMPOTENT_BROWSER_MESSAGE_TYPES, restoredSession, 100, persistFn);
244
+ expect(result).toBe(true); // Should be deduplicated
245
+
246
+ // Step 4: A new message should still pass through
247
+ const newMsg = { type: "user_message" as const, content: "world", client_msg_id: "restart-id-2" };
248
+ expect(deduplicateBrowserMessage(newMsg, IDEMPOTENT_BROWSER_MESSAGE_TYPES, restoredSession, 100, persistFn)).toBe(false);
249
+ });
250
+ });
251
+ });
252
+
253
+ // ─── IDEMPOTENT_BROWSER_MESSAGE_TYPES ─────────────────────────────────────────
254
+
255
+ describe("IDEMPOTENT_BROWSER_MESSAGE_TYPES", () => {
256
+ it("contains the expected message types", () => {
257
+ const expected = [
258
+ "user_message", "permission_response", "interrupt", "set_model",
259
+ "set_permission_mode", "mcp_get_status", "mcp_toggle", "mcp_reconnect",
260
+ "mcp_set_servers", "set_ai_validation",
261
+ ];
262
+ for (const type of expected) {
263
+ expect(IDEMPOTENT_BROWSER_MESSAGE_TYPES.has(type)).toBe(true);
264
+ }
265
+ });
266
+
267
+ it("does not contain session_subscribe or session_ack", () => {
268
+ // These are session management messages, not idempotent user actions
269
+ expect(IDEMPOTENT_BROWSER_MESSAGE_TYPES.has("session_subscribe")).toBe(false);
270
+ expect(IDEMPOTENT_BROWSER_MESSAGE_TYPES.has("session_ack")).toBe(false);
271
+ });
272
+ });
@@ -0,0 +1,72 @@
1
+ import type { BrowserOutgoingMessage } from "./session-types.js";
2
+ import type { Session } from "./ws-bridge-types.js";
3
+ import {
4
+ isDuplicateClientMessage,
5
+ rememberClientMessage,
6
+ } from "./ws-bridge-replay.js";
7
+
8
+ // ─── Browser Ingest Pipeline ────────────────────────────────────────────────
9
+ // Pure functions for parsing and deduplicating browser WebSocket messages.
10
+ // Extracted from WsBridge.handleBrowserMessage and routeBrowserMessage
11
+ // to enable isolated testing of idempotent message scenarios.
12
+
13
+ /** Message types that support client_msg_id-based deduplication. */
14
+ export const IDEMPOTENT_BROWSER_MESSAGE_TYPES: ReadonlySet<string> = new Set([
15
+ "user_message",
16
+ "permission_response",
17
+ "interrupt",
18
+ "set_model",
19
+ "set_permission_mode",
20
+ "mcp_get_status",
21
+ "mcp_toggle",
22
+ "mcp_reconnect",
23
+ "mcp_set_servers",
24
+ "set_ai_validation",
25
+ ]);
26
+
27
+ /**
28
+ * Parse a raw browser WebSocket message into a typed BrowserOutgoingMessage.
29
+ * Returns null if parsing fails (malformed JSON).
30
+ */
31
+ export function parseBrowserMessage(raw: string | Buffer): BrowserOutgoingMessage | null {
32
+ const data = typeof raw === "string" ? raw : raw.toString("utf-8");
33
+ try {
34
+ return JSON.parse(data) as BrowserOutgoingMessage;
35
+ } catch {
36
+ console.warn(`[ws-bridge] Failed to parse browser message: ${data.substring(0, 200)}`);
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Check if a browser message is a duplicate based on client_msg_id.
43
+ * Returns true if the message should be skipped.
44
+ *
45
+ * Only checks messages whose type is in `idempotentTypes` and that have
46
+ * a non-empty `client_msg_id` field. For non-idempotent types or messages
47
+ * without client_msg_id, always returns false.
48
+ *
49
+ * If not a duplicate, remembers the client_msg_id for future dedup checks.
50
+ */
51
+ export function deduplicateBrowserMessage(
52
+ msg: BrowserOutgoingMessage,
53
+ idempotentTypes: ReadonlySet<string>,
54
+ session: Session,
55
+ processedIdLimit: number,
56
+ persistFn: (session: Session) => void,
57
+ ): boolean {
58
+ if (
59
+ !idempotentTypes.has(msg.type)
60
+ || !("client_msg_id" in msg)
61
+ || !msg.client_msg_id
62
+ ) {
63
+ return false;
64
+ }
65
+
66
+ if (isDuplicateClientMessage(session, msg.client_msg_id)) {
67
+ return true;
68
+ }
69
+
70
+ rememberClientMessage(session, msg.client_msg_id, processedIdLimit, persistFn);
71
+ return false;
72
+ }
@@ -0,0 +1,112 @@
1
+ import type { ServerWebSocket } from "bun";
2
+ import type { BrowserSocketData, Session, SocketData } from "./ws-bridge-types.js";
3
+ import type {
4
+ BrowserIncomingMessage,
5
+ ReplayableBrowserIncomingMessage,
6
+ } from "./session-types.js";
7
+
8
+ /**
9
+ * Infer the CLI's current status from server-side session state.
10
+ * Used as a ground-truth correction after event replay to prevent
11
+ * stale "running"/"generating" state when `result` was pruned from
12
+ * the event buffer.
13
+ */
14
+ function inferCliStatus(session: Session): "idle" | "running" | "compacting" | null {
15
+ if (session.state.is_compacting) return "compacting";
16
+ const last = session.messageHistory[session.messageHistory.length - 1];
17
+ if (!last) return "idle";
18
+ // `result` means the last turn completed → idle
19
+ if (last.type === "result") return "idle";
20
+ // `assistant` means CLI sent a response and is executing tools or streaming → running
21
+ if (last.type === "assistant") return "running";
22
+ // For other types (user_message, system_event), default to idle
23
+ return "idle";
24
+ }
25
+
26
+ export function handleSessionSubscribe(
27
+ session: Session,
28
+ ws: ServerWebSocket<SocketData> | undefined,
29
+ lastSeq: number,
30
+ sendToBrowser: (ws: ServerWebSocket<SocketData>, msg: BrowserIncomingMessage) => void,
31
+ isHistoryBackedEvent: (msg: ReplayableBrowserIncomingMessage) => boolean,
32
+ ): void {
33
+ if (!ws) return;
34
+ const data = ws.data as BrowserSocketData;
35
+ data.subscribed = true;
36
+ const lastAckSeq = Number.isFinite(lastSeq) ? Math.max(0, Math.floor(lastSeq)) : 0;
37
+ data.lastAckSeq = lastAckSeq;
38
+
39
+ if (lastAckSeq === 0 && session.messageHistory.length > 0) {
40
+ sendToBrowser(ws, {
41
+ type: "message_history",
42
+ messages: session.messageHistory,
43
+ });
44
+ }
45
+
46
+ if (session.eventBuffer.length === 0) return;
47
+ if (lastAckSeq >= session.nextEventSeq - 1) return;
48
+
49
+ const earliest = session.eventBuffer[0]?.seq ?? session.nextEventSeq;
50
+ const hasGap = lastAckSeq > 0 && lastAckSeq < earliest - 1;
51
+ if (hasGap) {
52
+ if (session.messageHistory.length > 0) {
53
+ sendToBrowser(ws, {
54
+ type: "message_history",
55
+ messages: session.messageHistory,
56
+ });
57
+ }
58
+ const transientMissed = session.eventBuffer
59
+ .filter((evt) => evt.seq > lastAckSeq && !isHistoryBackedEvent(evt.message));
60
+ if (transientMissed.length > 0) {
61
+ sendToBrowser(ws, {
62
+ type: "event_replay",
63
+ events: transientMissed,
64
+ });
65
+ }
66
+ // Send ground-truth status after replay to correct stale streaming state
67
+ sendToBrowser(ws, { type: "status_change", status: inferCliStatus(session) });
68
+ // Send authoritative session_phase so replayed transient phases don't leave stale cliConnected
69
+ sendToBrowser(ws, {
70
+ type: "session_phase",
71
+ phase: session.stateMachine.phase,
72
+ previousPhase: session.stateMachine.phase,
73
+ });
74
+ return;
75
+ }
76
+
77
+ const sentFullHistory = lastAckSeq === 0 && session.messageHistory.length > 0;
78
+ const missed = session.eventBuffer.filter(
79
+ (evt) => evt.seq > lastAckSeq && (!sentFullHistory || !isHistoryBackedEvent(evt.message)),
80
+ );
81
+ if (missed.length === 0) return;
82
+ sendToBrowser(ws, {
83
+ type: "event_replay",
84
+ events: missed,
85
+ });
86
+ // Send ground-truth status after replay to correct stale streaming state
87
+ sendToBrowser(ws, { type: "status_change", status: inferCliStatus(session) });
88
+ // Send authoritative session_phase so replayed transient phases don't leave stale cliConnected
89
+ sendToBrowser(ws, {
90
+ type: "session_phase",
91
+ phase: session.stateMachine.phase,
92
+ previousPhase: session.stateMachine.phase,
93
+ });
94
+ }
95
+
96
+ export function handleSessionAck(
97
+ session: Session,
98
+ ws: ServerWebSocket<SocketData> | undefined,
99
+ lastSeq: number,
100
+ persistSession: (session: Session) => void,
101
+ ): void {
102
+ const normalized = Number.isFinite(lastSeq) ? Math.max(0, Math.floor(lastSeq)) : 0;
103
+ if (ws) {
104
+ const data = ws.data as BrowserSocketData;
105
+ const prior = typeof data.lastAckSeq === "number" ? data.lastAckSeq : 0;
106
+ data.lastAckSeq = Math.max(prior, normalized);
107
+ }
108
+ if (normalized > session.lastAckSeq) {
109
+ session.lastAckSeq = normalized;
110
+ persistSession(session);
111
+ }
112
+ }