@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,1837 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock settings-manager before importing the module under test
|
|
4
|
+
vi.mock("./settings-manager.js", () => ({
|
|
5
|
+
DEFAULT_ANTHROPIC_MODEL: "claude-sonnet-4-6",
|
|
6
|
+
getSettings: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
// Mock ai-validator before importing the module under test
|
|
10
|
+
vi.mock("./ai-validator.js", () => ({
|
|
11
|
+
validatePermission: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
import { attachCodexAdapterHandlers } from "./ws-bridge-codex.js";
|
|
15
|
+
import type { BrowserIncomingMessage, SessionState } from "./session-types.js";
|
|
16
|
+
import type { Session } from "./ws-bridge-types.js";
|
|
17
|
+
import type { CodexAdapter } from "./codex-adapter.js";
|
|
18
|
+
import type { CodexAttachDeps } from "./ws-bridge-codex.js";
|
|
19
|
+
import * as settingsManager from "./settings-manager.js";
|
|
20
|
+
import * as aiValidator from "./ai-validator.js";
|
|
21
|
+
import { companionBus } from "./event-bus.js";
|
|
22
|
+
import { SessionStateMachine } from "./session-state-machine.js";
|
|
23
|
+
|
|
24
|
+
// ── Mock Factories ──────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
function createMockSession(overrides = {}): Session {
|
|
27
|
+
return {
|
|
28
|
+
id: "test-session",
|
|
29
|
+
backendType: "codex",
|
|
30
|
+
backendAdapter: null,
|
|
31
|
+
browserSockets: new Set(),
|
|
32
|
+
state: {
|
|
33
|
+
session_id: "test-session",
|
|
34
|
+
backend_type: "codex",
|
|
35
|
+
model: "",
|
|
36
|
+
cwd: "",
|
|
37
|
+
tools: [],
|
|
38
|
+
permissionMode: "default",
|
|
39
|
+
claude_code_version: "",
|
|
40
|
+
mcp_servers: [],
|
|
41
|
+
agents: [],
|
|
42
|
+
slash_commands: [],
|
|
43
|
+
skills: [],
|
|
44
|
+
total_cost_usd: 0,
|
|
45
|
+
num_turns: 0,
|
|
46
|
+
context_used_percent: 0,
|
|
47
|
+
is_compacting: false,
|
|
48
|
+
git_branch: "",
|
|
49
|
+
is_worktree: false,
|
|
50
|
+
is_containerized: false,
|
|
51
|
+
repo_root: "",
|
|
52
|
+
git_ahead: 0,
|
|
53
|
+
git_behind: 0,
|
|
54
|
+
total_lines_added: 0,
|
|
55
|
+
total_lines_removed: 0,
|
|
56
|
+
} as SessionState,
|
|
57
|
+
pendingPermissions: new Map(),
|
|
58
|
+
messageHistory: [] as BrowserIncomingMessage[],
|
|
59
|
+
pendingMessages: [] as string[],
|
|
60
|
+
nextEventSeq: 0,
|
|
61
|
+
eventBuffer: [],
|
|
62
|
+
lastAckSeq: 0,
|
|
63
|
+
processedClientMessageIds: [],
|
|
64
|
+
processedClientMessageIdSet: new Set(),
|
|
65
|
+
lastCliActivityTs: Date.now(),
|
|
66
|
+
stateMachine: new SessionStateMachine("test-session"),
|
|
67
|
+
...overrides,
|
|
68
|
+
} as Session;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function createMockAdapter() {
|
|
72
|
+
const handlers: Record<string, Function> = {};
|
|
73
|
+
return {
|
|
74
|
+
onBrowserMessage: vi.fn((fn: Function) => {
|
|
75
|
+
handlers.onBrowserMessage = fn;
|
|
76
|
+
}),
|
|
77
|
+
onSessionMeta: vi.fn((fn: Function) => {
|
|
78
|
+
handlers.onSessionMeta = fn;
|
|
79
|
+
}),
|
|
80
|
+
onDisconnect: vi.fn((fn: Function) => {
|
|
81
|
+
handlers.onDisconnect = fn;
|
|
82
|
+
}),
|
|
83
|
+
sendBrowserMessage: vi.fn(),
|
|
84
|
+
/** Helper to trigger a registered handler in tests */
|
|
85
|
+
_trigger: (event: string, data: any) => handlers[event]?.(data),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function createMockDeps(overrides = {}): CodexAttachDeps {
|
|
90
|
+
return {
|
|
91
|
+
persistSession: vi.fn(),
|
|
92
|
+
refreshGitInfo: vi.fn(),
|
|
93
|
+
broadcastToBrowsers: vi.fn(),
|
|
94
|
+
autoNamingAttempted: new Set<string>(),
|
|
95
|
+
...overrides,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
describe("attachCodexAdapterHandlers", () => {
|
|
102
|
+
let session: Session;
|
|
103
|
+
let adapter: ReturnType<typeof createMockAdapter>;
|
|
104
|
+
let deps: CodexAttachDeps;
|
|
105
|
+
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
vi.clearAllMocks();
|
|
108
|
+
companionBus.clear();
|
|
109
|
+
session = createMockSession();
|
|
110
|
+
adapter = createMockAdapter();
|
|
111
|
+
deps = createMockDeps();
|
|
112
|
+
|
|
113
|
+
// Default: AI validation disabled — existing tests should not be affected
|
|
114
|
+
vi.mocked(settingsManager.getSettings).mockReturnValue({
|
|
115
|
+
anthropicApiKey: "",
|
|
116
|
+
anthropicModel: "claude-sonnet-4-6",
|
|
117
|
+
linearApiKey: "",
|
|
118
|
+
linearAutoTransition: false,
|
|
119
|
+
linearAutoTransitionStateId: "",
|
|
120
|
+
linearAutoTransitionStateName: "",
|
|
121
|
+
linearArchiveTransition: false,
|
|
122
|
+
linearArchiveTransitionStateId: "",
|
|
123
|
+
linearArchiveTransitionStateName: "",
|
|
124
|
+
linearOAuthClientId: "",
|
|
125
|
+
linearOAuthClientSecret: "",
|
|
126
|
+
linearOAuthWebhookSecret: "",
|
|
127
|
+
linearOAuthAccessToken: "",
|
|
128
|
+
linearOAuthRefreshToken: "",
|
|
129
|
+
claudeCodeOAuthToken: "",
|
|
130
|
+
openaiApiKey: "",
|
|
131
|
+
onboardingCompleted: false,
|
|
132
|
+
aiValidationEnabled: false,
|
|
133
|
+
aiValidationAutoApprove: true,
|
|
134
|
+
aiValidationAutoDeny: false,
|
|
135
|
+
publicUrl: "",
|
|
136
|
+
updateChannel: "stable",
|
|
137
|
+
dockerAutoUpdate: false,
|
|
138
|
+
updatedAt: 0,
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ── Handler registration ────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
it("registers onBrowserMessage, onSessionMeta, and onDisconnect handlers", () => {
|
|
145
|
+
// Verifies that attachCodexAdapterHandlers wires all three adapter callbacks.
|
|
146
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
147
|
+
|
|
148
|
+
expect(adapter.onBrowserMessage).toHaveBeenCalledOnce();
|
|
149
|
+
expect(adapter.onSessionMeta).toHaveBeenCalledOnce();
|
|
150
|
+
expect(adapter.onDisconnect).toHaveBeenCalledOnce();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ── session_init ────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
it("session_init updates session state with backend_type and persists", () => {
|
|
156
|
+
// session_init should merge the incoming session state into session.state,
|
|
157
|
+
// set backend_type to "codex", call refreshGitInfo, and persist.
|
|
158
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
159
|
+
|
|
160
|
+
const sessionInitPayload: BrowserIncomingMessage = {
|
|
161
|
+
type: "session_init",
|
|
162
|
+
session: {
|
|
163
|
+
session_id: "test-session",
|
|
164
|
+
backend_type: "codex",
|
|
165
|
+
model: "o3-pro",
|
|
166
|
+
cwd: "/home/user/project",
|
|
167
|
+
tools: [],
|
|
168
|
+
permissionMode: "default",
|
|
169
|
+
claude_code_version: "",
|
|
170
|
+
mcp_servers: [],
|
|
171
|
+
agents: [],
|
|
172
|
+
slash_commands: [],
|
|
173
|
+
skills: [],
|
|
174
|
+
total_cost_usd: 0,
|
|
175
|
+
num_turns: 0,
|
|
176
|
+
context_used_percent: 0,
|
|
177
|
+
is_compacting: false,
|
|
178
|
+
git_branch: "",
|
|
179
|
+
is_worktree: false,
|
|
180
|
+
is_containerized: false,
|
|
181
|
+
repo_root: "",
|
|
182
|
+
git_ahead: 0,
|
|
183
|
+
git_behind: 0,
|
|
184
|
+
total_lines_added: 0,
|
|
185
|
+
total_lines_removed: 0,
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
adapter._trigger("onBrowserMessage", sessionInitPayload);
|
|
190
|
+
|
|
191
|
+
expect(session.state.model).toBe("o3-pro");
|
|
192
|
+
expect(session.state.cwd).toBe("/home/user/project");
|
|
193
|
+
expect(session.state.backend_type).toBe("codex");
|
|
194
|
+
expect(deps.refreshGitInfo).toHaveBeenCalledWith(session, { notifyPoller: true });
|
|
195
|
+
expect(deps.persistSession).toHaveBeenCalledWith(session);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("session_init preserves pre-populated commands/skills when adapter sends empty arrays", () => {
|
|
199
|
+
// When prePopulateCommands has set commands/skills on the session before
|
|
200
|
+
// the Codex adapter sends session_init with empty arrays, the pre-populated
|
|
201
|
+
// data should be preserved (Codex does not provide its own commands/skills).
|
|
202
|
+
session.state.slash_commands = ["pre-cmd-1", "pre-cmd-2"];
|
|
203
|
+
session.state.skills = ["pre-skill"];
|
|
204
|
+
|
|
205
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
206
|
+
|
|
207
|
+
adapter._trigger("onBrowserMessage", {
|
|
208
|
+
type: "session_init",
|
|
209
|
+
session: {
|
|
210
|
+
session_id: "test-session",
|
|
211
|
+
backend_type: "codex",
|
|
212
|
+
model: "o3-pro",
|
|
213
|
+
cwd: "/home/user/project",
|
|
214
|
+
tools: [],
|
|
215
|
+
permissionMode: "default",
|
|
216
|
+
claude_code_version: "",
|
|
217
|
+
mcp_servers: [],
|
|
218
|
+
agents: [],
|
|
219
|
+
slash_commands: [],
|
|
220
|
+
skills: [],
|
|
221
|
+
total_cost_usd: 0,
|
|
222
|
+
num_turns: 0,
|
|
223
|
+
context_used_percent: 0,
|
|
224
|
+
is_compacting: false,
|
|
225
|
+
git_branch: "",
|
|
226
|
+
is_worktree: false,
|
|
227
|
+
is_containerized: false,
|
|
228
|
+
repo_root: "",
|
|
229
|
+
git_ahead: 0,
|
|
230
|
+
git_behind: 0,
|
|
231
|
+
total_lines_added: 0,
|
|
232
|
+
total_lines_removed: 0,
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Pre-populated data should survive the Codex session_init merge
|
|
237
|
+
expect(session.state.slash_commands).toEqual(["pre-cmd-1", "pre-cmd-2"]);
|
|
238
|
+
expect(session.state.skills).toEqual(["pre-skill"]);
|
|
239
|
+
// Other fields should still be updated
|
|
240
|
+
expect(session.state.model).toBe("o3-pro");
|
|
241
|
+
expect(session.state.cwd).toBe("/home/user/project");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("session_init allows overwriting commands/skills when adapter sends non-empty arrays", () => {
|
|
245
|
+
// If a future Codex version sends actual commands/skills, they should
|
|
246
|
+
// overwrite the pre-populated data.
|
|
247
|
+
session.state.slash_commands = ["pre-cmd"];
|
|
248
|
+
session.state.skills = ["pre-skill"];
|
|
249
|
+
|
|
250
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
251
|
+
|
|
252
|
+
adapter._trigger("onBrowserMessage", {
|
|
253
|
+
type: "session_init",
|
|
254
|
+
session: {
|
|
255
|
+
session_id: "test-session",
|
|
256
|
+
backend_type: "codex",
|
|
257
|
+
model: "o3-pro",
|
|
258
|
+
cwd: "/home/user/project",
|
|
259
|
+
tools: [],
|
|
260
|
+
permissionMode: "default",
|
|
261
|
+
claude_code_version: "",
|
|
262
|
+
mcp_servers: [],
|
|
263
|
+
agents: [],
|
|
264
|
+
slash_commands: ["codex-cmd"],
|
|
265
|
+
skills: ["codex-skill"],
|
|
266
|
+
total_cost_usd: 0,
|
|
267
|
+
num_turns: 0,
|
|
268
|
+
context_used_percent: 0,
|
|
269
|
+
is_compacting: false,
|
|
270
|
+
git_branch: "",
|
|
271
|
+
is_worktree: false,
|
|
272
|
+
is_containerized: false,
|
|
273
|
+
repo_root: "",
|
|
274
|
+
git_ahead: 0,
|
|
275
|
+
git_behind: 0,
|
|
276
|
+
total_lines_added: 0,
|
|
277
|
+
total_lines_removed: 0,
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Non-empty arrays from the adapter should overwrite pre-populated data
|
|
282
|
+
expect(session.state.slash_commands).toEqual(["codex-cmd"]);
|
|
283
|
+
expect(session.state.skills).toEqual(["codex-skill"]);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ── session_update ──────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
it("session_update preserves pre-populated commands/skills when update has empty arrays", () => {
|
|
289
|
+
// Same preservation logic applies to session_update messages.
|
|
290
|
+
session.state.slash_commands = ["pre-cmd"];
|
|
291
|
+
session.state.skills = ["pre-skill"];
|
|
292
|
+
|
|
293
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
294
|
+
|
|
295
|
+
adapter._trigger("onBrowserMessage", {
|
|
296
|
+
type: "session_update",
|
|
297
|
+
session: { model: "gpt-4.1", slash_commands: [], skills: [] },
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
expect(session.state.slash_commands).toEqual(["pre-cmd"]);
|
|
301
|
+
expect(session.state.skills).toEqual(["pre-skill"]);
|
|
302
|
+
expect(session.state.model).toBe("gpt-4.1");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("session_update merges partial state and sets backend_type to codex", () => {
|
|
306
|
+
// session_update should spread the partial session fields into state,
|
|
307
|
+
// force backend_type to "codex", refresh git info, and persist.
|
|
308
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
309
|
+
|
|
310
|
+
adapter._trigger("onBrowserMessage", {
|
|
311
|
+
type: "session_update",
|
|
312
|
+
session: { model: "gpt-4.1", permissionMode: "bypassPermissions" },
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
expect(session.state.model).toBe("gpt-4.1");
|
|
316
|
+
expect(session.state.permissionMode).toBe("bypassPermissions");
|
|
317
|
+
expect(session.state.backend_type).toBe("codex");
|
|
318
|
+
expect(deps.refreshGitInfo).toHaveBeenCalledWith(session, { notifyPoller: true });
|
|
319
|
+
expect(deps.persistSession).toHaveBeenCalled();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// ── status_change ───────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
it("status_change sets is_compacting to true when status is 'compacting'", () => {
|
|
325
|
+
// When the adapter emits a status_change with status "compacting",
|
|
326
|
+
// the handler should set session.state.is_compacting = true.
|
|
327
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
328
|
+
|
|
329
|
+
adapter._trigger("onBrowserMessage", {
|
|
330
|
+
type: "status_change",
|
|
331
|
+
status: "compacting",
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
expect(session.state.is_compacting).toBe(true);
|
|
335
|
+
expect(deps.persistSession).toHaveBeenCalled();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("status_change sets is_compacting to false when status is not 'compacting'", () => {
|
|
339
|
+
// When status is something other than "compacting" (e.g. null),
|
|
340
|
+
// is_compacting should be false.
|
|
341
|
+
session.state.is_compacting = true;
|
|
342
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
343
|
+
|
|
344
|
+
adapter._trigger("onBrowserMessage", {
|
|
345
|
+
type: "status_change",
|
|
346
|
+
status: null,
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
expect(session.state.is_compacting).toBe(false);
|
|
350
|
+
expect(deps.persistSession).toHaveBeenCalled();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// ── assistant message ───────────────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
it("assistant message is pushed to messageHistory with timestamp", () => {
|
|
356
|
+
// Assistant messages should be appended to the session's messageHistory
|
|
357
|
+
// array with a timestamp, and persisted.
|
|
358
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
359
|
+
|
|
360
|
+
const assistantMsg: BrowserIncomingMessage = {
|
|
361
|
+
type: "assistant",
|
|
362
|
+
message: {
|
|
363
|
+
id: "msg-1",
|
|
364
|
+
type: "message",
|
|
365
|
+
role: "assistant",
|
|
366
|
+
model: "o3-pro",
|
|
367
|
+
content: [{ type: "text", text: "Hello world" }],
|
|
368
|
+
stop_reason: "end_turn",
|
|
369
|
+
usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
370
|
+
},
|
|
371
|
+
parent_tool_use_id: null,
|
|
372
|
+
timestamp: 1700000000000,
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
adapter._trigger("onBrowserMessage", assistantMsg);
|
|
376
|
+
|
|
377
|
+
expect(session.messageHistory).toHaveLength(1);
|
|
378
|
+
expect(session.messageHistory[0].type).toBe("assistant");
|
|
379
|
+
expect((session.messageHistory[0] as any).timestamp).toBe(1700000000000);
|
|
380
|
+
expect(deps.persistSession).toHaveBeenCalled();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("assistant message gets a default timestamp when none is provided", () => {
|
|
384
|
+
// If the assistant message doesn't have a timestamp, it should use Date.now().
|
|
385
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
386
|
+
|
|
387
|
+
const beforeTime = Date.now();
|
|
388
|
+
adapter._trigger("onBrowserMessage", {
|
|
389
|
+
type: "assistant",
|
|
390
|
+
message: {
|
|
391
|
+
id: "msg-2",
|
|
392
|
+
type: "message",
|
|
393
|
+
role: "assistant",
|
|
394
|
+
model: "o3-pro",
|
|
395
|
+
content: [{ type: "text", text: "No timestamp" }],
|
|
396
|
+
stop_reason: "end_turn",
|
|
397
|
+
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
398
|
+
},
|
|
399
|
+
parent_tool_use_id: null,
|
|
400
|
+
});
|
|
401
|
+
const afterTime = Date.now();
|
|
402
|
+
|
|
403
|
+
const stored = session.messageHistory[0] as any;
|
|
404
|
+
expect(stored.timestamp).toBeGreaterThanOrEqual(beforeTime);
|
|
405
|
+
expect(stored.timestamp).toBeLessThanOrEqual(afterTime);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// ── result message ──────────────────────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
it("result message is pushed to messageHistory", () => {
|
|
411
|
+
// Result messages should also be appended to messageHistory and persisted.
|
|
412
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
413
|
+
|
|
414
|
+
const resultMsg: BrowserIncomingMessage = {
|
|
415
|
+
type: "result",
|
|
416
|
+
data: {
|
|
417
|
+
type: "result",
|
|
418
|
+
subtype: "success",
|
|
419
|
+
is_error: false,
|
|
420
|
+
result: undefined,
|
|
421
|
+
duration_ms: 100,
|
|
422
|
+
duration_api_ms: 80,
|
|
423
|
+
num_turns: 1,
|
|
424
|
+
total_cost_usd: 0.01,
|
|
425
|
+
stop_reason: "end_turn",
|
|
426
|
+
usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
427
|
+
uuid: "result-uuid-1",
|
|
428
|
+
session_id: "test-session",
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
adapter._trigger("onBrowserMessage", resultMsg);
|
|
433
|
+
|
|
434
|
+
expect(session.messageHistory).toHaveLength(1);
|
|
435
|
+
expect(session.messageHistory[0].type).toBe("result");
|
|
436
|
+
expect(deps.persistSession).toHaveBeenCalled();
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// ── permission_request ──────────────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
it("permission_request is added to pendingPermissions", () => {
|
|
442
|
+
// When a permission_request comes in, it should be stored in the session's
|
|
443
|
+
// pendingPermissions map keyed by request_id, and persisted.
|
|
444
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
445
|
+
|
|
446
|
+
const permMsg: BrowserIncomingMessage = {
|
|
447
|
+
type: "permission_request",
|
|
448
|
+
request: {
|
|
449
|
+
request_id: "perm-1",
|
|
450
|
+
tool_name: "Bash",
|
|
451
|
+
input: { command: "ls -la" },
|
|
452
|
+
description: "Execute: ls -la",
|
|
453
|
+
tool_use_id: "tool-1",
|
|
454
|
+
timestamp: Date.now(),
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
adapter._trigger("onBrowserMessage", permMsg);
|
|
459
|
+
|
|
460
|
+
expect(session.pendingPermissions.has("perm-1")).toBe(true);
|
|
461
|
+
expect(session.pendingPermissions.get("perm-1")).toEqual(
|
|
462
|
+
expect.objectContaining({ request_id: "perm-1", tool_name: "Bash" }),
|
|
463
|
+
);
|
|
464
|
+
expect(deps.persistSession).toHaveBeenCalled();
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("permission_cancelled removes entry from pendingPermissions", () => {
|
|
468
|
+
// When the adapter emits permission_cancelled (e.g. after a WS reconnect),
|
|
469
|
+
// the bridge should remove the corresponding entry from pendingPermissions
|
|
470
|
+
// so the browser doesn't show a stale approval dialog.
|
|
471
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
472
|
+
|
|
473
|
+
// Pre-populate a pending permission
|
|
474
|
+
session.pendingPermissions.set("perm-stale", {
|
|
475
|
+
request_id: "perm-stale",
|
|
476
|
+
tool_name: "Bash",
|
|
477
|
+
input: { command: "rm -rf /" },
|
|
478
|
+
description: "Execute: rm -rf /",
|
|
479
|
+
tool_use_id: "tool-stale",
|
|
480
|
+
timestamp: Date.now(),
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
adapter._trigger("onBrowserMessage", {
|
|
484
|
+
type: "permission_cancelled",
|
|
485
|
+
request_id: "perm-stale",
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
expect(session.pendingPermissions.has("perm-stale")).toBe(false);
|
|
489
|
+
expect(deps.persistSession).toHaveBeenCalled();
|
|
490
|
+
expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(
|
|
491
|
+
session,
|
|
492
|
+
expect.objectContaining({ type: "permission_cancelled", request_id: "perm-stale" }),
|
|
493
|
+
);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// ── broadcast to browsers ───────────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
it("all messages are broadcast to browsers", () => {
|
|
499
|
+
// Every message that goes through onBrowserMessage should be broadcast
|
|
500
|
+
// to connected browser sockets.
|
|
501
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
502
|
+
|
|
503
|
+
const testMessages: BrowserIncomingMessage[] = [
|
|
504
|
+
{
|
|
505
|
+
type: "session_init",
|
|
506
|
+
session: session.state,
|
|
507
|
+
},
|
|
508
|
+
{
|
|
509
|
+
type: "session_update",
|
|
510
|
+
session: { model: "updated-model" },
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
type: "status_change",
|
|
514
|
+
status: "compacting",
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
type: "assistant",
|
|
518
|
+
message: {
|
|
519
|
+
id: "msg-1",
|
|
520
|
+
type: "message",
|
|
521
|
+
role: "assistant",
|
|
522
|
+
model: "o3-pro",
|
|
523
|
+
content: [{ type: "text", text: "hello" }],
|
|
524
|
+
stop_reason: "end_turn",
|
|
525
|
+
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
526
|
+
},
|
|
527
|
+
parent_tool_use_id: null,
|
|
528
|
+
timestamp: Date.now(),
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
type: "result",
|
|
532
|
+
data: {
|
|
533
|
+
type: "result",
|
|
534
|
+
subtype: "success",
|
|
535
|
+
is_error: false,
|
|
536
|
+
result: undefined,
|
|
537
|
+
duration_ms: 0,
|
|
538
|
+
duration_api_ms: 0,
|
|
539
|
+
num_turns: 1,
|
|
540
|
+
total_cost_usd: 0,
|
|
541
|
+
stop_reason: "end_turn",
|
|
542
|
+
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
543
|
+
uuid: "r-1",
|
|
544
|
+
session_id: "test-session",
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
type: "permission_request",
|
|
549
|
+
request: {
|
|
550
|
+
request_id: "perm-2",
|
|
551
|
+
tool_name: "Edit",
|
|
552
|
+
input: {},
|
|
553
|
+
description: "Edit file",
|
|
554
|
+
tool_use_id: "tool-2",
|
|
555
|
+
timestamp: Date.now(),
|
|
556
|
+
},
|
|
557
|
+
},
|
|
558
|
+
];
|
|
559
|
+
|
|
560
|
+
for (const msg of testMessages) {
|
|
561
|
+
adapter._trigger("onBrowserMessage", msg);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Each message should trigger one broadcastToBrowsers call, plus the initial
|
|
565
|
+
// cli_connected broadcast that happens during attachCodexAdapterHandlers setup.
|
|
566
|
+
// The first call (index 0) is cli_connected, then each message adds one more.
|
|
567
|
+
expect(deps.broadcastToBrowsers).toHaveBeenCalledTimes(testMessages.length + 1);
|
|
568
|
+
|
|
569
|
+
// First call is cli_connected (from attach)
|
|
570
|
+
expect(deps.broadcastToBrowsers).toHaveBeenNthCalledWith(1, session, {
|
|
571
|
+
type: "cli_connected",
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
// Verify each subsequent call passed the session and the original message
|
|
575
|
+
for (let i = 0; i < testMessages.length; i++) {
|
|
576
|
+
expect(deps.broadcastToBrowsers).toHaveBeenNthCalledWith(
|
|
577
|
+
i + 2,
|
|
578
|
+
session,
|
|
579
|
+
testMessages[i],
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
// ── auto-naming via onFirstTurnCompleted ────────────────────────────────
|
|
585
|
+
|
|
586
|
+
it("result triggers session:first-turn-completed event for auto-naming on first successful result", () => {
|
|
587
|
+
// When a non-error result arrives and auto-naming hasn't been attempted yet,
|
|
588
|
+
// the handler should emit session:first-turn-completed with the first user_message content.
|
|
589
|
+
const onFirstTurnCompleted = vi.fn();
|
|
590
|
+
companionBus.on("session:first-turn-completed", onFirstTurnCompleted);
|
|
591
|
+
|
|
592
|
+
session.messageHistory.push({
|
|
593
|
+
type: "user_message",
|
|
594
|
+
content: "What is the meaning of life?",
|
|
595
|
+
} as any);
|
|
596
|
+
|
|
597
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
598
|
+
|
|
599
|
+
adapter._trigger("onBrowserMessage", {
|
|
600
|
+
type: "result",
|
|
601
|
+
data: {
|
|
602
|
+
type: "result",
|
|
603
|
+
subtype: "success",
|
|
604
|
+
is_error: false,
|
|
605
|
+
result: null,
|
|
606
|
+
duration_ms: 100,
|
|
607
|
+
duration_api_ms: 80,
|
|
608
|
+
num_turns: 1,
|
|
609
|
+
total_cost_usd: 0,
|
|
610
|
+
stop_reason: "end_turn",
|
|
611
|
+
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
612
|
+
uuid: "r-2",
|
|
613
|
+
session_id: "test-session",
|
|
614
|
+
},
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
expect(onFirstTurnCompleted).toHaveBeenCalledOnce();
|
|
618
|
+
expect(onFirstTurnCompleted).toHaveBeenCalledWith({
|
|
619
|
+
sessionId: "test-session",
|
|
620
|
+
firstUserMessage: "What is the meaning of life?",
|
|
621
|
+
});
|
|
622
|
+
// The session ID should be recorded in autoNamingAttempted
|
|
623
|
+
expect(deps.autoNamingAttempted.has("test-session")).toBe(true);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it("result does NOT trigger session:first-turn-completed a second time (only once per session)", () => {
|
|
627
|
+
// Auto-naming should only fire once per session. Subsequent results should not
|
|
628
|
+
// re-trigger the event.
|
|
629
|
+
const onFirstTurnCompleted = vi.fn();
|
|
630
|
+
companionBus.on("session:first-turn-completed", onFirstTurnCompleted);
|
|
631
|
+
|
|
632
|
+
session.messageHistory.push({
|
|
633
|
+
type: "user_message",
|
|
634
|
+
content: "First message",
|
|
635
|
+
} as any);
|
|
636
|
+
|
|
637
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
638
|
+
|
|
639
|
+
const resultMsg = {
|
|
640
|
+
type: "result",
|
|
641
|
+
data: {
|
|
642
|
+
type: "result",
|
|
643
|
+
subtype: "success",
|
|
644
|
+
is_error: false,
|
|
645
|
+
result: null,
|
|
646
|
+
duration_ms: 0,
|
|
647
|
+
duration_api_ms: 0,
|
|
648
|
+
num_turns: 1,
|
|
649
|
+
total_cost_usd: 0,
|
|
650
|
+
stop_reason: "end_turn",
|
|
651
|
+
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
652
|
+
uuid: "r-3",
|
|
653
|
+
session_id: "test-session",
|
|
654
|
+
},
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
adapter._trigger("onBrowserMessage", resultMsg);
|
|
658
|
+
adapter._trigger("onBrowserMessage", resultMsg);
|
|
659
|
+
|
|
660
|
+
// Should only be called once despite two result messages
|
|
661
|
+
expect(onFirstTurnCompleted).toHaveBeenCalledOnce();
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
it("result does NOT trigger session:first-turn-completed when result is an error", () => {
|
|
665
|
+
// Error results should not trigger auto-naming.
|
|
666
|
+
const onFirstTurnCompleted = vi.fn();
|
|
667
|
+
companionBus.on("session:first-turn-completed", onFirstTurnCompleted);
|
|
668
|
+
|
|
669
|
+
session.messageHistory.push({
|
|
670
|
+
type: "user_message",
|
|
671
|
+
content: "Some message",
|
|
672
|
+
} as any);
|
|
673
|
+
|
|
674
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
675
|
+
|
|
676
|
+
adapter._trigger("onBrowserMessage", {
|
|
677
|
+
type: "result",
|
|
678
|
+
data: {
|
|
679
|
+
type: "result",
|
|
680
|
+
subtype: "error_during_execution",
|
|
681
|
+
is_error: true,
|
|
682
|
+
result: "Something went wrong",
|
|
683
|
+
duration_ms: 0,
|
|
684
|
+
duration_api_ms: 0,
|
|
685
|
+
num_turns: 1,
|
|
686
|
+
total_cost_usd: 0,
|
|
687
|
+
stop_reason: "error",
|
|
688
|
+
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
689
|
+
uuid: "r-4",
|
|
690
|
+
session_id: "test-session",
|
|
691
|
+
},
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
expect(onFirstTurnCompleted).not.toHaveBeenCalled();
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it("result does NOT trigger session:first-turn-completed when no user_message exists", () => {
|
|
698
|
+
// If there's no user_message in the history, the event should not be emitted
|
|
699
|
+
// even on a successful result.
|
|
700
|
+
const onFirstTurnCompleted = vi.fn();
|
|
701
|
+
companionBus.on("session:first-turn-completed", onFirstTurnCompleted);
|
|
702
|
+
|
|
703
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
704
|
+
|
|
705
|
+
adapter._trigger("onBrowserMessage", {
|
|
706
|
+
type: "result",
|
|
707
|
+
data: {
|
|
708
|
+
type: "result",
|
|
709
|
+
subtype: "success",
|
|
710
|
+
is_error: false,
|
|
711
|
+
result: null,
|
|
712
|
+
duration_ms: 0,
|
|
713
|
+
duration_api_ms: 0,
|
|
714
|
+
num_turns: 1,
|
|
715
|
+
total_cost_usd: 0,
|
|
716
|
+
stop_reason: "end_turn",
|
|
717
|
+
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
718
|
+
uuid: "r-5",
|
|
719
|
+
session_id: "test-session",
|
|
720
|
+
},
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
expect(onFirstTurnCompleted).not.toHaveBeenCalled();
|
|
724
|
+
// But the session should still be marked as naming-attempted
|
|
725
|
+
expect(deps.autoNamingAttempted.has("test-session")).toBe(true);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
it("result emits session:first-turn-completed safely even with no bus subscribers", () => {
|
|
729
|
+
// When no subscriber is listening on the bus, the event should still be
|
|
730
|
+
// emitted without errors and autoNamingAttempted should be updated.
|
|
731
|
+
session.messageHistory.push({
|
|
732
|
+
type: "user_message",
|
|
733
|
+
content: "Some message",
|
|
734
|
+
} as any);
|
|
735
|
+
|
|
736
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
737
|
+
|
|
738
|
+
adapter._trigger("onBrowserMessage", {
|
|
739
|
+
type: "result",
|
|
740
|
+
data: {
|
|
741
|
+
type: "result",
|
|
742
|
+
subtype: "success",
|
|
743
|
+
is_error: false,
|
|
744
|
+
result: null,
|
|
745
|
+
duration_ms: 0,
|
|
746
|
+
duration_api_ms: 0,
|
|
747
|
+
num_turns: 1,
|
|
748
|
+
total_cost_usd: 0,
|
|
749
|
+
stop_reason: "end_turn",
|
|
750
|
+
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
751
|
+
uuid: "r-6",
|
|
752
|
+
session_id: "test-session",
|
|
753
|
+
},
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
// autoNamingAttempted should be set — the bus event fires even without subscribers
|
|
757
|
+
expect(deps.autoNamingAttempted.has("test-session")).toBe(true);
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
// ── onSessionMeta ───────────────────────────────────────────────────────
|
|
761
|
+
|
|
762
|
+
it("onSessionMeta updates model and cwd on session state", () => {
|
|
763
|
+
// When session metadata arrives, it should update model, cwd, and
|
|
764
|
+
// set backend_type to "codex", then refresh git info and persist.
|
|
765
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
766
|
+
|
|
767
|
+
adapter._trigger("onSessionMeta", {
|
|
768
|
+
cliSessionId: "codex-thread-123",
|
|
769
|
+
model: "o3-pro",
|
|
770
|
+
cwd: "/home/user/project",
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
expect(session.state.model).toBe("o3-pro");
|
|
774
|
+
expect(session.state.cwd).toBe("/home/user/project");
|
|
775
|
+
expect(session.state.backend_type).toBe("codex");
|
|
776
|
+
expect(deps.refreshGitInfo).toHaveBeenCalledWith(session, {
|
|
777
|
+
broadcastUpdate: true,
|
|
778
|
+
notifyPoller: true,
|
|
779
|
+
});
|
|
780
|
+
expect(deps.persistSession).toHaveBeenCalledWith(session);
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
it("onSessionMeta emits session:cli-id-received when cliSessionId is present", () => {
|
|
784
|
+
// When the meta includes a cliSessionId, the bus event should be emitted
|
|
785
|
+
// to track the mapping from our session ID to the Codex thread ID.
|
|
786
|
+
const onCLISessionId = vi.fn();
|
|
787
|
+
companionBus.on("session:cli-id-received", onCLISessionId);
|
|
788
|
+
|
|
789
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
790
|
+
|
|
791
|
+
adapter._trigger("onSessionMeta", {
|
|
792
|
+
cliSessionId: "codex-thread-456",
|
|
793
|
+
model: "gpt-4.1",
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
expect(onCLISessionId).toHaveBeenCalledWith({ sessionId: "test-session", cliSessionId: "codex-thread-456" });
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it("onSessionMeta does not emit session:cli-id-received when cliSessionId is absent", () => {
|
|
800
|
+
// If no cliSessionId in the meta, the bus event should not be emitted.
|
|
801
|
+
const onCLISessionId = vi.fn();
|
|
802
|
+
companionBus.on("session:cli-id-received", onCLISessionId);
|
|
803
|
+
|
|
804
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
805
|
+
|
|
806
|
+
adapter._trigger("onSessionMeta", { model: "gpt-4.1" });
|
|
807
|
+
|
|
808
|
+
expect(onCLISessionId).not.toHaveBeenCalled();
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
it("onSessionMeta emits session:cli-id-received safely even with no bus subscribers", () => {
|
|
812
|
+
// When no subscriber is listening, the event should fire without errors.
|
|
813
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
814
|
+
|
|
815
|
+
// Should not throw
|
|
816
|
+
adapter._trigger("onSessionMeta", { cliSessionId: "thread-789" });
|
|
817
|
+
|
|
818
|
+
expect(session.state.backend_type).toBe("codex");
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
it("onSessionMeta handles partial meta (only model or only cwd)", () => {
|
|
822
|
+
// The handler should only update fields that are present in the meta object.
|
|
823
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
824
|
+
|
|
825
|
+
session.state.model = "old-model";
|
|
826
|
+
session.state.cwd = "/old/path";
|
|
827
|
+
|
|
828
|
+
// Only model provided
|
|
829
|
+
adapter._trigger("onSessionMeta", { model: "new-model" });
|
|
830
|
+
|
|
831
|
+
expect(session.state.model).toBe("new-model");
|
|
832
|
+
expect(session.state.cwd).toBe("/old/path"); // unchanged
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
// ── onDisconnect ────────────────────────────────────────────────────────
|
|
836
|
+
|
|
837
|
+
it("onDisconnect clears pending permissions and broadcasts cli_disconnected", () => {
|
|
838
|
+
// When the adapter disconnects, all pending permissions should be cancelled
|
|
839
|
+
// (broadcast permission_cancelled for each), the map cleared, backendAdapter set to null,
|
|
840
|
+
// session persisted, and a cli_disconnected message broadcast.
|
|
841
|
+
// Simulate the real flow: ws-bridge sets session.backendAdapter before calling handlers.
|
|
842
|
+
session.backendAdapter = adapter as unknown as CodexAdapter;
|
|
843
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
844
|
+
|
|
845
|
+
// Add some pending permissions first
|
|
846
|
+
session.pendingPermissions.set("perm-a", {
|
|
847
|
+
request_id: "perm-a",
|
|
848
|
+
tool_name: "Bash",
|
|
849
|
+
input: {},
|
|
850
|
+
description: "test",
|
|
851
|
+
tool_use_id: "t-a",
|
|
852
|
+
timestamp: Date.now(),
|
|
853
|
+
});
|
|
854
|
+
session.pendingPermissions.set("perm-b", {
|
|
855
|
+
request_id: "perm-b",
|
|
856
|
+
tool_name: "Edit",
|
|
857
|
+
input: {},
|
|
858
|
+
description: "test",
|
|
859
|
+
tool_use_id: "t-b",
|
|
860
|
+
timestamp: Date.now(),
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
adapter._trigger("onDisconnect", undefined);
|
|
864
|
+
|
|
865
|
+
// Pending permissions should be cleared
|
|
866
|
+
expect(session.pendingPermissions.size).toBe(0);
|
|
867
|
+
|
|
868
|
+
// backendAdapter should be nulled out
|
|
869
|
+
expect(session.backendAdapter).toBeNull();
|
|
870
|
+
|
|
871
|
+
// Should broadcast permission_cancelled for each pending permission
|
|
872
|
+
expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(session, {
|
|
873
|
+
type: "permission_cancelled",
|
|
874
|
+
request_id: "perm-a",
|
|
875
|
+
});
|
|
876
|
+
expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(session, {
|
|
877
|
+
type: "permission_cancelled",
|
|
878
|
+
request_id: "perm-b",
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
// Should broadcast cli_disconnected
|
|
882
|
+
expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(session, {
|
|
883
|
+
type: "cli_disconnected",
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
expect(deps.persistSession).toHaveBeenCalled();
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
it("onDisconnect with no pending permissions still broadcasts cli_disconnected", () => {
|
|
890
|
+
// Even when there are no pending permissions to cancel, the disconnect handler
|
|
891
|
+
// should still broadcast cli_disconnected and persist.
|
|
892
|
+
// Simulate the real flow: ws-bridge sets session.backendAdapter before calling handlers.
|
|
893
|
+
session.backendAdapter = adapter as unknown as CodexAdapter;
|
|
894
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
895
|
+
|
|
896
|
+
adapter._trigger("onDisconnect", undefined);
|
|
897
|
+
|
|
898
|
+
expect(session.pendingPermissions.size).toBe(0);
|
|
899
|
+
expect(session.backendAdapter).toBeNull();
|
|
900
|
+
expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(session, {
|
|
901
|
+
type: "cli_disconnected",
|
|
902
|
+
});
|
|
903
|
+
expect(deps.persistSession).toHaveBeenCalled();
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
it("onDisconnect from stale adapter is ignored when adapter has been replaced", () => {
|
|
907
|
+
// When a session is relaunched, the new adapter is set on session.backendAdapter
|
|
908
|
+
// before the old adapter's disconnect fires. The old adapter's disconnect should
|
|
909
|
+
// be a no-op so it doesn't null out the new adapter.
|
|
910
|
+
const oldAdapter = createMockAdapter();
|
|
911
|
+
const newAdapter = createMockAdapter();
|
|
912
|
+
|
|
913
|
+
// Simulate: old adapter is attached
|
|
914
|
+
session.backendAdapter = oldAdapter as unknown as CodexAdapter;
|
|
915
|
+
attachCodexAdapterHandlers("test-session", session, oldAdapter as unknown as CodexAdapter, deps);
|
|
916
|
+
|
|
917
|
+
// Simulate: relaunch replaces the adapter
|
|
918
|
+
session.backendAdapter = newAdapter as unknown as CodexAdapter;
|
|
919
|
+
attachCodexAdapterHandlers("test-session", session, newAdapter as unknown as CodexAdapter, deps);
|
|
920
|
+
|
|
921
|
+
// Clear broadcast calls from the two cli_connected broadcasts during attach
|
|
922
|
+
(deps.broadcastToBrowsers as ReturnType<typeof vi.fn>).mockClear();
|
|
923
|
+
|
|
924
|
+
// Old adapter fires disconnect (happens async after kill)
|
|
925
|
+
oldAdapter._trigger("onDisconnect", undefined);
|
|
926
|
+
|
|
927
|
+
// session.backendAdapter should still be the NEW adapter, not null
|
|
928
|
+
expect(session.backendAdapter).toBe(newAdapter);
|
|
929
|
+
// No cli_disconnected broadcast should have happened
|
|
930
|
+
expect(deps.broadcastToBrowsers).not.toHaveBeenCalledWith(session, {
|
|
931
|
+
type: "cli_disconnected",
|
|
932
|
+
});
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
it("onDisconnect triggers auto-relaunch when browsers are still connected", () => {
|
|
936
|
+
// When the transport drops mid-conversation and browsers are still connected,
|
|
937
|
+
// the session should be auto-relaunched instead of leaving users with a dead session.
|
|
938
|
+
const onRelaunchNeeded = vi.fn();
|
|
939
|
+
companionBus.on("session:relaunch-needed", onRelaunchNeeded);
|
|
940
|
+
|
|
941
|
+
session.backendAdapter = adapter as unknown as CodexAdapter;
|
|
942
|
+
// Simulate a connected browser
|
|
943
|
+
const fakeBrowserWs = {} as any;
|
|
944
|
+
session.browserSockets.add(fakeBrowserWs);
|
|
945
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
946
|
+
|
|
947
|
+
adapter._trigger("onDisconnect", undefined);
|
|
948
|
+
|
|
949
|
+
expect(onRelaunchNeeded).toHaveBeenCalledWith({ sessionId: "test-session" });
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
it("onDisconnect does NOT auto-relaunch when no browsers are connected", () => {
|
|
953
|
+
// If no browsers are watching, don't waste resources relaunching — the relaunch
|
|
954
|
+
// will happen when a browser reconnects via handleBrowserOpen.
|
|
955
|
+
const onRelaunchNeeded = vi.fn();
|
|
956
|
+
companionBus.on("session:relaunch-needed", onRelaunchNeeded);
|
|
957
|
+
|
|
958
|
+
session.backendAdapter = adapter as unknown as CodexAdapter;
|
|
959
|
+
expect(session.browserSockets.size).toBe(0);
|
|
960
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
961
|
+
|
|
962
|
+
adapter._trigger("onDisconnect", undefined);
|
|
963
|
+
|
|
964
|
+
expect(onRelaunchNeeded).not.toHaveBeenCalled();
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
it("onDisconnect works safely even with no bus subscribers for relaunch", () => {
|
|
968
|
+
// When no subscriber is listening for session:relaunch-needed, disconnect
|
|
969
|
+
// should still work without errors.
|
|
970
|
+
session.backendAdapter = adapter as unknown as CodexAdapter;
|
|
971
|
+
const fakeBrowserWs = {} as any;
|
|
972
|
+
session.browserSockets.add(fakeBrowserWs);
|
|
973
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
974
|
+
|
|
975
|
+
// Should not throw
|
|
976
|
+
adapter._trigger("onDisconnect", undefined);
|
|
977
|
+
|
|
978
|
+
expect(session.backendAdapter).toBeNull();
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
it("stale adapter disconnect does NOT trigger auto-relaunch", () => {
|
|
982
|
+
// When a stale adapter disconnects after being replaced, it should not
|
|
983
|
+
// trigger a relaunch (the new adapter is already active).
|
|
984
|
+
const onRelaunchNeeded = vi.fn();
|
|
985
|
+
companionBus.on("session:relaunch-needed", onRelaunchNeeded);
|
|
986
|
+
|
|
987
|
+
const oldAdapter = createMockAdapter();
|
|
988
|
+
const newAdapter = createMockAdapter();
|
|
989
|
+
const fakeBrowserWs = {} as any;
|
|
990
|
+
session.browserSockets.add(fakeBrowserWs);
|
|
991
|
+
|
|
992
|
+
session.backendAdapter = oldAdapter as unknown as CodexAdapter;
|
|
993
|
+
attachCodexAdapterHandlers("test-session", session, oldAdapter as unknown as CodexAdapter, deps);
|
|
994
|
+
|
|
995
|
+
// Relaunch replaces the adapter
|
|
996
|
+
session.backendAdapter = newAdapter as unknown as CodexAdapter;
|
|
997
|
+
attachCodexAdapterHandlers("test-session", session, newAdapter as unknown as CodexAdapter, deps);
|
|
998
|
+
onRelaunchNeeded.mockClear();
|
|
999
|
+
|
|
1000
|
+
// Old adapter fires disconnect
|
|
1001
|
+
oldAdapter._trigger("onDisconnect", undefined);
|
|
1002
|
+
|
|
1003
|
+
// Should NOT trigger relaunch since the old adapter was stale
|
|
1004
|
+
expect(onRelaunchNeeded).not.toHaveBeenCalled();
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
// ── Pending message flushing ────────────────────────────────────────────
|
|
1008
|
+
|
|
1009
|
+
it("flushes pending messages to adapter on attach", () => {
|
|
1010
|
+
// If there are queued messages in session.pendingMessages, they should be
|
|
1011
|
+
// JSON-parsed and sent to the adapter via sendBrowserMessage during attach.
|
|
1012
|
+
const userMsg = JSON.stringify({ type: "user_message", content: "Hello" });
|
|
1013
|
+
const permResp = JSON.stringify({
|
|
1014
|
+
type: "permission_response",
|
|
1015
|
+
request_id: "perm-1",
|
|
1016
|
+
behavior: "allow",
|
|
1017
|
+
});
|
|
1018
|
+
session.pendingMessages = [userMsg, permResp];
|
|
1019
|
+
|
|
1020
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
1021
|
+
|
|
1022
|
+
// Both messages should be sent to the adapter
|
|
1023
|
+
expect(adapter.sendBrowserMessage).toHaveBeenCalledTimes(2);
|
|
1024
|
+
expect(adapter.sendBrowserMessage).toHaveBeenCalledWith({ type: "user_message", content: "Hello" });
|
|
1025
|
+
expect(adapter.sendBrowserMessage).toHaveBeenCalledWith({
|
|
1026
|
+
type: "permission_response",
|
|
1027
|
+
request_id: "perm-1",
|
|
1028
|
+
behavior: "allow",
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
// pendingMessages should be drained
|
|
1032
|
+
expect(session.pendingMessages).toHaveLength(0);
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
it("does not call sendBrowserMessage when pendingMessages is empty", () => {
|
|
1036
|
+
// No messages to flush — sendBrowserMessage should not be called.
|
|
1037
|
+
session.pendingMessages = [];
|
|
1038
|
+
|
|
1039
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
1040
|
+
|
|
1041
|
+
expect(adapter.sendBrowserMessage).not.toHaveBeenCalled();
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
it("gracefully handles invalid JSON in pendingMessages", () => {
|
|
1045
|
+
// If a queued message is invalid JSON, it should be skipped without throwing.
|
|
1046
|
+
// The valid messages around it should still be flushed.
|
|
1047
|
+
session.pendingMessages = [
|
|
1048
|
+
JSON.stringify({ type: "user_message", content: "Valid" }),
|
|
1049
|
+
"NOT VALID JSON {{{",
|
|
1050
|
+
JSON.stringify({ type: "user_message", content: "Also valid" }),
|
|
1051
|
+
];
|
|
1052
|
+
|
|
1053
|
+
// Should not throw
|
|
1054
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
1055
|
+
|
|
1056
|
+
// Only the two valid messages should be sent
|
|
1057
|
+
expect(adapter.sendBrowserMessage).toHaveBeenCalledTimes(2);
|
|
1058
|
+
expect(adapter.sendBrowserMessage).toHaveBeenCalledWith({ type: "user_message", content: "Valid" });
|
|
1059
|
+
expect(adapter.sendBrowserMessage).toHaveBeenCalledWith({ type: "user_message", content: "Also valid" });
|
|
1060
|
+
expect(session.pendingMessages).toHaveLength(0);
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
// ── cli_connected broadcast ─────────────────────────────────────────────
|
|
1064
|
+
|
|
1065
|
+
it("broadcasts cli_connected on attach", () => {
|
|
1066
|
+
// After setting up handlers and flushing pending messages, the function
|
|
1067
|
+
// should broadcast cli_connected to all browser sockets.
|
|
1068
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
1069
|
+
|
|
1070
|
+
expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(session, {
|
|
1071
|
+
type: "cli_connected",
|
|
1072
|
+
});
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
it("broadcasts cli_connected after flushing pending messages", () => {
|
|
1076
|
+
// cli_connected should come after pending messages are flushed, ensuring
|
|
1077
|
+
// browsers know the adapter is ready only after queued work is processed.
|
|
1078
|
+
session.pendingMessages = [JSON.stringify({ type: "user_message", content: "Hello" })];
|
|
1079
|
+
|
|
1080
|
+
const callOrder: string[] = [];
|
|
1081
|
+
(adapter.sendBrowserMessage as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
|
1082
|
+
callOrder.push("sendBrowserMessage");
|
|
1083
|
+
});
|
|
1084
|
+
(deps.broadcastToBrowsers as ReturnType<typeof vi.fn>).mockImplementation((_session: any, msg: any) => {
|
|
1085
|
+
if (msg.type === "cli_connected") {
|
|
1086
|
+
callOrder.push("cli_connected_broadcast");
|
|
1087
|
+
}
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
1091
|
+
|
|
1092
|
+
expect(callOrder).toEqual(["sendBrowserMessage", "cli_connected_broadcast"]);
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
// ── AI Validation Mode ──────────────────────────────────────────────────
|
|
1096
|
+
|
|
1097
|
+
describe("AI validation mode", () => {
|
|
1098
|
+
/** Helper: configure settings for AI validation enabled with all auto-actions on */
|
|
1099
|
+
function enableAiValidation() {
|
|
1100
|
+
vi.mocked(settingsManager.getSettings).mockReturnValue({
|
|
1101
|
+
anthropicApiKey: "test-api-key",
|
|
1102
|
+
anthropicModel: "claude-sonnet-4-6",
|
|
1103
|
+
linearApiKey: "",
|
|
1104
|
+
linearAutoTransition: false,
|
|
1105
|
+
linearAutoTransitionStateId: "",
|
|
1106
|
+
linearAutoTransitionStateName: "",
|
|
1107
|
+
linearArchiveTransition: false,
|
|
1108
|
+
linearArchiveTransitionStateId: "",
|
|
1109
|
+
linearArchiveTransitionStateName: "",
|
|
1110
|
+
linearOAuthClientId: "",
|
|
1111
|
+
linearOAuthClientSecret: "",
|
|
1112
|
+
linearOAuthWebhookSecret: "",
|
|
1113
|
+
linearOAuthAccessToken: "",
|
|
1114
|
+
linearOAuthRefreshToken: "",
|
|
1115
|
+
claudeCodeOAuthToken: "",
|
|
1116
|
+
openaiApiKey: "",
|
|
1117
|
+
onboardingCompleted: false,
|
|
1118
|
+
aiValidationEnabled: true,
|
|
1119
|
+
aiValidationAutoApprove: true,
|
|
1120
|
+
aiValidationAutoDeny: true,
|
|
1121
|
+
publicUrl: "",
|
|
1122
|
+
updateChannel: "stable",
|
|
1123
|
+
dockerAutoUpdate: false,
|
|
1124
|
+
updatedAt: 0,
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/** Helper: create a permission_request BrowserIncomingMessage for the given tool */
|
|
1129
|
+
function makePermissionMsg(
|
|
1130
|
+
toolName: string,
|
|
1131
|
+
requestId = "perm-ai-1",
|
|
1132
|
+
): BrowserIncomingMessage {
|
|
1133
|
+
return {
|
|
1134
|
+
type: "permission_request",
|
|
1135
|
+
request: {
|
|
1136
|
+
request_id: requestId,
|
|
1137
|
+
tool_name: toolName,
|
|
1138
|
+
input: { command: "ls -la" },
|
|
1139
|
+
description: `Execute: ${toolName}`,
|
|
1140
|
+
tool_use_id: `tool-${requestId}`,
|
|
1141
|
+
timestamp: Date.now(),
|
|
1142
|
+
},
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
it("auto-approves when AI validation returns safe verdict", async () => {
|
|
1147
|
+
// When AI validation is enabled and the validator returns "safe",
|
|
1148
|
+
// the handler should broadcast permission_auto_resolved with behavior "allow"
|
|
1149
|
+
// and send a permission_response to the CLI adapter without prompting the user.
|
|
1150
|
+
enableAiValidation();
|
|
1151
|
+
vi.mocked(aiValidator.validatePermission).mockResolvedValue({
|
|
1152
|
+
verdict: "safe",
|
|
1153
|
+
reason: "Read-only tool",
|
|
1154
|
+
ruleBasedOnly: true,
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
1158
|
+
adapter._trigger("onBrowserMessage", makePermissionMsg("Bash"));
|
|
1159
|
+
|
|
1160
|
+
// Allow the async handleCodexAiValidation to resolve
|
|
1161
|
+
await vi.waitFor(() => {
|
|
1162
|
+
expect(adapter.sendBrowserMessage).toHaveBeenCalled();
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
// Should broadcast permission_auto_resolved to browsers
|
|
1166
|
+
expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(session, {
|
|
1167
|
+
type: "permission_auto_resolved",
|
|
1168
|
+
request: expect.objectContaining({
|
|
1169
|
+
request_id: "perm-ai-1",
|
|
1170
|
+
tool_name: "Bash",
|
|
1171
|
+
ai_validation: { verdict: "safe", reason: "Read-only tool", ruleBasedOnly: true },
|
|
1172
|
+
}),
|
|
1173
|
+
behavior: "allow",
|
|
1174
|
+
reason: "Read-only tool",
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
// Should send allow response back to CLI
|
|
1178
|
+
expect(adapter.sendBrowserMessage).toHaveBeenCalledWith({
|
|
1179
|
+
type: "permission_response",
|
|
1180
|
+
request_id: "perm-ai-1",
|
|
1181
|
+
behavior: "allow",
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
// Should NOT store in pendingPermissions (auto-resolved, no manual action needed)
|
|
1185
|
+
expect(session.pendingPermissions.has("perm-ai-1")).toBe(false);
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
it("auto-denies when AI validation returns dangerous verdict", async () => {
|
|
1189
|
+
// When AI validation is enabled and the validator returns "dangerous",
|
|
1190
|
+
// the handler should broadcast permission_auto_resolved with behavior "deny"
|
|
1191
|
+
// and send a permission_response "deny" to the CLI adapter.
|
|
1192
|
+
enableAiValidation();
|
|
1193
|
+
vi.mocked(aiValidator.validatePermission).mockResolvedValue({
|
|
1194
|
+
verdict: "dangerous",
|
|
1195
|
+
reason: "Recursive delete of root directory",
|
|
1196
|
+
ruleBasedOnly: true,
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
1200
|
+
adapter._trigger("onBrowserMessage", makePermissionMsg("Bash", "perm-danger"));
|
|
1201
|
+
|
|
1202
|
+
await vi.waitFor(() => {
|
|
1203
|
+
expect(adapter.sendBrowserMessage).toHaveBeenCalled();
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
// Should broadcast permission_auto_resolved with "deny" to browsers
|
|
1207
|
+
expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(session, {
|
|
1208
|
+
type: "permission_auto_resolved",
|
|
1209
|
+
request: expect.objectContaining({
|
|
1210
|
+
request_id: "perm-danger",
|
|
1211
|
+
tool_name: "Bash",
|
|
1212
|
+
ai_validation: { verdict: "dangerous", reason: "Recursive delete of root directory", ruleBasedOnly: true },
|
|
1213
|
+
}),
|
|
1214
|
+
behavior: "deny",
|
|
1215
|
+
reason: "Recursive delete of root directory",
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
// Should send deny response back to CLI
|
|
1219
|
+
expect(adapter.sendBrowserMessage).toHaveBeenCalledWith({
|
|
1220
|
+
type: "permission_response",
|
|
1221
|
+
request_id: "perm-danger",
|
|
1222
|
+
behavior: "deny",
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
// Should NOT store in pendingPermissions
|
|
1226
|
+
expect(session.pendingPermissions.has("perm-danger")).toBe(false);
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
it("falls through to manual review when AI validation returns uncertain verdict", async () => {
|
|
1230
|
+
// When the validator returns "uncertain", the handler should NOT auto-resolve.
|
|
1231
|
+
// Instead it should store the permission in pendingPermissions and broadcast
|
|
1232
|
+
// the permission_request to browsers for manual review, with ai_validation info attached.
|
|
1233
|
+
enableAiValidation();
|
|
1234
|
+
vi.mocked(aiValidator.validatePermission).mockResolvedValue({
|
|
1235
|
+
verdict: "uncertain",
|
|
1236
|
+
reason: "Complex bash pipeline",
|
|
1237
|
+
ruleBasedOnly: false,
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
1241
|
+
adapter._trigger("onBrowserMessage", makePermissionMsg("Bash", "perm-uncertain"));
|
|
1242
|
+
|
|
1243
|
+
await vi.waitFor(() => {
|
|
1244
|
+
expect(session.pendingPermissions.has("perm-uncertain")).toBe(true);
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
// Should store in pendingPermissions for manual review
|
|
1248
|
+
const stored = session.pendingPermissions.get("perm-uncertain");
|
|
1249
|
+
expect(stored).toBeDefined();
|
|
1250
|
+
expect(stored!.ai_validation).toEqual({
|
|
1251
|
+
verdict: "uncertain",
|
|
1252
|
+
reason: "Complex bash pipeline",
|
|
1253
|
+
ruleBasedOnly: false,
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
// Should persist session
|
|
1257
|
+
expect(deps.persistSession).toHaveBeenCalled();
|
|
1258
|
+
|
|
1259
|
+
// Should broadcast permission_request to browsers (not permission_auto_resolved)
|
|
1260
|
+
expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(session, {
|
|
1261
|
+
type: "permission_request",
|
|
1262
|
+
request: expect.objectContaining({
|
|
1263
|
+
request_id: "perm-uncertain",
|
|
1264
|
+
ai_validation: { verdict: "uncertain", reason: "Complex bash pipeline", ruleBasedOnly: false },
|
|
1265
|
+
}),
|
|
1266
|
+
});
|
|
1267
|
+
|
|
1268
|
+
// Should NOT send any response back to CLI (user must decide)
|
|
1269
|
+
expect(adapter.sendBrowserMessage).not.toHaveBeenCalled();
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
it("skips AI validation when disabled — uses normal permission flow", () => {
|
|
1273
|
+
// When aiValidationEnabled is false, the handler should go through the normal
|
|
1274
|
+
// flow: store in pendingPermissions, persist, and broadcast the permission_request
|
|
1275
|
+
// without calling validatePermission at all.
|
|
1276
|
+
vi.mocked(settingsManager.getSettings).mockReturnValue({
|
|
1277
|
+
anthropicApiKey: "test-api-key",
|
|
1278
|
+
anthropicModel: "claude-sonnet-4-6",
|
|
1279
|
+
linearApiKey: "",
|
|
1280
|
+
linearAutoTransition: false,
|
|
1281
|
+
linearAutoTransitionStateId: "",
|
|
1282
|
+
linearAutoTransitionStateName: "",
|
|
1283
|
+
linearArchiveTransition: false,
|
|
1284
|
+
linearArchiveTransitionStateId: "",
|
|
1285
|
+
linearArchiveTransitionStateName: "",
|
|
1286
|
+
linearOAuthClientId: "",
|
|
1287
|
+
linearOAuthClientSecret: "",
|
|
1288
|
+
linearOAuthWebhookSecret: "",
|
|
1289
|
+
linearOAuthAccessToken: "",
|
|
1290
|
+
linearOAuthRefreshToken: "",
|
|
1291
|
+
claudeCodeOAuthToken: "",
|
|
1292
|
+
openaiApiKey: "",
|
|
1293
|
+
onboardingCompleted: false,
|
|
1294
|
+
aiValidationEnabled: false, // disabled
|
|
1295
|
+
aiValidationAutoApprove: true,
|
|
1296
|
+
aiValidationAutoDeny: true,
|
|
1297
|
+
publicUrl: "",
|
|
1298
|
+
updateChannel: "stable",
|
|
1299
|
+
dockerAutoUpdate: false,
|
|
1300
|
+
updatedAt: 0,
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
1304
|
+
adapter._trigger("onBrowserMessage", makePermissionMsg("Bash", "perm-no-ai"));
|
|
1305
|
+
|
|
1306
|
+
// validatePermission should NOT have been called
|
|
1307
|
+
expect(aiValidator.validatePermission).not.toHaveBeenCalled();
|
|
1308
|
+
|
|
1309
|
+
// Should store in pendingPermissions (normal flow)
|
|
1310
|
+
expect(session.pendingPermissions.has("perm-no-ai")).toBe(true);
|
|
1311
|
+
|
|
1312
|
+
// Should broadcast permission_request to browsers
|
|
1313
|
+
expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(
|
|
1314
|
+
session,
|
|
1315
|
+
expect.objectContaining({ type: "permission_request" }),
|
|
1316
|
+
);
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
it("skips AI validation when anthropicApiKey is empty", () => {
|
|
1320
|
+
// Even if aiValidationEnabled is true, an empty API key means we can't call
|
|
1321
|
+
// the AI — fall through to normal manual flow.
|
|
1322
|
+
vi.mocked(settingsManager.getSettings).mockReturnValue({
|
|
1323
|
+
anthropicApiKey: "", // empty
|
|
1324
|
+
anthropicModel: "claude-sonnet-4-6",
|
|
1325
|
+
linearApiKey: "",
|
|
1326
|
+
linearAutoTransition: false,
|
|
1327
|
+
linearAutoTransitionStateId: "",
|
|
1328
|
+
linearAutoTransitionStateName: "",
|
|
1329
|
+
linearArchiveTransition: false,
|
|
1330
|
+
linearArchiveTransitionStateId: "",
|
|
1331
|
+
linearArchiveTransitionStateName: "",
|
|
1332
|
+
linearOAuthClientId: "",
|
|
1333
|
+
linearOAuthClientSecret: "",
|
|
1334
|
+
linearOAuthWebhookSecret: "",
|
|
1335
|
+
linearOAuthAccessToken: "",
|
|
1336
|
+
linearOAuthRefreshToken: "",
|
|
1337
|
+
claudeCodeOAuthToken: "",
|
|
1338
|
+
openaiApiKey: "",
|
|
1339
|
+
onboardingCompleted: false,
|
|
1340
|
+
aiValidationEnabled: true,
|
|
1341
|
+
aiValidationAutoApprove: true,
|
|
1342
|
+
aiValidationAutoDeny: true,
|
|
1343
|
+
publicUrl: "",
|
|
1344
|
+
updateChannel: "stable",
|
|
1345
|
+
dockerAutoUpdate: false,
|
|
1346
|
+
updatedAt: 0,
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
1350
|
+
adapter._trigger("onBrowserMessage", makePermissionMsg("Bash", "perm-no-key"));
|
|
1351
|
+
|
|
1352
|
+
// Should NOT call AI validator
|
|
1353
|
+
expect(aiValidator.validatePermission).not.toHaveBeenCalled();
|
|
1354
|
+
|
|
1355
|
+
// Should fall through to normal flow
|
|
1356
|
+
expect(session.pendingPermissions.has("perm-no-key")).toBe(true);
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
it("skips AI validation for AskUserQuestion tool even when enabled", () => {
|
|
1360
|
+
// AskUserQuestion is an interactive tool that always requires the user's direct
|
|
1361
|
+
// attention — it should never be auto-resolved by AI validation.
|
|
1362
|
+
enableAiValidation();
|
|
1363
|
+
|
|
1364
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
1365
|
+
adapter._trigger("onBrowserMessage", makePermissionMsg("AskUserQuestion", "perm-ask"));
|
|
1366
|
+
|
|
1367
|
+
// Should NOT call AI validator
|
|
1368
|
+
expect(aiValidator.validatePermission).not.toHaveBeenCalled();
|
|
1369
|
+
|
|
1370
|
+
// Should go through normal flow
|
|
1371
|
+
expect(session.pendingPermissions.has("perm-ask")).toBe(true);
|
|
1372
|
+
expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(
|
|
1373
|
+
session,
|
|
1374
|
+
expect.objectContaining({ type: "permission_request" }),
|
|
1375
|
+
);
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
it("skips AI validation for ExitPlanMode tool even when enabled", () => {
|
|
1379
|
+
// ExitPlanMode is an interactive tool that always requires the user's direct
|
|
1380
|
+
// attention — it should never be auto-resolved by AI validation.
|
|
1381
|
+
enableAiValidation();
|
|
1382
|
+
|
|
1383
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
1384
|
+
adapter._trigger("onBrowserMessage", makePermissionMsg("ExitPlanMode", "perm-exit"));
|
|
1385
|
+
|
|
1386
|
+
// Should NOT call AI validator
|
|
1387
|
+
expect(aiValidator.validatePermission).not.toHaveBeenCalled();
|
|
1388
|
+
|
|
1389
|
+
// Should go through normal flow
|
|
1390
|
+
expect(session.pendingPermissions.has("perm-exit")).toBe(true);
|
|
1391
|
+
expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(
|
|
1392
|
+
session,
|
|
1393
|
+
expect.objectContaining({ type: "permission_request" }),
|
|
1394
|
+
);
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
it("falls through to manual flow when AI validation throws an error", async () => {
|
|
1398
|
+
// When validatePermission rejects with an error, the .catch() handler should
|
|
1399
|
+
// fall through to the normal manual flow: store in pendingPermissions, persist,
|
|
1400
|
+
// and broadcast to browsers.
|
|
1401
|
+
enableAiValidation();
|
|
1402
|
+
vi.mocked(aiValidator.validatePermission).mockRejectedValue(
|
|
1403
|
+
new Error("Network timeout"),
|
|
1404
|
+
);
|
|
1405
|
+
|
|
1406
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
1407
|
+
adapter._trigger("onBrowserMessage", makePermissionMsg("Bash", "perm-err"));
|
|
1408
|
+
|
|
1409
|
+
// Wait for the .catch() path to execute
|
|
1410
|
+
await vi.waitFor(() => {
|
|
1411
|
+
expect(session.pendingPermissions.has("perm-err")).toBe(true);
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
// Should persist session
|
|
1415
|
+
expect(deps.persistSession).toHaveBeenCalled();
|
|
1416
|
+
|
|
1417
|
+
// Should broadcast permission_request to browsers for manual review
|
|
1418
|
+
expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(
|
|
1419
|
+
session,
|
|
1420
|
+
expect.objectContaining({
|
|
1421
|
+
type: "permission_request",
|
|
1422
|
+
request: expect.objectContaining({ request_id: "perm-err" }),
|
|
1423
|
+
}),
|
|
1424
|
+
);
|
|
1425
|
+
|
|
1426
|
+
// Should NOT auto-resolve
|
|
1427
|
+
expect(adapter.sendBrowserMessage).not.toHaveBeenCalled();
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
it("does not auto-approve safe verdict when aiValidationAutoApprove is false", async () => {
|
|
1431
|
+
// When the verdict is "safe" but auto-approve is disabled, the handler
|
|
1432
|
+
// should fall through to manual review instead of auto-approving.
|
|
1433
|
+
vi.mocked(settingsManager.getSettings).mockReturnValue({
|
|
1434
|
+
anthropicApiKey: "test-api-key",
|
|
1435
|
+
anthropicModel: "claude-sonnet-4-6",
|
|
1436
|
+
linearApiKey: "",
|
|
1437
|
+
linearAutoTransition: false,
|
|
1438
|
+
linearAutoTransitionStateId: "",
|
|
1439
|
+
linearAutoTransitionStateName: "",
|
|
1440
|
+
linearArchiveTransition: false,
|
|
1441
|
+
linearArchiveTransitionStateId: "",
|
|
1442
|
+
linearArchiveTransitionStateName: "",
|
|
1443
|
+
linearOAuthClientId: "",
|
|
1444
|
+
linearOAuthClientSecret: "",
|
|
1445
|
+
linearOAuthWebhookSecret: "",
|
|
1446
|
+
linearOAuthAccessToken: "",
|
|
1447
|
+
linearOAuthRefreshToken: "",
|
|
1448
|
+
claudeCodeOAuthToken: "",
|
|
1449
|
+
openaiApiKey: "",
|
|
1450
|
+
onboardingCompleted: false,
|
|
1451
|
+
aiValidationEnabled: true,
|
|
1452
|
+
aiValidationAutoApprove: false, // disabled
|
|
1453
|
+
aiValidationAutoDeny: true,
|
|
1454
|
+
publicUrl: "",
|
|
1455
|
+
updateChannel: "stable",
|
|
1456
|
+
dockerAutoUpdate: false,
|
|
1457
|
+
updatedAt: 0,
|
|
1458
|
+
});
|
|
1459
|
+
|
|
1460
|
+
vi.mocked(aiValidator.validatePermission).mockResolvedValue({
|
|
1461
|
+
verdict: "safe",
|
|
1462
|
+
reason: "Standard dev command",
|
|
1463
|
+
ruleBasedOnly: false,
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
1467
|
+
adapter._trigger("onBrowserMessage", makePermissionMsg("Bash", "perm-safe-no-auto"));
|
|
1468
|
+
|
|
1469
|
+
await vi.waitFor(() => {
|
|
1470
|
+
expect(session.pendingPermissions.has("perm-safe-no-auto")).toBe(true);
|
|
1471
|
+
});
|
|
1472
|
+
|
|
1473
|
+
// Should NOT auto-resolve — falls through to manual
|
|
1474
|
+
expect(adapter.sendBrowserMessage).not.toHaveBeenCalled();
|
|
1475
|
+
|
|
1476
|
+
// Should broadcast permission_request with ai_validation info attached
|
|
1477
|
+
expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(session, {
|
|
1478
|
+
type: "permission_request",
|
|
1479
|
+
request: expect.objectContaining({
|
|
1480
|
+
request_id: "perm-safe-no-auto",
|
|
1481
|
+
ai_validation: { verdict: "safe", reason: "Standard dev command", ruleBasedOnly: false },
|
|
1482
|
+
}),
|
|
1483
|
+
});
|
|
1484
|
+
});
|
|
1485
|
+
|
|
1486
|
+
it("propagates actionable AI service error reason through to browser permission request", async () => {
|
|
1487
|
+
// When aiEvaluate returns an uncertain verdict due to a service failure (e.g., invalid key),
|
|
1488
|
+
// the specific error reason should be attached to the permission request sent to browsers,
|
|
1489
|
+
// allowing users to see why AI analysis failed and take corrective action.
|
|
1490
|
+
enableAiValidation();
|
|
1491
|
+
vi.mocked(aiValidator.validatePermission).mockResolvedValue({
|
|
1492
|
+
verdict: "uncertain",
|
|
1493
|
+
reason: "Invalid Anthropic API key: invalid x-api-key",
|
|
1494
|
+
ruleBasedOnly: false,
|
|
1495
|
+
});
|
|
1496
|
+
|
|
1497
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
1498
|
+
adapter._trigger("onBrowserMessage", makePermissionMsg("Bash", "perm-api-err"));
|
|
1499
|
+
|
|
1500
|
+
await vi.waitFor(() => {
|
|
1501
|
+
expect(session.pendingPermissions.has("perm-api-err")).toBe(true);
|
|
1502
|
+
});
|
|
1503
|
+
|
|
1504
|
+
// The permission stored and broadcast should carry the actionable reason
|
|
1505
|
+
const stored = session.pendingPermissions.get("perm-api-err");
|
|
1506
|
+
expect(stored!.ai_validation).toEqual({
|
|
1507
|
+
verdict: "uncertain",
|
|
1508
|
+
reason: "Invalid Anthropic API key: invalid x-api-key",
|
|
1509
|
+
ruleBasedOnly: false,
|
|
1510
|
+
});
|
|
1511
|
+
|
|
1512
|
+
// Browser should receive the specific reason, not a generic "AI service request failed"
|
|
1513
|
+
expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(session, {
|
|
1514
|
+
type: "permission_request",
|
|
1515
|
+
request: expect.objectContaining({
|
|
1516
|
+
request_id: "perm-api-err",
|
|
1517
|
+
ai_validation: expect.objectContaining({
|
|
1518
|
+
reason: "Invalid Anthropic API key: invalid x-api-key",
|
|
1519
|
+
}),
|
|
1520
|
+
}),
|
|
1521
|
+
});
|
|
1522
|
+
|
|
1523
|
+
// Manual review — no auto-resolution
|
|
1524
|
+
expect(adapter.sendBrowserMessage).not.toHaveBeenCalled();
|
|
1525
|
+
});
|
|
1526
|
+
|
|
1527
|
+
it("logs AI validation errors with session and tool context", async () => {
|
|
1528
|
+
// When AI validation throws, the console.warn should include session ID,
|
|
1529
|
+
// tool name, and request ID for debugging correlation.
|
|
1530
|
+
enableAiValidation();
|
|
1531
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
1532
|
+
vi.mocked(aiValidator.validatePermission).mockRejectedValue(
|
|
1533
|
+
new Error("Connection refused"),
|
|
1534
|
+
);
|
|
1535
|
+
|
|
1536
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
1537
|
+
adapter._trigger("onBrowserMessage", makePermissionMsg("Bash", "perm-log-test"));
|
|
1538
|
+
|
|
1539
|
+
await vi.waitFor(() => {
|
|
1540
|
+
expect(session.pendingPermissions.has("perm-log-test")).toBe(true);
|
|
1541
|
+
});
|
|
1542
|
+
|
|
1543
|
+
// The console.warn should contain session/tool/request context
|
|
1544
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
1545
|
+
expect.stringContaining("tool=Bash"),
|
|
1546
|
+
expect.any(Error),
|
|
1547
|
+
);
|
|
1548
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
1549
|
+
expect.stringContaining("session=test-session"),
|
|
1550
|
+
expect.any(Error),
|
|
1551
|
+
);
|
|
1552
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
1553
|
+
expect.stringContaining("request_id=perm-log-test"),
|
|
1554
|
+
expect.any(Error),
|
|
1555
|
+
);
|
|
1556
|
+
|
|
1557
|
+
warnSpy.mockRestore();
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
it("does not auto-deny dangerous verdict when aiValidationAutoDeny is false", async () => {
|
|
1561
|
+
// When the verdict is "dangerous" but auto-deny is disabled, the handler
|
|
1562
|
+
// should fall through to manual review instead of auto-denying.
|
|
1563
|
+
vi.mocked(settingsManager.getSettings).mockReturnValue({
|
|
1564
|
+
anthropicApiKey: "test-api-key",
|
|
1565
|
+
anthropicModel: "claude-sonnet-4-6",
|
|
1566
|
+
linearApiKey: "",
|
|
1567
|
+
linearAutoTransition: false,
|
|
1568
|
+
linearAutoTransitionStateId: "",
|
|
1569
|
+
linearAutoTransitionStateName: "",
|
|
1570
|
+
linearArchiveTransition: false,
|
|
1571
|
+
linearArchiveTransitionStateId: "",
|
|
1572
|
+
linearArchiveTransitionStateName: "",
|
|
1573
|
+
linearOAuthClientId: "",
|
|
1574
|
+
linearOAuthClientSecret: "",
|
|
1575
|
+
linearOAuthWebhookSecret: "",
|
|
1576
|
+
linearOAuthAccessToken: "",
|
|
1577
|
+
linearOAuthRefreshToken: "",
|
|
1578
|
+
claudeCodeOAuthToken: "",
|
|
1579
|
+
openaiApiKey: "",
|
|
1580
|
+
onboardingCompleted: false,
|
|
1581
|
+
aiValidationEnabled: true,
|
|
1582
|
+
aiValidationAutoApprove: true,
|
|
1583
|
+
aiValidationAutoDeny: false, // disabled
|
|
1584
|
+
publicUrl: "",
|
|
1585
|
+
updateChannel: "stable",
|
|
1586
|
+
dockerAutoUpdate: false,
|
|
1587
|
+
updatedAt: 0,
|
|
1588
|
+
});
|
|
1589
|
+
|
|
1590
|
+
vi.mocked(aiValidator.validatePermission).mockResolvedValue({
|
|
1591
|
+
verdict: "dangerous",
|
|
1592
|
+
reason: "Recursive delete",
|
|
1593
|
+
ruleBasedOnly: true,
|
|
1594
|
+
});
|
|
1595
|
+
|
|
1596
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
1597
|
+
adapter._trigger("onBrowserMessage", makePermissionMsg("Bash", "perm-danger-no-auto"));
|
|
1598
|
+
|
|
1599
|
+
await vi.waitFor(() => {
|
|
1600
|
+
expect(session.pendingPermissions.has("perm-danger-no-auto")).toBe(true);
|
|
1601
|
+
});
|
|
1602
|
+
|
|
1603
|
+
// Should NOT auto-resolve — falls through to manual
|
|
1604
|
+
expect(adapter.sendBrowserMessage).not.toHaveBeenCalled();
|
|
1605
|
+
|
|
1606
|
+
// Should broadcast permission_request with ai_validation info attached
|
|
1607
|
+
expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(session, {
|
|
1608
|
+
type: "permission_request",
|
|
1609
|
+
request: expect.objectContaining({
|
|
1610
|
+
request_id: "perm-danger-no-auto",
|
|
1611
|
+
ai_validation: { verdict: "dangerous", reason: "Recursive delete", ruleBasedOnly: true },
|
|
1612
|
+
}),
|
|
1613
|
+
});
|
|
1614
|
+
});
|
|
1615
|
+
});
|
|
1616
|
+
|
|
1617
|
+
// ── Per-session listeners (chat relay) ──────────────────────────────────
|
|
1618
|
+
|
|
1619
|
+
describe("per-session bus events (chat relay)", () => {
|
|
1620
|
+
it("emits message:assistant on bus when assistant message arrives", () => {
|
|
1621
|
+
// Chat relay relies on bus events to forward agent responses
|
|
1622
|
+
// to external platforms. The Codex path must emit these just like the
|
|
1623
|
+
// Claude Code path does.
|
|
1624
|
+
const listener = vi.fn();
|
|
1625
|
+
companionBus.on("message:assistant", ({ sessionId, message }) => {
|
|
1626
|
+
if (sessionId === "test-session") listener(message);
|
|
1627
|
+
});
|
|
1628
|
+
|
|
1629
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
1630
|
+
|
|
1631
|
+
const assistantMsg: BrowserIncomingMessage = {
|
|
1632
|
+
type: "assistant",
|
|
1633
|
+
message: {
|
|
1634
|
+
id: "msg-listener",
|
|
1635
|
+
type: "message",
|
|
1636
|
+
role: "assistant",
|
|
1637
|
+
model: "o3-pro",
|
|
1638
|
+
content: [{ type: "text", text: "Hello from Codex" }],
|
|
1639
|
+
stop_reason: "end_turn",
|
|
1640
|
+
usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
1641
|
+
},
|
|
1642
|
+
parent_tool_use_id: null,
|
|
1643
|
+
timestamp: 1700000000000,
|
|
1644
|
+
};
|
|
1645
|
+
|
|
1646
|
+
adapter._trigger("onBrowserMessage", assistantMsg);
|
|
1647
|
+
|
|
1648
|
+
expect(listener).toHaveBeenCalledOnce();
|
|
1649
|
+
// The listener should receive the message with timestamp
|
|
1650
|
+
expect(listener.mock.calls[0][0]).toMatchObject({
|
|
1651
|
+
type: "assistant",
|
|
1652
|
+
timestamp: 1700000000000,
|
|
1653
|
+
});
|
|
1654
|
+
});
|
|
1655
|
+
|
|
1656
|
+
it("emits message:result on bus when result message arrives", () => {
|
|
1657
|
+
// Result events signal turn completion so chat relay can post
|
|
1658
|
+
// accumulated text back to the platform.
|
|
1659
|
+
const listener = vi.fn();
|
|
1660
|
+
companionBus.on("message:result", ({ sessionId, message }) => {
|
|
1661
|
+
if (sessionId === "test-session") listener(message);
|
|
1662
|
+
});
|
|
1663
|
+
|
|
1664
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
1665
|
+
|
|
1666
|
+
const resultMsg: BrowserIncomingMessage = {
|
|
1667
|
+
type: "result",
|
|
1668
|
+
data: {
|
|
1669
|
+
type: "result",
|
|
1670
|
+
subtype: "success",
|
|
1671
|
+
is_error: false,
|
|
1672
|
+
result: undefined,
|
|
1673
|
+
duration_ms: 100,
|
|
1674
|
+
duration_api_ms: 80,
|
|
1675
|
+
num_turns: 1,
|
|
1676
|
+
total_cost_usd: 0.01,
|
|
1677
|
+
stop_reason: "end_turn",
|
|
1678
|
+
usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
1679
|
+
uuid: "result-listener-1",
|
|
1680
|
+
session_id: "test-session",
|
|
1681
|
+
},
|
|
1682
|
+
};
|
|
1683
|
+
|
|
1684
|
+
adapter._trigger("onBrowserMessage", resultMsg);
|
|
1685
|
+
|
|
1686
|
+
expect(listener).toHaveBeenCalledOnce();
|
|
1687
|
+
expect(listener.mock.calls[0][0]).toMatchObject({ type: "result" });
|
|
1688
|
+
});
|
|
1689
|
+
|
|
1690
|
+
it("does not invoke session-filtered listeners for a different session", () => {
|
|
1691
|
+
// Bus subscribers that filter by sessionId should not fire when
|
|
1692
|
+
// messages arrive for a different session.
|
|
1693
|
+
const listener = vi.fn();
|
|
1694
|
+
companionBus.on("message:assistant", ({ sessionId, message }) => {
|
|
1695
|
+
if (sessionId === "other-session") listener(message);
|
|
1696
|
+
});
|
|
1697
|
+
|
|
1698
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
1699
|
+
|
|
1700
|
+
adapter._trigger("onBrowserMessage", {
|
|
1701
|
+
type: "assistant",
|
|
1702
|
+
message: {
|
|
1703
|
+
id: "msg-other",
|
|
1704
|
+
type: "message",
|
|
1705
|
+
role: "assistant",
|
|
1706
|
+
model: "o3-pro",
|
|
1707
|
+
content: [{ type: "text", text: "Hello" }],
|
|
1708
|
+
stop_reason: "end_turn",
|
|
1709
|
+
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
1710
|
+
},
|
|
1711
|
+
parent_tool_use_id: null,
|
|
1712
|
+
timestamp: Date.now(),
|
|
1713
|
+
});
|
|
1714
|
+
|
|
1715
|
+
expect(listener).not.toHaveBeenCalled();
|
|
1716
|
+
});
|
|
1717
|
+
|
|
1718
|
+
it("does not throw when no bus subscribers are registered", () => {
|
|
1719
|
+
// When no subscribers are listening on the bus, emitting events
|
|
1720
|
+
// should not throw.
|
|
1721
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
1722
|
+
|
|
1723
|
+
expect(() => {
|
|
1724
|
+
adapter._trigger("onBrowserMessage", {
|
|
1725
|
+
type: "assistant",
|
|
1726
|
+
message: {
|
|
1727
|
+
id: "msg-no-listener",
|
|
1728
|
+
type: "message",
|
|
1729
|
+
role: "assistant",
|
|
1730
|
+
model: "o3-pro",
|
|
1731
|
+
content: [{ type: "text", text: "Hello" }],
|
|
1732
|
+
stop_reason: "end_turn",
|
|
1733
|
+
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
1734
|
+
},
|
|
1735
|
+
parent_tool_use_id: null,
|
|
1736
|
+
timestamp: Date.now(),
|
|
1737
|
+
});
|
|
1738
|
+
}).not.toThrow();
|
|
1739
|
+
});
|
|
1740
|
+
});
|
|
1741
|
+
|
|
1742
|
+
// ── lastCliActivityTs tracking ──────────────────────────────────────────
|
|
1743
|
+
|
|
1744
|
+
describe("lastCliActivityTs tracking for idle detection", () => {
|
|
1745
|
+
it("updates lastCliActivityTs when adapter emits messages", () => {
|
|
1746
|
+
// Codex sessions route through the adapter, not routeCLIMessage.
|
|
1747
|
+
// Without updating lastCliActivityTs here, the idle kill watchdog
|
|
1748
|
+
// would incorrectly kill active Codex sessions.
|
|
1749
|
+
const initialTs = session.lastCliActivityTs;
|
|
1750
|
+
|
|
1751
|
+
// Advance time — use try/finally to ensure fake timers are restored
|
|
1752
|
+
// even if assertions fail, preventing leaks into subsequent tests.
|
|
1753
|
+
vi.useFakeTimers();
|
|
1754
|
+
try {
|
|
1755
|
+
vi.advanceTimersByTime(5000);
|
|
1756
|
+
|
|
1757
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
1758
|
+
|
|
1759
|
+
// Simulate an assistant message from Codex
|
|
1760
|
+
adapter._trigger("onBrowserMessage", {
|
|
1761
|
+
type: "assistant",
|
|
1762
|
+
message: {
|
|
1763
|
+
id: "msg-1",
|
|
1764
|
+
type: "message",
|
|
1765
|
+
role: "assistant",
|
|
1766
|
+
model: "o4-mini",
|
|
1767
|
+
content: [{ type: "text", text: "Hi" }],
|
|
1768
|
+
stop_reason: "end_turn",
|
|
1769
|
+
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
1770
|
+
},
|
|
1771
|
+
parent_tool_use_id: null,
|
|
1772
|
+
timestamp: Date.now(),
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
// lastCliActivityTs should have been updated
|
|
1776
|
+
expect(session.lastCliActivityTs).toBeGreaterThan(initialTs);
|
|
1777
|
+
} finally {
|
|
1778
|
+
vi.useRealTimers();
|
|
1779
|
+
}
|
|
1780
|
+
});
|
|
1781
|
+
|
|
1782
|
+
it("updates lastCliActivityTs on result messages", () => {
|
|
1783
|
+
// Result messages (turn completed) should also update activity tracking
|
|
1784
|
+
const oldTs = Date.now() - 60000;
|
|
1785
|
+
session.lastCliActivityTs = oldTs;
|
|
1786
|
+
|
|
1787
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
1788
|
+
|
|
1789
|
+
adapter._trigger("onBrowserMessage", {
|
|
1790
|
+
type: "result",
|
|
1791
|
+
subtype: "result",
|
|
1792
|
+
data: { result: "Task completed" },
|
|
1793
|
+
});
|
|
1794
|
+
|
|
1795
|
+
expect(session.lastCliActivityTs).toBeGreaterThan(oldTs);
|
|
1796
|
+
});
|
|
1797
|
+
|
|
1798
|
+
it("updates lastCliActivityTs on session_init messages", () => {
|
|
1799
|
+
// Even session_init should count as activity
|
|
1800
|
+
const oldTs = Date.now() - 60000;
|
|
1801
|
+
session.lastCliActivityTs = oldTs;
|
|
1802
|
+
|
|
1803
|
+
attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
|
|
1804
|
+
|
|
1805
|
+
adapter._trigger("onBrowserMessage", {
|
|
1806
|
+
type: "session_init",
|
|
1807
|
+
session: {
|
|
1808
|
+
session_id: "test-session",
|
|
1809
|
+
backend_type: "codex",
|
|
1810
|
+
model: "o4-mini",
|
|
1811
|
+
cwd: "/tmp",
|
|
1812
|
+
tools: [],
|
|
1813
|
+
permissionMode: "bypassPermissions",
|
|
1814
|
+
claude_code_version: "",
|
|
1815
|
+
mcp_servers: [],
|
|
1816
|
+
agents: [],
|
|
1817
|
+
slash_commands: [],
|
|
1818
|
+
skills: [],
|
|
1819
|
+
total_cost_usd: 0,
|
|
1820
|
+
num_turns: 0,
|
|
1821
|
+
context_used_percent: 0,
|
|
1822
|
+
is_compacting: false,
|
|
1823
|
+
git_branch: "",
|
|
1824
|
+
is_worktree: false,
|
|
1825
|
+
is_containerized: false,
|
|
1826
|
+
repo_root: "",
|
|
1827
|
+
git_ahead: 0,
|
|
1828
|
+
git_behind: 0,
|
|
1829
|
+
total_lines_added: 0,
|
|
1830
|
+
total_lines_removed: 0,
|
|
1831
|
+
},
|
|
1832
|
+
});
|
|
1833
|
+
|
|
1834
|
+
expect(session.lastCliActivityTs).toBeGreaterThan(oldTs);
|
|
1835
|
+
});
|
|
1836
|
+
});
|
|
1837
|
+
});
|