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