@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,606 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import {
3
+ SessionStateMachine,
4
+ VALID_TRANSITIONS,
5
+ type SessionPhase,
6
+ type SessionTransitionEvent,
7
+ } from "./session-state-machine.js";
8
+
9
+ const ALL_PHASES: SessionPhase[] = [
10
+ "starting",
11
+ "initializing",
12
+ "ready",
13
+ "streaming",
14
+ "awaiting_permission",
15
+ "compacting",
16
+ "reconnecting",
17
+ "terminated",
18
+ ];
19
+
20
+ describe("SessionStateMachine", () => {
21
+ let sm: SessionStateMachine;
22
+
23
+ beforeEach(() => {
24
+ sm = new SessionStateMachine("test-session");
25
+ });
26
+
27
+ // ── Constructor ────────────────────────────────────────────────────
28
+
29
+ describe("constructor", () => {
30
+ it("defaults to 'starting' phase", () => {
31
+ // The default initial phase should be "starting" when no second argument is passed.
32
+ const machine = new SessionStateMachine("s1");
33
+ expect(machine.phase).toBe("starting");
34
+ expect(machine.sessionId).toBe("s1");
35
+ });
36
+
37
+ it("accepts a custom initial phase", () => {
38
+ // When a second argument is provided, the machine should start in that phase.
39
+ const machine = new SessionStateMachine("s2", "ready");
40
+ expect(machine.phase).toBe("ready");
41
+ });
42
+
43
+ it("stores the sessionId", () => {
44
+ const machine = new SessionStateMachine("my-session-id");
45
+ expect(machine.sessionId).toBe("my-session-id");
46
+ });
47
+ });
48
+
49
+ // ── Valid transitions ──────────────────────────────────────────────
50
+
51
+ describe("valid transitions", () => {
52
+ // Helper that creates a fresh machine in the given phase and asserts
53
+ // that transitioning to `to` succeeds and updates the phase.
54
+ function expectValidTransition(
55
+ from: SessionPhase,
56
+ to: SessionPhase,
57
+ ): void {
58
+ const machine = new SessionStateMachine("t", from);
59
+ const result = machine.transition(to, `${from}->${to}`);
60
+ expect(result).toBe(true);
61
+ expect(machine.phase).toBe(to);
62
+ }
63
+
64
+ // starting -> initializing, streaming, reconnecting, terminated
65
+ it("starting -> initializing", () =>
66
+ expectValidTransition("starting", "initializing"));
67
+ it("starting -> streaming", () =>
68
+ expectValidTransition("starting", "streaming"));
69
+ it("starting -> reconnecting", () =>
70
+ expectValidTransition("starting", "reconnecting"));
71
+ it("starting -> terminated", () =>
72
+ expectValidTransition("starting", "terminated"));
73
+
74
+ // initializing -> ready, streaming, reconnecting, terminated
75
+ it("initializing -> ready", () =>
76
+ expectValidTransition("initializing", "ready"));
77
+ it("initializing -> streaming", () =>
78
+ expectValidTransition("initializing", "streaming"));
79
+ it("initializing -> reconnecting", () =>
80
+ expectValidTransition("initializing", "reconnecting"));
81
+ it("initializing -> terminated", () =>
82
+ expectValidTransition("initializing", "terminated"));
83
+
84
+ // ready -> streaming, compacting, reconnecting, terminated
85
+ it("ready -> streaming", () =>
86
+ expectValidTransition("ready", "streaming"));
87
+ it("ready -> compacting", () =>
88
+ expectValidTransition("ready", "compacting"));
89
+ it("ready -> reconnecting", () =>
90
+ expectValidTransition("ready", "reconnecting"));
91
+ it("ready -> terminated", () =>
92
+ expectValidTransition("ready", "terminated"));
93
+
94
+ // streaming -> ready, initializing, awaiting_permission, compacting, reconnecting, terminated
95
+ it("streaming -> ready", () =>
96
+ expectValidTransition("streaming", "ready"));
97
+ it("streaming -> initializing", () =>
98
+ expectValidTransition("streaming", "initializing"));
99
+ it("streaming -> awaiting_permission", () =>
100
+ expectValidTransition("streaming", "awaiting_permission"));
101
+ it("streaming -> compacting", () =>
102
+ expectValidTransition("streaming", "compacting"));
103
+ it("streaming -> reconnecting", () =>
104
+ expectValidTransition("streaming", "reconnecting"));
105
+ it("streaming -> terminated", () =>
106
+ expectValidTransition("streaming", "terminated"));
107
+
108
+ // awaiting_permission -> streaming, ready, reconnecting, terminated
109
+ it("awaiting_permission -> streaming", () =>
110
+ expectValidTransition("awaiting_permission", "streaming"));
111
+ it("awaiting_permission -> ready", () =>
112
+ expectValidTransition("awaiting_permission", "ready"));
113
+ it("awaiting_permission -> reconnecting", () =>
114
+ expectValidTransition("awaiting_permission", "reconnecting"));
115
+ it("awaiting_permission -> terminated", () =>
116
+ expectValidTransition("awaiting_permission", "terminated"));
117
+
118
+ // compacting -> ready, streaming, reconnecting, terminated
119
+ it("compacting -> ready", () =>
120
+ expectValidTransition("compacting", "ready"));
121
+ it("compacting -> streaming", () =>
122
+ expectValidTransition("compacting", "streaming"));
123
+ it("compacting -> reconnecting", () =>
124
+ expectValidTransition("compacting", "reconnecting"));
125
+ it("compacting -> terminated", () =>
126
+ expectValidTransition("compacting", "terminated"));
127
+
128
+ // reconnecting -> initializing, starting, ready, streaming, terminated
129
+ it("reconnecting -> initializing", () =>
130
+ expectValidTransition("reconnecting", "initializing"));
131
+ it("reconnecting -> starting", () =>
132
+ expectValidTransition("reconnecting", "starting"));
133
+ it("reconnecting -> ready", () =>
134
+ expectValidTransition("reconnecting", "ready"));
135
+ it("reconnecting -> streaming", () =>
136
+ expectValidTransition("reconnecting", "streaming"));
137
+ it("reconnecting -> terminated", () =>
138
+ expectValidTransition("reconnecting", "terminated"));
139
+
140
+ // terminated -> starting
141
+ it("terminated -> starting", () =>
142
+ expectValidTransition("terminated", "starting"));
143
+ });
144
+
145
+ // ── Blocked transitions ────────────────────────────────────────────
146
+
147
+ describe("blocked transitions", () => {
148
+ // Helper that creates a fresh machine in the given phase and asserts
149
+ // that transitioning to `to` fails (returns false) and does NOT change the phase.
150
+ function expectBlockedTransition(
151
+ from: SessionPhase,
152
+ to: SessionPhase,
153
+ ): void {
154
+ const warnSpy = vi
155
+ .spyOn(console, "warn")
156
+ .mockImplementation(() => {});
157
+ const machine = new SessionStateMachine("t", from);
158
+ const result = machine.transition(to, `blocked-${from}->${to}`);
159
+ expect(result).toBe(false);
160
+ expect(machine.phase).toBe(from);
161
+ // Structured logger outputs the transition details; verify they appear
162
+ expect(warnSpy).toHaveBeenCalled();
163
+ const warnOutput = warnSpy.mock.calls[0][0] as string;
164
+ expect(warnOutput).toContain(from);
165
+ expect(warnOutput).toContain(to);
166
+ warnSpy.mockRestore();
167
+ }
168
+
169
+ it("starting -> ready is blocked", () =>
170
+ expectBlockedTransition("starting", "ready"));
171
+ it("terminated -> ready is blocked", () =>
172
+ expectBlockedTransition("terminated", "ready"));
173
+ it("terminated -> streaming is blocked", () =>
174
+ expectBlockedTransition("terminated", "streaming"));
175
+ // reconnecting -> ready and reconnecting -> streaming are now valid
176
+ // transitions (needed for Codex adapter WS reconnect recovery).
177
+ it("ready -> initializing is blocked", () =>
178
+ expectBlockedTransition("ready", "initializing"));
179
+ it("awaiting_permission -> compacting is blocked", () =>
180
+ expectBlockedTransition("awaiting_permission", "compacting"));
181
+ });
182
+
183
+ // ── Same-state transition ──────────────────────────────────────────
184
+
185
+ describe("same-state transition", () => {
186
+ it("returns true without calling listeners", () => {
187
+ // A transition to the same phase should be a no-op: return true,
188
+ // keep the same phase, and NOT notify any listeners.
189
+ const listener = vi.fn();
190
+ sm.onTransition(listener);
191
+ const result = sm.transition("starting", "self-transition");
192
+ expect(result).toBe(true);
193
+ expect(sm.phase).toBe("starting");
194
+ expect(listener).not.toHaveBeenCalled();
195
+ });
196
+
197
+ it("works for every phase", () => {
198
+ // Verify same-state transitions are no-ops for all phases.
199
+ for (const phase of ALL_PHASES) {
200
+ const machine = new SessionStateMachine("t", phase);
201
+ const listener = vi.fn();
202
+ machine.onTransition(listener);
203
+ expect(machine.transition(phase, "self")).toBe(true);
204
+ expect(machine.phase).toBe(phase);
205
+ expect(listener).not.toHaveBeenCalled();
206
+ }
207
+ });
208
+ });
209
+
210
+ // ── Guard methods ──────────────────────────────────────────────────
211
+
212
+ describe("guard methods", () => {
213
+ describe("canAcceptUserMessage()", () => {
214
+ it("returns true only in 'ready'", () => {
215
+ // canAcceptUserMessage should be true exclusively when the session is idle ("ready").
216
+ for (const phase of ALL_PHASES) {
217
+ const machine = new SessionStateMachine("t", phase);
218
+ if (phase === "ready") {
219
+ expect(machine.canAcceptUserMessage()).toBe(true);
220
+ } else {
221
+ expect(machine.canAcceptUserMessage()).toBe(false);
222
+ }
223
+ }
224
+ });
225
+ });
226
+
227
+ describe("canRespondToPermission()", () => {
228
+ it("returns true only in 'awaiting_permission'", () => {
229
+ // canRespondToPermission should be true exclusively during a pending permission request.
230
+ for (const phase of ALL_PHASES) {
231
+ const machine = new SessionStateMachine("t", phase);
232
+ if (phase === "awaiting_permission") {
233
+ expect(machine.canRespondToPermission()).toBe(true);
234
+ } else {
235
+ expect(machine.canRespondToPermission()).toBe(false);
236
+ }
237
+ }
238
+ });
239
+ });
240
+
241
+ describe("canSendToCLI()", () => {
242
+ it("returns false for terminated, reconnecting, starting", () => {
243
+ // The CLI socket is unreachable in these phases.
244
+ const unreachable: SessionPhase[] = [
245
+ "terminated",
246
+ "reconnecting",
247
+ "starting",
248
+ ];
249
+ for (const phase of unreachable) {
250
+ const machine = new SessionStateMachine("t", phase);
251
+ expect(machine.canSendToCLI()).toBe(false);
252
+ }
253
+ });
254
+
255
+ it("returns true for initializing, ready, streaming, awaiting_permission, compacting", () => {
256
+ // The CLI socket is expected to be reachable in these phases.
257
+ const reachable: SessionPhase[] = [
258
+ "initializing",
259
+ "ready",
260
+ "streaming",
261
+ "awaiting_permission",
262
+ "compacting",
263
+ ];
264
+ for (const phase of reachable) {
265
+ const machine = new SessionStateMachine("t", phase);
266
+ expect(machine.canSendToCLI()).toBe(true);
267
+ }
268
+ });
269
+ });
270
+
271
+ describe("isActive()", () => {
272
+ it("returns false only in 'terminated'", () => {
273
+ // isActive is false exclusively when the session has terminated.
274
+ for (const phase of ALL_PHASES) {
275
+ const machine = new SessionStateMachine("t", phase);
276
+ if (phase === "terminated") {
277
+ expect(machine.isActive()).toBe(false);
278
+ } else {
279
+ expect(machine.isActive()).toBe(true);
280
+ }
281
+ }
282
+ });
283
+ });
284
+
285
+ describe("isIdle()", () => {
286
+ it("returns true only in 'ready'", () => {
287
+ // isIdle is true exclusively when the session is idle ("ready").
288
+ for (const phase of ALL_PHASES) {
289
+ const machine = new SessionStateMachine("t", phase);
290
+ if (phase === "ready") {
291
+ expect(machine.isIdle()).toBe(true);
292
+ } else {
293
+ expect(machine.isIdle()).toBe(false);
294
+ }
295
+ }
296
+ });
297
+ });
298
+ });
299
+
300
+ // ── Listener tests ─────────────────────────────────────────────────
301
+
302
+ describe("listeners", () => {
303
+ it("listener is called with correct SessionTransitionEvent", () => {
304
+ // When a valid transition occurs, the listener should receive an event
305
+ // containing sessionId, from, to, trigger, and a numeric timestamp.
306
+ const listener = vi.fn();
307
+ sm.onTransition(listener);
308
+
309
+ sm.transition("initializing", "cli-connected");
310
+
311
+ expect(listener).toHaveBeenCalledOnce();
312
+ const event: SessionTransitionEvent = listener.mock.calls[0][0];
313
+ expect(event.sessionId).toBe("test-session");
314
+ expect(event.from).toBe("starting");
315
+ expect(event.to).toBe("initializing");
316
+ expect(event.trigger).toBe("cli-connected");
317
+ expect(typeof event.timestamp).toBe("number");
318
+ expect(event.timestamp).toBeGreaterThan(0);
319
+ });
320
+
321
+ it("multiple listeners are all called", () => {
322
+ const l1 = vi.fn();
323
+ const l2 = vi.fn();
324
+ const l3 = vi.fn();
325
+ sm.onTransition(l1);
326
+ sm.onTransition(l2);
327
+ sm.onTransition(l3);
328
+
329
+ sm.transition("initializing", "test");
330
+
331
+ expect(l1).toHaveBeenCalledOnce();
332
+ expect(l2).toHaveBeenCalledOnce();
333
+ expect(l3).toHaveBeenCalledOnce();
334
+ });
335
+
336
+ it("unsubscribe removes the listener", () => {
337
+ const listener = vi.fn();
338
+ const unsub = sm.onTransition(listener);
339
+
340
+ // Unsubscribe before any transition
341
+ unsub();
342
+ sm.transition("initializing", "test");
343
+
344
+ expect(listener).not.toHaveBeenCalled();
345
+ });
346
+
347
+ it("unsubscribe only removes the specific listener", () => {
348
+ // When one listener unsubscribes, other listeners should still fire.
349
+ const l1 = vi.fn();
350
+ const l2 = vi.fn();
351
+ const unsub1 = sm.onTransition(l1);
352
+ sm.onTransition(l2);
353
+
354
+ unsub1();
355
+ sm.transition("initializing", "test");
356
+
357
+ expect(l1).not.toHaveBeenCalled();
358
+ expect(l2).toHaveBeenCalledOnce();
359
+ });
360
+
361
+ it("listener error is caught and logged without breaking the transition", () => {
362
+ // If a listener throws, the error should be caught, logged, and
363
+ // subsequent listeners should still be called. The transition itself
364
+ // should still succeed.
365
+ const errorSpy = vi
366
+ .spyOn(console, "error")
367
+ .mockImplementation(() => {});
368
+ const badListener = () => {
369
+ throw new Error("listener boom");
370
+ };
371
+ const goodListener = vi.fn();
372
+
373
+ sm.onTransition(badListener);
374
+ sm.onTransition(goodListener);
375
+
376
+ const result = sm.transition("initializing", "test");
377
+
378
+ expect(result).toBe(true);
379
+ expect(sm.phase).toBe("initializing");
380
+ expect(goodListener).toHaveBeenCalledOnce();
381
+ expect(errorSpy).toHaveBeenCalledWith(
382
+ expect.stringContaining("[state-machine] Listener error"),
383
+ expect.any(Error),
384
+ );
385
+ errorSpy.mockRestore();
386
+ });
387
+
388
+ it("listeners are NOT called on a blocked transition", () => {
389
+ // When a transition is invalid/blocked, no listeners should be notified.
390
+ const warnSpy = vi
391
+ .spyOn(console, "warn")
392
+ .mockImplementation(() => {});
393
+ const listener = vi.fn();
394
+ sm.onTransition(listener);
395
+
396
+ // starting -> ready is blocked
397
+ sm.transition("ready", "invalid");
398
+
399
+ expect(listener).not.toHaveBeenCalled();
400
+ warnSpy.mockRestore();
401
+ });
402
+
403
+ it("listeners are NOT called on a same-state transition", () => {
404
+ // Same-state transitions are no-ops and should not notify listeners.
405
+ const listener = vi.fn();
406
+ sm.onTransition(listener);
407
+
408
+ sm.transition("starting", "no-op");
409
+
410
+ expect(listener).not.toHaveBeenCalled();
411
+ });
412
+
413
+ it("snapshot safety: listeners added during dispatch do not fire in the same cycle", () => {
414
+ // Verifies that the listener array is snapshotted before iteration,
415
+ // so a listener that adds another listener during dispatch does not
416
+ // cause the new listener to fire in the same transition.
417
+ const lateListener = vi.fn();
418
+ const adder = () => {
419
+ sm.onTransition(lateListener);
420
+ };
421
+
422
+ sm.onTransition(adder);
423
+ sm.transition("initializing", "test");
424
+
425
+ // lateListener was added during dispatch but should NOT have been called
426
+ expect(lateListener).not.toHaveBeenCalled();
427
+ });
428
+ });
429
+
430
+ // ── forceState ─────────────────────────────────────────────────────
431
+
432
+ describe("forceState", () => {
433
+ it("sets state without validation", () => {
434
+ // forceState should allow setting to any phase, even if the transition
435
+ // would normally be blocked (e.g. starting -> awaiting_permission).
436
+ sm.forceState("awaiting_permission");
437
+ expect(sm.phase).toBe("awaiting_permission");
438
+ });
439
+
440
+ it("does not call listeners", () => {
441
+ const listener = vi.fn();
442
+ sm.onTransition(listener);
443
+
444
+ sm.forceState("ready");
445
+
446
+ expect(sm.phase).toBe("ready");
447
+ expect(listener).not.toHaveBeenCalled();
448
+ });
449
+
450
+ it("works for any target state", () => {
451
+ // forceState should work for every defined phase, regardless of current state.
452
+ for (const phase of ALL_PHASES) {
453
+ sm.forceState(phase);
454
+ expect(sm.phase).toBe(phase);
455
+ }
456
+ });
457
+
458
+ it("allows normally-invalid state jumps", () => {
459
+ // Verify that forceState bypasses the transition table entirely.
460
+ // terminated -> streaming is not in VALID_TRANSITIONS but forceState should allow it.
461
+ sm.forceState("terminated");
462
+ expect(sm.phase).toBe("terminated");
463
+ sm.forceState("streaming");
464
+ expect(sm.phase).toBe("streaming");
465
+ });
466
+ });
467
+
468
+ // ── Full lifecycle scenario ────────────────────────────────────────
469
+
470
+ describe("full lifecycle scenario", () => {
471
+ it("walks through a complete session lifecycle", () => {
472
+ // Simulates a realistic session lifecycle:
473
+ // starting -> initializing -> ready -> streaming -> awaiting_permission
474
+ // -> streaming -> ready -> reconnecting -> initializing -> ready
475
+ // -> terminated -> starting
476
+ const events: SessionTransitionEvent[] = [];
477
+ sm.onTransition((e) => events.push(e));
478
+
479
+ // CLI connects
480
+ expect(sm.transition("initializing", "cli-ws-connected")).toBe(true);
481
+ expect(sm.phase).toBe("initializing");
482
+
483
+ // system.init received
484
+ expect(sm.transition("ready", "system-init")).toBe(true);
485
+ expect(sm.phase).toBe("ready");
486
+ expect(sm.canAcceptUserMessage()).toBe(true);
487
+ expect(sm.isIdle()).toBe(true);
488
+
489
+ // User sends message, streaming begins
490
+ expect(sm.transition("streaming", "user-message")).toBe(true);
491
+ expect(sm.phase).toBe("streaming");
492
+ expect(sm.canAcceptUserMessage()).toBe(false);
493
+ expect(sm.canSendToCLI()).toBe(true);
494
+
495
+ // Tool call requires permission
496
+ expect(
497
+ sm.transition("awaiting_permission", "tool-control-request"),
498
+ ).toBe(true);
499
+ expect(sm.phase).toBe("awaiting_permission");
500
+ expect(sm.canRespondToPermission()).toBe(true);
501
+
502
+ // User approves, streaming resumes
503
+ expect(sm.transition("streaming", "permission-granted")).toBe(true);
504
+ expect(sm.phase).toBe("streaming");
505
+ expect(sm.canRespondToPermission()).toBe(false);
506
+
507
+ // Streaming completes
508
+ expect(sm.transition("ready", "result-received")).toBe(true);
509
+ expect(sm.phase).toBe("ready");
510
+ expect(sm.isIdle()).toBe(true);
511
+
512
+ // Network interruption
513
+ expect(sm.transition("reconnecting", "ws-close")).toBe(true);
514
+ expect(sm.phase).toBe("reconnecting");
515
+ expect(sm.canSendToCLI()).toBe(false);
516
+ expect(sm.isActive()).toBe(true);
517
+
518
+ // CLI reconnects
519
+ expect(sm.transition("initializing", "cli-ws-reconnected")).toBe(
520
+ true,
521
+ );
522
+ expect(sm.phase).toBe("initializing");
523
+ expect(sm.canSendToCLI()).toBe(true);
524
+
525
+ // Re-initialized
526
+ expect(sm.transition("ready", "system-init")).toBe(true);
527
+ expect(sm.phase).toBe("ready");
528
+
529
+ // Session terminated
530
+ expect(sm.transition("terminated", "process-exit")).toBe(true);
531
+ expect(sm.phase).toBe("terminated");
532
+ expect(sm.isActive()).toBe(false);
533
+ expect(sm.canSendToCLI()).toBe(false);
534
+
535
+ // Restarted
536
+ expect(sm.transition("starting", "relaunch")).toBe(true);
537
+ expect(sm.phase).toBe("starting");
538
+ expect(sm.isActive()).toBe(true);
539
+
540
+ // Verify all transitions were recorded
541
+ expect(events).toHaveLength(11);
542
+ expect(events.map((e) => `${e.from}->${e.to}`)).toEqual([
543
+ "starting->initializing",
544
+ "initializing->ready",
545
+ "ready->streaming",
546
+ "streaming->awaiting_permission",
547
+ "awaiting_permission->streaming",
548
+ "streaming->ready",
549
+ "ready->reconnecting",
550
+ "reconnecting->initializing",
551
+ "initializing->ready",
552
+ "ready->terminated",
553
+ "terminated->starting",
554
+ ]);
555
+
556
+ // All events should have the correct sessionId and valid timestamps
557
+ for (const event of events) {
558
+ expect(event.sessionId).toBe("test-session");
559
+ expect(typeof event.timestamp).toBe("number");
560
+ expect(event.timestamp).toBeGreaterThan(0);
561
+ }
562
+ });
563
+
564
+ it("handles early user message: starting -> streaming -> initializing -> ready", () => {
565
+ // When a user sends a message before the CLI connects, the session
566
+ // transitions starting -> streaming. When the CLI later connects,
567
+ // it goes streaming -> initializing, then proceeds normally.
568
+ const earlyMsg = new SessionStateMachine("early-msg", "starting");
569
+ const events: SessionTransitionEvent[] = [];
570
+ earlyMsg.onTransition((e) => events.push(e));
571
+
572
+ expect(earlyMsg.transition("streaming", "user_message")).toBe(true);
573
+ expect(earlyMsg.transition("initializing", "cli_ws_open")).toBe(true);
574
+ expect(earlyMsg.transition("ready", "system_init")).toBe(true);
575
+ expect(earlyMsg.transition("streaming", "user_message")).toBe(true);
576
+
577
+ expect(events.map((e) => `${e.from}->${e.to}`)).toEqual([
578
+ "starting->streaming",
579
+ "streaming->initializing",
580
+ "initializing->ready",
581
+ "ready->streaming",
582
+ ]);
583
+ });
584
+ });
585
+
586
+ // ── VALID_TRANSITIONS table completeness ───────────────────────────
587
+
588
+ describe("VALID_TRANSITIONS table", () => {
589
+ it("every phase has an entry in the transition table", () => {
590
+ // All defined phases should have a row in the transition table,
591
+ // ensuring no phase is silently missing from the map.
592
+ for (const phase of ALL_PHASES) {
593
+ expect(VALID_TRANSITIONS.has(phase)).toBe(true);
594
+ }
595
+ });
596
+
597
+ it("all target phases in the table are valid SessionPhase values", () => {
598
+ // Ensures no typos or invalid phases in the transition targets.
599
+ for (const [, targets] of VALID_TRANSITIONS) {
600
+ for (const target of targets) {
601
+ expect(ALL_PHASES).toContain(target);
602
+ }
603
+ }
604
+ });
605
+ });
606
+ });