@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,302 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Stub Bun.hash for vitest (runs under Node, not Bun).
|
|
4
|
+
if (typeof globalThis.Bun === "undefined") {
|
|
5
|
+
(globalThis as any).Bun = {
|
|
6
|
+
hash(input: string | Uint8Array): number {
|
|
7
|
+
const s = typeof input === "string" ? input : new TextDecoder().decode(input);
|
|
8
|
+
let h = 0;
|
|
9
|
+
for (let i = 0; i < s.length; i++) {
|
|
10
|
+
h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
|
|
11
|
+
}
|
|
12
|
+
return h >>> 0;
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
import { parseNDJSON, isDuplicateCLIMessage, type CLIDedupState } from "./ws-bridge-cli-ingest.js";
|
|
18
|
+
import type { CLIMessage } from "./session-types.js";
|
|
19
|
+
|
|
20
|
+
function makeDedupState(): CLIDedupState {
|
|
21
|
+
return {
|
|
22
|
+
recentCLIMessageHashes: [],
|
|
23
|
+
recentCLIMessageHashSet: new Set(),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ─── parseNDJSON ──────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
describe("parseNDJSON", () => {
|
|
30
|
+
it("returns empty array for empty string", () => {
|
|
31
|
+
expect(parseNDJSON("")).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns empty array for whitespace-only input", () => {
|
|
35
|
+
expect(parseNDJSON(" \n \n ")).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("parses a single JSON line", () => {
|
|
39
|
+
const line = '{"type":"system","subtype":"init"}';
|
|
40
|
+
expect(parseNDJSON(line)).toEqual([line]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("parses multiple JSON lines separated by newlines", () => {
|
|
44
|
+
const line1 = '{"type":"assistant","message":{}}';
|
|
45
|
+
const line2 = '{"type":"result","data":{}}';
|
|
46
|
+
const input = `${line1}\n${line2}`;
|
|
47
|
+
expect(parseNDJSON(input)).toEqual([line1, line2]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("filters blank lines between valid JSON", () => {
|
|
51
|
+
const line1 = '{"type":"system"}';
|
|
52
|
+
const line2 = '{"type":"result"}';
|
|
53
|
+
const input = `${line1}\n\n\n${line2}\n`;
|
|
54
|
+
expect(parseNDJSON(input)).toEqual([line1, line2]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("handles Buffer input", () => {
|
|
58
|
+
const line = '{"type":"assistant"}';
|
|
59
|
+
const buffer = Buffer.from(line, "utf-8");
|
|
60
|
+
expect(parseNDJSON(buffer)).toEqual([line]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("handles multi-line NDJSON with trailing newline", () => {
|
|
64
|
+
const input = '{"a":1}\n{"b":2}\n';
|
|
65
|
+
expect(parseNDJSON(input)).toEqual(['{"a":1}', '{"b":2}']);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ─── isDuplicateCLIMessage ────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
describe("isDuplicateCLIMessage", () => {
|
|
72
|
+
describe("assistant/result/system messages (hash-based dedup)", () => {
|
|
73
|
+
it("returns false for first occurrence", () => {
|
|
74
|
+
const state = makeDedupState();
|
|
75
|
+
const line = '{"type":"assistant","message":{}}';
|
|
76
|
+
const msg: CLIMessage = { type: "assistant" } as any;
|
|
77
|
+
expect(isDuplicateCLIMessage(msg, line, state, 100)).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns true for duplicate assistant message", () => {
|
|
81
|
+
const state = makeDedupState();
|
|
82
|
+
const line = '{"type":"assistant","message":{"id":"m1"}}';
|
|
83
|
+
const msg: CLIMessage = { type: "assistant" } as any;
|
|
84
|
+
|
|
85
|
+
// First occurrence — not duplicate
|
|
86
|
+
expect(isDuplicateCLIMessage(msg, line, state, 100)).toBe(false);
|
|
87
|
+
// Same content again — duplicate
|
|
88
|
+
expect(isDuplicateCLIMessage(msg, line, state, 100)).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("returns true for duplicate result message", () => {
|
|
92
|
+
const state = makeDedupState();
|
|
93
|
+
const line = '{"type":"result","num_turns":3}';
|
|
94
|
+
const msg: CLIMessage = { type: "result" } as any;
|
|
95
|
+
|
|
96
|
+
expect(isDuplicateCLIMessage(msg, line, state, 100)).toBe(false);
|
|
97
|
+
expect(isDuplicateCLIMessage(msg, line, state, 100)).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("returns true for duplicate system message", () => {
|
|
101
|
+
const state = makeDedupState();
|
|
102
|
+
const line = '{"type":"system","subtype":"init","model":"claude"}';
|
|
103
|
+
const msg: CLIMessage = { type: "system", subtype: "init" } as any;
|
|
104
|
+
|
|
105
|
+
expect(isDuplicateCLIMessage(msg, line, state, 100)).toBe(false);
|
|
106
|
+
expect(isDuplicateCLIMessage(msg, line, state, 100)).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("different content is not a duplicate", () => {
|
|
110
|
+
const state = makeDedupState();
|
|
111
|
+
const msg: CLIMessage = { type: "assistant" } as any;
|
|
112
|
+
|
|
113
|
+
expect(isDuplicateCLIMessage(msg, '{"type":"assistant","id":"1"}', state, 100)).toBe(false);
|
|
114
|
+
expect(isDuplicateCLIMessage(msg, '{"type":"assistant","id":"2"}', state, 100)).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("stream_event messages (uuid-based dedup)", () => {
|
|
119
|
+
it("returns false for first occurrence with uuid", () => {
|
|
120
|
+
const state = makeDedupState();
|
|
121
|
+
const line = '{"type":"stream_event","uuid":"evt-1"}';
|
|
122
|
+
const msg = { type: "stream_event", uuid: "evt-1" } as CLIMessage;
|
|
123
|
+
|
|
124
|
+
expect(isDuplicateCLIMessage(msg, line, state, 100)).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("returns true for duplicate stream_event with same uuid", () => {
|
|
128
|
+
const state = makeDedupState();
|
|
129
|
+
const line = '{"type":"stream_event","uuid":"evt-1"}';
|
|
130
|
+
const msg = { type: "stream_event", uuid: "evt-1" } as CLIMessage;
|
|
131
|
+
|
|
132
|
+
expect(isDuplicateCLIMessage(msg, line, state, 100)).toBe(false);
|
|
133
|
+
expect(isDuplicateCLIMessage(msg, line, state, 100)).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("different uuids are not duplicates", () => {
|
|
137
|
+
const state = makeDedupState();
|
|
138
|
+
const msg1 = { type: "stream_event", uuid: "evt-1" } as CLIMessage;
|
|
139
|
+
const msg2 = { type: "stream_event", uuid: "evt-2" } as CLIMessage;
|
|
140
|
+
|
|
141
|
+
expect(isDuplicateCLIMessage(msg1, "", state, 100)).toBe(false);
|
|
142
|
+
expect(isDuplicateCLIMessage(msg2, "", state, 100)).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("stream_event without uuid is never considered a duplicate", () => {
|
|
146
|
+
const state = makeDedupState();
|
|
147
|
+
const msg = { type: "stream_event" } as CLIMessage;
|
|
148
|
+
|
|
149
|
+
// Same message twice without uuid — both pass through
|
|
150
|
+
expect(isDuplicateCLIMessage(msg, '{"type":"stream_event"}', state, 100)).toBe(false);
|
|
151
|
+
expect(isDuplicateCLIMessage(msg, '{"type":"stream_event"}', state, 100)).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("types that skip dedup", () => {
|
|
156
|
+
it.each([
|
|
157
|
+
"keep_alive",
|
|
158
|
+
"control_request",
|
|
159
|
+
"control_response",
|
|
160
|
+
"tool_progress",
|
|
161
|
+
"tool_use_summary",
|
|
162
|
+
"auth_status",
|
|
163
|
+
])("'%s' messages are never deduplicated", (type) => {
|
|
164
|
+
const state = makeDedupState();
|
|
165
|
+
const msg = { type } as CLIMessage;
|
|
166
|
+
const line = JSON.stringify(msg);
|
|
167
|
+
|
|
168
|
+
// Same message twice — both pass through
|
|
169
|
+
expect(isDuplicateCLIMessage(msg, line, state, 100)).toBe(false);
|
|
170
|
+
expect(isDuplicateCLIMessage(msg, line, state, 100)).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("window eviction", () => {
|
|
175
|
+
it("evicts oldest hash when window size is exceeded", () => {
|
|
176
|
+
const state = makeDedupState();
|
|
177
|
+
const windowSize = 3;
|
|
178
|
+
|
|
179
|
+
// Fill the window with 3 messages
|
|
180
|
+
const lines = ["msg-A", "msg-B", "msg-C"];
|
|
181
|
+
for (const line of lines) {
|
|
182
|
+
isDuplicateCLIMessage({ type: "assistant" } as any, line, state, windowSize);
|
|
183
|
+
}
|
|
184
|
+
expect(state.recentCLIMessageHashes).toHaveLength(3);
|
|
185
|
+
|
|
186
|
+
// Add a 4th — should evict "msg-A"
|
|
187
|
+
isDuplicateCLIMessage({ type: "assistant" } as any, "msg-D", state, windowSize);
|
|
188
|
+
expect(state.recentCLIMessageHashes).toHaveLength(3);
|
|
189
|
+
|
|
190
|
+
// "msg-A" should no longer be considered a duplicate (evicted from window)
|
|
191
|
+
expect(isDuplicateCLIMessage({ type: "assistant" } as any, "msg-A", state, windowSize)).toBe(false);
|
|
192
|
+
// "msg-B" should still be a duplicate (still in window... unless evicted by adding msg-A back)
|
|
193
|
+
// After adding msg-A back, window is [msg-C, msg-D, msg-A], so msg-B is evicted
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("maintains correct window size under heavy traffic", () => {
|
|
197
|
+
const state = makeDedupState();
|
|
198
|
+
const windowSize = 10;
|
|
199
|
+
|
|
200
|
+
// Send 50 unique messages
|
|
201
|
+
for (let i = 0; i < 50; i++) {
|
|
202
|
+
isDuplicateCLIMessage(
|
|
203
|
+
{ type: "assistant" } as any,
|
|
204
|
+
`message-${i}`,
|
|
205
|
+
state,
|
|
206
|
+
windowSize,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Window should cap at windowSize
|
|
211
|
+
expect(state.recentCLIMessageHashes).toHaveLength(windowSize);
|
|
212
|
+
expect(state.recentCLIMessageHashSet.size).toBe(windowSize);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("reconnect scenarios", () => {
|
|
217
|
+
it("filters all replayed messages after CLI reconnect", () => {
|
|
218
|
+
// Simulate: CLI sends 10 messages, then reconnects and replays all 10.
|
|
219
|
+
// All replayed messages should be filtered as duplicates.
|
|
220
|
+
const state = makeDedupState();
|
|
221
|
+
const messages = Array.from({ length: 10 }, (_, i) => ({
|
|
222
|
+
line: `{"type":"assistant","id":"${i}"}`,
|
|
223
|
+
msg: { type: "assistant" } as CLIMessage,
|
|
224
|
+
}));
|
|
225
|
+
|
|
226
|
+
// First send: all unique
|
|
227
|
+
for (const { line, msg } of messages) {
|
|
228
|
+
expect(isDuplicateCLIMessage(msg, line, state, 100)).toBe(false);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Replay (reconnect): all duplicates
|
|
232
|
+
for (const { line, msg } of messages) {
|
|
233
|
+
expect(isDuplicateCLIMessage(msg, line, state, 100)).toBe(true);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("filters replayed messages but passes new ones after partial overlap", () => {
|
|
238
|
+
// Simulate: CLI sends messages 0-9, reconnects, replays messages 5-9
|
|
239
|
+
// plus sends new messages 10-14. Messages 5-9 should be filtered,
|
|
240
|
+
// messages 10-14 should pass through.
|
|
241
|
+
const state = makeDedupState();
|
|
242
|
+
const allLines = Array.from({ length: 15 }, (_, i) =>
|
|
243
|
+
`{"type":"assistant","content":"msg-${i}"}`,
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
// Original send: messages 0-9
|
|
247
|
+
for (let i = 0; i < 10; i++) {
|
|
248
|
+
isDuplicateCLIMessage({ type: "assistant" } as CLIMessage, allLines[i], state, 100);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Reconnect: replay 5-9 (duplicate) + new 10-14 (unique)
|
|
252
|
+
const replayResults: boolean[] = [];
|
|
253
|
+
for (let i = 5; i < 15; i++) {
|
|
254
|
+
replayResults.push(
|
|
255
|
+
isDuplicateCLIMessage({ type: "assistant" } as CLIMessage, allLines[i], state, 100),
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// First 5 (indices 5-9) should be duplicates
|
|
260
|
+
expect(replayResults.slice(0, 5)).toEqual([true, true, true, true, true]);
|
|
261
|
+
// Last 5 (indices 10-14) should be new
|
|
262
|
+
expect(replayResults.slice(5, 10)).toEqual([false, false, false, false, false]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("filters replayed stream_events by uuid after reconnect", () => {
|
|
266
|
+
const state = makeDedupState();
|
|
267
|
+
|
|
268
|
+
// Original: 5 stream_events
|
|
269
|
+
for (let i = 0; i < 5; i++) {
|
|
270
|
+
const msg = { type: "stream_event", uuid: `uuid-${i}` } as CLIMessage;
|
|
271
|
+
expect(isDuplicateCLIMessage(msg, "", state, 100)).toBe(false);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Reconnect replay: same 5 stream_events + 3 new ones
|
|
275
|
+
for (let i = 0; i < 5; i++) {
|
|
276
|
+
const msg = { type: "stream_event", uuid: `uuid-${i}` } as CLIMessage;
|
|
277
|
+
expect(isDuplicateCLIMessage(msg, "", state, 100)).toBe(true);
|
|
278
|
+
}
|
|
279
|
+
for (let i = 5; i < 8; i++) {
|
|
280
|
+
const msg = { type: "stream_event", uuid: `uuid-${i}` } as CLIMessage;
|
|
281
|
+
expect(isDuplicateCLIMessage(msg, "", state, 100)).toBe(false);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("shares dedup window between assistant messages and stream_events", () => {
|
|
286
|
+
// The dedup state is shared — stream_event uuids and message hashes
|
|
287
|
+
// live in the same rolling window. Verify they don't interfere.
|
|
288
|
+
const state = makeDedupState();
|
|
289
|
+
|
|
290
|
+
// Mix of types
|
|
291
|
+
const assistantLine = '{"type":"assistant","content":"hello"}';
|
|
292
|
+
isDuplicateCLIMessage({ type: "assistant" } as CLIMessage, assistantLine, state, 100);
|
|
293
|
+
|
|
294
|
+
const streamMsg = { type: "stream_event", uuid: "evt-1" } as CLIMessage;
|
|
295
|
+
isDuplicateCLIMessage(streamMsg, "", state, 100);
|
|
296
|
+
|
|
297
|
+
// Both should be deduplicated on replay
|
|
298
|
+
expect(isDuplicateCLIMessage({ type: "assistant" } as CLIMessage, assistantLine, state, 100)).toBe(true);
|
|
299
|
+
expect(isDuplicateCLIMessage(streamMsg, "", state, 100)).toBe(true);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { CLIMessage } from "./session-types.js";
|
|
2
|
+
|
|
3
|
+
// ─── CLI Ingest Pipeline ────────────────────────────────────────────────────
|
|
4
|
+
// Pure functions for parsing and deduplicating CLI (NDJSON) messages.
|
|
5
|
+
// Extracted from WsBridge.handleCLIMessage to enable isolated testing
|
|
6
|
+
// of reconnect/replay deduplication scenarios.
|
|
7
|
+
|
|
8
|
+
/** State needed for CLI message deduplication. Matches a subset of Session. */
|
|
9
|
+
export interface CLIDedupState {
|
|
10
|
+
recentCLIMessageHashes: string[];
|
|
11
|
+
recentCLIMessageHashSet: Set<string>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse raw NDJSON data into individual line strings.
|
|
16
|
+
* Splits on newlines and filters blank lines.
|
|
17
|
+
*/
|
|
18
|
+
export function parseNDJSON(raw: string | Buffer): string[] {
|
|
19
|
+
const data = typeof raw === "string" ? raw : raw.toString("utf-8");
|
|
20
|
+
return data.split("\n").filter((l) => l.trim());
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Check if a CLI message is a duplicate based on a rolling hash window.
|
|
25
|
+
* On WS reconnect, the CLI replays in-flight messages; this dedup prevents
|
|
26
|
+
* duplicates from reaching downstream handlers.
|
|
27
|
+
*
|
|
28
|
+
* - `assistant`, `result`, `system` messages: deduped by content hash (Bun.hash)
|
|
29
|
+
* - `stream_event` messages: deduped by their stable `uuid` field
|
|
30
|
+
* - All other types (keep_alive, control_request, tool_progress, etc.): never deduped
|
|
31
|
+
*
|
|
32
|
+
* Returns true if the message is a duplicate and should be skipped.
|
|
33
|
+
* Mutates the dedupState window as a side effect.
|
|
34
|
+
*/
|
|
35
|
+
export function isDuplicateCLIMessage(
|
|
36
|
+
msg: CLIMessage,
|
|
37
|
+
rawLine: string,
|
|
38
|
+
state: CLIDedupState,
|
|
39
|
+
windowSize: number,
|
|
40
|
+
): boolean {
|
|
41
|
+
if (msg.type === "assistant" || msg.type === "result" || msg.type === "system") {
|
|
42
|
+
// Namespace with "h:" prefix to prevent collisions with uuid-based keys
|
|
43
|
+
const key = `h:${Bun.hash(rawLine).toString(36)}`;
|
|
44
|
+
if (state.recentCLIMessageHashSet.has(key)) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
state.recentCLIMessageHashes.push(key);
|
|
48
|
+
state.recentCLIMessageHashSet.add(key);
|
|
49
|
+
while (state.recentCLIMessageHashes.length > windowSize) {
|
|
50
|
+
const old = state.recentCLIMessageHashes.shift()!;
|
|
51
|
+
state.recentCLIMessageHashSet.delete(old);
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (msg.type === "stream_event" && (msg as { uuid?: string }).uuid) {
|
|
57
|
+
// Namespace with "u:" prefix to prevent collisions with hash-based keys.
|
|
58
|
+
// Current CLI versions (1.0+) always provide UUIDs on stream_event messages.
|
|
59
|
+
// UUID-less stream_events from older protocol versions fall through to no-dedup below.
|
|
60
|
+
const key = `u:${(msg as { uuid: string }).uuid}`;
|
|
61
|
+
if (state.recentCLIMessageHashSet.has(key)) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
state.recentCLIMessageHashes.push(key);
|
|
65
|
+
state.recentCLIMessageHashSet.add(key);
|
|
66
|
+
while (state.recentCLIMessageHashes.length > windowSize) {
|
|
67
|
+
const old = state.recentCLIMessageHashes.shift()!;
|
|
68
|
+
state.recentCLIMessageHashSet.delete(old);
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// All other message types (keep_alive, control_request, tool_progress, etc.)
|
|
74
|
+
// are never considered duplicates — they're either stateless or handled by
|
|
75
|
+
// separate mechanisms. stream_event without uuid also falls through here;
|
|
76
|
+
// current CLI versions (1.0+) always provide UUIDs, but older protocol
|
|
77
|
+
// versions may not. In that case, reconnect replay could produce duplicate
|
|
78
|
+
// stream content in the UI — acceptable since stream_events are transient
|
|
79
|
+
// and the final assistant message is always deduplicated.
|
|
80
|
+
return false;
|
|
81
|
+
}
|