@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,294 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { ReplayAdapter } from "./replay-adapter.js";
|
|
3
|
+
import type { Recording } from "../replay.js";
|
|
4
|
+
import type { BrowserIncomingMessage } from "../session-types.js";
|
|
5
|
+
|
|
6
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
function makeRecording(browserMessages: { type: string; [key: string]: unknown }[], delayMs = 100): Recording {
|
|
9
|
+
const entries = browserMessages.map((msg, i) => ({
|
|
10
|
+
ts: 1000 + i * delayMs,
|
|
11
|
+
dir: "out" as const,
|
|
12
|
+
raw: JSON.stringify(msg),
|
|
13
|
+
ch: "browser" as const,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
header: {
|
|
18
|
+
_header: true as const,
|
|
19
|
+
version: 1 as const,
|
|
20
|
+
session_id: "test-session",
|
|
21
|
+
backend_type: "claude" as const,
|
|
22
|
+
started_at: 1000,
|
|
23
|
+
cwd: "/test",
|
|
24
|
+
},
|
|
25
|
+
entries,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── Tests ───────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
describe("ReplayAdapter", () => {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
vi.useFakeTimers();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("basic replay", () => {
|
|
37
|
+
it("emits all browser messages in order", async () => {
|
|
38
|
+
const messages = [
|
|
39
|
+
{ type: "session_init", session: {} },
|
|
40
|
+
{ type: "assistant", text: "Hello" },
|
|
41
|
+
{ type: "result", subtype: "success" },
|
|
42
|
+
];
|
|
43
|
+
const adapter = new ReplayAdapter(makeRecording(messages), Infinity);
|
|
44
|
+
const received: BrowserIncomingMessage[] = [];
|
|
45
|
+
adapter.onBrowserMessage((msg) => received.push(msg));
|
|
46
|
+
adapter.onSessionMeta(() => {});
|
|
47
|
+
adapter.onDisconnect(() => {});
|
|
48
|
+
|
|
49
|
+
adapter.play();
|
|
50
|
+
// Flush all microtasks/timers for instant mode
|
|
51
|
+
await vi.runAllTimersAsync();
|
|
52
|
+
|
|
53
|
+
// Should receive all messages plus the final cli_disconnected
|
|
54
|
+
expect(received.length).toBe(messages.length + 1);
|
|
55
|
+
expect(received[0].type).toBe("session_init");
|
|
56
|
+
expect(received[1].type).toBe("assistant");
|
|
57
|
+
expect(received[2].type).toBe("result");
|
|
58
|
+
expect(received[3].type).toBe("cli_disconnected");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("emits session metadata on first play", async () => {
|
|
62
|
+
const adapter = new ReplayAdapter(makeRecording([{ type: "session_init", session: {} }]), Infinity);
|
|
63
|
+
const metaCalls: { cliSessionId?: string; cwd?: string }[] = [];
|
|
64
|
+
adapter.onBrowserMessage(() => {});
|
|
65
|
+
adapter.onSessionMeta((meta) => metaCalls.push(meta));
|
|
66
|
+
adapter.onDisconnect(() => {});
|
|
67
|
+
|
|
68
|
+
adapter.play();
|
|
69
|
+
await vi.runAllTimersAsync();
|
|
70
|
+
|
|
71
|
+
expect(metaCalls).toHaveLength(1);
|
|
72
|
+
expect(metaCalls[0].cliSessionId).toBe("test-session");
|
|
73
|
+
expect(metaCalls[0].cwd).toBe("/test");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("IBackendAdapter interface", () => {
|
|
78
|
+
it("isConnected returns true while playing", () => {
|
|
79
|
+
const adapter = new ReplayAdapter(makeRecording([{ type: "assistant" }]), Infinity);
|
|
80
|
+
adapter.onBrowserMessage(() => {});
|
|
81
|
+
adapter.onSessionMeta(() => {});
|
|
82
|
+
adapter.onDisconnect(() => {});
|
|
83
|
+
|
|
84
|
+
expect(adapter.isConnected()).toBe(false); // idle
|
|
85
|
+
adapter.play();
|
|
86
|
+
expect(adapter.isConnected()).toBe(true); // playing
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("isConnected returns false after disconnect", async () => {
|
|
90
|
+
const adapter = new ReplayAdapter(makeRecording([{ type: "assistant" }]), Infinity);
|
|
91
|
+
adapter.onBrowserMessage(() => {});
|
|
92
|
+
adapter.onSessionMeta(() => {});
|
|
93
|
+
adapter.onDisconnect(() => {});
|
|
94
|
+
|
|
95
|
+
adapter.play();
|
|
96
|
+
await vi.runAllTimersAsync();
|
|
97
|
+
expect(adapter.isConnected()).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("send() returns true (no-op for replay)", () => {
|
|
101
|
+
const adapter = new ReplayAdapter(makeRecording([]), Infinity);
|
|
102
|
+
expect(adapter.send({ type: "user_message" } as any)).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("disconnect stops replay and calls disconnect callback", async () => {
|
|
106
|
+
const adapter = new ReplayAdapter(makeRecording([{ type: "a" }, { type: "b" }]), 1);
|
|
107
|
+
const disconnected = vi.fn();
|
|
108
|
+
adapter.onBrowserMessage(() => {});
|
|
109
|
+
adapter.onSessionMeta(() => {});
|
|
110
|
+
adapter.onDisconnect(disconnected);
|
|
111
|
+
|
|
112
|
+
adapter.play();
|
|
113
|
+
await adapter.disconnect();
|
|
114
|
+
expect(disconnected).toHaveBeenCalledOnce();
|
|
115
|
+
expect(adapter.isConnected()).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("pause and resume", () => {
|
|
120
|
+
it("pauses and resumes playback", async () => {
|
|
121
|
+
const messages = [
|
|
122
|
+
{ type: "a" },
|
|
123
|
+
{ type: "b" },
|
|
124
|
+
{ type: "c" },
|
|
125
|
+
];
|
|
126
|
+
// Use a real delay (100ms between messages)
|
|
127
|
+
const adapter = new ReplayAdapter(makeRecording(messages, 100), 1);
|
|
128
|
+
const received: BrowserIncomingMessage[] = [];
|
|
129
|
+
adapter.onBrowserMessage((msg) => received.push(msg));
|
|
130
|
+
adapter.onSessionMeta(() => {});
|
|
131
|
+
adapter.onDisconnect(() => {});
|
|
132
|
+
|
|
133
|
+
adapter.play();
|
|
134
|
+
|
|
135
|
+
// First message is instant (no delay for first entry)
|
|
136
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
137
|
+
expect(received.length).toBeGreaterThanOrEqual(1);
|
|
138
|
+
|
|
139
|
+
adapter.pause();
|
|
140
|
+
const countAtPause = received.length;
|
|
141
|
+
|
|
142
|
+
// Advance time — no more messages should arrive while paused
|
|
143
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
144
|
+
expect(received.length).toBe(countAtPause);
|
|
145
|
+
|
|
146
|
+
// Resume
|
|
147
|
+
adapter.play();
|
|
148
|
+
await vi.runAllTimersAsync();
|
|
149
|
+
|
|
150
|
+
// All messages plus cli_disconnected
|
|
151
|
+
expect(received.length).toBe(messages.length + 1);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("speed control", () => {
|
|
156
|
+
it("2x speed completes faster than 1x", async () => {
|
|
157
|
+
// Two messages 1000ms apart at 1x speed
|
|
158
|
+
const messages = [{ type: "a" }, { type: "b" }];
|
|
159
|
+
const adapter = new ReplayAdapter(makeRecording(messages, 1000), 2);
|
|
160
|
+
const received: BrowserIncomingMessage[] = [];
|
|
161
|
+
adapter.onBrowserMessage((msg) => received.push(msg));
|
|
162
|
+
adapter.onSessionMeta(() => {});
|
|
163
|
+
adapter.onDisconnect(() => {});
|
|
164
|
+
|
|
165
|
+
adapter.play();
|
|
166
|
+
// At 2x speed, 1000ms delay becomes 500ms. Advance 600ms — should see both messages.
|
|
167
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
168
|
+
// First message is instant (index 0), second should have arrived by 500ms
|
|
169
|
+
expect(received.filter((m) => m.type !== "cli_disconnected").length).toBe(2);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("setSpeed mid-play affects subsequent messages", async () => {
|
|
173
|
+
const messages = [{ type: "a" }, { type: "b" }, { type: "c" }];
|
|
174
|
+
const adapter = new ReplayAdapter(makeRecording(messages, 1000), 1);
|
|
175
|
+
const received: BrowserIncomingMessage[] = [];
|
|
176
|
+
adapter.onBrowserMessage((msg) => received.push(msg));
|
|
177
|
+
adapter.onSessionMeta(() => {});
|
|
178
|
+
adapter.onDisconnect(() => {});
|
|
179
|
+
|
|
180
|
+
adapter.play();
|
|
181
|
+
// First message is instant
|
|
182
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
183
|
+
expect(received.length).toBeGreaterThanOrEqual(1);
|
|
184
|
+
|
|
185
|
+
// Switch to instant mode
|
|
186
|
+
adapter.setSpeed(Infinity);
|
|
187
|
+
await vi.runAllTimersAsync();
|
|
188
|
+
|
|
189
|
+
// All messages + cli_disconnected
|
|
190
|
+
expect(received.length).toBe(messages.length + 1);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("ignores invalid speed values", () => {
|
|
194
|
+
const adapter = new ReplayAdapter(makeRecording([{ type: "a" }]), 5);
|
|
195
|
+
adapter.onBrowserMessage(() => {});
|
|
196
|
+
adapter.onSessionMeta(() => {});
|
|
197
|
+
adapter.onDisconnect(() => {});
|
|
198
|
+
|
|
199
|
+
adapter.setSpeed(0);
|
|
200
|
+
adapter.setSpeed(-1);
|
|
201
|
+
// Speed should remain unchanged (verified indirectly via progress state)
|
|
202
|
+
expect(adapter.getProgress().state).toBe("idle");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("play() is idempotent while already playing", async () => {
|
|
206
|
+
const messages = [{ type: "a" }, { type: "b" }];
|
|
207
|
+
const adapter = new ReplayAdapter(makeRecording(messages, 100), 1);
|
|
208
|
+
const received: BrowserIncomingMessage[] = [];
|
|
209
|
+
adapter.onBrowserMessage((msg) => received.push(msg));
|
|
210
|
+
adapter.onSessionMeta(() => {});
|
|
211
|
+
adapter.onDisconnect(() => {});
|
|
212
|
+
|
|
213
|
+
adapter.play();
|
|
214
|
+
adapter.play(); // Should be no-op (no overlapping timers)
|
|
215
|
+
adapter.play();
|
|
216
|
+
await vi.runAllTimersAsync();
|
|
217
|
+
|
|
218
|
+
// Should still get exactly the expected number of messages (no duplicates)
|
|
219
|
+
expect(received.filter((m) => m.type !== "cli_disconnected").length).toBe(2);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe("getProgress", () => {
|
|
224
|
+
it("reports progress correctly", async () => {
|
|
225
|
+
const messages = [{ type: "a" }, { type: "b" }, { type: "c" }];
|
|
226
|
+
const adapter = new ReplayAdapter(makeRecording(messages), Infinity);
|
|
227
|
+
adapter.onBrowserMessage(() => {});
|
|
228
|
+
adapter.onSessionMeta(() => {});
|
|
229
|
+
adapter.onDisconnect(() => {});
|
|
230
|
+
|
|
231
|
+
expect(adapter.getProgress()).toEqual({
|
|
232
|
+
current: 0,
|
|
233
|
+
total: 3,
|
|
234
|
+
percentComplete: 0,
|
|
235
|
+
state: "idle",
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
adapter.play();
|
|
239
|
+
await vi.runAllTimersAsync();
|
|
240
|
+
|
|
241
|
+
expect(adapter.getProgress()).toEqual({
|
|
242
|
+
current: 3,
|
|
243
|
+
total: 3,
|
|
244
|
+
percentComplete: 100,
|
|
245
|
+
state: "finished",
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe("edge cases", () => {
|
|
251
|
+
it("handles empty recording", async () => {
|
|
252
|
+
const adapter = new ReplayAdapter(
|
|
253
|
+
{ header: { _header: true, version: 1, session_id: "s", backend_type: "claude", started_at: 0, cwd: "/" }, entries: [] },
|
|
254
|
+
Infinity,
|
|
255
|
+
);
|
|
256
|
+
const received: BrowserIncomingMessage[] = [];
|
|
257
|
+
const disconnected = vi.fn();
|
|
258
|
+
adapter.onBrowserMessage((msg) => received.push(msg));
|
|
259
|
+
adapter.onSessionMeta(() => {});
|
|
260
|
+
adapter.onDisconnect(disconnected);
|
|
261
|
+
|
|
262
|
+
adapter.play();
|
|
263
|
+
await vi.runAllTimersAsync();
|
|
264
|
+
|
|
265
|
+
// Only cli_disconnected for an empty recording
|
|
266
|
+
expect(received).toHaveLength(1);
|
|
267
|
+
expect(received[0].type).toBe("cli_disconnected");
|
|
268
|
+
expect(disconnected).toHaveBeenCalledOnce();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("handles malformed entry JSON gracefully", async () => {
|
|
272
|
+
const recording: Recording = {
|
|
273
|
+
header: { _header: true, version: 1, session_id: "s", backend_type: "claude", started_at: 0, cwd: "/" },
|
|
274
|
+
entries: [
|
|
275
|
+
{ ts: 100, dir: "out", raw: "not valid json", ch: "browser" },
|
|
276
|
+
{ ts: 200, dir: "out", raw: JSON.stringify({ type: "assistant" }), ch: "browser" },
|
|
277
|
+
],
|
|
278
|
+
};
|
|
279
|
+
const adapter = new ReplayAdapter(recording, Infinity);
|
|
280
|
+
const received: BrowserIncomingMessage[] = [];
|
|
281
|
+
adapter.onBrowserMessage((msg) => received.push(msg));
|
|
282
|
+
adapter.onSessionMeta(() => {});
|
|
283
|
+
adapter.onDisconnect(() => {});
|
|
284
|
+
|
|
285
|
+
adapter.play();
|
|
286
|
+
await vi.runAllTimersAsync();
|
|
287
|
+
|
|
288
|
+
// Should skip the malformed entry and emit the valid one + cli_disconnected
|
|
289
|
+
expect(received).toHaveLength(2);
|
|
290
|
+
expect(received[0].type).toBe("assistant");
|
|
291
|
+
expect(received[1].type).toBe("cli_disconnected");
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
});
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReplayAdapter — replays a recorded session as a fake live backend.
|
|
3
|
+
*
|
|
4
|
+
* Implements IBackendAdapter so the WsBridge treats it identically to a real
|
|
5
|
+
* Claude Code or Codex backend. The browser has no idea it's watching a replay.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { IBackendAdapter } from "../backend-adapter.js";
|
|
9
|
+
import type { BrowserIncomingMessage, BrowserOutgoingMessage } from "../session-types.js";
|
|
10
|
+
import type { Recording } from "../replay.js";
|
|
11
|
+
import { filterEntries } from "../replay.js";
|
|
12
|
+
|
|
13
|
+
type State = "idle" | "playing" | "paused" | "finished";
|
|
14
|
+
|
|
15
|
+
export class ReplayAdapter implements IBackendAdapter {
|
|
16
|
+
private state: State = "idle";
|
|
17
|
+
private speed: number;
|
|
18
|
+
|
|
19
|
+
private browserMessageCb: ((msg: BrowserIncomingMessage) => void) | null = null;
|
|
20
|
+
private sessionMetaCb: ((meta: { cliSessionId?: string; model?: string; cwd?: string }) => void) | null = null;
|
|
21
|
+
private disconnectCb: (() => void) | null = null;
|
|
22
|
+
|
|
23
|
+
/** Outgoing browser messages from the recording, in order. */
|
|
24
|
+
private readonly entries: { ts: number; raw: string }[];
|
|
25
|
+
private currentIndex = 0;
|
|
26
|
+
private pendingTimer: ReturnType<typeof setTimeout> | null = null;
|
|
27
|
+
|
|
28
|
+
/** Tracks wall-clock time when the current timer was scheduled (for pause/resume drift fix). */
|
|
29
|
+
private timerScheduledAt = 0;
|
|
30
|
+
/** Tracks the delay used for the current timer (for pause/resume drift fix). */
|
|
31
|
+
private timerDelayMs = 0;
|
|
32
|
+
/** Remaining ms when paused mid-timer, used on resume to avoid timeline drift. */
|
|
33
|
+
private pausedRemainingMs = 0;
|
|
34
|
+
|
|
35
|
+
private readonly recording: Recording;
|
|
36
|
+
|
|
37
|
+
constructor(recording: Recording, speed = 1) {
|
|
38
|
+
this.recording = recording;
|
|
39
|
+
this.speed = speed;
|
|
40
|
+
|
|
41
|
+
// Extract outgoing browser messages (what the server originally sent)
|
|
42
|
+
this.entries = filterEntries(recording.entries, "out", "browser").map((e) => ({
|
|
43
|
+
ts: e.ts,
|
|
44
|
+
raw: e.raw,
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── IBackendAdapter interface ────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
send(_msg: BrowserOutgoingMessage): boolean {
|
|
51
|
+
// Replay doesn't accept input from browsers — it just plays back.
|
|
52
|
+
// Permission responses, user messages, etc. are ignored.
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
isConnected(): boolean {
|
|
57
|
+
return this.state === "playing" || this.state === "paused";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async disconnect(): Promise<void> {
|
|
61
|
+
this.clearTimer();
|
|
62
|
+
this.state = "finished";
|
|
63
|
+
this.disconnectCb?.();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
onBrowserMessage(cb: (msg: BrowserIncomingMessage) => void): void {
|
|
67
|
+
this.browserMessageCb = cb;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
onSessionMeta(cb: (meta: { cliSessionId?: string; model?: string; cwd?: string }) => void): void {
|
|
71
|
+
this.sessionMetaCb = cb;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
onDisconnect(cb: () => void): void {
|
|
75
|
+
this.disconnectCb = cb;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Replay controls ─────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
play(): void {
|
|
81
|
+
if (this.state === "finished") return;
|
|
82
|
+
if (this.state === "playing") return;
|
|
83
|
+
|
|
84
|
+
// Emit session metadata from recording header on first play
|
|
85
|
+
if (this.state === "idle") {
|
|
86
|
+
this.sessionMetaCb?.({
|
|
87
|
+
cliSessionId: this.recording.header.session_id,
|
|
88
|
+
cwd: this.recording.header.cwd,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
this.state = "playing";
|
|
93
|
+
this.scheduleNext();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
pause(): void {
|
|
97
|
+
if (this.state !== "playing") return;
|
|
98
|
+
// Calculate how much time remained on the current timer so resume doesn't drift
|
|
99
|
+
this.pausedRemainingMs = Math.max(0, this.timerDelayMs - (Date.now() - this.timerScheduledAt));
|
|
100
|
+
this.clearTimer();
|
|
101
|
+
this.state = "paused";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
setSpeed(multiplier: number): void {
|
|
105
|
+
if (multiplier <= 0) return;
|
|
106
|
+
const oldSpeed = this.speed;
|
|
107
|
+
this.speed = multiplier;
|
|
108
|
+
|
|
109
|
+
if (this.state === "playing") {
|
|
110
|
+
this.clearTimer();
|
|
111
|
+
this.scheduleNext();
|
|
112
|
+
} else if (this.state === "paused" && this.pausedRemainingMs > 0) {
|
|
113
|
+
// Recalculate remaining time with the new speed ratio
|
|
114
|
+
this.pausedRemainingMs = this.pausedRemainingMs * (oldSpeed / multiplier);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
getProgress(): { current: number; total: number; percentComplete: number; state: State } {
|
|
119
|
+
const total = this.entries.length;
|
|
120
|
+
const current = this.currentIndex;
|
|
121
|
+
return {
|
|
122
|
+
current,
|
|
123
|
+
total,
|
|
124
|
+
percentComplete: total > 0 ? Math.round((current / total) * 100) : 100,
|
|
125
|
+
state: this.state,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Internal scheduling ─────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
private scheduleNext(): void {
|
|
132
|
+
if (this.state !== "playing") return;
|
|
133
|
+
if (this.currentIndex >= this.entries.length) {
|
|
134
|
+
this.finish();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const entry = this.entries[this.currentIndex];
|
|
139
|
+
|
|
140
|
+
let delayMs: number;
|
|
141
|
+
|
|
142
|
+
if (this.pausedRemainingMs > 0) {
|
|
143
|
+
// Resuming after pause — use the remaining time from the interrupted timer
|
|
144
|
+
delayMs = this.pausedRemainingMs;
|
|
145
|
+
this.pausedRemainingMs = 0;
|
|
146
|
+
} else {
|
|
147
|
+
// Calculate delay based on timing difference from previous entry
|
|
148
|
+
delayMs = 0;
|
|
149
|
+
if (this.currentIndex > 0) {
|
|
150
|
+
const prevTs = this.entries[this.currentIndex - 1].ts;
|
|
151
|
+
delayMs = (entry.ts - prevTs) / this.speed;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Instant mode: no delay at all
|
|
155
|
+
if (!Number.isFinite(this.speed) || this.speed === Infinity) {
|
|
156
|
+
delayMs = 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Cap maximum delay to prevent excessively long waits
|
|
160
|
+
delayMs = Math.min(delayMs, 5000 / this.speed);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
this.timerDelayMs = delayMs;
|
|
164
|
+
this.timerScheduledAt = Date.now();
|
|
165
|
+
|
|
166
|
+
if (delayMs <= 0) {
|
|
167
|
+
// Emit synchronously for instant mode, but use microtask to avoid stack overflow
|
|
168
|
+
this.pendingTimer = setTimeout(() => this.emitEntry(), 0);
|
|
169
|
+
} else {
|
|
170
|
+
this.pendingTimer = setTimeout(() => this.emitEntry(), delayMs);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private emitEntry(): void {
|
|
175
|
+
if (this.state !== "playing") return;
|
|
176
|
+
if (this.currentIndex >= this.entries.length) {
|
|
177
|
+
this.finish();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const entry = this.entries[this.currentIndex];
|
|
182
|
+
this.currentIndex++;
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const msg = JSON.parse(entry.raw) as BrowserIncomingMessage;
|
|
186
|
+
this.browserMessageCb?.(msg);
|
|
187
|
+
} catch {
|
|
188
|
+
// Skip malformed entries
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
this.scheduleNext();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private finish(): void {
|
|
195
|
+
this.state = "finished";
|
|
196
|
+
// Emit a cli_disconnected so the browser knows the session ended
|
|
197
|
+
this.browserMessageCb?.({ type: "cli_disconnected" } as BrowserIncomingMessage);
|
|
198
|
+
this.disconnectCb?.();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private clearTimer(): void {
|
|
202
|
+
if (this.pendingTimer !== null) {
|
|
203
|
+
clearTimeout(this.pendingTimer);
|
|
204
|
+
this.pendingTimer = null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|