@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,1363 @@
|
|
|
1
|
+
import { vi, describe, it, expect, beforeEach, afterEach, afterAll } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ─── Stub Bun global for vitest (runs under Node, not Bun) ──────────────────
|
|
4
|
+
// Bun.hash is used by isDuplicateCLIMessage in ws-bridge-cli-ingest.ts.
|
|
5
|
+
// A simple string hash is sufficient for test determinism.
|
|
6
|
+
if (typeof globalThis.Bun === "undefined") {
|
|
7
|
+
(globalThis as any).Bun = {
|
|
8
|
+
hash(input: string | Uint8Array): number {
|
|
9
|
+
const s = typeof input === "string" ? input : new TextDecoder().decode(input);
|
|
10
|
+
let h = 0;
|
|
11
|
+
for (let i = 0; i < s.length; i++) {
|
|
12
|
+
h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
|
|
13
|
+
}
|
|
14
|
+
return h >>> 0; // unsigned 32-bit
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Mock node:crypto to return deterministic UUIDs for control_request IDs
|
|
20
|
+
let uuidCounter = 0;
|
|
21
|
+
vi.mock("node:crypto", () => ({
|
|
22
|
+
randomUUID: () => `test-uuid-${uuidCounter++}`,
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
// Mock settings-manager to prevent real file system reads
|
|
26
|
+
vi.mock("./settings-manager.js", () => ({
|
|
27
|
+
getSettings: () => ({
|
|
28
|
+
aiValidationEnabled: false,
|
|
29
|
+
aiValidationAutoApprove: false,
|
|
30
|
+
aiValidationAutoDeny: false,
|
|
31
|
+
anthropicApiKey: "",
|
|
32
|
+
}),
|
|
33
|
+
DEFAULT_ANTHROPIC_MODEL: "claude-sonnet-4-6",
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
import { ClaudeAdapter } from "./claude-adapter.js";
|
|
37
|
+
import { log } from "./logger.js";
|
|
38
|
+
|
|
39
|
+
// ─── Mock socket factory ────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/** Creates a minimal mock ServerWebSocket<SocketData> for CLI connections. */
|
|
42
|
+
function createMockSocket(sessionId: string) {
|
|
43
|
+
return {
|
|
44
|
+
data: { kind: "cli" as const, sessionId },
|
|
45
|
+
send: vi.fn(),
|
|
46
|
+
close: vi.fn(),
|
|
47
|
+
readyState: 1,
|
|
48
|
+
} as any;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── Helper: build NDJSON CLI messages ──────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
function makeInitMsg(overrides: Record<string, unknown> = {}) {
|
|
54
|
+
return JSON.stringify({
|
|
55
|
+
type: "system",
|
|
56
|
+
subtype: "init",
|
|
57
|
+
session_id: "cli-123",
|
|
58
|
+
model: "claude-sonnet-4-6",
|
|
59
|
+
cwd: "/test",
|
|
60
|
+
tools: ["Bash", "Read"],
|
|
61
|
+
permissionMode: "default",
|
|
62
|
+
claude_code_version: "1.0",
|
|
63
|
+
mcp_servers: [],
|
|
64
|
+
agents: [],
|
|
65
|
+
slash_commands: [],
|
|
66
|
+
skills: [],
|
|
67
|
+
output_style: "normal",
|
|
68
|
+
uuid: "uuid-1",
|
|
69
|
+
apiKeySource: "env",
|
|
70
|
+
...overrides,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function makeAssistantMsg(overrides: Record<string, unknown> = {}) {
|
|
75
|
+
return JSON.stringify({
|
|
76
|
+
type: "assistant",
|
|
77
|
+
message: {
|
|
78
|
+
id: "msg-1",
|
|
79
|
+
type: "message",
|
|
80
|
+
role: "assistant",
|
|
81
|
+
model: "claude-sonnet-4-6",
|
|
82
|
+
content: [{ type: "text", text: "Hello world" }],
|
|
83
|
+
stop_reason: "end_turn",
|
|
84
|
+
usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
85
|
+
},
|
|
86
|
+
parent_tool_use_id: null,
|
|
87
|
+
uuid: "asst-uuid-1",
|
|
88
|
+
session_id: "cli-123",
|
|
89
|
+
...overrides,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function makeResultMsg(overrides: Record<string, unknown> = {}) {
|
|
94
|
+
return JSON.stringify({
|
|
95
|
+
type: "result",
|
|
96
|
+
subtype: "success",
|
|
97
|
+
is_error: false,
|
|
98
|
+
result: "Done",
|
|
99
|
+
duration_ms: 1000,
|
|
100
|
+
duration_api_ms: 800,
|
|
101
|
+
num_turns: 1,
|
|
102
|
+
total_cost_usd: 0.01,
|
|
103
|
+
stop_reason: "end_turn",
|
|
104
|
+
usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
105
|
+
context_used_percent: 5,
|
|
106
|
+
uuid: "result-uuid-1",
|
|
107
|
+
session_id: "cli-123",
|
|
108
|
+
...overrides,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function makeStreamEventMsg(overrides: Record<string, unknown> = {}) {
|
|
113
|
+
return JSON.stringify({
|
|
114
|
+
type: "stream_event",
|
|
115
|
+
event: { type: "content_block_delta", delta: { type: "text_delta", text: "Hi" } },
|
|
116
|
+
parent_tool_use_id: null,
|
|
117
|
+
uuid: "stream-uuid-1",
|
|
118
|
+
session_id: "cli-123",
|
|
119
|
+
...overrides,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function makeControlRequestMsg(overrides: Record<string, unknown> = {}) {
|
|
124
|
+
return JSON.stringify({
|
|
125
|
+
type: "control_request",
|
|
126
|
+
request_id: "ctrl-req-1",
|
|
127
|
+
request: {
|
|
128
|
+
subtype: "can_use_tool",
|
|
129
|
+
tool_name: "Bash",
|
|
130
|
+
input: { command: "ls" },
|
|
131
|
+
description: "List files",
|
|
132
|
+
tool_use_id: "tu-1",
|
|
133
|
+
...((overrides as any).request ?? {}),
|
|
134
|
+
},
|
|
135
|
+
...overrides,
|
|
136
|
+
// Restore request if it was overridden
|
|
137
|
+
...(overrides.request ? { request: overrides.request } : {}),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function makeToolProgressMsg(overrides: Record<string, unknown> = {}) {
|
|
142
|
+
return JSON.stringify({
|
|
143
|
+
type: "tool_progress",
|
|
144
|
+
tool_use_id: "tu-1",
|
|
145
|
+
tool_name: "Bash",
|
|
146
|
+
parent_tool_use_id: null,
|
|
147
|
+
elapsed_time_seconds: 2,
|
|
148
|
+
uuid: "tp-uuid-1",
|
|
149
|
+
session_id: "cli-123",
|
|
150
|
+
...overrides,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function makeAuthStatusMsg(overrides: Record<string, unknown> = {}) {
|
|
155
|
+
return JSON.stringify({
|
|
156
|
+
type: "auth_status",
|
|
157
|
+
isAuthenticating: true,
|
|
158
|
+
output: ["Authenticating..."],
|
|
159
|
+
uuid: "auth-uuid-1",
|
|
160
|
+
session_id: "cli-123",
|
|
161
|
+
...overrides,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function makeKeepAliveMsg() {
|
|
166
|
+
return JSON.stringify({ type: "keep_alive" });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function makeSystemStatusMsg(overrides: Record<string, unknown> = {}) {
|
|
170
|
+
return JSON.stringify({
|
|
171
|
+
type: "system",
|
|
172
|
+
subtype: "status",
|
|
173
|
+
status: "compacting",
|
|
174
|
+
uuid: "status-uuid-1",
|
|
175
|
+
session_id: "cli-123",
|
|
176
|
+
...overrides,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── Test suite ─────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
let adapter: ClaudeAdapter;
|
|
183
|
+
let browserMessageCb: ReturnType<typeof vi.fn>;
|
|
184
|
+
let sessionMetaCb: ReturnType<typeof vi.fn>;
|
|
185
|
+
let disconnectCb: ReturnType<typeof vi.fn>;
|
|
186
|
+
let onActivityUpdate: ReturnType<typeof vi.fn>;
|
|
187
|
+
|
|
188
|
+
beforeEach(() => {
|
|
189
|
+
uuidCounter = 0;
|
|
190
|
+
onActivityUpdate = vi.fn();
|
|
191
|
+
adapter = new ClaudeAdapter("sess-1", { onActivityUpdate: onActivityUpdate as unknown as () => void });
|
|
192
|
+
browserMessageCb = vi.fn();
|
|
193
|
+
sessionMetaCb = vi.fn();
|
|
194
|
+
disconnectCb = vi.fn();
|
|
195
|
+
adapter.onBrowserMessage(browserMessageCb as any);
|
|
196
|
+
adapter.onSessionMeta(sessionMetaCb as any);
|
|
197
|
+
adapter.onDisconnect(disconnectCb as unknown as () => void);
|
|
198
|
+
|
|
199
|
+
// Suppress console output to prevent Vitest EnvironmentTeardownError
|
|
200
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
201
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
202
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
afterEach(() => {
|
|
206
|
+
vi.restoreAllMocks();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Prevent "Closing rpc while onUserConsoleLog was pending" during teardown
|
|
210
|
+
afterAll(() => {
|
|
211
|
+
const noop = () => {};
|
|
212
|
+
console.log = noop;
|
|
213
|
+
console.warn = noop;
|
|
214
|
+
console.error = noop;
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("Protocol drift handling", () => {
|
|
218
|
+
it("logs and surfaces unknown Claude message types", () => {
|
|
219
|
+
const spy = vi.spyOn(log, "warn").mockImplementation(() => {});
|
|
220
|
+
|
|
221
|
+
adapter.handleRawMessage(`${JSON.stringify({ type: "brand_new_message", payload: { x: 1 } })}\n`);
|
|
222
|
+
|
|
223
|
+
expect(spy).toHaveBeenCalledWith(
|
|
224
|
+
"protocol-monitor",
|
|
225
|
+
"Backend protocol drift detected",
|
|
226
|
+
expect.objectContaining({
|
|
227
|
+
backend: "claude",
|
|
228
|
+
sessionId: "sess-1",
|
|
229
|
+
direction: "incoming",
|
|
230
|
+
messageKind: "message",
|
|
231
|
+
messageName: "brand_new_message",
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
234
|
+
expect(browserMessageCb).toHaveBeenCalledWith(
|
|
235
|
+
expect.objectContaining({
|
|
236
|
+
type: "error",
|
|
237
|
+
message: expect.stringContaining("brand_new_message"),
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
spy.mockRestore();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("deduplicates repeated Claude parse-error drift logs", () => {
|
|
245
|
+
const spy = vi.spyOn(log, "warn").mockImplementation(() => {});
|
|
246
|
+
|
|
247
|
+
adapter.handleRawMessage("not-json\nstill-not-json\n");
|
|
248
|
+
|
|
249
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
250
|
+
expect(spy).toHaveBeenCalledWith(
|
|
251
|
+
"protocol-monitor",
|
|
252
|
+
"Backend protocol drift detected",
|
|
253
|
+
expect.objectContaining({
|
|
254
|
+
backend: "claude",
|
|
255
|
+
sessionId: "sess-1",
|
|
256
|
+
messageKind: "parse_error",
|
|
257
|
+
messageName: "ndjson",
|
|
258
|
+
}),
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
spy.mockRestore();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("surfaces parse errors to the browser as error messages", () => {
|
|
265
|
+
// Parse errors should notify the browser so the user sees something
|
|
266
|
+
// instead of a silent failure.
|
|
267
|
+
const spy = vi.spyOn(log, "warn").mockImplementation(() => {});
|
|
268
|
+
|
|
269
|
+
adapter.handleRawMessage("{{broken-json}}\n");
|
|
270
|
+
|
|
271
|
+
expect(browserMessageCb).toHaveBeenCalledWith(
|
|
272
|
+
expect.objectContaining({
|
|
273
|
+
type: "error",
|
|
274
|
+
message: expect.stringContaining("parse_error"),
|
|
275
|
+
}),
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
spy.mockRestore();
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// ─── Known non-standard CLI message types ────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
describe("Known non-standard CLI message types", () => {
|
|
285
|
+
it("rate_limit_event is silently consumed without protocol drift warning", () => {
|
|
286
|
+
// The CLI sends rate_limit_event messages with throttle/allow status.
|
|
287
|
+
// These should be silently consumed and NOT trigger protocol drift logs.
|
|
288
|
+
const spy = vi.spyOn(log, "warn").mockImplementation(() => {});
|
|
289
|
+
const ws = createMockSocket("sess-1");
|
|
290
|
+
adapter.attachWebSocket(ws);
|
|
291
|
+
|
|
292
|
+
adapter.handleRawMessage(
|
|
293
|
+
JSON.stringify({
|
|
294
|
+
type: "rate_limit_event",
|
|
295
|
+
rate_limit_info: { is_rate_limited: false, resets_at: null },
|
|
296
|
+
uuid: "rl-uuid-1",
|
|
297
|
+
}) + "\n",
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
// Should NOT produce a protocol drift warning
|
|
301
|
+
expect(spy).not.toHaveBeenCalledWith(
|
|
302
|
+
"protocol-monitor",
|
|
303
|
+
"Backend protocol drift detected",
|
|
304
|
+
expect.anything(),
|
|
305
|
+
);
|
|
306
|
+
// Should NOT emit an error to the browser
|
|
307
|
+
expect(browserMessageCb).not.toHaveBeenCalledWith(
|
|
308
|
+
expect.objectContaining({ type: "error" }),
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
spy.mockRestore();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("user echo with plain string content is silently dropped to avoid duplicates", () => {
|
|
315
|
+
// Plain string echoes are duplicates of messages the browser already has
|
|
316
|
+
// (the browser sends user_message → ws-bridge stores it → CLI echoes it
|
|
317
|
+
// back). Silently drop them to prevent duplicate messages in the UI.
|
|
318
|
+
const spy = vi.spyOn(log, "warn").mockImplementation(() => {});
|
|
319
|
+
const ws = createMockSocket("sess-1");
|
|
320
|
+
adapter.attachWebSocket(ws);
|
|
321
|
+
|
|
322
|
+
adapter.handleRawMessage(
|
|
323
|
+
JSON.stringify({
|
|
324
|
+
type: "user",
|
|
325
|
+
message: { role: "user", content: "Hello from browser" },
|
|
326
|
+
uuid: "user-echo-1",
|
|
327
|
+
session_id: "cli-123",
|
|
328
|
+
}) + "\n",
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
// Should NOT produce a protocol drift warning
|
|
332
|
+
expect(spy).not.toHaveBeenCalledWith(
|
|
333
|
+
"protocol-monitor",
|
|
334
|
+
"Backend protocol drift detected",
|
|
335
|
+
expect.anything(),
|
|
336
|
+
);
|
|
337
|
+
// Should NOT emit to browser — plain string echoes are dropped
|
|
338
|
+
expect(browserMessageCb).not.toHaveBeenCalledWith(
|
|
339
|
+
expect.objectContaining({ type: "user_message" }),
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
spy.mockRestore();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("user echo with non-string content is silently dropped", () => {
|
|
346
|
+
// User echo messages with tool_result arrays are redundant — the tool
|
|
347
|
+
// results are already present in the subsequent assistant message content
|
|
348
|
+
// blocks. Forwarding them caused raw JSON text bubbles in the chat UI.
|
|
349
|
+
const ws = createMockSocket("sess-1");
|
|
350
|
+
adapter.attachWebSocket(ws);
|
|
351
|
+
|
|
352
|
+
const complexContent = [
|
|
353
|
+
{ type: "tool_result", tool_use_id: "t1", content: "result" },
|
|
354
|
+
];
|
|
355
|
+
adapter.handleRawMessage(
|
|
356
|
+
JSON.stringify({
|
|
357
|
+
type: "user",
|
|
358
|
+
message: { role: "user", content: complexContent },
|
|
359
|
+
uuid: "user-echo-2",
|
|
360
|
+
session_id: "cli-123",
|
|
361
|
+
}) + "\n",
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
// Should NOT emit a user_message to the browser
|
|
365
|
+
expect(browserMessageCb).not.toHaveBeenCalledWith(
|
|
366
|
+
expect.objectContaining({ type: "user_message" }),
|
|
367
|
+
);
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// ─── Connection lifecycle ───────────────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
describe("Connection lifecycle", () => {
|
|
374
|
+
it("isConnected() returns false initially when no WebSocket is attached", () => {
|
|
375
|
+
// A freshly created adapter has no CLI socket, so it should not be connected.
|
|
376
|
+
expect(adapter.isConnected()).toBe(false);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("attachWebSocket stores the socket and makes isConnected() return true", () => {
|
|
380
|
+
// Attaching a mock WebSocket should mark the adapter as connected.
|
|
381
|
+
const ws = createMockSocket("sess-1");
|
|
382
|
+
adapter.attachWebSocket(ws);
|
|
383
|
+
expect(adapter.isConnected()).toBe(true);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("detachWebSocket clears the socket and calls disconnectCb", () => {
|
|
387
|
+
// Detaching the current socket should clear the connection and notify
|
|
388
|
+
// the bridge via the disconnect callback.
|
|
389
|
+
const ws = createMockSocket("sess-1");
|
|
390
|
+
adapter.attachWebSocket(ws);
|
|
391
|
+
expect(adapter.isConnected()).toBe(true);
|
|
392
|
+
|
|
393
|
+
adapter.detachWebSocket(ws);
|
|
394
|
+
expect(adapter.isConnected()).toBe(false);
|
|
395
|
+
expect(disconnectCb).toHaveBeenCalledOnce();
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("detachWebSocket with a stale socket (different ws) does nothing", () => {
|
|
399
|
+
// If a new WebSocket replaced an old one, closing the old one should
|
|
400
|
+
// NOT clear the current connection or trigger the disconnect callback.
|
|
401
|
+
const ws1 = createMockSocket("sess-1");
|
|
402
|
+
const ws2 = createMockSocket("sess-1");
|
|
403
|
+
adapter.attachWebSocket(ws1);
|
|
404
|
+
|
|
405
|
+
// Replace with ws2
|
|
406
|
+
adapter.attachWebSocket(ws2);
|
|
407
|
+
|
|
408
|
+
// Detach ws1 (stale) — should be ignored
|
|
409
|
+
adapter.detachWebSocket(ws1);
|
|
410
|
+
expect(adapter.isConnected()).toBe(true);
|
|
411
|
+
expect(disconnectCb).not.toHaveBeenCalled();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("disconnect() closes the socket and clears the connection", async () => {
|
|
415
|
+
// disconnect() should call close() on the socket and clear it.
|
|
416
|
+
const ws = createMockSocket("sess-1");
|
|
417
|
+
adapter.attachWebSocket(ws);
|
|
418
|
+
|
|
419
|
+
await adapter.disconnect();
|
|
420
|
+
expect(ws.close).toHaveBeenCalledOnce();
|
|
421
|
+
expect(adapter.isConnected()).toBe(false);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("disconnect() with no socket is a no-op", async () => {
|
|
425
|
+
// Calling disconnect when there's no socket should not throw.
|
|
426
|
+
await adapter.disconnect();
|
|
427
|
+
expect(adapter.isConnected()).toBe(false);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("handleTransportClose() clears socket without calling disconnectCb", () => {
|
|
431
|
+
// handleTransportClose is used when a WS proxy drops — it clears the
|
|
432
|
+
// socket reference without triggering the disconnect callback so the
|
|
433
|
+
// CLI can reconnect.
|
|
434
|
+
const ws = createMockSocket("sess-1");
|
|
435
|
+
adapter.attachWebSocket(ws);
|
|
436
|
+
|
|
437
|
+
adapter.handleTransportClose();
|
|
438
|
+
expect(adapter.isConnected()).toBe(false);
|
|
439
|
+
expect(disconnectCb).not.toHaveBeenCalled();
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// ─── Message queuing ────────────────────────────────────────────────────────
|
|
444
|
+
|
|
445
|
+
describe("Message queuing", () => {
|
|
446
|
+
it("messages sent via send() before WebSocket connects are queued", () => {
|
|
447
|
+
// Without an attached WebSocket, outgoing messages should be queued
|
|
448
|
+
// and not lost. We verify by attaching a socket later and checking
|
|
449
|
+
// that the queued messages are flushed.
|
|
450
|
+
const result = adapter.send({ type: "user_message", content: "hello" });
|
|
451
|
+
expect(result).toBe(true);
|
|
452
|
+
// No socket attached — nothing was sent yet.
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("queued messages are flushed when attachWebSocket is called", () => {
|
|
456
|
+
// Send messages while disconnected, then verify they are delivered
|
|
457
|
+
// when the WebSocket attaches.
|
|
458
|
+
adapter.send({ type: "user_message", content: "first" });
|
|
459
|
+
adapter.send({ type: "user_message", content: "second" });
|
|
460
|
+
|
|
461
|
+
const ws = createMockSocket("sess-1");
|
|
462
|
+
adapter.attachWebSocket(ws);
|
|
463
|
+
|
|
464
|
+
// Both queued messages should have been flushed to the socket.
|
|
465
|
+
// Each message results in a send() call with NDJSON + newline.
|
|
466
|
+
expect(ws.send).toHaveBeenCalledTimes(2);
|
|
467
|
+
|
|
468
|
+
// Verify the first message content
|
|
469
|
+
const firstCall = ws.send.mock.calls[0][0] as string;
|
|
470
|
+
const parsed1 = JSON.parse(firstCall.trim());
|
|
471
|
+
expect(parsed1.type).toBe("user");
|
|
472
|
+
expect(parsed1.message.content).toBe("first");
|
|
473
|
+
|
|
474
|
+
// Verify the second message content
|
|
475
|
+
const secondCall = ws.send.mock.calls[1][0] as string;
|
|
476
|
+
const parsed2 = JSON.parse(secondCall.trim());
|
|
477
|
+
expect(parsed2.type).toBe("user");
|
|
478
|
+
expect(parsed2.message.content).toBe("second");
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("messages sent after WebSocket connects go directly to the socket", () => {
|
|
482
|
+
// Once a socket is attached, messages should be sent immediately
|
|
483
|
+
// without queuing.
|
|
484
|
+
const ws = createMockSocket("sess-1");
|
|
485
|
+
adapter.attachWebSocket(ws);
|
|
486
|
+
|
|
487
|
+
adapter.send({ type: "user_message", content: "direct" });
|
|
488
|
+
|
|
489
|
+
expect(ws.send).toHaveBeenCalledTimes(1);
|
|
490
|
+
const sent = JSON.parse((ws.send.mock.calls[0][0] as string).trim());
|
|
491
|
+
expect(sent.type).toBe("user");
|
|
492
|
+
expect(sent.message.content).toBe("direct");
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// ─── send() — outgoing message translation ──────────────────────────────────
|
|
497
|
+
|
|
498
|
+
describe("send() — outgoing message translation", () => {
|
|
499
|
+
let ws: ReturnType<typeof createMockSocket>;
|
|
500
|
+
|
|
501
|
+
beforeEach(() => {
|
|
502
|
+
ws = createMockSocket("sess-1");
|
|
503
|
+
adapter.attachWebSocket(ws);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
/** Helper to parse the last NDJSON sent on the mock socket. */
|
|
507
|
+
function getLastSent(): any {
|
|
508
|
+
const calls = ws.send.mock.calls;
|
|
509
|
+
return JSON.parse((calls[calls.length - 1][0] as string).trim());
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
it("user_message → sends NDJSON with type 'user' and user-role message", () => {
|
|
513
|
+
// A user_message from the browser should be translated into Claude Code's
|
|
514
|
+
// NDJSON format: { type: "user", message: { role: "user", content }, ... }
|
|
515
|
+
adapter.send({ type: "user_message", content: "Hello Claude" });
|
|
516
|
+
const sent = getLastSent();
|
|
517
|
+
expect(sent.type).toBe("user");
|
|
518
|
+
expect(sent.message.role).toBe("user");
|
|
519
|
+
expect(sent.message.content).toBe("Hello Claude");
|
|
520
|
+
expect(sent.parent_tool_use_id).toBeNull();
|
|
521
|
+
expect(sent.session_id).toBe("");
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("user_message with session_id passes it through", () => {
|
|
525
|
+
// When a session_id is provided in the user_message, it should be included.
|
|
526
|
+
adapter.send({ type: "user_message", content: "hi", session_id: "sid-1" });
|
|
527
|
+
const sent = getLastSent();
|
|
528
|
+
expect(sent.session_id).toBe("sid-1");
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("user_message with images → includes image blocks in content array", () => {
|
|
532
|
+
// Images should be prepended as image blocks before a text block
|
|
533
|
+
// in the content array, following the Claude content block format.
|
|
534
|
+
adapter.send({
|
|
535
|
+
type: "user_message",
|
|
536
|
+
content: "Describe this",
|
|
537
|
+
images: [{ media_type: "image/png", data: "base64data" }],
|
|
538
|
+
});
|
|
539
|
+
const sent = getLastSent();
|
|
540
|
+
expect(sent.type).toBe("user");
|
|
541
|
+
expect(Array.isArray(sent.message.content)).toBe(true);
|
|
542
|
+
expect(sent.message.content).toHaveLength(2);
|
|
543
|
+
|
|
544
|
+
// First block: image
|
|
545
|
+
expect(sent.message.content[0].type).toBe("image");
|
|
546
|
+
expect(sent.message.content[0].source.type).toBe("base64");
|
|
547
|
+
expect(sent.message.content[0].source.media_type).toBe("image/png");
|
|
548
|
+
expect(sent.message.content[0].source.data).toBe("base64data");
|
|
549
|
+
|
|
550
|
+
// Second block: text
|
|
551
|
+
expect(sent.message.content[1].type).toBe("text");
|
|
552
|
+
expect(sent.message.content[1].text).toBe("Describe this");
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it("permission_response allow → sends correct control_response NDJSON", () => {
|
|
556
|
+
// An "allow" permission response should be translated into a
|
|
557
|
+
// control_response with behavior: "allow" and updatedInput.
|
|
558
|
+
adapter.send({
|
|
559
|
+
type: "permission_response",
|
|
560
|
+
request_id: "req-1",
|
|
561
|
+
behavior: "allow",
|
|
562
|
+
});
|
|
563
|
+
const sent = getLastSent();
|
|
564
|
+
expect(sent.type).toBe("control_response");
|
|
565
|
+
expect(sent.response.subtype).toBe("success");
|
|
566
|
+
expect(sent.response.request_id).toBe("req-1");
|
|
567
|
+
expect(sent.response.response.behavior).toBe("allow");
|
|
568
|
+
expect(sent.response.response.updatedInput).toEqual({});
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it("permission_response allow with updated_input and updated_permissions", () => {
|
|
572
|
+
// When updated_input and updated_permissions are provided, they should
|
|
573
|
+
// be forwarded in the control_response.
|
|
574
|
+
adapter.send({
|
|
575
|
+
type: "permission_response",
|
|
576
|
+
request_id: "req-2",
|
|
577
|
+
behavior: "allow",
|
|
578
|
+
updated_input: { command: "ls -la" },
|
|
579
|
+
updated_permissions: [{ type: "addRules" as const, rules: [{ toolName: "Bash" }], behavior: "allow" as const, destination: "project" as any }],
|
|
580
|
+
});
|
|
581
|
+
const sent = getLastSent();
|
|
582
|
+
expect(sent.response.response.updatedInput).toEqual({ command: "ls -la" });
|
|
583
|
+
expect(sent.response.response.updatedPermissions).toHaveLength(1);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it("permission_response deny → sends control_response NDJSON with deny message", () => {
|
|
587
|
+
// A "deny" permission response should include behavior: "deny" and
|
|
588
|
+
// a message explaining the denial.
|
|
589
|
+
adapter.send({
|
|
590
|
+
type: "permission_response",
|
|
591
|
+
request_id: "req-3",
|
|
592
|
+
behavior: "deny",
|
|
593
|
+
message: "Not allowed",
|
|
594
|
+
});
|
|
595
|
+
const sent = getLastSent();
|
|
596
|
+
expect(sent.type).toBe("control_response");
|
|
597
|
+
expect(sent.response.subtype).toBe("success");
|
|
598
|
+
expect(sent.response.request_id).toBe("req-3");
|
|
599
|
+
expect(sent.response.response.behavior).toBe("deny");
|
|
600
|
+
expect(sent.response.response.message).toBe("Not allowed");
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it("permission_response deny without explicit message uses default", () => {
|
|
604
|
+
// When no denial message is provided, a default should be used.
|
|
605
|
+
adapter.send({
|
|
606
|
+
type: "permission_response",
|
|
607
|
+
request_id: "req-4",
|
|
608
|
+
behavior: "deny",
|
|
609
|
+
});
|
|
610
|
+
const sent = getLastSent();
|
|
611
|
+
expect(sent.response.response.message).toBe("Denied by user");
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it("interrupt → sends control_request with subtype 'interrupt'", () => {
|
|
615
|
+
// An interrupt should be translated into a control_request with
|
|
616
|
+
// a deterministic UUID (mocked) and subtype "interrupt".
|
|
617
|
+
adapter.send({ type: "interrupt" });
|
|
618
|
+
const sent = getLastSent();
|
|
619
|
+
expect(sent.type).toBe("control_request");
|
|
620
|
+
expect(sent.request.subtype).toBe("interrupt");
|
|
621
|
+
expect(sent.request_id).toMatch(/^test-uuid-/);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it("set_model → sends control_request with subtype 'set_model'", () => {
|
|
625
|
+
// The set_model message should forward the model name in a control_request.
|
|
626
|
+
adapter.send({ type: "set_model", model: "claude-opus-4-6" });
|
|
627
|
+
const sent = getLastSent();
|
|
628
|
+
expect(sent.type).toBe("control_request");
|
|
629
|
+
expect(sent.request.subtype).toBe("set_model");
|
|
630
|
+
expect(sent.request.model).toBe("claude-opus-4-6");
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it("set_permission_mode → sends control_request with subtype 'set_permission_mode'", () => {
|
|
634
|
+
// The permission mode change should be forwarded to the CLI backend.
|
|
635
|
+
adapter.send({ type: "set_permission_mode", mode: "plan" });
|
|
636
|
+
const sent = getLastSent();
|
|
637
|
+
expect(sent.type).toBe("control_request");
|
|
638
|
+
expect(sent.request.subtype).toBe("set_permission_mode");
|
|
639
|
+
expect(sent.request.mode).toBe("plan");
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it("set_ai_validation → returns true without sending anything", () => {
|
|
643
|
+
// AI validation state is managed at the bridge level, not forwarded
|
|
644
|
+
// to the CLI. send() should return true (accepted) but not send any data.
|
|
645
|
+
const result = adapter.send({
|
|
646
|
+
type: "set_ai_validation",
|
|
647
|
+
aiValidationEnabled: true,
|
|
648
|
+
});
|
|
649
|
+
expect(result).toBe(true);
|
|
650
|
+
// No message should have been sent to the socket
|
|
651
|
+
expect(ws.send).not.toHaveBeenCalled();
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("session_subscribe → returns false (handled at bridge level)", () => {
|
|
655
|
+
// session_subscribe is handled by the bridge, not forwarded to the backend.
|
|
656
|
+
const result = adapter.send({ type: "session_subscribe", last_seq: 0 });
|
|
657
|
+
expect(result).toBe(false);
|
|
658
|
+
expect(ws.send).not.toHaveBeenCalled();
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it("session_ack → returns false (handled at bridge level)", () => {
|
|
662
|
+
// session_ack is handled by the bridge, not forwarded to the backend.
|
|
663
|
+
const result = adapter.send({ type: "session_ack", last_seq: 5 });
|
|
664
|
+
expect(result).toBe(false);
|
|
665
|
+
expect(ws.send).not.toHaveBeenCalled();
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it("mcp_get_status → sends control_request with subtype 'mcp_status'", () => {
|
|
669
|
+
// MCP status request should be sent as a control_request and tracked
|
|
670
|
+
// for async response resolution.
|
|
671
|
+
adapter.send({ type: "mcp_get_status" });
|
|
672
|
+
const sent = getLastSent();
|
|
673
|
+
expect(sent.type).toBe("control_request");
|
|
674
|
+
expect(sent.request.subtype).toBe("mcp_status");
|
|
675
|
+
expect(sent.request_id).toMatch(/^test-uuid-/);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it("send() returns true for accepted messages", () => {
|
|
679
|
+
// Verify that all accepted message types return true.
|
|
680
|
+
expect(adapter.send({ type: "user_message", content: "hi" })).toBe(true);
|
|
681
|
+
expect(adapter.send({ type: "interrupt" })).toBe(true);
|
|
682
|
+
expect(adapter.send({ type: "set_model", model: "m" })).toBe(true);
|
|
683
|
+
expect(adapter.send({ type: "set_permission_mode", mode: "plan" })).toBe(true);
|
|
684
|
+
expect(adapter.send({ type: "mcp_get_status" })).toBe(true);
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
// ─── handleRawMessage() — incoming CLI message routing ──────────────────────
|
|
689
|
+
|
|
690
|
+
describe("handleRawMessage() — incoming CLI message routing", () => {
|
|
691
|
+
let ws: ReturnType<typeof createMockSocket>;
|
|
692
|
+
|
|
693
|
+
beforeEach(() => {
|
|
694
|
+
ws = createMockSocket("sess-1");
|
|
695
|
+
adapter.attachWebSocket(ws);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it("system init → emits sessionMetaCb and browserMessageCb with session_init", () => {
|
|
699
|
+
// A system init message from the CLI should update session metadata
|
|
700
|
+
// (cliSessionId, model, cwd) and broadcast a session_init to browsers.
|
|
701
|
+
adapter.handleRawMessage(makeInitMsg());
|
|
702
|
+
|
|
703
|
+
expect(sessionMetaCb).toHaveBeenCalledOnce();
|
|
704
|
+
expect(sessionMetaCb).toHaveBeenCalledWith({
|
|
705
|
+
cliSessionId: "cli-123",
|
|
706
|
+
model: "claude-sonnet-4-6",
|
|
707
|
+
cwd: "/test",
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
expect(browserMessageCb).toHaveBeenCalledOnce();
|
|
711
|
+
const msg = browserMessageCb.mock.calls[0][0];
|
|
712
|
+
expect(msg.type).toBe("session_init");
|
|
713
|
+
expect(msg.session.session_id).toBe("cli-123");
|
|
714
|
+
expect(msg.session.model).toBe("claude-sonnet-4-6");
|
|
715
|
+
expect(msg.session.cwd).toBe("/test");
|
|
716
|
+
expect(msg.session.tools).toEqual(["Bash", "Read"]);
|
|
717
|
+
expect(msg.session.permissionMode).toBe("default");
|
|
718
|
+
expect(msg.session.claude_code_version).toBe("1.0");
|
|
719
|
+
expect(msg.session.mcp_servers).toEqual([]);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
it("system status → emits browserMessageCb with status_change", () => {
|
|
723
|
+
// A system status message (e.g. compacting) should be translated to
|
|
724
|
+
// a status_change browser message.
|
|
725
|
+
adapter.handleRawMessage(makeSystemStatusMsg());
|
|
726
|
+
|
|
727
|
+
expect(browserMessageCb).toHaveBeenCalledOnce();
|
|
728
|
+
const msg = browserMessageCb.mock.calls[0][0];
|
|
729
|
+
expect(msg.type).toBe("status_change");
|
|
730
|
+
expect(msg.status).toBe("compacting");
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it("system status with null status → emits status_change with null", () => {
|
|
734
|
+
// When the CLI sends status: null, it means the status is cleared.
|
|
735
|
+
adapter.handleRawMessage(makeSystemStatusMsg({ status: null }));
|
|
736
|
+
|
|
737
|
+
const msg = browserMessageCb.mock.calls[0][0];
|
|
738
|
+
expect(msg.type).toBe("status_change");
|
|
739
|
+
expect(msg.status).toBeNull();
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
it("assistant → emits browserMessageCb with assistant message including timestamp", () => {
|
|
743
|
+
// An assistant message should be forwarded with its full message payload
|
|
744
|
+
// and a server-assigned timestamp.
|
|
745
|
+
const now = Date.now();
|
|
746
|
+
adapter.handleRawMessage(makeAssistantMsg());
|
|
747
|
+
|
|
748
|
+
expect(browserMessageCb).toHaveBeenCalledOnce();
|
|
749
|
+
const msg = browserMessageCb.mock.calls[0][0];
|
|
750
|
+
expect(msg.type).toBe("assistant");
|
|
751
|
+
expect(msg.message.id).toBe("msg-1");
|
|
752
|
+
expect(msg.message.role).toBe("assistant");
|
|
753
|
+
expect(msg.message.content[0].text).toBe("Hello world");
|
|
754
|
+
expect(msg.parent_tool_use_id).toBeNull();
|
|
755
|
+
// Timestamp should be roughly "now"
|
|
756
|
+
expect(msg.timestamp).toBeGreaterThanOrEqual(now);
|
|
757
|
+
expect(msg.timestamp).toBeLessThanOrEqual(Date.now());
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
it("result → emits browserMessageCb with result data", () => {
|
|
761
|
+
// A result message should be forwarded as-is in the data field.
|
|
762
|
+
adapter.handleRawMessage(makeResultMsg());
|
|
763
|
+
|
|
764
|
+
expect(browserMessageCb).toHaveBeenCalledOnce();
|
|
765
|
+
const msg = browserMessageCb.mock.calls[0][0];
|
|
766
|
+
expect(msg.type).toBe("result");
|
|
767
|
+
expect(msg.data.subtype).toBe("success");
|
|
768
|
+
expect(msg.data.total_cost_usd).toBe(0.01);
|
|
769
|
+
expect(msg.data.num_turns).toBe(1);
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
it("stream_event → emits browserMessageCb with stream_event", () => {
|
|
773
|
+
// Stream events should be forwarded with the event payload and
|
|
774
|
+
// parent_tool_use_id.
|
|
775
|
+
adapter.handleRawMessage(makeStreamEventMsg());
|
|
776
|
+
|
|
777
|
+
expect(browserMessageCb).toHaveBeenCalledOnce();
|
|
778
|
+
const msg = browserMessageCb.mock.calls[0][0];
|
|
779
|
+
expect(msg.type).toBe("stream_event");
|
|
780
|
+
expect(msg.event.type).toBe("content_block_delta");
|
|
781
|
+
expect(msg.parent_tool_use_id).toBeNull();
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it("control_request (can_use_tool) → emits browserMessageCb with permission_request", () => {
|
|
785
|
+
// A tool permission request from the CLI should be translated into
|
|
786
|
+
// a permission_request browser message with all relevant fields.
|
|
787
|
+
adapter.handleRawMessage(makeControlRequestMsg());
|
|
788
|
+
|
|
789
|
+
expect(browserMessageCb).toHaveBeenCalledOnce();
|
|
790
|
+
const msg = browserMessageCb.mock.calls[0][0];
|
|
791
|
+
expect(msg.type).toBe("permission_request");
|
|
792
|
+
expect(msg.request.request_id).toBe("ctrl-req-1");
|
|
793
|
+
expect(msg.request.tool_name).toBe("Bash");
|
|
794
|
+
expect(msg.request.input).toEqual({ command: "ls" });
|
|
795
|
+
expect(msg.request.description).toBe("List files");
|
|
796
|
+
expect(msg.request.tool_use_id).toBe("tu-1");
|
|
797
|
+
// Timestamp should be set by the adapter
|
|
798
|
+
expect(typeof msg.request.timestamp).toBe("number");
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
it("tool_progress → emits browserMessageCb with tool_progress", () => {
|
|
802
|
+
// Tool progress updates should be forwarded to the browser.
|
|
803
|
+
adapter.handleRawMessage(makeToolProgressMsg());
|
|
804
|
+
|
|
805
|
+
expect(browserMessageCb).toHaveBeenCalledOnce();
|
|
806
|
+
const msg = browserMessageCb.mock.calls[0][0];
|
|
807
|
+
expect(msg.type).toBe("tool_progress");
|
|
808
|
+
expect(msg.tool_use_id).toBe("tu-1");
|
|
809
|
+
expect(msg.tool_name).toBe("Bash");
|
|
810
|
+
expect(msg.elapsed_time_seconds).toBe(2);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
it("auth_status → emits browserMessageCb with auth_status", () => {
|
|
814
|
+
// Auth status updates should be forwarded with all relevant fields.
|
|
815
|
+
adapter.handleRawMessage(makeAuthStatusMsg());
|
|
816
|
+
|
|
817
|
+
expect(browserMessageCb).toHaveBeenCalledOnce();
|
|
818
|
+
const msg = browserMessageCb.mock.calls[0][0];
|
|
819
|
+
expect(msg.type).toBe("auth_status");
|
|
820
|
+
expect(msg.isAuthenticating).toBe(true);
|
|
821
|
+
expect(msg.output).toEqual(["Authenticating..."]);
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
it("auth_status with error → includes error in emission", () => {
|
|
825
|
+
// When the auth status includes an error, it should be forwarded.
|
|
826
|
+
adapter.handleRawMessage(makeAuthStatusMsg({ error: "Token expired" }));
|
|
827
|
+
|
|
828
|
+
const msg = browserMessageCb.mock.calls[0][0];
|
|
829
|
+
expect(msg.type).toBe("auth_status");
|
|
830
|
+
expect(msg.error).toBe("Token expired");
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it("keep_alive → no emission to browser", () => {
|
|
834
|
+
// Keep-alive messages are silently consumed and should not be forwarded.
|
|
835
|
+
adapter.handleRawMessage(makeKeepAliveMsg());
|
|
836
|
+
expect(browserMessageCb).not.toHaveBeenCalled();
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
it("tool_use_summary → emits browserMessageCb with tool_use_summary", () => {
|
|
840
|
+
// Tool use summary messages should be forwarded with summary text and tool IDs.
|
|
841
|
+
const summaryMsg = JSON.stringify({
|
|
842
|
+
type: "tool_use_summary",
|
|
843
|
+
summary: "Ran bash command successfully",
|
|
844
|
+
preceding_tool_use_ids: ["tu-1", "tu-2"],
|
|
845
|
+
uuid: "tus-uuid-1",
|
|
846
|
+
session_id: "cli-123",
|
|
847
|
+
});
|
|
848
|
+
adapter.handleRawMessage(summaryMsg);
|
|
849
|
+
|
|
850
|
+
expect(browserMessageCb).toHaveBeenCalledOnce();
|
|
851
|
+
const msg = browserMessageCb.mock.calls[0][0];
|
|
852
|
+
expect(msg.type).toBe("tool_use_summary");
|
|
853
|
+
expect(msg.summary).toBe("Ran bash command successfully");
|
|
854
|
+
expect(msg.tool_use_ids).toEqual(["tu-1", "tu-2"]);
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
it("multiple NDJSON lines in one message are all processed", () => {
|
|
858
|
+
// The CLI may send multiple JSON objects separated by newlines in
|
|
859
|
+
// a single WebSocket message. All should be parsed and routed.
|
|
860
|
+
const combined = makeStreamEventMsg({ uuid: "s1" }) + "\n" + makeToolProgressMsg();
|
|
861
|
+
adapter.handleRawMessage(combined);
|
|
862
|
+
expect(browserMessageCb).toHaveBeenCalledTimes(2);
|
|
863
|
+
expect(browserMessageCb.mock.calls[0][0].type).toBe("stream_event");
|
|
864
|
+
expect(browserMessageCb.mock.calls[1][0].type).toBe("tool_progress");
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
it("malformed JSON lines are skipped without crashing", () => {
|
|
868
|
+
// If a line in the NDJSON cannot be parsed, it should be skipped
|
|
869
|
+
// and subsequent valid lines should still be processed.
|
|
870
|
+
// The parse error also surfaces as an error message to the browser.
|
|
871
|
+
const spy = vi.spyOn(log, "warn").mockImplementation(() => {});
|
|
872
|
+
const raw = "not json\n" + makeAssistantMsg();
|
|
873
|
+
adapter.handleRawMessage(raw);
|
|
874
|
+
// Parse error surfaced + valid assistant message processed
|
|
875
|
+
const calls = browserMessageCb.mock.calls.map((args: any[]) => args[0].type);
|
|
876
|
+
expect(calls).toContain("error");
|
|
877
|
+
expect(calls).toContain("assistant");
|
|
878
|
+
spy.mockRestore();
|
|
879
|
+
});
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
// ─── Activity update callback ───────────────────────────────────────────────
|
|
883
|
+
|
|
884
|
+
describe("Activity update callback", () => {
|
|
885
|
+
let ws: ReturnType<typeof createMockSocket>;
|
|
886
|
+
|
|
887
|
+
beforeEach(() => {
|
|
888
|
+
ws = createMockSocket("sess-1");
|
|
889
|
+
adapter.attachWebSocket(ws);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
it("onActivityUpdate called on non-keepalive messages", () => {
|
|
893
|
+
// The activity update callback is used for idle detection. It should
|
|
894
|
+
// fire for all message types except keep_alive.
|
|
895
|
+
adapter.handleRawMessage(makeAssistantMsg());
|
|
896
|
+
expect(onActivityUpdate).toHaveBeenCalledOnce();
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
it("onActivityUpdate NOT called on keep_alive messages", () => {
|
|
900
|
+
// Keep-alive messages don't represent real activity and should not
|
|
901
|
+
// trigger the activity update callback.
|
|
902
|
+
adapter.handleRawMessage(makeKeepAliveMsg());
|
|
903
|
+
expect(onActivityUpdate).not.toHaveBeenCalled();
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
it("onActivityUpdate called for system, result, stream_event, control_request, tool_progress", () => {
|
|
907
|
+
// Verify the callback fires for multiple different message types.
|
|
908
|
+
adapter.handleRawMessage(makeInitMsg());
|
|
909
|
+
adapter.handleRawMessage(makeResultMsg());
|
|
910
|
+
adapter.handleRawMessage(makeStreamEventMsg());
|
|
911
|
+
adapter.handleRawMessage(makeControlRequestMsg());
|
|
912
|
+
adapter.handleRawMessage(makeToolProgressMsg());
|
|
913
|
+
|
|
914
|
+
// init + result + stream_event + control_request + tool_progress = 5 calls
|
|
915
|
+
expect(onActivityUpdate).toHaveBeenCalledTimes(5);
|
|
916
|
+
});
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
// ─── Deduplication ──────────────────────────────────────────────────────────
|
|
920
|
+
|
|
921
|
+
describe("Deduplication", () => {
|
|
922
|
+
let ws: ReturnType<typeof createMockSocket>;
|
|
923
|
+
|
|
924
|
+
beforeEach(() => {
|
|
925
|
+
ws = createMockSocket("sess-1");
|
|
926
|
+
adapter.attachWebSocket(ws);
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
it("duplicate assistant messages are filtered out", () => {
|
|
930
|
+
// When the CLI replays messages on WebSocket reconnect, the same
|
|
931
|
+
// assistant message sent twice should only be processed once.
|
|
932
|
+
const assistantNdjson = makeAssistantMsg();
|
|
933
|
+
adapter.handleRawMessage(assistantNdjson);
|
|
934
|
+
adapter.handleRawMessage(assistantNdjson);
|
|
935
|
+
|
|
936
|
+
// Only the first should have been emitted
|
|
937
|
+
expect(browserMessageCb).toHaveBeenCalledOnce();
|
|
938
|
+
expect(browserMessageCb.mock.calls[0][0].type).toBe("assistant");
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
it("duplicate stream_events with same uuid are filtered out", () => {
|
|
942
|
+
// Stream events with the same UUID should be deduplicated.
|
|
943
|
+
const streamNdjson = makeStreamEventMsg({ uuid: "dup-stream-uuid" });
|
|
944
|
+
adapter.handleRawMessage(streamNdjson);
|
|
945
|
+
adapter.handleRawMessage(streamNdjson);
|
|
946
|
+
|
|
947
|
+
// Only the first should have been emitted
|
|
948
|
+
expect(browserMessageCb).toHaveBeenCalledOnce();
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
it("stream_events with different uuids are NOT filtered", () => {
|
|
952
|
+
// Different UUIDs indicate distinct events that should both be processed.
|
|
953
|
+
adapter.handleRawMessage(makeStreamEventMsg({ uuid: "stream-1" }));
|
|
954
|
+
adapter.handleRawMessage(makeStreamEventMsg({ uuid: "stream-2" }));
|
|
955
|
+
|
|
956
|
+
expect(browserMessageCb).toHaveBeenCalledTimes(2);
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
it("non-deduplicable message types (tool_progress, control_request) are never filtered", () => {
|
|
960
|
+
// Tool progress and control request messages should never be deduplicated,
|
|
961
|
+
// even if sent identically twice.
|
|
962
|
+
const toolProgressNdjson = makeToolProgressMsg();
|
|
963
|
+
adapter.handleRawMessage(toolProgressNdjson);
|
|
964
|
+
adapter.handleRawMessage(toolProgressNdjson);
|
|
965
|
+
expect(browserMessageCb).toHaveBeenCalledTimes(2);
|
|
966
|
+
});
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
// ─── Control request/response flow ──────────────────────────────────────────
|
|
970
|
+
|
|
971
|
+
describe("Control request/response flow", () => {
|
|
972
|
+
let ws: ReturnType<typeof createMockSocket>;
|
|
973
|
+
|
|
974
|
+
beforeEach(() => {
|
|
975
|
+
ws = createMockSocket("sess-1");
|
|
976
|
+
adapter.attachWebSocket(ws);
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
it("MCP status request creates pending control request and resolves on response", () => {
|
|
980
|
+
// When mcp_get_status is sent, the adapter creates a pending control
|
|
981
|
+
// request. When the CLI responds with the matching request_id, the
|
|
982
|
+
// adapter should resolve it and emit mcp_status to browsers.
|
|
983
|
+
uuidCounter = 100; // Ensure deterministic request_id
|
|
984
|
+
adapter.send({ type: "mcp_get_status" });
|
|
985
|
+
|
|
986
|
+
// Extract the request_id from what was sent to the CLI
|
|
987
|
+
const sentRaw = (ws.send.mock.calls[0][0] as string).trim();
|
|
988
|
+
const sent = JSON.parse(sentRaw);
|
|
989
|
+
const requestId = sent.request_id;
|
|
990
|
+
|
|
991
|
+
// Simulate CLI response with matching request_id and MCP servers
|
|
992
|
+
const controlResponse = JSON.stringify({
|
|
993
|
+
type: "control_response",
|
|
994
|
+
response: {
|
|
995
|
+
subtype: "success",
|
|
996
|
+
request_id: requestId,
|
|
997
|
+
response: {
|
|
998
|
+
mcpServers: [
|
|
999
|
+
{ name: "test-server", status: "connected", config: { type: "stdio" }, scope: "project", tools: [] },
|
|
1000
|
+
],
|
|
1001
|
+
},
|
|
1002
|
+
},
|
|
1003
|
+
});
|
|
1004
|
+
adapter.handleRawMessage(controlResponse);
|
|
1005
|
+
|
|
1006
|
+
// The adapter should have emitted an mcp_status browser message
|
|
1007
|
+
expect(browserMessageCb).toHaveBeenCalledOnce();
|
|
1008
|
+
const msg = browserMessageCb.mock.calls[0][0];
|
|
1009
|
+
expect(msg.type).toBe("mcp_status");
|
|
1010
|
+
expect(msg.servers).toHaveLength(1);
|
|
1011
|
+
expect(msg.servers[0].name).toBe("test-server");
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
it("control response with no pending request is silently ignored", () => {
|
|
1015
|
+
// If a control_response arrives with an unknown request_id,
|
|
1016
|
+
// it should be silently ignored (no crash, no emission).
|
|
1017
|
+
const controlResponse = JSON.stringify({
|
|
1018
|
+
type: "control_response",
|
|
1019
|
+
response: {
|
|
1020
|
+
subtype: "success",
|
|
1021
|
+
request_id: "unknown-request-id",
|
|
1022
|
+
response: {},
|
|
1023
|
+
},
|
|
1024
|
+
});
|
|
1025
|
+
adapter.handleRawMessage(controlResponse);
|
|
1026
|
+
// No emission to the browser
|
|
1027
|
+
expect(browserMessageCb).not.toHaveBeenCalled();
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
it("error control response logs warning and doesn't call resolve", () => {
|
|
1031
|
+
// When the CLI responds with an error control_response, the adapter
|
|
1032
|
+
// should log a warning and NOT call the resolve callback. The pending
|
|
1033
|
+
// request should be cleaned up.
|
|
1034
|
+
uuidCounter = 200;
|
|
1035
|
+
adapter.send({ type: "mcp_get_status" });
|
|
1036
|
+
|
|
1037
|
+
const sentRaw = (ws.send.mock.calls[0][0] as string).trim();
|
|
1038
|
+
const sent = JSON.parse(sentRaw);
|
|
1039
|
+
const requestId = sent.request_id;
|
|
1040
|
+
|
|
1041
|
+
const errorResponse = JSON.stringify({
|
|
1042
|
+
type: "control_response",
|
|
1043
|
+
response: {
|
|
1044
|
+
subtype: "error",
|
|
1045
|
+
request_id: requestId,
|
|
1046
|
+
error: "MCP status unavailable",
|
|
1047
|
+
},
|
|
1048
|
+
});
|
|
1049
|
+
adapter.handleRawMessage(errorResponse);
|
|
1050
|
+
|
|
1051
|
+
// console.warn should have been called with the error
|
|
1052
|
+
expect(console.warn).toHaveBeenCalledWith(
|
|
1053
|
+
expect.stringContaining("mcp_status failed"),
|
|
1054
|
+
// Note: console.warn is a mock, we just check the first arg
|
|
1055
|
+
);
|
|
1056
|
+
// No mcp_status should have been emitted
|
|
1057
|
+
expect(browserMessageCb).not.toHaveBeenCalled();
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
it("pending control request is removed after successful resolution", () => {
|
|
1061
|
+
// After a control response resolves a pending request, sending the
|
|
1062
|
+
// same response again should be a no-op (not double-resolve).
|
|
1063
|
+
uuidCounter = 300;
|
|
1064
|
+
adapter.send({ type: "mcp_get_status" });
|
|
1065
|
+
|
|
1066
|
+
const sentRaw = (ws.send.mock.calls[0][0] as string).trim();
|
|
1067
|
+
const sent = JSON.parse(sentRaw);
|
|
1068
|
+
const requestId = sent.request_id;
|
|
1069
|
+
|
|
1070
|
+
const successResponse = JSON.stringify({
|
|
1071
|
+
type: "control_response",
|
|
1072
|
+
response: {
|
|
1073
|
+
subtype: "success",
|
|
1074
|
+
request_id: requestId,
|
|
1075
|
+
response: { mcpServers: [] },
|
|
1076
|
+
},
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
// First resolution
|
|
1080
|
+
adapter.handleRawMessage(successResponse);
|
|
1081
|
+
expect(browserMessageCb).toHaveBeenCalledOnce();
|
|
1082
|
+
|
|
1083
|
+
// Second resolution — should be ignored (pending already removed)
|
|
1084
|
+
browserMessageCb.mockClear();
|
|
1085
|
+
adapter.handleRawMessage(successResponse);
|
|
1086
|
+
expect(browserMessageCb).not.toHaveBeenCalled();
|
|
1087
|
+
});
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
// ─── System init flushes pending messages ───────────────────────────────────
|
|
1091
|
+
|
|
1092
|
+
describe("System init flushes pending messages", () => {
|
|
1093
|
+
it("messages queued before init are flushed after init", () => {
|
|
1094
|
+
// When the CLI socket is connected but the adapter has pending messages
|
|
1095
|
+
// from before the connection, the init handler also flushes them.
|
|
1096
|
+
// This tests the scenario where messages are sent before the socket
|
|
1097
|
+
// is attached, then the socket attaches (flushing queue), and additional
|
|
1098
|
+
// messages are sent before init — those get queued internally too.
|
|
1099
|
+
|
|
1100
|
+
// First, send a message before socket is attached (queued)
|
|
1101
|
+
adapter.send({ type: "user_message", content: "queued-msg" });
|
|
1102
|
+
|
|
1103
|
+
// Attach socket — this flushes the pendingMessages
|
|
1104
|
+
const ws = createMockSocket("sess-1");
|
|
1105
|
+
adapter.attachWebSocket(ws);
|
|
1106
|
+
|
|
1107
|
+
expect(ws.send).toHaveBeenCalledTimes(1);
|
|
1108
|
+
const firstSent = JSON.parse((ws.send.mock.calls[0][0] as string).trim());
|
|
1109
|
+
expect(firstSent.message.content).toBe("queued-msg");
|
|
1110
|
+
});
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
// ─── Edge cases ─────────────────────────────────────────────────────────────
|
|
1114
|
+
|
|
1115
|
+
describe("Edge cases", () => {
|
|
1116
|
+
it("adapter works without onActivityUpdate callback", () => {
|
|
1117
|
+
// Creating an adapter without the onActivityUpdate option should not
|
|
1118
|
+
// cause errors when processing messages.
|
|
1119
|
+
const plainAdapter = new ClaudeAdapter("sess-2");
|
|
1120
|
+
const cb = vi.fn();
|
|
1121
|
+
plainAdapter.onBrowserMessage(cb);
|
|
1122
|
+
|
|
1123
|
+
const ws = createMockSocket("sess-2");
|
|
1124
|
+
plainAdapter.attachWebSocket(ws);
|
|
1125
|
+
plainAdapter.handleRawMessage(makeAssistantMsg());
|
|
1126
|
+
|
|
1127
|
+
expect(cb).toHaveBeenCalledOnce();
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
it("adapter works without any callbacks registered", () => {
|
|
1131
|
+
// Processing messages without any registered callbacks should not throw.
|
|
1132
|
+
const plainAdapter = new ClaudeAdapter("sess-3");
|
|
1133
|
+
const ws = createMockSocket("sess-3");
|
|
1134
|
+
plainAdapter.attachWebSocket(ws);
|
|
1135
|
+
|
|
1136
|
+
// Should not throw even without callbacks
|
|
1137
|
+
expect(() => plainAdapter.handleRawMessage(makeInitMsg())).not.toThrow();
|
|
1138
|
+
expect(() => plainAdapter.handleRawMessage(makeAssistantMsg())).not.toThrow();
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
it("system init with agents, slash_commands, and skills", () => {
|
|
1142
|
+
// Verify that optional fields from the init message are forwarded.
|
|
1143
|
+
const cb = vi.fn();
|
|
1144
|
+
adapter.onBrowserMessage(cb);
|
|
1145
|
+
|
|
1146
|
+
const ws = createMockSocket("sess-1");
|
|
1147
|
+
adapter.attachWebSocket(ws);
|
|
1148
|
+
|
|
1149
|
+
adapter.handleRawMessage(
|
|
1150
|
+
makeInitMsg({
|
|
1151
|
+
agents: ["agent-1"],
|
|
1152
|
+
slash_commands: ["/help"],
|
|
1153
|
+
skills: ["skill-1"],
|
|
1154
|
+
}),
|
|
1155
|
+
);
|
|
1156
|
+
|
|
1157
|
+
// We have 2 registered callbacks for browserMessage (one from beforeEach, one here)
|
|
1158
|
+
// but only the last one registered on the adapter will fire (it overwrites).
|
|
1159
|
+
const msg = cb.mock.calls[0][0];
|
|
1160
|
+
expect(msg.session.agents).toEqual(["agent-1"]);
|
|
1161
|
+
expect(msg.session.slash_commands).toEqual(["/help"]);
|
|
1162
|
+
expect(msg.session.skills).toEqual(["skill-1"]);
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
it("empty NDJSON data produces no emissions", () => {
|
|
1166
|
+
// An empty string or whitespace-only message should produce no emissions.
|
|
1167
|
+
const ws = createMockSocket("sess-1");
|
|
1168
|
+
adapter.attachWebSocket(ws);
|
|
1169
|
+
adapter.handleRawMessage("");
|
|
1170
|
+
adapter.handleRawMessage(" \n \n ");
|
|
1171
|
+
expect(browserMessageCb).not.toHaveBeenCalled();
|
|
1172
|
+
});
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
// ─── control_cancel_request handling ─────────────────────────────────────────
|
|
1176
|
+
|
|
1177
|
+
describe("control_cancel_request", () => {
|
|
1178
|
+
it("emits permission_cancelled to browser", () => {
|
|
1179
|
+
// When the CLI cancels a pending control request (e.g. tool permission
|
|
1180
|
+
// that is no longer needed), the adapter should notify browsers so they
|
|
1181
|
+
// can remove the pending permission UI.
|
|
1182
|
+
const ws = createMockSocket("sess-1");
|
|
1183
|
+
adapter.attachWebSocket(ws);
|
|
1184
|
+
|
|
1185
|
+
const msg = JSON.stringify({ type: "control_cancel_request", request_id: "req-cancel-1" });
|
|
1186
|
+
adapter.handleRawMessage(msg);
|
|
1187
|
+
|
|
1188
|
+
expect(browserMessageCb).toHaveBeenCalledOnce();
|
|
1189
|
+
const emitted = browserMessageCb.mock.calls[0][0];
|
|
1190
|
+
expect(emitted.type).toBe("permission_cancelled");
|
|
1191
|
+
expect(emitted.request_id).toBe("req-cancel-1");
|
|
1192
|
+
});
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
// ─── Enriched can_use_tool fields ────────────────────────────────────────────
|
|
1196
|
+
|
|
1197
|
+
describe("enriched can_use_tool", () => {
|
|
1198
|
+
it("forwards title, display_name, blocked_path, decision_reason", () => {
|
|
1199
|
+
// Newer CLI versions may include enriched fields on can_use_tool requests
|
|
1200
|
+
// (title, display_name, blocked_path, decision_reason). The adapter should
|
|
1201
|
+
// forward all of these to the browser in the permission_request.
|
|
1202
|
+
const ws = createMockSocket("sess-1");
|
|
1203
|
+
adapter.attachWebSocket(ws);
|
|
1204
|
+
|
|
1205
|
+
const msg = JSON.stringify({
|
|
1206
|
+
type: "control_request",
|
|
1207
|
+
request_id: "req-enriched-1",
|
|
1208
|
+
request: {
|
|
1209
|
+
subtype: "can_use_tool",
|
|
1210
|
+
tool_name: "Edit",
|
|
1211
|
+
input: { file_path: "/test.ts" },
|
|
1212
|
+
tool_use_id: "tu-enriched-1",
|
|
1213
|
+
title: "Edit a TypeScript file",
|
|
1214
|
+
display_name: "File Editor",
|
|
1215
|
+
blocked_path: "/test.ts",
|
|
1216
|
+
decision_reason: "File is outside trusted directories",
|
|
1217
|
+
},
|
|
1218
|
+
});
|
|
1219
|
+
adapter.handleRawMessage(msg);
|
|
1220
|
+
|
|
1221
|
+
expect(browserMessageCb).toHaveBeenCalledOnce();
|
|
1222
|
+
const emitted = browserMessageCb.mock.calls[0][0];
|
|
1223
|
+
expect(emitted.type).toBe("permission_request");
|
|
1224
|
+
const perm = emitted.request;
|
|
1225
|
+
expect(perm.title).toBe("Edit a TypeScript file");
|
|
1226
|
+
expect(perm.display_name).toBe("File Editor");
|
|
1227
|
+
expect(perm.blocked_path).toBe("/test.ts");
|
|
1228
|
+
expect(perm.decision_reason).toBe("File is outside trusted directories");
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
it("works without enriched fields (backward compat)", () => {
|
|
1232
|
+
// Older CLI versions do not include enriched fields. The adapter should
|
|
1233
|
+
// still emit a valid permission_request with those fields undefined.
|
|
1234
|
+
const ws = createMockSocket("sess-1");
|
|
1235
|
+
adapter.attachWebSocket(ws);
|
|
1236
|
+
|
|
1237
|
+
const msg = JSON.stringify({
|
|
1238
|
+
type: "control_request",
|
|
1239
|
+
request_id: "req-basic-1",
|
|
1240
|
+
request: {
|
|
1241
|
+
subtype: "can_use_tool",
|
|
1242
|
+
tool_name: "Bash",
|
|
1243
|
+
input: { command: "ls" },
|
|
1244
|
+
tool_use_id: "tu-basic-1",
|
|
1245
|
+
},
|
|
1246
|
+
});
|
|
1247
|
+
adapter.handleRawMessage(msg);
|
|
1248
|
+
|
|
1249
|
+
expect(browserMessageCb).toHaveBeenCalledOnce();
|
|
1250
|
+
const emitted = browserMessageCb.mock.calls[0][0];
|
|
1251
|
+
expect(emitted.type).toBe("permission_request");
|
|
1252
|
+
const perm = emitted.request;
|
|
1253
|
+
expect(perm.title).toBeUndefined();
|
|
1254
|
+
expect(perm.display_name).toBeUndefined();
|
|
1255
|
+
});
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
// ─── end_session outgoing ────────────────────────────────────────────────────
|
|
1259
|
+
|
|
1260
|
+
describe("end_session", () => {
|
|
1261
|
+
it("sends end_session control_request to CLI", () => {
|
|
1262
|
+
// The browser can request the session to end. This should be translated
|
|
1263
|
+
// into a control_request with subtype "end_session" and the reason forwarded.
|
|
1264
|
+
const ws = createMockSocket("sess-1");
|
|
1265
|
+
adapter.attachWebSocket(ws);
|
|
1266
|
+
|
|
1267
|
+
adapter.send({ type: "end_session", reason: "user closed" } as any);
|
|
1268
|
+
|
|
1269
|
+
const sent = JSON.parse((ws.send.mock.calls[0][0] as string).trim());
|
|
1270
|
+
expect(sent.type).toBe("control_request");
|
|
1271
|
+
expect(sent.request.subtype).toBe("end_session");
|
|
1272
|
+
expect(sent.request.reason).toBe("user closed");
|
|
1273
|
+
});
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
// ─── stop_task outgoing ──────────────────────────────────────────────────────
|
|
1277
|
+
|
|
1278
|
+
describe("stop_task", () => {
|
|
1279
|
+
it("sends stop_task control_request to CLI", () => {
|
|
1280
|
+
// The browser can stop a running task. This should be translated into
|
|
1281
|
+
// a control_request with subtype "stop_task" and the task_id forwarded.
|
|
1282
|
+
const ws = createMockSocket("sess-1");
|
|
1283
|
+
adapter.attachWebSocket(ws);
|
|
1284
|
+
|
|
1285
|
+
adapter.send({ type: "stop_task", task_id: "task-123" } as any);
|
|
1286
|
+
|
|
1287
|
+
const sent = JSON.parse((ws.send.mock.calls[0][0] as string).trim());
|
|
1288
|
+
expect(sent.type).toBe("control_request");
|
|
1289
|
+
expect(sent.request.subtype).toBe("stop_task");
|
|
1290
|
+
expect(sent.request.task_id).toBe("task-123");
|
|
1291
|
+
});
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
// ─── update_environment_variables outgoing ───────────────────────────────────
|
|
1295
|
+
|
|
1296
|
+
describe("update_environment_variables", () => {
|
|
1297
|
+
it("sends update_environment_variables directly (not as control_request)", () => {
|
|
1298
|
+
// Environment variable updates are sent as a top-level message type,
|
|
1299
|
+
// not wrapped in a control_request, because the CLI expects them
|
|
1300
|
+
// as a distinct message kind.
|
|
1301
|
+
const ws = createMockSocket("sess-1");
|
|
1302
|
+
adapter.attachWebSocket(ws);
|
|
1303
|
+
|
|
1304
|
+
adapter.send({ type: "update_environment_variables", variables: { TOKEN: "new-val" } } as any);
|
|
1305
|
+
|
|
1306
|
+
const sent = JSON.parse((ws.send.mock.calls[0][0] as string).trim());
|
|
1307
|
+
expect(sent.type).toBe("update_environment_variables");
|
|
1308
|
+
expect(sent.variables.TOKEN).toBe("new-val");
|
|
1309
|
+
});
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
// ─── Streamlined messages ────────────────────────────────────────────────────
|
|
1313
|
+
|
|
1314
|
+
describe("streamlined messages", () => {
|
|
1315
|
+
it("forwards streamlined_text to browser", () => {
|
|
1316
|
+
// In simplified output mode, the CLI sends streamlined_text messages
|
|
1317
|
+
// instead of full assistant messages. These should be forwarded as-is.
|
|
1318
|
+
const ws = createMockSocket("sess-1");
|
|
1319
|
+
adapter.attachWebSocket(ws);
|
|
1320
|
+
|
|
1321
|
+
const msg = JSON.stringify({ type: "streamlined_text", text: "Hello world", session_id: "s1", uuid: "u1" });
|
|
1322
|
+
adapter.handleRawMessage(msg);
|
|
1323
|
+
|
|
1324
|
+
expect(browserMessageCb).toHaveBeenCalledOnce();
|
|
1325
|
+
const emitted = browserMessageCb.mock.calls[0][0];
|
|
1326
|
+
expect(emitted.type).toBe("streamlined_text");
|
|
1327
|
+
expect(emitted.text).toBe("Hello world");
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
it("forwards streamlined_tool_use_summary to browser", () => {
|
|
1331
|
+
// In simplified output mode, tool use summaries are sent as
|
|
1332
|
+
// streamlined_tool_use_summary. These should be forwarded with the summary text.
|
|
1333
|
+
const ws = createMockSocket("sess-1");
|
|
1334
|
+
adapter.attachWebSocket(ws);
|
|
1335
|
+
|
|
1336
|
+
const msg = JSON.stringify({ type: "streamlined_tool_use_summary", tool_summary: "Read 2 files", session_id: "s1", uuid: "u2" });
|
|
1337
|
+
adapter.handleRawMessage(msg);
|
|
1338
|
+
|
|
1339
|
+
expect(browserMessageCb).toHaveBeenCalledOnce();
|
|
1340
|
+
const emitted = browserMessageCb.mock.calls[0][0];
|
|
1341
|
+
expect(emitted.type).toBe("streamlined_tool_use_summary");
|
|
1342
|
+
expect(emitted.tool_summary).toBe("Read 2 files");
|
|
1343
|
+
});
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
// ─── Prompt suggestion ───────────────────────────────────────────────────────
|
|
1347
|
+
|
|
1348
|
+
describe("prompt_suggestion", () => {
|
|
1349
|
+
it("forwards prompt suggestions to browser", () => {
|
|
1350
|
+
// The CLI can suggest next prompts to the user. These should be forwarded
|
|
1351
|
+
// so the browser can render suggestion chips in the UI.
|
|
1352
|
+
const ws = createMockSocket("sess-1");
|
|
1353
|
+
adapter.attachWebSocket(ws);
|
|
1354
|
+
|
|
1355
|
+
const msg = JSON.stringify({ type: "prompt_suggestion", suggestions: ["Fix the bug", "Add tests"], session_id: "s1", uuid: "u3" });
|
|
1356
|
+
adapter.handleRawMessage(msg);
|
|
1357
|
+
|
|
1358
|
+
expect(browserMessageCb).toHaveBeenCalledOnce();
|
|
1359
|
+
const emitted = browserMessageCb.mock.calls[0][0];
|
|
1360
|
+
expect(emitted.type).toBe("prompt_suggestion");
|
|
1361
|
+
expect(emitted.suggestions).toEqual(["Fix the bug", "Add tests"]);
|
|
1362
|
+
});
|
|
1363
|
+
});
|