@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.
- package/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
- package/dist/assets/CronManager-EGwLJONv.js +1 -0
- package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
- package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
- package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
- package/dist/assets/Playground-BV3k0RbV.js +109 -0
- package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
- package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
- package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
- package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
- package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
- package/dist/assets/index-BhUa1e6X.css +1 -0
- package/dist/assets/index-DkqeP-R9.js +134 -0
- package/dist/assets/sw-register-BibwRdvC.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +20 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/sw.js +2 -0
- package/package.json +104 -0
- package/server/agent-cron-migrator.test.ts +610 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.test.ts +1108 -0
- package/server/agent-executor.ts +346 -0
- package/server/agent-store.test.ts +588 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-types.ts +138 -0
- package/server/ai-validation-settings.test.ts +128 -0
- package/server/ai-validation-settings.ts +35 -0
- package/server/ai-validator.test.ts +387 -0
- package/server/ai-validator.ts +271 -0
- package/server/auth-manager.test.ts +83 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-namer.test.ts +252 -0
- package/server/auto-namer.ts +78 -0
- package/server/backend-adapter.test.ts +38 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.test.ts +98 -0
- package/server/cache-headers.ts +61 -0
- package/server/claude-adapter.test.ts +1363 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.test.ts +44 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-protocol-contract.test.ts +71 -0
- package/server/claude-protocol-drift.test.ts +78 -0
- package/server/claude-session-discovery.test.ts +132 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.test.ts +158 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.test.ts +1343 -0
- package/server/cli-launcher.ts +1298 -0
- package/server/cli.test.ts +16 -0
- package/server/codex-adapter.test.ts +5545 -0
- package/server/codex-adapter.ts +3062 -0
- package/server/codex-container-auth.test.ts +50 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.test.ts +61 -0
- package/server/codex-home.ts +26 -0
- package/server/codex-protocol-contract.test.ts +96 -0
- package/server/codex-protocol-drift.test.ts +123 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.test.ts +179 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.test.ts +1211 -0
- package/server/container-manager.ts +1053 -0
- package/server/cron-scheduler.test.ts +957 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.test.ts +422 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/env-manager.test.ts +268 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +64 -0
- package/server/event-bus.test.ts +244 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.test.ts +307 -0
- package/server/execution-store.ts +170 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.test.ts +938 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.test.ts +498 -0
- package/server/github-pr.ts +379 -0
- package/server/image-pull-manager.test.ts +303 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +396 -0
- package/server/linear-agent-bridge.test.ts +1157 -0
- package/server/linear-agent-bridge.ts +629 -0
- package/server/linear-agent.test.ts +473 -0
- package/server/linear-agent.ts +479 -0
- package/server/linear-cache.test.ts +136 -0
- package/server/linear-cache.ts +113 -0
- package/server/linear-connections.test.ts +350 -0
- package/server/linear-connections.ts +231 -0
- package/server/linear-credential-migration.test.ts +337 -0
- package/server/linear-credential-migration.ts +63 -0
- package/server/linear-oauth-connections-migration.test.ts +268 -0
- package/server/linear-oauth-connections.test.ts +365 -0
- package/server/linear-oauth-connections.ts +294 -0
- package/server/linear-project-manager.test.ts +162 -0
- package/server/linear-project-manager.ts +111 -0
- package/server/linear-prompt-builder.test.ts +74 -0
- package/server/linear-prompt-builder.ts +61 -0
- package/server/linear-staging.test.ts +276 -0
- package/server/linear-staging.ts +142 -0
- package/server/logger.test.ts +393 -0
- package/server/logger.ts +259 -0
- package/server/metrics-collector.test.ts +413 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.test.ts +264 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.test.ts +333 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.test.ts +552 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.test.ts +31 -0
- package/server/paths.ts +11 -0
- package/server/pr-poller.test.ts +191 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.test.ts +211 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/recorder.test.ts +454 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.test.ts +150 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.test.ts +140 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.test.ts +44 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.test.ts +417 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.test.ts +262 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.test.ts +294 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.test.ts +337 -0
- package/server/relay-client.ts +320 -0
- package/server/replay.test.ts +200 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.test.ts +1400 -0
- package/server/routes/agent-routes.ts +409 -0
- package/server/routes/cron-routes.test.ts +881 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.test.ts +383 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/fs-routes.test.ts +1198 -0
- package/server/routes/fs-routes.ts +605 -0
- package/server/routes/git-routes.test.ts +813 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/linear-agent-routes.test.ts +721 -0
- package/server/routes/linear-agent-routes.ts +304 -0
- package/server/routes/linear-connection-routes.test.ts +927 -0
- package/server/routes/linear-connection-routes.ts +244 -0
- package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
- package/server/routes/linear-oauth-connection-routes.ts +129 -0
- package/server/routes/linear-routes.test.ts +1510 -0
- package/server/routes/linear-routes.ts +953 -0
- package/server/routes/metrics-routes.test.ts +103 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/sandbox-routes.test.ts +513 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +270 -0
- package/server/routes/skills-routes.test.ts +690 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/system-routes.test.ts +637 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.test.ts +176 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes.test.ts +4655 -0
- package/server/routes.ts +1277 -0
- package/server/sandbox-manager.test.ts +378 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.test.ts +1419 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.test.ts +661 -0
- package/server/session-creation-service.ts +473 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-linear-issues.test.ts +118 -0
- package/server/session-linear-issues.ts +88 -0
- package/server/session-names.test.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.test.ts +1784 -0
- package/server/session-orchestrator.ts +973 -0
- package/server/session-state-machine.test.ts +606 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.test.ts +290 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +509 -0
- package/server/settings-manager.test.ts +275 -0
- package/server/settings-manager.ts +173 -0
- package/server/tailscale-manager.test.ts +553 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.test.ts +306 -0
- package/server/update-checker.ts +197 -0
- package/server/usage-limits.test.ts +536 -0
- package/server/usage-limits.ts +225 -0
- package/server/worktree-tracker.test.ts +243 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.test.ts +59 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.test.ts +272 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.test.ts +302 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.test.ts +1837 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.test.ts +124 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.test.ts +296 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.test.ts +234 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.test.ts +44 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +106 -0
- package/server/ws-bridge.test.ts +4777 -0
- 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
|
+
});
|