@vibelet/cli 0.1.35 → 0.1.37
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/app.json +5 -0
- package/dist/advertised-hosts.d.ts +34 -0
- package/dist/advertised-hosts.d.ts.map +1 -0
- package/dist/advertised-hosts.js +176 -0
- package/dist/advertised-hosts.js.map +1 -0
- package/dist/advertised-hosts.test.d.ts +2 -0
- package/dist/advertised-hosts.test.d.ts.map +1 -0
- package/dist/advertised-hosts.test.js +96 -0
- package/dist/advertised-hosts.test.js.map +1 -0
- package/dist/audit.d.ts +30 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +73 -0
- package/dist/audit.js.map +1 -0
- package/dist/audit.test.d.ts +2 -0
- package/dist/audit.test.d.ts.map +1 -0
- package/dist/audit.test.js +33 -0
- package/dist/audit.test.js.map +1 -0
- package/dist/auth.d.ts +6 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +27 -0
- package/dist/auth.js.map +1 -0
- package/dist/claude-hooks.d.ts +58 -0
- package/dist/claude-hooks.d.ts.map +1 -0
- package/dist/claude-hooks.js +129 -0
- package/dist/claude-hooks.js.map +1 -0
- package/dist/cli-version.d.ts +3 -0
- package/dist/cli-version.d.ts.map +1 -0
- package/dist/cli-version.js +35 -0
- package/dist/cli-version.js.map +1 -0
- package/dist/cli-version.test.d.ts +2 -0
- package/dist/cli-version.test.d.ts.map +1 -0
- package/dist/cli-version.test.js +38 -0
- package/dist/cli-version.test.js.map +1 -0
- package/dist/config.d.ts +30 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +327 -0
- package/dist/config.js.map +1 -0
- package/dist/config.test.d.ts +2 -0
- package/dist/config.test.d.ts.map +1 -0
- package/dist/config.test.js +184 -0
- package/dist/config.test.js.map +1 -0
- package/dist/dev-auth.test.d.ts +2 -0
- package/dist/dev-auth.test.d.ts.map +1 -0
- package/dist/dev-auth.test.js +154 -0
- package/dist/dev-auth.test.js.map +1 -0
- package/dist/dev-script.test.d.ts +2 -0
- package/dist/dev-script.test.d.ts.map +1 -0
- package/dist/dev-script.test.js +412 -0
- package/dist/dev-script.test.js.map +1 -0
- package/dist/drivers/claude.d.ts +34 -0
- package/dist/drivers/claude.d.ts.map +1 -0
- package/dist/drivers/claude.js +413 -0
- package/dist/drivers/claude.js.map +1 -0
- package/dist/drivers/claude.test.d.ts +2 -0
- package/dist/drivers/claude.test.d.ts.map +1 -0
- package/dist/drivers/claude.test.js +951 -0
- package/dist/drivers/claude.test.js.map +1 -0
- package/dist/drivers/codex.d.ts +38 -0
- package/dist/drivers/codex.d.ts.map +1 -0
- package/dist/drivers/codex.js +771 -0
- package/dist/drivers/codex.js.map +1 -0
- package/dist/drivers/codex.test.d.ts +2 -0
- package/dist/drivers/codex.test.d.ts.map +1 -0
- package/dist/drivers/codex.test.js +939 -0
- package/dist/drivers/codex.test.js.map +1 -0
- package/dist/drivers/types.d.ts +14 -0
- package/dist/drivers/types.d.ts.map +1 -0
- package/dist/drivers/types.js +2 -0
- package/dist/drivers/types.js.map +1 -0
- package/dist/e2e.test.d.ts +2 -0
- package/dist/e2e.test.d.ts.map +1 -0
- package/dist/e2e.test.js +111 -0
- package/dist/e2e.test.js.map +1 -0
- package/dist/identity.d.ts +10 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +66 -0
- package/dist/identity.js.map +1 -0
- package/dist/identity.test.d.ts +2 -0
- package/dist/identity.test.d.ts.map +1 -0
- package/dist/identity.test.js +25 -0
- package/dist/identity.test.js.map +1 -0
- package/dist/index-entry.test.d.ts +2 -0
- package/dist/index-entry.test.d.ts.map +1 -0
- package/dist/index-entry.test.js +272 -0
- package/dist/index-entry.test.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +707 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +31 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +75 -0
- package/dist/logger.js.map +1 -0
- package/dist/metrics.d.ts +52 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +89 -0
- package/dist/metrics.js.map +1 -0
- package/dist/pairing-store.d.ts +29 -0
- package/dist/pairing-store.d.ts.map +1 -0
- package/dist/pairing-store.js +131 -0
- package/dist/pairing-store.js.map +1 -0
- package/dist/pairing-store.test.d.ts +2 -0
- package/dist/pairing-store.test.d.ts.map +1 -0
- package/dist/pairing-store.test.js +47 -0
- package/dist/pairing-store.test.js.map +1 -0
- package/dist/paths.d.ts +16 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +18 -0
- package/dist/paths.js.map +1 -0
- package/dist/perf-compare.d.ts +13 -0
- package/dist/perf-compare.d.ts.map +1 -0
- package/dist/perf-compare.js +125 -0
- package/dist/perf-compare.js.map +1 -0
- package/dist/port-conflict.d.ts +9 -0
- package/dist/port-conflict.d.ts.map +1 -0
- package/dist/port-conflict.js +33 -0
- package/dist/port-conflict.js.map +1 -0
- package/dist/port-conflict.test.d.ts +2 -0
- package/dist/port-conflict.test.d.ts.map +1 -0
- package/dist/port-conflict.test.js +38 -0
- package/dist/port-conflict.test.js.map +1 -0
- package/dist/process-scanner.d.ts +43 -0
- package/dist/process-scanner.d.ts.map +1 -0
- package/dist/process-scanner.js +453 -0
- package/dist/process-scanner.js.map +1 -0
- package/dist/process-scanner.perf.test.d.ts +2 -0
- package/dist/process-scanner.perf.test.d.ts.map +1 -0
- package/dist/process-scanner.perf.test.js +186 -0
- package/dist/process-scanner.perf.test.js.map +1 -0
- package/dist/process-scanner.test.d.ts +2 -0
- package/dist/process-scanner.test.d.ts.map +1 -0
- package/dist/process-scanner.test.js +399 -0
- package/dist/process-scanner.test.js.map +1 -0
- package/dist/push-protocol.d.ts +15 -0
- package/dist/push-protocol.d.ts.map +1 -0
- package/dist/push-protocol.js +23 -0
- package/dist/push-protocol.js.map +1 -0
- package/dist/push-protocol.test.d.ts +2 -0
- package/dist/push-protocol.test.d.ts.map +1 -0
- package/dist/push-protocol.test.js +57 -0
- package/dist/push-protocol.test.js.map +1 -0
- package/dist/push-store.d.ts +22 -0
- package/dist/push-store.d.ts.map +1 -0
- package/dist/push-store.js +103 -0
- package/dist/push-store.js.map +1 -0
- package/dist/push-store.test.d.ts +2 -0
- package/dist/push-store.test.d.ts.map +1 -0
- package/dist/push-store.test.js +79 -0
- package/dist/push-store.test.js.map +1 -0
- package/dist/push.d.ts +65 -0
- package/dist/push.d.ts.map +1 -0
- package/dist/push.js +202 -0
- package/dist/push.js.map +1 -0
- package/dist/push.test.d.ts +2 -0
- package/dist/push.test.d.ts.map +1 -0
- package/dist/push.test.js +199 -0
- package/dist/push.test.js.map +1 -0
- package/dist/safe-stdio.d.ts +3 -0
- package/dist/safe-stdio.d.ts.map +1 -0
- package/dist/safe-stdio.js +46 -0
- package/dist/safe-stdio.js.map +1 -0
- package/dist/scanner.d.ts +30 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +859 -0
- package/dist/scanner.js.map +1 -0
- package/dist/scanner.perf.test.d.ts +2 -0
- package/dist/scanner.perf.test.d.ts.map +1 -0
- package/dist/scanner.perf.test.js +320 -0
- package/dist/scanner.perf.test.js.map +1 -0
- package/dist/scanner.test.d.ts +2 -0
- package/dist/scanner.test.d.ts.map +1 -0
- package/dist/scanner.test.js +948 -0
- package/dist/scanner.test.js.map +1 -0
- package/dist/session-inventory.d.ts +63 -0
- package/dist/session-inventory.d.ts.map +1 -0
- package/dist/session-inventory.js +525 -0
- package/dist/session-inventory.js.map +1 -0
- package/dist/session-inventory.perf.test.d.ts +2 -0
- package/dist/session-inventory.perf.test.d.ts.map +1 -0
- package/dist/session-inventory.perf.test.js +220 -0
- package/dist/session-inventory.perf.test.js.map +1 -0
- package/dist/session-inventory.test.d.ts +2 -0
- package/dist/session-inventory.test.d.ts.map +1 -0
- package/dist/session-inventory.test.js +712 -0
- package/dist/session-inventory.test.js.map +1 -0
- package/dist/session-manager.d.ts +75 -0
- package/dist/session-manager.d.ts.map +1 -0
- package/dist/session-manager.js +1515 -0
- package/dist/session-manager.js.map +1 -0
- package/dist/session-manager.test.d.ts +2 -0
- package/dist/session-manager.test.d.ts.map +1 -0
- package/dist/session-manager.test.js +2861 -0
- package/dist/session-manager.test.js.map +1 -0
- package/dist/session-store.d.ts +42 -0
- package/dist/session-store.d.ts.map +1 -0
- package/dist/session-store.js +163 -0
- package/dist/session-store.js.map +1 -0
- package/dist/session-store.test.d.ts +2 -0
- package/dist/session-store.test.d.ts.map +1 -0
- package/dist/session-store.test.js +236 -0
- package/dist/session-store.test.js.map +1 -0
- package/dist/session-title.d.ts +6 -0
- package/dist/session-title.d.ts.map +1 -0
- package/dist/session-title.js +105 -0
- package/dist/session-title.js.map +1 -0
- package/dist/session-title.perf.test.d.ts +2 -0
- package/dist/session-title.perf.test.d.ts.map +1 -0
- package/dist/session-title.perf.test.js +99 -0
- package/dist/session-title.perf.test.js.map +1 -0
- package/dist/session-title.test.d.ts +2 -0
- package/dist/session-title.test.d.ts.map +1 -0
- package/dist/session-title.test.js +199 -0
- package/dist/session-title.test.js.map +1 -0
- package/dist/shutdown-endpoint.test.d.ts +2 -0
- package/dist/shutdown-endpoint.test.d.ts.map +1 -0
- package/dist/shutdown-endpoint.test.js +93 -0
- package/dist/shutdown-endpoint.test.js.map +1 -0
- package/dist/storage-housekeeping.d.ts +28 -0
- package/dist/storage-housekeeping.d.ts.map +1 -0
- package/dist/storage-housekeeping.js +76 -0
- package/dist/storage-housekeeping.js.map +1 -0
- package/dist/storage-housekeeping.test.d.ts +2 -0
- package/dist/storage-housekeeping.test.d.ts.map +1 -0
- package/dist/storage-housekeeping.test.js +65 -0
- package/dist/storage-housekeeping.test.js.map +1 -0
- package/dist/test-daemon-harness.d.ts +31 -0
- package/dist/test-daemon-harness.d.ts.map +1 -0
- package/dist/test-daemon-harness.js +337 -0
- package/dist/test-daemon-harness.js.map +1 -0
- package/dist/token-auth.test.d.ts +2 -0
- package/dist/token-auth.test.d.ts.map +1 -0
- package/dist/token-auth.test.js +52 -0
- package/dist/token-auth.test.js.map +1 -0
- package/dist/utils.d.ts +4 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +40 -0
- package/dist/utils.js.map +1 -0
- package/dist/utils.test.d.ts +2 -0
- package/dist/utils.test.d.ts.map +1 -0
- package/dist/utils.test.js +54 -0
- package/dist/utils.test.js.map +1 -0
- package/dist/ws-data.d.ts +4 -0
- package/dist/ws-data.d.ts.map +1 -0
- package/dist/ws-data.js +20 -0
- package/dist/ws-data.js.map +1 -0
- package/dist/ws-data.test.d.ts +2 -0
- package/dist/ws-data.test.d.ts.map +1 -0
- package/dist/ws-data.test.js +17 -0
- package/dist/ws-data.test.js.map +1 -0
- package/package.json +24 -24
- package/perf-reporter.mjs +138 -0
- package/scripts/build-release.mjs +41 -0
- package/scripts/dev.mjs +537 -0
- package/src/advertised-hosts.test.ts +125 -0
- package/src/advertised-hosts.ts +225 -0
- package/src/audit.test.ts +38 -0
- package/src/audit.ts +117 -0
- package/src/auth.ts +31 -0
- package/src/claude-hooks.ts +195 -0
- package/src/cli-version.test.ts +36 -0
- package/src/cli-version.ts +46 -0
- package/src/config.test.ts +254 -0
- package/src/config.ts +324 -0
- package/src/dev-auth.test.ts +183 -0
- package/src/dev-script.test.ts +511 -0
- package/src/drivers/claude.test.ts +1186 -0
- package/src/drivers/claude.ts +443 -0
- package/src/drivers/codex.test.ts +1096 -0
- package/src/drivers/codex.ts +879 -0
- package/src/drivers/types.ts +15 -0
- package/src/e2e.test.ts +139 -0
- package/src/identity.test.ts +26 -0
- package/src/identity.ts +82 -0
- package/src/index-entry.test.ts +336 -0
- package/src/index.ts +781 -0
- package/src/logger.ts +112 -0
- package/src/metrics.ts +117 -0
- package/src/pairing-store.test.ts +53 -0
- package/src/pairing-store.ts +154 -0
- package/src/paths.ts +19 -0
- package/src/perf-compare.ts +164 -0
- package/src/port-conflict.test.ts +45 -0
- package/src/port-conflict.ts +44 -0
- package/src/process-scanner.perf.test.ts +222 -0
- package/src/process-scanner.test.ts +575 -0
- package/src/process-scanner.ts +514 -0
- package/src/push-protocol.test.ts +74 -0
- package/src/push-protocol.ts +36 -0
- package/src/push-store.test.ts +89 -0
- package/src/push-store.ts +126 -0
- package/src/push.test.ts +234 -0
- package/src/push.ts +318 -0
- package/src/safe-stdio.ts +51 -0
- package/src/scanner.perf.test.ts +359 -0
- package/src/scanner.test.ts +1045 -0
- package/src/scanner.ts +924 -0
- package/src/session-inventory.perf.test.ts +250 -0
- package/src/session-inventory.test.ts +1002 -0
- package/src/session-inventory.ts +721 -0
- package/src/session-manager.test.ts +3430 -0
- package/src/session-manager.ts +1775 -0
- package/src/session-store.test.ts +276 -0
- package/src/session-store.ts +202 -0
- package/src/session-title.perf.test.ts +118 -0
- package/src/session-title.test.ts +286 -0
- package/src/session-title.ts +108 -0
- package/src/shutdown-endpoint.test.ts +95 -0
- package/src/storage-housekeeping.test.ts +78 -0
- package/src/storage-housekeeping.ts +111 -0
- package/src/test-daemon-harness.ts +410 -0
- package/src/token-auth.test.ts +67 -0
- package/src/utils.test.ts +65 -0
- package/src/utils.ts +47 -0
- package/src/ws-data.test.ts +20 -0
- package/src/ws-data.ts +26 -0
- package/tsconfig.json +12 -0
- package/README.md +0 -80
- package/bin/cloudflared-quick-tunnel.mjs +0 -11
- package/bin/cloudflared-resolver.mjs +0 -68
- package/bin/vibelet-runtime-policy.mjs +0 -36
- package/bin/vibelet.cjs +0 -12
- package/bin/vibelet.mjs +0 -1035
- package/dist/index.cjs +0 -125
|
@@ -0,0 +1,3430 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, rm, writeFile, mkdir } from 'fs/promises';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { tmpdir } from 'os';
|
|
6
|
+
import type { WebSocket } from 'ws';
|
|
7
|
+
import type { AgentType, ServerMessage } from '@vibelet/shared';
|
|
8
|
+
import type { Driver } from './drivers/types.js';
|
|
9
|
+
import { SessionManager } from './session-manager.js';
|
|
10
|
+
import { config } from './config.js';
|
|
11
|
+
import {
|
|
12
|
+
__emitExternalInventoryBackfillForTests,
|
|
13
|
+
__resetExternalInventoryStateForTests,
|
|
14
|
+
} from './session-inventory.js';
|
|
15
|
+
|
|
16
|
+
// ── Mock helpers ──────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
function mockWs(open = true): WebSocket & { sent: string[] } {
|
|
19
|
+
const sent: string[] = [];
|
|
20
|
+
return {
|
|
21
|
+
readyState: open ? 1 : 3, // 1 = OPEN, 3 = CLOSED
|
|
22
|
+
send(data: string) { sent.push(data); },
|
|
23
|
+
sent,
|
|
24
|
+
} as any;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function mockDriver(): Driver & {
|
|
28
|
+
calls: Record<string, unknown[]>;
|
|
29
|
+
_emitMessage: (msg: ServerMessage) => void;
|
|
30
|
+
_emitExit: (code: number | null) => void;
|
|
31
|
+
} {
|
|
32
|
+
const calls: Record<string, unknown[]> = {
|
|
33
|
+
start: [],
|
|
34
|
+
sendPrompt: [],
|
|
35
|
+
respondApproval: [],
|
|
36
|
+
interrupt: [],
|
|
37
|
+
stop: [],
|
|
38
|
+
setApprovalMode: [],
|
|
39
|
+
};
|
|
40
|
+
let messageHandler: ((msg: ServerMessage) => void) | null = null;
|
|
41
|
+
let exitHandler: ((code: number | null) => void) | null = null;
|
|
42
|
+
return {
|
|
43
|
+
async start(cwd?: string, resumeSessionId?: string, approvalMode?: string) {
|
|
44
|
+
calls.start.push({ cwd, resumeSessionId, approvalMode });
|
|
45
|
+
return 'mock-session-id';
|
|
46
|
+
},
|
|
47
|
+
sendPrompt(text: string) { calls.sendPrompt.push(text); },
|
|
48
|
+
respondApproval(requestId: string, approved: boolean) {
|
|
49
|
+
calls.respondApproval.push({ requestId, approved });
|
|
50
|
+
return true;
|
|
51
|
+
},
|
|
52
|
+
interrupt() { calls.interrupt.push(true); },
|
|
53
|
+
stop() { calls.stop.push(true); },
|
|
54
|
+
setApprovalMode(mode: string) { calls.setApprovalMode.push(mode); },
|
|
55
|
+
onMessage(handler: (msg: ServerMessage) => void) { messageHandler = handler; },
|
|
56
|
+
onExit(handler: (code: number | null) => void) { exitHandler = handler; },
|
|
57
|
+
calls,
|
|
58
|
+
_emitMessage(msg: ServerMessage) { messageHandler?.(msg); },
|
|
59
|
+
_emitExit(code: number | null) { exitHandler?.(code); },
|
|
60
|
+
} as any;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function createDeferred<T>() {
|
|
64
|
+
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
65
|
+
let reject!: (reason?: unknown) => void;
|
|
66
|
+
const promise = new Promise<T>((res, rej) => {
|
|
67
|
+
resolve = res;
|
|
68
|
+
reject = rej;
|
|
69
|
+
});
|
|
70
|
+
return { promise, resolve, reject };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function flushAsyncWork(): Promise<void> {
|
|
74
|
+
await Promise.resolve();
|
|
75
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface ActiveSession {
|
|
79
|
+
sessionId: string;
|
|
80
|
+
agent: AgentType;
|
|
81
|
+
cwd: string;
|
|
82
|
+
approvalMode?: string;
|
|
83
|
+
driver: Driver | null;
|
|
84
|
+
clients: Set<WebSocket>;
|
|
85
|
+
title: string;
|
|
86
|
+
createdAt: string;
|
|
87
|
+
lastActivityAt: string;
|
|
88
|
+
active: boolean;
|
|
89
|
+
lastActivityTs: number;
|
|
90
|
+
isResponding: boolean;
|
|
91
|
+
currentReplyText: string;
|
|
92
|
+
acceptedClientMessageIds: string[];
|
|
93
|
+
lastUserMessage?: string;
|
|
94
|
+
syntheticApprovalRetries: Record<string, { message: string; toolName: string }>;
|
|
95
|
+
pendingApproval?: {
|
|
96
|
+
requestId: string;
|
|
97
|
+
toolName: string;
|
|
98
|
+
input: Record<string, unknown>;
|
|
99
|
+
description: string;
|
|
100
|
+
approvalContext?: {
|
|
101
|
+
provider: 'codex';
|
|
102
|
+
kind: 'command-execution' | 'file-change' | 'request-user-input-approval' | 'exec-command-legacy' | 'apply-patch-legacy';
|
|
103
|
+
rpcId: number;
|
|
104
|
+
questionId?: string;
|
|
105
|
+
approveLabel?: string;
|
|
106
|
+
denyLabel?: string;
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
startupInProgress: boolean;
|
|
110
|
+
bufferedPrompts: string[];
|
|
111
|
+
startupToken: number;
|
|
112
|
+
managed?: boolean;
|
|
113
|
+
claudeHookSecret?: string;
|
|
114
|
+
pendingClaudeHookApprovals: Map<string, {
|
|
115
|
+
requestId: string;
|
|
116
|
+
promise: Promise<unknown>;
|
|
117
|
+
resolve: (response: unknown) => void;
|
|
118
|
+
}>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function createManager(pushSender?: (title: string, body: string, data?: Record<string, unknown>) => Promise<void> | void): SessionManager {
|
|
122
|
+
const manager = new SessionManager(pushSender ?? (() => {}));
|
|
123
|
+
// Avoid reading/writing from disk by replacing store internals
|
|
124
|
+
const store = (manager as any).store;
|
|
125
|
+
store.records = [];
|
|
126
|
+
store.deletedSessionIds = new Set<string>();
|
|
127
|
+
store.scheduleSave = () => {}; // no-op to prevent disk writes
|
|
128
|
+
store.saveToDisk = () => {}; // no-op to prevent disk writes
|
|
129
|
+
return manager;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function injectSession(
|
|
133
|
+
manager: SessionManager,
|
|
134
|
+
overrides: Partial<ActiveSession> & { sessionId: string },
|
|
135
|
+
): ActiveSession {
|
|
136
|
+
const session: ActiveSession = {
|
|
137
|
+
agent: 'codex',
|
|
138
|
+
cwd: '/tmp',
|
|
139
|
+
driver: mockDriver(),
|
|
140
|
+
clients: new Set<WebSocket>(),
|
|
141
|
+
title: 'Test session',
|
|
142
|
+
createdAt: '2026-03-20T00:00:00.000Z',
|
|
143
|
+
lastActivityAt: '2026-03-20T00:00:00.000Z',
|
|
144
|
+
active: true,
|
|
145
|
+
lastActivityTs: Date.now(),
|
|
146
|
+
isResponding: false,
|
|
147
|
+
currentReplyText: '',
|
|
148
|
+
acceptedClientMessageIds: [],
|
|
149
|
+
syntheticApprovalRetries: {},
|
|
150
|
+
startupInProgress: false,
|
|
151
|
+
bufferedPrompts: [],
|
|
152
|
+
startupToken: 0,
|
|
153
|
+
pendingClaudeHookApprovals: new Map(),
|
|
154
|
+
...overrides,
|
|
155
|
+
};
|
|
156
|
+
(manager as any).sessions.set(session.sessionId, session);
|
|
157
|
+
return session;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function parseReply(ws: WebSocket & { sent: string[] }, index = 0): any {
|
|
161
|
+
return JSON.parse(ws.sent[index]);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
test.beforeEach(() => {
|
|
165
|
+
__resetExternalInventoryStateForTests();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ── broadcast ─────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
test('broadcast: sends to all open clients', () => {
|
|
171
|
+
const manager = createManager();
|
|
172
|
+
const ws1 = mockWs();
|
|
173
|
+
const ws2 = mockWs();
|
|
174
|
+
injectSession(manager, {
|
|
175
|
+
sessionId: 's1',
|
|
176
|
+
clients: new Set<WebSocket>([ws1, ws2]),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const msg: ServerMessage = { type: 'text.delta', sessionId: 's1', content: 'hello' };
|
|
180
|
+
(manager as any).broadcast('s1', msg);
|
|
181
|
+
|
|
182
|
+
assert.equal(ws1.sent.length, 1);
|
|
183
|
+
assert.equal(ws2.sent.length, 1);
|
|
184
|
+
assert.deepEqual(JSON.parse(ws1.sent[0]), msg);
|
|
185
|
+
assert.deepEqual(JSON.parse(ws2.sent[0]), msg);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('broadcast: skips clients with readyState !== 1', () => {
|
|
189
|
+
const manager = createManager();
|
|
190
|
+
const wsOpen = mockWs(true);
|
|
191
|
+
const wsClosed = mockWs(false);
|
|
192
|
+
injectSession(manager, {
|
|
193
|
+
sessionId: 's1',
|
|
194
|
+
clients: new Set<WebSocket>([wsOpen, wsClosed]),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
(manager as any).broadcast('s1', { type: 'text.delta', sessionId: 's1', content: 'x' });
|
|
198
|
+
|
|
199
|
+
assert.equal(wsOpen.sent.length, 1);
|
|
200
|
+
assert.equal(wsClosed.sent.length, 0);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('broadcast: does nothing when session not found', () => {
|
|
204
|
+
const manager = createManager();
|
|
205
|
+
// Should not throw
|
|
206
|
+
(manager as any).broadcast('nonexistent', { type: 'text.delta', sessionId: 'nonexistent', content: 'x' });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('broadcast: session.done sends push with the accumulated assistant reply preview', () => {
|
|
210
|
+
const pushCalls: Array<{ title: string; body: string; data?: Record<string, unknown> }> = [];
|
|
211
|
+
const manager = createManager((title, body, data) => {
|
|
212
|
+
pushCalls.push({ title, body, data });
|
|
213
|
+
});
|
|
214
|
+
injectSession(manager, {
|
|
215
|
+
sessionId: 's1',
|
|
216
|
+
title: 'Fix login flow',
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
(manager as any).broadcast('s1', { type: 'text.delta', sessionId: 's1', content: 'First line.\n' });
|
|
220
|
+
(manager as any).broadcast('s1', { type: 'text.delta', sessionId: 's1', content: 'Second line.' });
|
|
221
|
+
(manager as any).broadcast('s1', { type: 'session.done', sessionId: 's1' });
|
|
222
|
+
|
|
223
|
+
assert.deepEqual(pushCalls, [{
|
|
224
|
+
title: 'Fix login flow',
|
|
225
|
+
body: 'First line. Second line.',
|
|
226
|
+
data: { sessionId: 's1', agent: 'codex', eventType: 'reply_ready' },
|
|
227
|
+
}]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('broadcast: session.done falls back to a generic push body when the turn has no assistant text', () => {
|
|
231
|
+
const pushCalls: Array<{ title: string; body: string; data?: Record<string, unknown> }> = [];
|
|
232
|
+
const manager = createManager((title, body, data) => {
|
|
233
|
+
pushCalls.push({ title, body, data });
|
|
234
|
+
});
|
|
235
|
+
injectSession(manager, {
|
|
236
|
+
sessionId: 's1',
|
|
237
|
+
title: 'Tool-only session',
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
(manager as any).broadcast('s1', { type: 'session.done', sessionId: 's1' });
|
|
241
|
+
|
|
242
|
+
assert.deepEqual(pushCalls, [{
|
|
243
|
+
title: 'Tool-only session',
|
|
244
|
+
body: 'Done.',
|
|
245
|
+
data: { sessionId: 's1', agent: 'codex', eventType: 'reply_ready' },
|
|
246
|
+
}]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('broadcast: approval.request sends to all globalClients, not just session clients', () => {
|
|
250
|
+
const manager = createManager();
|
|
251
|
+
const sessionClient = mockWs(); // subscribed to session s1
|
|
252
|
+
const globalOnly = mockWs(); // NOT subscribed to s1, but is a global client
|
|
253
|
+
|
|
254
|
+
injectSession(manager, {
|
|
255
|
+
sessionId: 's1',
|
|
256
|
+
clients: new Set<WebSocket>([sessionClient]),
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// Register both as global clients
|
|
260
|
+
manager.addGlobalClient(sessionClient);
|
|
261
|
+
manager.addGlobalClient(globalOnly);
|
|
262
|
+
|
|
263
|
+
// A normal text.delta should only go to session clients
|
|
264
|
+
(manager as any).broadcast('s1', { type: 'text.delta', sessionId: 's1', content: 'x' });
|
|
265
|
+
assert.equal(sessionClient.sent.length, 1);
|
|
266
|
+
assert.equal(globalOnly.sent.length, 0, 'text.delta should not reach non-session clients');
|
|
267
|
+
|
|
268
|
+
// An approval.request should go to ALL global clients
|
|
269
|
+
const approvalMsg: ServerMessage = {
|
|
270
|
+
type: 'approval.request',
|
|
271
|
+
sessionId: 's1',
|
|
272
|
+
requestId: 'req-1',
|
|
273
|
+
toolName: 'Write',
|
|
274
|
+
input: { file_path: '/tmp/test.ts' },
|
|
275
|
+
description: 'Write to /tmp/test.ts',
|
|
276
|
+
};
|
|
277
|
+
(manager as any).broadcast('s1', approvalMsg);
|
|
278
|
+
assert.equal(sessionClient.sent.length, 2, 'session client should receive approval');
|
|
279
|
+
assert.equal(globalOnly.sent.length, 1, 'global-only client should also receive approval');
|
|
280
|
+
assert.deepEqual(JSON.parse(globalOnly.sent[0]), approvalMsg);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('broadcast: approval.request skips closed globalClients', () => {
|
|
284
|
+
const manager = createManager();
|
|
285
|
+
const wsOpen = mockWs(true);
|
|
286
|
+
const wsClosed = mockWs(false);
|
|
287
|
+
|
|
288
|
+
injectSession(manager, { sessionId: 's1' });
|
|
289
|
+
manager.addGlobalClient(wsOpen);
|
|
290
|
+
manager.addGlobalClient(wsClosed);
|
|
291
|
+
|
|
292
|
+
(manager as any).broadcast('s1', {
|
|
293
|
+
type: 'approval.request',
|
|
294
|
+
sessionId: 's1',
|
|
295
|
+
requestId: 'req-1',
|
|
296
|
+
toolName: 'Bash',
|
|
297
|
+
input: {},
|
|
298
|
+
description: 'Run command',
|
|
299
|
+
});
|
|
300
|
+
assert.equal(wsOpen.sent.length, 1);
|
|
301
|
+
assert.equal(wsClosed.sent.length, 0);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('session.delete broadcasts a sessions.changed invalidation hint to global clients', async () => {
|
|
305
|
+
const manager = createManager();
|
|
306
|
+
const ws = mockWs();
|
|
307
|
+
manager.addGlobalClient(ws);
|
|
308
|
+
injectSession(manager, {
|
|
309
|
+
sessionId: 'delete-broadcast',
|
|
310
|
+
driver: mockDriver(),
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
await manager.handle(ws, {
|
|
314
|
+
action: 'session.delete',
|
|
315
|
+
id: 'req-delete-broadcast',
|
|
316
|
+
sessionId: 'delete-broadcast',
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const messages = ws.sent.map((entry) => JSON.parse(entry));
|
|
320
|
+
assert.deepEqual(messages[0], {
|
|
321
|
+
type: 'sessions.changed',
|
|
322
|
+
version: 1,
|
|
323
|
+
reason: 'session_deleted',
|
|
324
|
+
});
|
|
325
|
+
assert.equal(messages[1].type, 'response');
|
|
326
|
+
assert.equal(messages[1].id, 'req-delete-broadcast');
|
|
327
|
+
assert.equal(messages[1].ok, true);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('inventory backfill broadcasts a sessions.changed invalidation hint to global clients', () => {
|
|
331
|
+
const manager = createManager();
|
|
332
|
+
const ws = mockWs();
|
|
333
|
+
manager.addGlobalClient(ws);
|
|
334
|
+
|
|
335
|
+
__emitExternalInventoryBackfillForTests();
|
|
336
|
+
|
|
337
|
+
assert.deepEqual(JSON.parse(ws.sent[0]), {
|
|
338
|
+
type: 'sessions.changed',
|
|
339
|
+
version: 1,
|
|
340
|
+
reason: 'inventory_backfilled',
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// ── removeClient ──────────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
test('removeClient: removes ws from all sessions and globalClients', () => {
|
|
347
|
+
const manager = createManager();
|
|
348
|
+
const ws = mockWs();
|
|
349
|
+
const s1 = injectSession(manager, { sessionId: 's1', clients: new Set<WebSocket>([ws]) });
|
|
350
|
+
const s2 = injectSession(manager, { sessionId: 's2', clients: new Set<WebSocket>([ws]) });
|
|
351
|
+
manager.addGlobalClient(ws);
|
|
352
|
+
|
|
353
|
+
manager.removeClient(ws);
|
|
354
|
+
|
|
355
|
+
assert.equal(s1.clients.size, 0);
|
|
356
|
+
assert.equal(s2.clients.size, 0);
|
|
357
|
+
// Verify globalClients is also cleaned up: broadcast approval should not reach removed client
|
|
358
|
+
injectSession(manager, { sessionId: 's3' });
|
|
359
|
+
(manager as any).broadcast('s3', {
|
|
360
|
+
type: 'approval.request', sessionId: 's3', requestId: 'r', toolName: 'X', input: {}, description: '',
|
|
361
|
+
});
|
|
362
|
+
assert.equal(ws.sent.length, 0, 'removed client should not receive broadcasts');
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test('removeClient: does nothing if ws not in any session', () => {
|
|
366
|
+
const manager = createManager();
|
|
367
|
+
const ws = mockWs();
|
|
368
|
+
injectSession(manager, { sessionId: 's1' });
|
|
369
|
+
|
|
370
|
+
// Should not throw
|
|
371
|
+
manager.removeClient(ws);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// ── shutdown ──────────────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
test('shutdown: stops all drivers and sets sessions inactive', () => {
|
|
377
|
+
const manager = createManager();
|
|
378
|
+
const driver1 = mockDriver();
|
|
379
|
+
const driver2 = mockDriver();
|
|
380
|
+
const ws = mockWs();
|
|
381
|
+
const s1 = injectSession(manager, { sessionId: 's1', driver: driver1, clients: new Set<WebSocket>([ws]) });
|
|
382
|
+
const s2 = injectSession(manager, { sessionId: 's2', driver: driver2, clients: new Set<WebSocket>([ws]) });
|
|
383
|
+
|
|
384
|
+
manager.shutdown();
|
|
385
|
+
|
|
386
|
+
assert.equal(driver1.calls.stop.length, 1);
|
|
387
|
+
assert.equal(driver2.calls.stop.length, 1);
|
|
388
|
+
assert.equal(s1.active, false);
|
|
389
|
+
assert.equal(s2.active, false);
|
|
390
|
+
assert.equal(s1.driver, null);
|
|
391
|
+
assert.equal(s2.driver, null);
|
|
392
|
+
assert.equal(s1.clients.size, 0);
|
|
393
|
+
assert.equal(s2.clients.size, 0);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test('shutdown: handles driver.stop() throwing without crashing', () => {
|
|
397
|
+
const manager = createManager();
|
|
398
|
+
const throwingDriver = mockDriver();
|
|
399
|
+
throwingDriver.stop = () => { throw new Error('boom'); };
|
|
400
|
+
injectSession(manager, { sessionId: 's1', driver: throwingDriver });
|
|
401
|
+
|
|
402
|
+
// Should not throw
|
|
403
|
+
manager.shutdown();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// ── handle: session.approve ───────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
test('session.approve: calls driver.respondApproval and replies ok', async () => {
|
|
409
|
+
const manager = createManager();
|
|
410
|
+
const ws = mockWs();
|
|
411
|
+
const driver = mockDriver();
|
|
412
|
+
injectSession(manager, { sessionId: 's1', driver });
|
|
413
|
+
|
|
414
|
+
await manager.handle(ws, {
|
|
415
|
+
action: 'session.approve',
|
|
416
|
+
id: 'req1',
|
|
417
|
+
sessionId: 's1',
|
|
418
|
+
requestId: 'tool-1',
|
|
419
|
+
approved: true,
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
assert.deepEqual(driver.calls.respondApproval, [{ requestId: 'tool-1', approved: true }]);
|
|
423
|
+
const reply = parseReply(ws);
|
|
424
|
+
assert.equal(reply.ok, true);
|
|
425
|
+
assert.equal(reply.id, 'req1');
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test('session.approve: retries Claude synthetic approval with one-turn acceptEdits even when driver is null', async () => {
|
|
429
|
+
const manager = createManager();
|
|
430
|
+
const ws = mockWs();
|
|
431
|
+
const driver = mockDriver();
|
|
432
|
+
(manager as any).createDriver = () => driver;
|
|
433
|
+
const session = injectSession(manager, {
|
|
434
|
+
sessionId: 's1',
|
|
435
|
+
agent: 'claude',
|
|
436
|
+
driver: null,
|
|
437
|
+
active: false,
|
|
438
|
+
lastUserMessage: 'Please make the requested edit.',
|
|
439
|
+
syntheticApprovalRetries: {
|
|
440
|
+
'claude-permission-denial:toolu_1': { message: 'Please make the requested edit.', toolName: 'Write' },
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
await manager.handle(ws, {
|
|
445
|
+
action: 'session.approve',
|
|
446
|
+
id: 'req1',
|
|
447
|
+
sessionId: 's1',
|
|
448
|
+
requestId: 'claude-permission-denial:toolu_1',
|
|
449
|
+
approved: true,
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
const reply = parseReply(ws);
|
|
453
|
+
assert.equal(reply.ok, true);
|
|
454
|
+
assert.deepEqual(driver.calls.respondApproval, []);
|
|
455
|
+
assert.deepEqual(driver.calls.start, [{
|
|
456
|
+
cwd: '/tmp',
|
|
457
|
+
resumeSessionId: 's1',
|
|
458
|
+
approvalMode: 'acceptEdits',
|
|
459
|
+
}]);
|
|
460
|
+
assert.deepEqual(driver.calls.sendPrompt, ['Please make the requested edit.']);
|
|
461
|
+
assert.equal(session.driver, driver);
|
|
462
|
+
assert.equal(session.active, true);
|
|
463
|
+
assert.equal(session.isResponding, true);
|
|
464
|
+
assert.deepEqual(session.syntheticApprovalRetries, {});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test('session.approve: resolves Claude hook approval without driver.respondApproval', async () => {
|
|
468
|
+
const manager = createManager();
|
|
469
|
+
const ws = mockWs();
|
|
470
|
+
const driver = mockDriver();
|
|
471
|
+
const session = injectSession(manager, {
|
|
472
|
+
sessionId: 'hook-approve-1',
|
|
473
|
+
agent: 'claude',
|
|
474
|
+
driver,
|
|
475
|
+
claudeHookSecret: 'secret-1',
|
|
476
|
+
});
|
|
477
|
+
(manager as any).claudeHookSessions.set('secret-1', session);
|
|
478
|
+
|
|
479
|
+
const hookPromise = manager.handleClaudePermissionHook('secret-1', {
|
|
480
|
+
tool_name: 'Bash',
|
|
481
|
+
tool_input: { command: 'pwd' },
|
|
482
|
+
tool_use_id: 'toolu_1',
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
assert.deepEqual(session.pendingApproval, {
|
|
486
|
+
requestId: 'claude-hook:toolu_1',
|
|
487
|
+
toolName: 'Bash',
|
|
488
|
+
input: { command: 'pwd' },
|
|
489
|
+
description: 'Claude requested permissions to use Bash.',
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
await manager.handle(ws, {
|
|
493
|
+
action: 'session.approve',
|
|
494
|
+
id: 'req-hook-approve',
|
|
495
|
+
sessionId: 'hook-approve-1',
|
|
496
|
+
requestId: 'claude-hook:toolu_1',
|
|
497
|
+
approved: true,
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
const reply = parseReply(ws);
|
|
501
|
+
assert.equal(reply.ok, true);
|
|
502
|
+
assert.deepEqual(driver.calls.respondApproval, []);
|
|
503
|
+
assert.equal(session.pendingApproval, undefined);
|
|
504
|
+
assert.equal(session.isResponding, true);
|
|
505
|
+
assert.deepEqual(await hookPromise, {
|
|
506
|
+
continue: true,
|
|
507
|
+
suppressOutput: true,
|
|
508
|
+
hookSpecificOutput: {
|
|
509
|
+
hookEventName: 'PreToolUse',
|
|
510
|
+
permissionDecision: 'allow',
|
|
511
|
+
},
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test('session.approve: Claude hook approval still resolves after switching to autoApprove during pending approval', async () => {
|
|
516
|
+
const manager = createManager();
|
|
517
|
+
const ws = mockWs();
|
|
518
|
+
const driver = mockDriver();
|
|
519
|
+
const session = injectSession(manager, {
|
|
520
|
+
sessionId: 'hook-approve-yolo-1',
|
|
521
|
+
agent: 'claude',
|
|
522
|
+
approvalMode: 'normal',
|
|
523
|
+
driver,
|
|
524
|
+
claudeHookSecret: 'secret-yolo-1',
|
|
525
|
+
});
|
|
526
|
+
(manager as any).claudeHookSessions.set('secret-yolo-1', session);
|
|
527
|
+
|
|
528
|
+
const hookPromise = manager.handleClaudePermissionHook('secret-yolo-1', {
|
|
529
|
+
tool_name: 'Bash',
|
|
530
|
+
tool_input: { command: 'pwd' },
|
|
531
|
+
tool_use_id: 'toolu_yolo_1',
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
await manager.handle(ws, {
|
|
535
|
+
action: 'session.setApprovalMode',
|
|
536
|
+
id: 'req-hook-mode',
|
|
537
|
+
sessionId: 'hook-approve-yolo-1',
|
|
538
|
+
approvalMode: 'autoApprove',
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
await manager.handle(ws, {
|
|
542
|
+
action: 'session.approve',
|
|
543
|
+
id: 'req-hook-approve-yolo',
|
|
544
|
+
sessionId: 'hook-approve-yolo-1',
|
|
545
|
+
requestId: 'claude-hook:toolu_yolo_1',
|
|
546
|
+
approved: true,
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
const modeReply = parseReply(ws, 0);
|
|
550
|
+
const approveReply = parseReply(ws, 1);
|
|
551
|
+
assert.equal(modeReply.ok, true);
|
|
552
|
+
assert.equal(approveReply.ok, true);
|
|
553
|
+
assert.equal(session.approvalMode, 'autoApprove');
|
|
554
|
+
assert.equal(session.driver, driver);
|
|
555
|
+
assert.equal(driver.calls.stop.length, 0);
|
|
556
|
+
assert.deepEqual(driver.calls.setApprovalMode, ['autoApprove']);
|
|
557
|
+
assert.deepEqual(driver.calls.respondApproval, []);
|
|
558
|
+
assert.equal(session.pendingApproval, undefined);
|
|
559
|
+
assert.equal(session.isResponding, true);
|
|
560
|
+
assert.deepEqual(await hookPromise, {
|
|
561
|
+
continue: true,
|
|
562
|
+
suppressOutput: true,
|
|
563
|
+
hookSpecificOutput: {
|
|
564
|
+
hookEventName: 'PreToolUse',
|
|
565
|
+
permissionDecision: 'allow',
|
|
566
|
+
},
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test('handleClaudePermissionHook auto-approves autoApprove sessions', async () => {
|
|
571
|
+
const manager = createManager();
|
|
572
|
+
const session = injectSession(manager, {
|
|
573
|
+
sessionId: 'hook-auto-1',
|
|
574
|
+
agent: 'claude',
|
|
575
|
+
approvalMode: 'autoApprove',
|
|
576
|
+
claudeHookSecret: 'secret-auto',
|
|
577
|
+
});
|
|
578
|
+
(manager as any).claudeHookSessions.set('secret-auto', session);
|
|
579
|
+
|
|
580
|
+
const response = await manager.handleClaudePermissionHook('secret-auto', {
|
|
581
|
+
tool_name: 'Write',
|
|
582
|
+
tool_input: { file_path: '/tmp/a.txt' },
|
|
583
|
+
tool_use_id: 'toolu_auto',
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
assert.deepEqual(response, {
|
|
587
|
+
continue: true,
|
|
588
|
+
suppressOutput: true,
|
|
589
|
+
hookSpecificOutput: {
|
|
590
|
+
hookEventName: 'PreToolUse',
|
|
591
|
+
permissionDecision: 'allow',
|
|
592
|
+
},
|
|
593
|
+
});
|
|
594
|
+
assert.equal(session.pendingApproval, undefined);
|
|
595
|
+
assert.equal(session.pendingClaudeHookApprovals.size, 0);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test('session.approve: returns error if session not found', async () => {
|
|
599
|
+
const manager = createManager();
|
|
600
|
+
const ws = mockWs();
|
|
601
|
+
|
|
602
|
+
await manager.handle(ws, {
|
|
603
|
+
action: 'session.approve',
|
|
604
|
+
id: 'req1',
|
|
605
|
+
sessionId: 'nonexistent',
|
|
606
|
+
requestId: 'tool-1',
|
|
607
|
+
approved: true,
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
const reply = parseReply(ws);
|
|
611
|
+
assert.equal(reply.ok, false);
|
|
612
|
+
assert.equal(reply.error, 'Session not found or inactive');
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test('session.approve: returns error if driver is null', async () => {
|
|
616
|
+
const manager = createManager();
|
|
617
|
+
const ws = mockWs();
|
|
618
|
+
injectSession(manager, { sessionId: 's1', driver: null });
|
|
619
|
+
|
|
620
|
+
await manager.handle(ws, {
|
|
621
|
+
action: 'session.approve',
|
|
622
|
+
id: 'req1',
|
|
623
|
+
sessionId: 's1',
|
|
624
|
+
requestId: 'tool-1',
|
|
625
|
+
approved: false,
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
const reply = parseReply(ws);
|
|
629
|
+
assert.equal(reply.ok, false);
|
|
630
|
+
assert.equal(reply.error, 'Session not found or inactive');
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test('session.approve: returns error when respondApproval fails (stdin not writable)', async () => {
|
|
634
|
+
const manager = createManager();
|
|
635
|
+
const ws = mockWs();
|
|
636
|
+
const driver = mockDriver();
|
|
637
|
+
// Override respondApproval to return false (simulates stdin not writable)
|
|
638
|
+
driver.respondApproval = (requestId: string, approved: boolean) => {
|
|
639
|
+
driver.calls.respondApproval.push({ requestId, approved });
|
|
640
|
+
return false;
|
|
641
|
+
};
|
|
642
|
+
const session = injectSession(manager, {
|
|
643
|
+
sessionId: 's1',
|
|
644
|
+
driver,
|
|
645
|
+
pendingApproval: {
|
|
646
|
+
requestId: 'tool-1',
|
|
647
|
+
toolName: 'Bash',
|
|
648
|
+
input: {},
|
|
649
|
+
description: 'Need approval',
|
|
650
|
+
},
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
await manager.handle(ws, {
|
|
654
|
+
action: 'session.approve',
|
|
655
|
+
id: 'req1',
|
|
656
|
+
sessionId: 's1',
|
|
657
|
+
requestId: 'tool-1',
|
|
658
|
+
approved: true,
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
const reply = parseReply(ws);
|
|
662
|
+
assert.equal(reply.ok, false);
|
|
663
|
+
assert.match(reply.error, /not running/i);
|
|
664
|
+
assert.deepEqual(session.pendingApproval, {
|
|
665
|
+
requestId: 'tool-1',
|
|
666
|
+
toolName: 'Bash',
|
|
667
|
+
input: {},
|
|
668
|
+
description: 'Need approval',
|
|
669
|
+
});
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// ── handle: session.interrupt ─────────────────────────────────────────
|
|
673
|
+
|
|
674
|
+
test('session.interrupt: calls driver.interrupt and replies ok', async () => {
|
|
675
|
+
const manager = createManager();
|
|
676
|
+
const ws = mockWs();
|
|
677
|
+
const driver = mockDriver();
|
|
678
|
+
injectSession(manager, { sessionId: 's1', driver });
|
|
679
|
+
|
|
680
|
+
await manager.handle(ws, {
|
|
681
|
+
action: 'session.interrupt',
|
|
682
|
+
id: 'req1',
|
|
683
|
+
sessionId: 's1',
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
assert.equal(driver.calls.interrupt.length, 1);
|
|
687
|
+
const reply = parseReply(ws);
|
|
688
|
+
assert.equal(reply.ok, true);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
test('session.interrupt: returns error if session not found', async () => {
|
|
692
|
+
const manager = createManager();
|
|
693
|
+
const ws = mockWs();
|
|
694
|
+
|
|
695
|
+
await manager.handle(ws, {
|
|
696
|
+
action: 'session.interrupt',
|
|
697
|
+
id: 'req1',
|
|
698
|
+
sessionId: 'nonexistent',
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
const reply = parseReply(ws);
|
|
702
|
+
assert.equal(reply.ok, false);
|
|
703
|
+
assert.equal(reply.error, 'Session not found or inactive');
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
// ── handle: session.stop ──────────────────────────────────────────────
|
|
707
|
+
|
|
708
|
+
test('session.stop: stops driver, sets active=false and driver=null', async () => {
|
|
709
|
+
const manager = createManager();
|
|
710
|
+
const ws = mockWs();
|
|
711
|
+
const driver = mockDriver();
|
|
712
|
+
const session = injectSession(manager, { sessionId: 's1', driver });
|
|
713
|
+
|
|
714
|
+
await manager.handle(ws, {
|
|
715
|
+
action: 'session.stop',
|
|
716
|
+
id: 'req1',
|
|
717
|
+
sessionId: 's1',
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
assert.equal(driver.calls.stop.length, 1);
|
|
721
|
+
assert.equal(session.active, false);
|
|
722
|
+
assert.equal(session.driver, null);
|
|
723
|
+
const reply = parseReply(ws);
|
|
724
|
+
assert.equal(reply.ok, true);
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
test('session.stop: returns error if session not found', async () => {
|
|
728
|
+
const manager = createManager();
|
|
729
|
+
const ws = mockWs();
|
|
730
|
+
|
|
731
|
+
await manager.handle(ws, {
|
|
732
|
+
action: 'session.stop',
|
|
733
|
+
id: 'req1',
|
|
734
|
+
sessionId: 'nonexistent',
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
const reply = parseReply(ws);
|
|
738
|
+
assert.equal(reply.ok, false);
|
|
739
|
+
assert.equal(reply.error, 'Session not found');
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
test('session.stop: works when driver is already null', async () => {
|
|
743
|
+
const manager = createManager();
|
|
744
|
+
const ws = mockWs();
|
|
745
|
+
const session = injectSession(manager, { sessionId: 's1', driver: null });
|
|
746
|
+
|
|
747
|
+
await manager.handle(ws, {
|
|
748
|
+
action: 'session.stop',
|
|
749
|
+
id: 'req1',
|
|
750
|
+
sessionId: 's1',
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
assert.equal(session.active, false);
|
|
754
|
+
const reply = parseReply(ws);
|
|
755
|
+
assert.equal(reply.ok, true);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
// ── handle: session.delete ────────────────────────────────────────────
|
|
759
|
+
|
|
760
|
+
test('session.delete: stops driver and removes session from map', async () => {
|
|
761
|
+
const manager = createManager();
|
|
762
|
+
const ws = mockWs();
|
|
763
|
+
const driver = mockDriver();
|
|
764
|
+
injectSession(manager, { sessionId: 's1', driver });
|
|
765
|
+
|
|
766
|
+
await manager.handle(ws, {
|
|
767
|
+
action: 'session.delete',
|
|
768
|
+
id: 'req1',
|
|
769
|
+
sessionId: 's1',
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
assert.equal(driver.calls.stop.length, 1);
|
|
773
|
+
assert.equal((manager as any).sessions.has('s1'), false);
|
|
774
|
+
const reply = parseReply(ws);
|
|
775
|
+
assert.equal(reply.ok, true);
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
test('session.delete: replies ok even if session does not exist', async () => {
|
|
779
|
+
const manager = createManager();
|
|
780
|
+
const ws = mockWs();
|
|
781
|
+
|
|
782
|
+
await manager.handle(ws, {
|
|
783
|
+
action: 'session.delete',
|
|
784
|
+
id: 'req1',
|
|
785
|
+
sessionId: 'nonexistent',
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
const reply = parseReply(ws);
|
|
789
|
+
assert.equal(reply.ok, true);
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
test('session.delete: removes record from sessionRecords', async () => {
|
|
793
|
+
const manager = createManager();
|
|
794
|
+
const ws = mockWs();
|
|
795
|
+
(manager as any).store.records = [
|
|
796
|
+
{ sessionId: 's1', agent: 'codex', cwd: '/tmp', title: 'T', createdAt: '', lastActivityAt: '' },
|
|
797
|
+
{ sessionId: 's2', agent: 'codex', cwd: '/tmp', title: 'T', createdAt: '', lastActivityAt: '' },
|
|
798
|
+
];
|
|
799
|
+
injectSession(manager, { sessionId: 's1' });
|
|
800
|
+
|
|
801
|
+
await manager.handle(ws, {
|
|
802
|
+
action: 'session.delete',
|
|
803
|
+
id: 'req1',
|
|
804
|
+
sessionId: 's1',
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
const records = (manager as any).store.records;
|
|
808
|
+
assert.equal(records.length, 1);
|
|
809
|
+
assert.equal(records[0].sessionId, 's2');
|
|
810
|
+
assert.equal((manager as any).store.isDeleted('s1'), true);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
// ── sendHistory ───────────────────────────────────────────────────────
|
|
814
|
+
|
|
815
|
+
test('sendHistory: adds ws to session.clients for reconnection', async () => {
|
|
816
|
+
const manager = createManager();
|
|
817
|
+
const ws = mockWs();
|
|
818
|
+
const session = injectSession(manager, { sessionId: 's1' });
|
|
819
|
+
|
|
820
|
+
assert.equal(session.clients.has(ws), false);
|
|
821
|
+
|
|
822
|
+
await manager.handle(ws, {
|
|
823
|
+
action: 'session.history',
|
|
824
|
+
id: 'req1',
|
|
825
|
+
sessionId: 's1',
|
|
826
|
+
agent: 'codex',
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
assert.equal(session.clients.has(ws), true);
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
test('sendHistory: includes the current partial reply for active responding sessions', async () => {
|
|
833
|
+
const manager = createManager();
|
|
834
|
+
const ws = mockWs();
|
|
835
|
+
injectSession(manager, {
|
|
836
|
+
sessionId: 's1',
|
|
837
|
+
isResponding: true,
|
|
838
|
+
currentReplyText: 'still streaming',
|
|
839
|
+
acceptedClientMessageIds: ['cm-streaming'],
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
await manager.handle(ws, {
|
|
843
|
+
action: 'session.history',
|
|
844
|
+
id: 'req-partial',
|
|
845
|
+
sessionId: 's1',
|
|
846
|
+
agent: 'codex',
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
const historyMsg = JSON.parse(ws.sent[0]);
|
|
850
|
+
assert.equal(historyMsg.type, 'session.history');
|
|
851
|
+
assert.equal(historyMsg.partialReplyText, 'still streaming');
|
|
852
|
+
assert.deepEqual(historyMsg.acceptedClientMessageIds, ['cm-streaming']);
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
test('sendHistory: infers responding state for external Claude sessions from transcript history', async () => {
|
|
856
|
+
const manager = createManager();
|
|
857
|
+
const ws = mockWs();
|
|
858
|
+
const tempHome = await mkdtemp(join(tmpdir(), 'vibelet-manager-home-'));
|
|
859
|
+
const projectDir = join(tempHome, '.claude', 'projects', '-test');
|
|
860
|
+
await mkdir(projectDir, { recursive: true });
|
|
861
|
+
await writeFile(join(projectDir, 'external-claude.jsonl'), [
|
|
862
|
+
JSON.stringify({ type: 'user', cwd: '/test', sessionId: 'external-claude', message: { role: 'user', content: 'Check types' } }),
|
|
863
|
+
JSON.stringify({
|
|
864
|
+
type: 'assistant',
|
|
865
|
+
message: {
|
|
866
|
+
role: 'assistant',
|
|
867
|
+
stop_reason: null,
|
|
868
|
+
content: [{ type: 'text', text: 'Typecheck is still running' }],
|
|
869
|
+
},
|
|
870
|
+
}),
|
|
871
|
+
].join('\n') + '\n', 'utf-8');
|
|
872
|
+
|
|
873
|
+
const originalHome = process.env.HOME;
|
|
874
|
+
process.env.HOME = tempHome;
|
|
875
|
+
try {
|
|
876
|
+
(manager as any).store.upsert({
|
|
877
|
+
sessionId: 'external-claude',
|
|
878
|
+
agent: 'claude',
|
|
879
|
+
cwd: '/test',
|
|
880
|
+
title: 'External Claude',
|
|
881
|
+
createdAt: '2026-03-20T00:00:00.000Z',
|
|
882
|
+
lastActivityAt: '2026-03-20T00:00:00.000Z',
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
await manager.handle(ws, {
|
|
886
|
+
action: 'session.history',
|
|
887
|
+
id: 'req-external-history',
|
|
888
|
+
sessionId: 'external-claude',
|
|
889
|
+
agent: 'claude',
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
const historyMsg = JSON.parse(ws.sent[0]);
|
|
893
|
+
assert.equal(historyMsg.type, 'session.history');
|
|
894
|
+
assert.equal(historyMsg.isResponding, true);
|
|
895
|
+
assert.equal(historyMsg.partialReplyText, 'Typecheck is still running');
|
|
896
|
+
} finally {
|
|
897
|
+
process.env.HOME = originalHome;
|
|
898
|
+
await rm(tempHome, { recursive: true, force: true });
|
|
899
|
+
}
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
test('reconnect.snapshot: returns active session state for the current session', async () => {
|
|
903
|
+
const manager = createManager();
|
|
904
|
+
const ws = mockWs();
|
|
905
|
+
injectSession(manager, {
|
|
906
|
+
sessionId: 'snap-1',
|
|
907
|
+
agent: 'codex',
|
|
908
|
+
approvalMode: 'autoApprove',
|
|
909
|
+
isResponding: true,
|
|
910
|
+
currentReplyText: 'half reply',
|
|
911
|
+
acceptedClientMessageIds: ['cm-snap-1'],
|
|
912
|
+
pendingApproval: {
|
|
913
|
+
requestId: 'approval-1',
|
|
914
|
+
toolName: 'Bash',
|
|
915
|
+
input: { command: 'pwd' },
|
|
916
|
+
description: 'Run pwd',
|
|
917
|
+
},
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
await manager.handle(ws, {
|
|
921
|
+
action: 'reconnect.snapshot',
|
|
922
|
+
id: 'snap-req',
|
|
923
|
+
activeSessionId: 'snap-1',
|
|
924
|
+
activeAgent: 'codex',
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
const reply = parseReply(ws);
|
|
928
|
+
assert.equal(reply.ok, true);
|
|
929
|
+
assert.ok(reply.data.snapshot);
|
|
930
|
+
assert.equal(reply.data.snapshot.activeSession.sessionId, 'snap-1');
|
|
931
|
+
assert.equal(reply.data.snapshot.activeSession.agent, 'codex');
|
|
932
|
+
assert.equal(reply.data.snapshot.activeSession.approvalMode, 'autoApprove');
|
|
933
|
+
assert.equal(reply.data.snapshot.activeSession.isResponding, true);
|
|
934
|
+
assert.equal(reply.data.snapshot.activeSession.partialReplyText, 'half reply');
|
|
935
|
+
assert.deepEqual(reply.data.snapshot.activeSession.acceptedClientMessageIds, ['cm-snap-1']);
|
|
936
|
+
assert.equal(reply.data.snapshot.activeSession.pendingApproval.requestId, 'approval-1');
|
|
937
|
+
assert.equal(reply.data.snapshot.pendingApprovals[0].requestId, 'approval-1');
|
|
938
|
+
assert.equal(reply.data.snapshot.inventoryVersion, 0);
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
test('reconnect.snapshot: restores external Claude running state from transcript history', async () => {
|
|
942
|
+
const manager = createManager();
|
|
943
|
+
const ws = mockWs();
|
|
944
|
+
const tempHome = await mkdtemp(join(tmpdir(), 'vibelet-manager-home-'));
|
|
945
|
+
const projectDir = join(tempHome, '.claude', 'projects', '-test');
|
|
946
|
+
await mkdir(projectDir, { recursive: true });
|
|
947
|
+
await writeFile(join(projectDir, 'snapshot-claude.jsonl'), [
|
|
948
|
+
JSON.stringify({ type: 'user', cwd: '/test', sessionId: 'snapshot-claude', message: { role: 'user', content: 'Inspect upload flow' } }),
|
|
949
|
+
JSON.stringify({
|
|
950
|
+
type: 'assistant',
|
|
951
|
+
message: {
|
|
952
|
+
role: 'assistant',
|
|
953
|
+
stop_reason: null,
|
|
954
|
+
content: [{ type: 'text', text: 'Still inspecting upload flow' }],
|
|
955
|
+
},
|
|
956
|
+
}),
|
|
957
|
+
].join('\n') + '\n', 'utf-8');
|
|
958
|
+
|
|
959
|
+
const originalHome = process.env.HOME;
|
|
960
|
+
process.env.HOME = tempHome;
|
|
961
|
+
try {
|
|
962
|
+
(manager as any).store.upsert({
|
|
963
|
+
sessionId: 'snapshot-claude',
|
|
964
|
+
agent: 'claude',
|
|
965
|
+
cwd: '/test',
|
|
966
|
+
title: 'Snapshot Claude',
|
|
967
|
+
createdAt: '2026-03-20T00:00:00.000Z',
|
|
968
|
+
lastActivityAt: '2026-03-20T00:00:00.000Z',
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
await manager.handle(ws, {
|
|
972
|
+
action: 'reconnect.snapshot',
|
|
973
|
+
id: 'snap-external',
|
|
974
|
+
activeSessionId: 'snapshot-claude',
|
|
975
|
+
activeAgent: 'claude',
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
const reply = parseReply(ws);
|
|
979
|
+
assert.equal(reply.ok, true);
|
|
980
|
+
assert.equal(reply.data.snapshot.activeSession.sessionId, 'snapshot-claude');
|
|
981
|
+
assert.equal(reply.data.snapshot.activeSession.isResponding, true);
|
|
982
|
+
assert.equal(reply.data.snapshot.activeSession.partialReplyText, 'Still inspecting upload flow');
|
|
983
|
+
} finally {
|
|
984
|
+
process.env.HOME = originalHome;
|
|
985
|
+
await rm(tempHome, { recursive: true, force: true });
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
test('reconnect.snapshot: returns pending approvals for non-active and persisted sessions', async () => {
|
|
990
|
+
const manager = createManager();
|
|
991
|
+
const ws = mockWs();
|
|
992
|
+
injectSession(manager, {
|
|
993
|
+
sessionId: 'live-approval',
|
|
994
|
+
agent: 'claude',
|
|
995
|
+
pendingApproval: {
|
|
996
|
+
requestId: 'req-live',
|
|
997
|
+
toolName: 'Bash',
|
|
998
|
+
input: { command: 'pwd' },
|
|
999
|
+
description: 'Run pwd',
|
|
1000
|
+
},
|
|
1001
|
+
});
|
|
1002
|
+
(manager as any).store.upsert({
|
|
1003
|
+
sessionId: 'stored-approval',
|
|
1004
|
+
agent: 'codex',
|
|
1005
|
+
cwd: '/tmp',
|
|
1006
|
+
title: 'Stored session',
|
|
1007
|
+
createdAt: '2026-03-20T00:00:00.000Z',
|
|
1008
|
+
lastActivityAt: '2026-03-20T00:00:00.000Z',
|
|
1009
|
+
pendingApproval: {
|
|
1010
|
+
requestId: 'req-stored',
|
|
1011
|
+
toolName: 'Bash',
|
|
1012
|
+
input: { command: 'ls' },
|
|
1013
|
+
description: 'Run ls',
|
|
1014
|
+
approvalContext: {
|
|
1015
|
+
provider: 'codex',
|
|
1016
|
+
kind: 'command-execution',
|
|
1017
|
+
rpcId: 77,
|
|
1018
|
+
},
|
|
1019
|
+
},
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
await manager.handle(ws, {
|
|
1023
|
+
action: 'reconnect.snapshot',
|
|
1024
|
+
id: 'snap-all',
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
const reply = parseReply(ws);
|
|
1028
|
+
assert.equal(reply.ok, true);
|
|
1029
|
+
const pendingApprovals = reply.data.snapshot.pendingApprovals;
|
|
1030
|
+
assert.ok(Array.isArray(pendingApprovals));
|
|
1031
|
+
assert.ok(pendingApprovals.some((approval: any) => approval.requestId === 'req-live' && approval.sessionId === 'live-approval'));
|
|
1032
|
+
assert.ok(pendingApprovals.some((approval: any) => approval.requestId === 'req-stored' && approval.sessionId === 'stored-approval'));
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
test('reconnect.snapshot: omits deleted activeSessionId', async () => {
|
|
1036
|
+
const manager = createManager();
|
|
1037
|
+
const ws = mockWs();
|
|
1038
|
+
(manager as any).store.remove('deleted-active');
|
|
1039
|
+
|
|
1040
|
+
await manager.handle(ws, {
|
|
1041
|
+
action: 'reconnect.snapshot',
|
|
1042
|
+
id: 'snap-deleted',
|
|
1043
|
+
activeSessionId: 'deleted-active',
|
|
1044
|
+
activeAgent: 'codex',
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
const reply = parseReply(ws);
|
|
1048
|
+
assert.equal(reply.ok, true);
|
|
1049
|
+
assert.ok(reply.data.snapshot);
|
|
1050
|
+
assert.equal(reply.data.snapshot.activeSession, undefined);
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
test('reconnect.snapshot: subscribes ws to active session broadcasts', async () => {
|
|
1054
|
+
const manager = createManager();
|
|
1055
|
+
const ws = mockWs();
|
|
1056
|
+
const session = injectSession(manager, {
|
|
1057
|
+
sessionId: 'snap-sub',
|
|
1058
|
+
agent: 'claude',
|
|
1059
|
+
isResponding: true,
|
|
1060
|
+
currentReplyText: 'streaming...',
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
// ws should NOT be in clients before snapshot
|
|
1064
|
+
assert.equal(session.clients.has(ws), false);
|
|
1065
|
+
|
|
1066
|
+
await manager.handle(ws, {
|
|
1067
|
+
action: 'reconnect.snapshot',
|
|
1068
|
+
id: 'snap-sub-req',
|
|
1069
|
+
activeSessionId: 'snap-sub',
|
|
1070
|
+
activeAgent: 'claude',
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
// After snapshot, ws should be subscribed to the session's broadcast list
|
|
1074
|
+
assert.equal(session.clients.has(ws), true);
|
|
1075
|
+
|
|
1076
|
+
// Verify broadcasts now reach the reconnected ws
|
|
1077
|
+
ws.sent.length = 0; // clear snapshot reply
|
|
1078
|
+
const delta: ServerMessage = { type: 'text.delta', sessionId: 'snap-sub', content: 'more text' };
|
|
1079
|
+
(manager as any).broadcast('snap-sub', delta);
|
|
1080
|
+
assert.equal(ws.sent.length, 1);
|
|
1081
|
+
assert.deepEqual(JSON.parse(ws.sent[0]), delta);
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
test('sendHistory: works when session does not exist in sessions map', async () => {
|
|
1085
|
+
const manager = createManager();
|
|
1086
|
+
const ws = mockWs();
|
|
1087
|
+
|
|
1088
|
+
// Should not throw; will just send empty history + response
|
|
1089
|
+
await manager.handle(ws, {
|
|
1090
|
+
action: 'session.history',
|
|
1091
|
+
id: 'req1',
|
|
1092
|
+
sessionId: 'nonexistent',
|
|
1093
|
+
agent: 'codex',
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
// Should have sent something (history msg + reply)
|
|
1097
|
+
assert.ok(ws.sent.length >= 1);
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
test('sendHistory: rejects deleted sessions that are no longer active', async () => {
|
|
1101
|
+
const manager = createManager();
|
|
1102
|
+
const ws = mockWs();
|
|
1103
|
+
(manager as any).store.remove('deleted-history');
|
|
1104
|
+
|
|
1105
|
+
await manager.handle(ws, {
|
|
1106
|
+
action: 'session.history',
|
|
1107
|
+
id: 'req-deleted-history',
|
|
1108
|
+
sessionId: 'deleted-history',
|
|
1109
|
+
agent: 'codex',
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
const reply = parseReply(ws);
|
|
1113
|
+
assert.equal(reply.ok, false);
|
|
1114
|
+
assert.equal(reply.error, 'Session not found');
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
// ── activeSessionSnapshots ────────────────────────────────────────────
|
|
1118
|
+
|
|
1119
|
+
test('activeSessionSnapshots: excludes pending_ sessions', () => {
|
|
1120
|
+
const manager = createManager();
|
|
1121
|
+
injectSession(manager, { sessionId: 'pending_abc', title: 'Pending' });
|
|
1122
|
+
injectSession(manager, { sessionId: 'real-session', title: 'Real' });
|
|
1123
|
+
|
|
1124
|
+
const snapshots = (manager as any).activeSessionSnapshots();
|
|
1125
|
+
|
|
1126
|
+
assert.equal(snapshots.length, 1);
|
|
1127
|
+
assert.equal(snapshots[0].sessionId, 'real-session');
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
test('activeSessionSnapshots: returns correct snapshot fields', () => {
|
|
1131
|
+
const manager = createManager();
|
|
1132
|
+
injectSession(manager, {
|
|
1133
|
+
sessionId: 'sess-1',
|
|
1134
|
+
agent: 'claude',
|
|
1135
|
+
cwd: '/home/user/project',
|
|
1136
|
+
title: 'My session',
|
|
1137
|
+
createdAt: '2026-03-20T01:00:00.000Z',
|
|
1138
|
+
lastActivityAt: '2026-03-20T02:00:00.000Z',
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
const snapshots = (manager as any).activeSessionSnapshots();
|
|
1142
|
+
|
|
1143
|
+
assert.equal(snapshots.length, 1);
|
|
1144
|
+
assert.deepEqual(snapshots[0], {
|
|
1145
|
+
sessionId: 'sess-1',
|
|
1146
|
+
agent: 'claude',
|
|
1147
|
+
cwd: '/home/user/project',
|
|
1148
|
+
approvalMode: undefined,
|
|
1149
|
+
title: 'My session',
|
|
1150
|
+
createdAt: '2026-03-20T01:00:00.000Z',
|
|
1151
|
+
lastActivityAt: '2026-03-20T02:00:00.000Z',
|
|
1152
|
+
managed: undefined,
|
|
1153
|
+
});
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
// ── reply ─────────────────────────────────────────────────────────────
|
|
1157
|
+
|
|
1158
|
+
test('reply: does not send if ws is closed', () => {
|
|
1159
|
+
const manager = createManager();
|
|
1160
|
+
const ws = mockWs(false); // readyState = 3
|
|
1161
|
+
|
|
1162
|
+
(manager as any).reply(ws, 'req1', true, { some: 'data' });
|
|
1163
|
+
|
|
1164
|
+
assert.equal(ws.sent.length, 0);
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
test('reply: sends correct response structure', () => {
|
|
1168
|
+
const manager = createManager();
|
|
1169
|
+
const ws = mockWs();
|
|
1170
|
+
|
|
1171
|
+
(manager as any).reply(ws, 'req1', false, undefined, 'oops');
|
|
1172
|
+
|
|
1173
|
+
const msg = parseReply(ws);
|
|
1174
|
+
assert.equal(msg.type, 'response');
|
|
1175
|
+
assert.equal(msg.id, 'req1');
|
|
1176
|
+
assert.equal(msg.ok, false);
|
|
1177
|
+
assert.equal(msg.error, 'oops');
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
// ── bindDriverLifecycle ───────────────────────────────────────────────
|
|
1181
|
+
|
|
1182
|
+
test('bindDriverLifecycle: onExit sets session inactive and driver null', () => {
|
|
1183
|
+
const manager = createManager();
|
|
1184
|
+
const driver = mockDriver();
|
|
1185
|
+
const session = injectSession(manager, { sessionId: 's1', driver });
|
|
1186
|
+
|
|
1187
|
+
(manager as any).bindDriverLifecycle(session, 'codex', '');
|
|
1188
|
+
|
|
1189
|
+
driver._emitExit(0);
|
|
1190
|
+
|
|
1191
|
+
assert.equal(session.active, false);
|
|
1192
|
+
assert.equal(session.driver, null);
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
test('bindDriverLifecycle: onMessage broadcasts to clients', () => {
|
|
1196
|
+
const manager = createManager();
|
|
1197
|
+
const driver = mockDriver();
|
|
1198
|
+
const ws = mockWs();
|
|
1199
|
+
const session = injectSession(manager, {
|
|
1200
|
+
sessionId: 's1',
|
|
1201
|
+
driver,
|
|
1202
|
+
clients: new Set<WebSocket>([ws]),
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
(manager as any).bindDriverLifecycle(session, 'codex', '');
|
|
1206
|
+
|
|
1207
|
+
const msg: ServerMessage = { type: 'text.delta', sessionId: 's1', content: 'hi' };
|
|
1208
|
+
driver._emitMessage(msg);
|
|
1209
|
+
|
|
1210
|
+
assert.equal(ws.sent.length, 1);
|
|
1211
|
+
assert.deepEqual(JSON.parse(ws.sent[0]), msg);
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
test('bindDriverLifecycle: remaps session ID when driver reports a new one', () => {
|
|
1215
|
+
const manager = createManager();
|
|
1216
|
+
const driver = mockDriver();
|
|
1217
|
+
const ws = mockWs();
|
|
1218
|
+
const session = injectSession(manager, {
|
|
1219
|
+
sessionId: 'old-id',
|
|
1220
|
+
driver,
|
|
1221
|
+
clients: new Set<WebSocket>([ws]),
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
(manager as any).bindDriverLifecycle(session, 'codex', '', ws);
|
|
1225
|
+
|
|
1226
|
+
// Driver reports a message with a different sessionId
|
|
1227
|
+
driver._emitMessage({ type: 'session.done', sessionId: 'new-id' } as ServerMessage);
|
|
1228
|
+
|
|
1229
|
+
// Session should be remapped
|
|
1230
|
+
assert.equal((manager as any).sessions.has('old-id'), false);
|
|
1231
|
+
assert.equal((manager as any).sessions.has('new-id'), true);
|
|
1232
|
+
assert.equal(session.sessionId, 'new-id');
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
test('bindDriverLifecycle: does not remap if new sessionId already exists', () => {
|
|
1236
|
+
const manager = createManager();
|
|
1237
|
+
const driver = mockDriver();
|
|
1238
|
+
injectSession(manager, { sessionId: 'existing-id' });
|
|
1239
|
+
const session = injectSession(manager, { sessionId: 'current-id', driver });
|
|
1240
|
+
|
|
1241
|
+
(manager as any).bindDriverLifecycle(session, 'codex', '');
|
|
1242
|
+
|
|
1243
|
+
// Driver reports sessionId that already exists as another session
|
|
1244
|
+
driver._emitMessage({ type: 'session.done', sessionId: 'existing-id' } as ServerMessage);
|
|
1245
|
+
|
|
1246
|
+
// Should NOT remap because existing-id already exists
|
|
1247
|
+
assert.equal((manager as any).sessions.has('current-id'), true);
|
|
1248
|
+
assert.equal(session.sessionId, 'current-id');
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
test('bindDriverLifecycle: does not remap for response type messages', () => {
|
|
1252
|
+
const manager = createManager();
|
|
1253
|
+
const driver = mockDriver();
|
|
1254
|
+
const session = injectSession(manager, { sessionId: 'my-id', driver });
|
|
1255
|
+
|
|
1256
|
+
(manager as any).bindDriverLifecycle(session, 'codex', '');
|
|
1257
|
+
|
|
1258
|
+
// response messages should not trigger remapping
|
|
1259
|
+
driver._emitMessage({ type: 'response', id: 'r1', ok: true, sessionId: 'different-id' } as unknown as ServerMessage);
|
|
1260
|
+
|
|
1261
|
+
assert.equal(session.sessionId, 'my-id');
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
// ── sendMessage (direct, active session) ─────────────────────────────
|
|
1265
|
+
|
|
1266
|
+
test('sendMessage: sends prompt to active session driver', async () => {
|
|
1267
|
+
const manager = createManager();
|
|
1268
|
+
const ws = mockWs();
|
|
1269
|
+
const driver = mockDriver();
|
|
1270
|
+
injectSession(manager, { sessionId: 's1', driver });
|
|
1271
|
+
|
|
1272
|
+
await manager.handle(ws, {
|
|
1273
|
+
action: 'session.send',
|
|
1274
|
+
id: 'req1',
|
|
1275
|
+
sessionId: 's1',
|
|
1276
|
+
message: 'hello',
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
assert.deepEqual(driver.calls.sendPrompt, ['hello']);
|
|
1280
|
+
const reply = parseReply(ws);
|
|
1281
|
+
assert.equal(reply.ok, true);
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
test('sendMessage: appends attached image paths to the prompt', async () => {
|
|
1285
|
+
const manager = createManager();
|
|
1286
|
+
const ws = mockWs();
|
|
1287
|
+
const driver = mockDriver();
|
|
1288
|
+
injectSession(manager, { sessionId: 's-images', driver });
|
|
1289
|
+
|
|
1290
|
+
await manager.handle(ws, {
|
|
1291
|
+
action: 'session.send',
|
|
1292
|
+
id: 'req-images',
|
|
1293
|
+
sessionId: 's-images',
|
|
1294
|
+
message: 'Please review these screenshots',
|
|
1295
|
+
images: ['/tmp/one.heic', '/tmp/two.png'],
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
assert.deepEqual(driver.calls.sendPrompt, [
|
|
1299
|
+
'Please review these screenshots\n\n[Attached image: /tmp/one.heic]\n[Attached image: /tmp/two.png]',
|
|
1300
|
+
]);
|
|
1301
|
+
const reply = parseReply(ws);
|
|
1302
|
+
assert.equal(reply.ok, true);
|
|
1303
|
+
});
|
|
1304
|
+
|
|
1305
|
+
test('sendMessage: buffers follow-up prompts while the session is already responding', async () => {
|
|
1306
|
+
const manager = createManager();
|
|
1307
|
+
const ws = mockWs();
|
|
1308
|
+
const driver = mockDriver();
|
|
1309
|
+
const session = injectSession(manager, {
|
|
1310
|
+
sessionId: 's-buffered',
|
|
1311
|
+
driver,
|
|
1312
|
+
isResponding: true,
|
|
1313
|
+
currentReplyText: 'current reply',
|
|
1314
|
+
lastUserMessage: 'current prompt',
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
(manager as any).bindDriverLifecycle(session, 'codex', '');
|
|
1318
|
+
|
|
1319
|
+
await manager.handle(ws, {
|
|
1320
|
+
action: 'session.send',
|
|
1321
|
+
id: 'req-buffered',
|
|
1322
|
+
sessionId: 's-buffered',
|
|
1323
|
+
message: 'follow-up prompt',
|
|
1324
|
+
clientMessageId: 'cm-buffered',
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
assert.deepEqual(driver.calls.sendPrompt, []);
|
|
1328
|
+
assert.deepEqual(session.bufferedPrompts, ['follow-up prompt']);
|
|
1329
|
+
assert.equal(session.currentReplyText, 'current reply');
|
|
1330
|
+
assert.equal(session.lastUserMessage, 'current prompt');
|
|
1331
|
+
assert.deepEqual(session.acceptedClientMessageIds, ['cm-buffered']);
|
|
1332
|
+
|
|
1333
|
+
driver._emitMessage({ type: 'session.done', sessionId: 's-buffered' });
|
|
1334
|
+
|
|
1335
|
+
assert.deepEqual(driver.calls.sendPrompt, ['follow-up prompt']);
|
|
1336
|
+
assert.equal(session.isResponding, true);
|
|
1337
|
+
assert.equal(session.currentReplyText, '');
|
|
1338
|
+
assert.equal(session.lastUserMessage, 'follow-up prompt');
|
|
1339
|
+
|
|
1340
|
+
const reply = parseReply(ws);
|
|
1341
|
+
assert.equal(reply.ok, true);
|
|
1342
|
+
assert.deepEqual(reply.data, {
|
|
1343
|
+
sessionId: 's-buffered',
|
|
1344
|
+
clientMessageId: 'cm-buffered',
|
|
1345
|
+
});
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1348
|
+
test('sendMessage: adds ws to session clients', async () => {
|
|
1349
|
+
const manager = createManager();
|
|
1350
|
+
const ws = mockWs();
|
|
1351
|
+
const driver = mockDriver();
|
|
1352
|
+
const session = injectSession(manager, { sessionId: 's1', driver });
|
|
1353
|
+
|
|
1354
|
+
assert.equal(session.clients.has(ws), false);
|
|
1355
|
+
|
|
1356
|
+
await manager.handle(ws, {
|
|
1357
|
+
action: 'session.send',
|
|
1358
|
+
id: 'req1',
|
|
1359
|
+
sessionId: 's1',
|
|
1360
|
+
message: 'hello',
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
assert.equal(session.clients.has(ws), true);
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
test('sendMessage: updates fallback title from first message', async () => {
|
|
1367
|
+
const manager = createManager();
|
|
1368
|
+
const ws = mockWs();
|
|
1369
|
+
const driver = mockDriver();
|
|
1370
|
+
const session = injectSession(manager, {
|
|
1371
|
+
sessionId: 's1',
|
|
1372
|
+
driver,
|
|
1373
|
+
title: 'New session', // fallback title
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
await manager.handle(ws, {
|
|
1377
|
+
action: 'session.send',
|
|
1378
|
+
id: 'req1',
|
|
1379
|
+
sessionId: 's1',
|
|
1380
|
+
message: 'Fix the login bug in auth module',
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
assert.equal(session.title, 'Fix the login bug in auth module');
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
test('sendMessage: does not overwrite real title', async () => {
|
|
1387
|
+
const manager = createManager();
|
|
1388
|
+
const ws = mockWs();
|
|
1389
|
+
const driver = mockDriver();
|
|
1390
|
+
const session = injectSession(manager, {
|
|
1391
|
+
sessionId: 's1',
|
|
1392
|
+
driver,
|
|
1393
|
+
title: 'My real title',
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
await manager.handle(ws, {
|
|
1397
|
+
action: 'session.send',
|
|
1398
|
+
id: 'req1',
|
|
1399
|
+
sessionId: 's1',
|
|
1400
|
+
message: 'Some new message',
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1403
|
+
assert.equal(session.title, 'My real title');
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
test('sendMessage: returns error when session not found and no agent provided', async () => {
|
|
1407
|
+
const manager = createManager();
|
|
1408
|
+
const ws = mockWs();
|
|
1409
|
+
|
|
1410
|
+
await manager.handle(ws, {
|
|
1411
|
+
action: 'session.send',
|
|
1412
|
+
id: 'req1',
|
|
1413
|
+
sessionId: 'nonexistent',
|
|
1414
|
+
message: 'hello',
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
const reply = parseReply(ws);
|
|
1418
|
+
assert.equal(reply.ok, false);
|
|
1419
|
+
assert.match(reply.error, /not found/i);
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
test('sendMessage: reconnects from sessionRecords when session not in memory', async () => {
|
|
1423
|
+
const manager = createManager();
|
|
1424
|
+
const ws = mockWs();
|
|
1425
|
+
|
|
1426
|
+
// Put a record in sessionRecords
|
|
1427
|
+
(manager as any).store.records = [
|
|
1428
|
+
{
|
|
1429
|
+
sessionId: 'rec-1',
|
|
1430
|
+
agent: 'codex',
|
|
1431
|
+
cwd: '/tmp',
|
|
1432
|
+
title: 'Recorded',
|
|
1433
|
+
createdAt: '2026-03-20T00:00:00.000Z',
|
|
1434
|
+
lastActivityAt: '2026-03-20T00:00:00.000Z',
|
|
1435
|
+
},
|
|
1436
|
+
];
|
|
1437
|
+
|
|
1438
|
+
// Mock createDriver to return a mock driver
|
|
1439
|
+
const driver = mockDriver();
|
|
1440
|
+
(manager as any).createDriver = () => driver;
|
|
1441
|
+
|
|
1442
|
+
await manager.handle(ws, {
|
|
1443
|
+
action: 'session.send',
|
|
1444
|
+
id: 'req1',
|
|
1445
|
+
sessionId: 'rec-1',
|
|
1446
|
+
message: 'hello from reconnect',
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
assert.deepEqual(driver.calls.sendPrompt, ['hello from reconnect']);
|
|
1450
|
+
const reply = parseReply(ws);
|
|
1451
|
+
assert.equal(reply.ok, true);
|
|
1452
|
+
// Session should now be in memory
|
|
1453
|
+
assert.equal((manager as any).sessions.has('rec-1'), true);
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
test('sendMessage: restores accepted client message ids from session records during auto-reconnect', async () => {
|
|
1457
|
+
const manager = createManager();
|
|
1458
|
+
const ws = mockWs();
|
|
1459
|
+
const driver = mockDriver();
|
|
1460
|
+
|
|
1461
|
+
(manager as any).store.records = [
|
|
1462
|
+
{
|
|
1463
|
+
sessionId: 'rec-dup',
|
|
1464
|
+
agent: 'codex',
|
|
1465
|
+
cwd: '/tmp',
|
|
1466
|
+
title: 'Recorded',
|
|
1467
|
+
createdAt: '2026-03-20T00:00:00.000Z',
|
|
1468
|
+
lastActivityAt: '2026-03-20T00:00:00.000Z',
|
|
1469
|
+
acceptedClientMessageIds: ['cm_saved'],
|
|
1470
|
+
},
|
|
1471
|
+
];
|
|
1472
|
+
(manager as any).createDriver = () => driver;
|
|
1473
|
+
|
|
1474
|
+
await manager.handle(ws, {
|
|
1475
|
+
action: 'session.send',
|
|
1476
|
+
id: 'req1',
|
|
1477
|
+
sessionId: 'rec-dup',
|
|
1478
|
+
message: 'hello from reconnect',
|
|
1479
|
+
clientMessageId: 'cm_saved',
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1482
|
+
assert.deepEqual(driver.calls.sendPrompt, []);
|
|
1483
|
+
const reply = parseReply(ws);
|
|
1484
|
+
assert.equal(reply.ok, true);
|
|
1485
|
+
assert.equal(reply.data.duplicate, true);
|
|
1486
|
+
});
|
|
1487
|
+
|
|
1488
|
+
test('sendMessage: dedupes replayed clientMessageId values', async () => {
|
|
1489
|
+
const manager = createManager();
|
|
1490
|
+
const ws = mockWs();
|
|
1491
|
+
const driver = mockDriver();
|
|
1492
|
+
const session = injectSession(manager, {
|
|
1493
|
+
sessionId: 's1',
|
|
1494
|
+
driver,
|
|
1495
|
+
acceptedClientMessageIds: ['cm_1'],
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
await manager.handle(ws, {
|
|
1499
|
+
action: 'session.send',
|
|
1500
|
+
id: 'req1',
|
|
1501
|
+
sessionId: 's1',
|
|
1502
|
+
message: 'hello',
|
|
1503
|
+
clientMessageId: 'cm_1',
|
|
1504
|
+
});
|
|
1505
|
+
|
|
1506
|
+
assert.deepEqual(driver.calls.sendPrompt, []);
|
|
1507
|
+
assert.equal(session.acceptedClientMessageIds.length, 1);
|
|
1508
|
+
const reply = parseReply(ws);
|
|
1509
|
+
assert.equal(reply.ok, true);
|
|
1510
|
+
assert.equal(reply.data.clientMessageId, 'cm_1');
|
|
1511
|
+
assert.equal(reply.data.duplicate, true);
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
// ── touchSession ─────────────────────────────────────────────────────
|
|
1515
|
+
|
|
1516
|
+
test('touchSession: updates lastActivityAt on active session', () => {
|
|
1517
|
+
const manager = createManager();
|
|
1518
|
+
const session = injectSession(manager, {
|
|
1519
|
+
sessionId: 's1',
|
|
1520
|
+
lastActivityAt: '2026-03-01T00:00:00.000Z',
|
|
1521
|
+
});
|
|
1522
|
+
|
|
1523
|
+
const before = session.lastActivityAt;
|
|
1524
|
+
(manager as any).touchSession('s1');
|
|
1525
|
+
assert.notEqual(session.lastActivityAt, before);
|
|
1526
|
+
});
|
|
1527
|
+
|
|
1528
|
+
test('touchSession: updates lastActivityAt on session record', () => {
|
|
1529
|
+
const manager = createManager();
|
|
1530
|
+
(manager as any).store.records = [
|
|
1531
|
+
{
|
|
1532
|
+
sessionId: 'rec-1',
|
|
1533
|
+
agent: 'codex' as AgentType,
|
|
1534
|
+
cwd: '/tmp',
|
|
1535
|
+
title: 'T',
|
|
1536
|
+
createdAt: '2026-03-01T00:00:00.000Z',
|
|
1537
|
+
lastActivityAt: '2026-03-01T00:00:00.000Z',
|
|
1538
|
+
},
|
|
1539
|
+
];
|
|
1540
|
+
|
|
1541
|
+
(manager as any).touchSession('rec-1');
|
|
1542
|
+
const updated = (manager as any).store.find('rec-1');
|
|
1543
|
+
assert.notEqual(updated.lastActivityAt, '2026-03-01T00:00:00.000Z');
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
// ── persistRecord / removeRecord ─────────────────────────────────────
|
|
1547
|
+
|
|
1548
|
+
test('store.upsert: adds new record to front of store', () => {
|
|
1549
|
+
const manager = createManager();
|
|
1550
|
+
const store = (manager as any).store;
|
|
1551
|
+
|
|
1552
|
+
store.upsert({
|
|
1553
|
+
sessionId: 's-new',
|
|
1554
|
+
agent: 'claude',
|
|
1555
|
+
cwd: '/project',
|
|
1556
|
+
title: 'New Session',
|
|
1557
|
+
createdAt: '2026-03-20T00:00:00.000Z',
|
|
1558
|
+
lastActivityAt: '2026-03-20T00:00:00.000Z',
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
const records = store.getAll();
|
|
1562
|
+
assert.equal(records.length, 1);
|
|
1563
|
+
assert.equal(records[0].sessionId, 's-new');
|
|
1564
|
+
assert.equal(records[0].agent, 'claude');
|
|
1565
|
+
});
|
|
1566
|
+
|
|
1567
|
+
test('store.upsert: updates existing record in place', () => {
|
|
1568
|
+
const manager = createManager();
|
|
1569
|
+
const store = (manager as any).store;
|
|
1570
|
+
store.records = [
|
|
1571
|
+
{ sessionId: 's1', agent: 'codex', cwd: '/old', title: 'Old', createdAt: '', lastActivityAt: '' },
|
|
1572
|
+
];
|
|
1573
|
+
|
|
1574
|
+
store.upsert({
|
|
1575
|
+
sessionId: 's1',
|
|
1576
|
+
agent: 'codex',
|
|
1577
|
+
cwd: '/new',
|
|
1578
|
+
title: 'Updated',
|
|
1579
|
+
createdAt: '',
|
|
1580
|
+
lastActivityAt: '',
|
|
1581
|
+
});
|
|
1582
|
+
|
|
1583
|
+
const records = store.getAll();
|
|
1584
|
+
assert.equal(records.length, 1);
|
|
1585
|
+
assert.equal(records[0].cwd, '/new');
|
|
1586
|
+
assert.equal(records[0].title, 'Updated');
|
|
1587
|
+
});
|
|
1588
|
+
|
|
1589
|
+
test('store.remove: removes matching record', () => {
|
|
1590
|
+
const manager = createManager();
|
|
1591
|
+
const store = (manager as any).store;
|
|
1592
|
+
store.records = [
|
|
1593
|
+
{ sessionId: 's1', agent: 'codex', cwd: '/tmp', title: 'T1', createdAt: '', lastActivityAt: '' },
|
|
1594
|
+
{ sessionId: 's2', agent: 'codex', cwd: '/tmp', title: 'T2', createdAt: '', lastActivityAt: '' },
|
|
1595
|
+
];
|
|
1596
|
+
|
|
1597
|
+
store.remove('s1');
|
|
1598
|
+
|
|
1599
|
+
const records = store.getAll();
|
|
1600
|
+
assert.equal(records.length, 1);
|
|
1601
|
+
assert.equal(records[0].sessionId, 's2');
|
|
1602
|
+
});
|
|
1603
|
+
|
|
1604
|
+
// ── createSession ────────────────────────────────────────────────────
|
|
1605
|
+
|
|
1606
|
+
test('createSession: codex returns a pending session immediately', async () => {
|
|
1607
|
+
const manager = createManager();
|
|
1608
|
+
const ws = mockWs();
|
|
1609
|
+
const deferred = createDeferred<string>();
|
|
1610
|
+
const driver = mockDriver();
|
|
1611
|
+
driver.start = async (cwd?: string, resumeSessionId?: string, approvalMode?: string) => {
|
|
1612
|
+
driver.calls.start.push({ cwd, resumeSessionId, approvalMode });
|
|
1613
|
+
return deferred.promise;
|
|
1614
|
+
};
|
|
1615
|
+
(manager as any).createDriver = () => driver;
|
|
1616
|
+
|
|
1617
|
+
await manager.handle(ws, {
|
|
1618
|
+
action: 'session.create',
|
|
1619
|
+
id: 'req1',
|
|
1620
|
+
agent: 'codex',
|
|
1621
|
+
cwd: '/tmp',
|
|
1622
|
+
});
|
|
1623
|
+
|
|
1624
|
+
const reply = parseReply(ws);
|
|
1625
|
+
assert.equal(reply.ok, true);
|
|
1626
|
+
assert.match(reply.data.sessionId, /^pending_/);
|
|
1627
|
+
const session = (manager as any).sessions.get(reply.data.sessionId);
|
|
1628
|
+
assert.ok(session, 'session should be stored');
|
|
1629
|
+
assert.equal(session.startupInProgress, true);
|
|
1630
|
+
assert.deepEqual(driver.calls.start[0], { cwd: '/tmp', resumeSessionId: undefined, approvalMode: undefined });
|
|
1631
|
+
});
|
|
1632
|
+
|
|
1633
|
+
test('createSession: stores approval mode', async () => {
|
|
1634
|
+
const manager = createManager();
|
|
1635
|
+
const ws = mockWs();
|
|
1636
|
+
const driver = mockDriver();
|
|
1637
|
+
(manager as any).createDriver = () => driver;
|
|
1638
|
+
|
|
1639
|
+
await manager.handle(ws, {
|
|
1640
|
+
action: 'session.create',
|
|
1641
|
+
id: 'req1',
|
|
1642
|
+
agent: 'claude',
|
|
1643
|
+
cwd: '/tmp',
|
|
1644
|
+
approvalMode: 'autoApprove',
|
|
1645
|
+
});
|
|
1646
|
+
|
|
1647
|
+
const reply = parseReply(ws);
|
|
1648
|
+
const session = (manager as any).sessions.get(reply.data.sessionId);
|
|
1649
|
+
assert.equal(session.approvalMode, 'autoApprove');
|
|
1650
|
+
});
|
|
1651
|
+
|
|
1652
|
+
test('createSession: codex startup failure is reported inside the pending session', async () => {
|
|
1653
|
+
const manager = createManager();
|
|
1654
|
+
const ws = mockWs();
|
|
1655
|
+
const driver = mockDriver();
|
|
1656
|
+
driver.start = async () => { throw new Error('spawn failed'); };
|
|
1657
|
+
(manager as any).createDriver = () => driver;
|
|
1658
|
+
|
|
1659
|
+
await manager.handle(ws, {
|
|
1660
|
+
action: 'session.create',
|
|
1661
|
+
id: 'req1',
|
|
1662
|
+
agent: 'codex',
|
|
1663
|
+
cwd: '/tmp',
|
|
1664
|
+
});
|
|
1665
|
+
|
|
1666
|
+
const reply = parseReply(ws);
|
|
1667
|
+
assert.equal(reply.ok, true);
|
|
1668
|
+
assert.match(reply.data.sessionId, /^pending_/);
|
|
1669
|
+
|
|
1670
|
+
await flushAsyncWork();
|
|
1671
|
+
|
|
1672
|
+
assert.equal(ws.sent.length, 2);
|
|
1673
|
+
const errorMsg = JSON.parse(ws.sent[1]);
|
|
1674
|
+
assert.equal(errorMsg.type, 'error');
|
|
1675
|
+
assert.equal(errorMsg.sessionId, reply.data.sessionId);
|
|
1676
|
+
assert.match(errorMsg.message, /spawn failed/);
|
|
1677
|
+
|
|
1678
|
+
const session = (manager as any).sessions.get(reply.data.sessionId);
|
|
1679
|
+
assert.ok(session, 'pending session should remain addressable');
|
|
1680
|
+
assert.equal(session.active, false);
|
|
1681
|
+
assert.equal(session.driver, null);
|
|
1682
|
+
assert.equal(session.startupInProgress, false);
|
|
1683
|
+
});
|
|
1684
|
+
|
|
1685
|
+
test('createSession: adds ws to session clients', async () => {
|
|
1686
|
+
const manager = createManager();
|
|
1687
|
+
const ws = mockWs();
|
|
1688
|
+
const deferred = createDeferred<string>();
|
|
1689
|
+
const driver = mockDriver();
|
|
1690
|
+
driver.start = async (cwd?: string, resumeSessionId?: string, approvalMode?: string) => {
|
|
1691
|
+
driver.calls.start.push({ cwd, resumeSessionId, approvalMode });
|
|
1692
|
+
return deferred.promise;
|
|
1693
|
+
};
|
|
1694
|
+
(manager as any).createDriver = () => driver;
|
|
1695
|
+
|
|
1696
|
+
await manager.handle(ws, {
|
|
1697
|
+
action: 'session.create',
|
|
1698
|
+
id: 'req1',
|
|
1699
|
+
agent: 'codex',
|
|
1700
|
+
cwd: '/tmp',
|
|
1701
|
+
});
|
|
1702
|
+
|
|
1703
|
+
const reply = parseReply(ws);
|
|
1704
|
+
const session = (manager as any).sessions.get(reply.data.sessionId);
|
|
1705
|
+
assert.equal(session.clients.has(ws), true);
|
|
1706
|
+
});
|
|
1707
|
+
|
|
1708
|
+
test('createSession: codex buffers the first prompt until startup completes, then remaps the session id', async () => {
|
|
1709
|
+
const manager = createManager();
|
|
1710
|
+
const ws = mockWs();
|
|
1711
|
+
const deferred = createDeferred<string>();
|
|
1712
|
+
const driver = mockDriver();
|
|
1713
|
+
driver.start = async (cwd?: string, resumeSessionId?: string, approvalMode?: string) => {
|
|
1714
|
+
driver.calls.start.push({ cwd, resumeSessionId, approvalMode });
|
|
1715
|
+
return deferred.promise;
|
|
1716
|
+
};
|
|
1717
|
+
(manager as any).createDriver = () => driver;
|
|
1718
|
+
|
|
1719
|
+
await manager.handle(ws, {
|
|
1720
|
+
action: 'session.create',
|
|
1721
|
+
id: 'req-create',
|
|
1722
|
+
agent: 'codex',
|
|
1723
|
+
cwd: '/tmp',
|
|
1724
|
+
});
|
|
1725
|
+
|
|
1726
|
+
const createReply = parseReply(ws);
|
|
1727
|
+
const pendingSessionId = createReply.data.sessionId;
|
|
1728
|
+
|
|
1729
|
+
await manager.handle(ws, {
|
|
1730
|
+
action: 'session.send',
|
|
1731
|
+
id: 'req-send',
|
|
1732
|
+
sessionId: pendingSessionId,
|
|
1733
|
+
message: 'hello buffered codex',
|
|
1734
|
+
clientMessageId: 'cm-buffered',
|
|
1735
|
+
});
|
|
1736
|
+
|
|
1737
|
+
const sendReply = parseReply(ws, 1);
|
|
1738
|
+
assert.equal(sendReply.ok, true);
|
|
1739
|
+
assert.equal(sendReply.data.sessionId, pendingSessionId);
|
|
1740
|
+
assert.deepEqual(driver.calls.sendPrompt, []);
|
|
1741
|
+
|
|
1742
|
+
const pendingSession = (manager as any).sessions.get(pendingSessionId);
|
|
1743
|
+
assert.deepEqual(pendingSession.bufferedPrompts, ['hello buffered codex']);
|
|
1744
|
+
|
|
1745
|
+
deferred.resolve('codex-thread-1');
|
|
1746
|
+
await flushAsyncWork();
|
|
1747
|
+
|
|
1748
|
+
assert.equal((manager as any).sessions.has(pendingSessionId), false);
|
|
1749
|
+
const liveSession = (manager as any).sessions.get('codex-thread-1');
|
|
1750
|
+
assert.ok(liveSession, 'resolved session should be stored under the real thread id');
|
|
1751
|
+
assert.equal(liveSession.startupInProgress, false);
|
|
1752
|
+
assert.deepEqual(driver.calls.sendPrompt, ['hello buffered codex']);
|
|
1753
|
+
|
|
1754
|
+
const idUpdate = JSON.parse(ws.sent[2]);
|
|
1755
|
+
assert.equal(idUpdate.type, 'response');
|
|
1756
|
+
assert.equal(idUpdate.ok, true);
|
|
1757
|
+
assert.deepEqual(idUpdate.data, {
|
|
1758
|
+
sessionId: 'codex-thread-1',
|
|
1759
|
+
oldSessionId: pendingSessionId,
|
|
1760
|
+
});
|
|
1761
|
+
});
|
|
1762
|
+
|
|
1763
|
+
test('interrupt: clears buffered prompt while codex startup is still pending', async () => {
|
|
1764
|
+
const manager = createManager();
|
|
1765
|
+
const ws = mockWs();
|
|
1766
|
+
const deferred = createDeferred<string>();
|
|
1767
|
+
const driver = mockDriver();
|
|
1768
|
+
driver.start = async (cwd?: string, resumeSessionId?: string, approvalMode?: string) => {
|
|
1769
|
+
driver.calls.start.push({ cwd, resumeSessionId, approvalMode });
|
|
1770
|
+
return deferred.promise;
|
|
1771
|
+
};
|
|
1772
|
+
(manager as any).createDriver = () => driver;
|
|
1773
|
+
|
|
1774
|
+
await manager.handle(ws, {
|
|
1775
|
+
action: 'session.create',
|
|
1776
|
+
id: 'req-create',
|
|
1777
|
+
agent: 'codex',
|
|
1778
|
+
cwd: '/tmp',
|
|
1779
|
+
});
|
|
1780
|
+
|
|
1781
|
+
const createReply = parseReply(ws);
|
|
1782
|
+
const pendingSessionId = createReply.data.sessionId;
|
|
1783
|
+
|
|
1784
|
+
await manager.handle(ws, {
|
|
1785
|
+
action: 'session.send',
|
|
1786
|
+
id: 'req-send',
|
|
1787
|
+
sessionId: pendingSessionId,
|
|
1788
|
+
message: 'stop me before startup finishes',
|
|
1789
|
+
clientMessageId: 'cm-stop',
|
|
1790
|
+
});
|
|
1791
|
+
|
|
1792
|
+
await manager.handle(ws, {
|
|
1793
|
+
action: 'session.interrupt',
|
|
1794
|
+
id: 'req-interrupt',
|
|
1795
|
+
sessionId: pendingSessionId,
|
|
1796
|
+
});
|
|
1797
|
+
|
|
1798
|
+
const pendingSession = (manager as any).sessions.get(pendingSessionId);
|
|
1799
|
+
assert.ok(pendingSession, 'pending session should still exist before startup resolves');
|
|
1800
|
+
assert.deepEqual(pendingSession.bufferedPrompts, []);
|
|
1801
|
+
assert.equal(pendingSession.isResponding, false);
|
|
1802
|
+
assert.equal(pendingSession.currentReplyText, '');
|
|
1803
|
+
assert.equal(pendingSession.lastUserMessage, undefined);
|
|
1804
|
+
assert.deepEqual(driver.calls.interrupt, []);
|
|
1805
|
+
|
|
1806
|
+
const interruptReply = parseReply(ws, 2);
|
|
1807
|
+
assert.equal(interruptReply.ok, true);
|
|
1808
|
+
const interruptedEvent = parseReply(ws, 3);
|
|
1809
|
+
assert.deepEqual(interruptedEvent, {
|
|
1810
|
+
type: 'session.interrupted',
|
|
1811
|
+
sessionId: pendingSessionId,
|
|
1812
|
+
});
|
|
1813
|
+
|
|
1814
|
+
deferred.resolve('codex-thread-2');
|
|
1815
|
+
await flushAsyncWork();
|
|
1816
|
+
|
|
1817
|
+
assert.deepEqual(driver.calls.sendPrompt, []);
|
|
1818
|
+
assert.equal((manager as any).sessions.has(pendingSessionId), false);
|
|
1819
|
+
const liveSession = (manager as any).sessions.get('codex-thread-2');
|
|
1820
|
+
assert.ok(liveSession, 'resolved session should still remap to the real thread id');
|
|
1821
|
+
|
|
1822
|
+
const idUpdate = parseReply(ws, 4);
|
|
1823
|
+
assert.equal(idUpdate.type, 'response');
|
|
1824
|
+
assert.equal(idUpdate.ok, true);
|
|
1825
|
+
assert.deepEqual(idUpdate.data, {
|
|
1826
|
+
sessionId: 'codex-thread-2',
|
|
1827
|
+
oldSessionId: pendingSessionId,
|
|
1828
|
+
});
|
|
1829
|
+
});
|
|
1830
|
+
|
|
1831
|
+
test('createSession: continueSession=true creates new session when no previous sessions exist', async () => {
|
|
1832
|
+
const manager = createManager();
|
|
1833
|
+
const ws = mockWs();
|
|
1834
|
+
const driver = mockDriver();
|
|
1835
|
+
(manager as any).createDriver = () => driver;
|
|
1836
|
+
|
|
1837
|
+
await manager.handle(ws, {
|
|
1838
|
+
action: 'session.create',
|
|
1839
|
+
id: 'req1',
|
|
1840
|
+
agent: 'codex',
|
|
1841
|
+
cwd: '/tmp',
|
|
1842
|
+
continueSession: true,
|
|
1843
|
+
});
|
|
1844
|
+
|
|
1845
|
+
const reply = parseReply(ws);
|
|
1846
|
+
assert.equal(reply.ok, true);
|
|
1847
|
+
assert.ok(reply.data.sessionId);
|
|
1848
|
+
});
|
|
1849
|
+
|
|
1850
|
+
test('createSession: continueSession=true skips deleted recent sessions', async () => {
|
|
1851
|
+
const manager = createManager();
|
|
1852
|
+
const ws = mockWs();
|
|
1853
|
+
const driver = mockDriver();
|
|
1854
|
+
(manager as any).createDriver = () => driver;
|
|
1855
|
+
(manager as any).listRecentSessionsForContinue = async () => [
|
|
1856
|
+
{
|
|
1857
|
+
sessionId: 'deleted-recent',
|
|
1858
|
+
agent: 'codex',
|
|
1859
|
+
cwd: '/tmp',
|
|
1860
|
+
title: 'Deleted recent',
|
|
1861
|
+
createdAt: '2026-03-20T00:00:00.000Z',
|
|
1862
|
+
lastActivityAt: '2026-03-21T00:00:00.000Z',
|
|
1863
|
+
sources: ['scanner'],
|
|
1864
|
+
runtime: { state: 'idle', confidence: 'high', resumeMode: 'resumeSession' },
|
|
1865
|
+
},
|
|
1866
|
+
{
|
|
1867
|
+
sessionId: 'kept-recent',
|
|
1868
|
+
agent: 'codex',
|
|
1869
|
+
cwd: '/tmp',
|
|
1870
|
+
title: 'Kept recent',
|
|
1871
|
+
createdAt: '2026-03-19T00:00:00.000Z',
|
|
1872
|
+
lastActivityAt: '2026-03-20T00:00:00.000Z',
|
|
1873
|
+
sources: ['scanner'],
|
|
1874
|
+
runtime: { state: 'idle', confidence: 'high', resumeMode: 'resumeSession' },
|
|
1875
|
+
},
|
|
1876
|
+
];
|
|
1877
|
+
(manager as any).store.remove('deleted-recent');
|
|
1878
|
+
|
|
1879
|
+
await manager.handle(ws, {
|
|
1880
|
+
action: 'session.create',
|
|
1881
|
+
id: 'req-skip-deleted',
|
|
1882
|
+
agent: 'codex',
|
|
1883
|
+
cwd: '/tmp',
|
|
1884
|
+
continueSession: true,
|
|
1885
|
+
});
|
|
1886
|
+
|
|
1887
|
+
assert.deepEqual(driver.calls.start[0], {
|
|
1888
|
+
cwd: '/tmp',
|
|
1889
|
+
resumeSessionId: 'kept-recent',
|
|
1890
|
+
approvalMode: undefined,
|
|
1891
|
+
});
|
|
1892
|
+
});
|
|
1893
|
+
|
|
1894
|
+
test('createSession: expands tilde in cwd', async () => {
|
|
1895
|
+
const manager = createManager();
|
|
1896
|
+
const ws = mockWs();
|
|
1897
|
+
const driver = mockDriver();
|
|
1898
|
+
let capturedCwd = '';
|
|
1899
|
+
driver.start = async (cwd: string) => { capturedCwd = cwd; return 'sess-1'; };
|
|
1900
|
+
(manager as any).createDriver = () => driver;
|
|
1901
|
+
|
|
1902
|
+
await manager.handle(ws, {
|
|
1903
|
+
action: 'session.create',
|
|
1904
|
+
id: 'req1',
|
|
1905
|
+
agent: 'codex',
|
|
1906
|
+
cwd: '~',
|
|
1907
|
+
});
|
|
1908
|
+
|
|
1909
|
+
// cwd should be expanded from ~ to absolute home directory path
|
|
1910
|
+
assert.ok(!capturedCwd.startsWith('~'));
|
|
1911
|
+
assert.ok(capturedCwd.startsWith('/'));
|
|
1912
|
+
});
|
|
1913
|
+
|
|
1914
|
+
test('createSession: returns error for nonexistent cwd', async () => {
|
|
1915
|
+
const manager = createManager();
|
|
1916
|
+
const ws = mockWs();
|
|
1917
|
+
const driver = mockDriver();
|
|
1918
|
+
(manager as any).createDriver = () => driver;
|
|
1919
|
+
|
|
1920
|
+
await manager.handle(ws, {
|
|
1921
|
+
action: 'session.create',
|
|
1922
|
+
id: 'req1',
|
|
1923
|
+
agent: 'claude',
|
|
1924
|
+
cwd: '/nonexistent/path/that/does/not/exist',
|
|
1925
|
+
});
|
|
1926
|
+
|
|
1927
|
+
const reply = parseReply(ws);
|
|
1928
|
+
assert.equal(reply.ok, false);
|
|
1929
|
+
assert.match(reply.error, /does not exist/i);
|
|
1930
|
+
assert.equal(driver.calls.start.length, 0, 'driver should not be started');
|
|
1931
|
+
});
|
|
1932
|
+
|
|
1933
|
+
test('createSession: returns error when cwd is a file, not a directory', async () => {
|
|
1934
|
+
const manager = createManager();
|
|
1935
|
+
const ws = mockWs();
|
|
1936
|
+
const driver = mockDriver();
|
|
1937
|
+
(manager as any).createDriver = () => driver;
|
|
1938
|
+
|
|
1939
|
+
// Use a known file path
|
|
1940
|
+
await manager.handle(ws, {
|
|
1941
|
+
action: 'session.create',
|
|
1942
|
+
id: 'req1',
|
|
1943
|
+
agent: 'claude',
|
|
1944
|
+
cwd: '/etc/hosts',
|
|
1945
|
+
});
|
|
1946
|
+
|
|
1947
|
+
const reply = parseReply(ws);
|
|
1948
|
+
assert.equal(reply.ok, false);
|
|
1949
|
+
assert.match(reply.error, /not a directory/i);
|
|
1950
|
+
assert.equal(driver.calls.start.length, 0, 'driver should not be started');
|
|
1951
|
+
});
|
|
1952
|
+
|
|
1953
|
+
// ── resumeSession ────────────────────────────────────────────────────
|
|
1954
|
+
|
|
1955
|
+
test('resumeSession: attaches to existing active session without creating new driver', async () => {
|
|
1956
|
+
const manager = createManager();
|
|
1957
|
+
const ws = mockWs();
|
|
1958
|
+
const driver = mockDriver();
|
|
1959
|
+
const session = injectSession(manager, { sessionId: 's1', driver, active: true });
|
|
1960
|
+
|
|
1961
|
+
let driverCreated = false;
|
|
1962
|
+
(manager as any).createDriver = () => { driverCreated = true; return mockDriver(); };
|
|
1963
|
+
|
|
1964
|
+
await manager.handle(ws, {
|
|
1965
|
+
action: 'session.resume',
|
|
1966
|
+
id: 'req1',
|
|
1967
|
+
sessionId: 's1',
|
|
1968
|
+
agent: 'codex',
|
|
1969
|
+
});
|
|
1970
|
+
|
|
1971
|
+
assert.equal(driverCreated, false);
|
|
1972
|
+
assert.equal(session.clients.has(ws), true);
|
|
1973
|
+
const reply = parseReply(ws);
|
|
1974
|
+
assert.equal(reply.ok, true);
|
|
1975
|
+
});
|
|
1976
|
+
|
|
1977
|
+
test('resumeSession: creates new driver for inactive session', async () => {
|
|
1978
|
+
const manager = createManager();
|
|
1979
|
+
const ws = mockWs();
|
|
1980
|
+
const driver = mockDriver();
|
|
1981
|
+
(manager as any).createDriver = () => driver;
|
|
1982
|
+
|
|
1983
|
+
await manager.handle(ws, {
|
|
1984
|
+
action: 'session.resume',
|
|
1985
|
+
id: 'req1',
|
|
1986
|
+
sessionId: 'new-session',
|
|
1987
|
+
agent: 'claude',
|
|
1988
|
+
});
|
|
1989
|
+
|
|
1990
|
+
const reply = parseReply(ws);
|
|
1991
|
+
assert.equal(reply.ok, true);
|
|
1992
|
+
assert.equal((manager as any).sessions.has(reply.data.sessionId), true);
|
|
1993
|
+
});
|
|
1994
|
+
|
|
1995
|
+
test('resumeSession: replies error when driver.start fails', async () => {
|
|
1996
|
+
const manager = createManager();
|
|
1997
|
+
const ws = mockWs();
|
|
1998
|
+
(manager as any).createDriver = () => ({
|
|
1999
|
+
...mockDriver(),
|
|
2000
|
+
start: async () => { throw new Error('resume failed'); },
|
|
2001
|
+
});
|
|
2002
|
+
|
|
2003
|
+
await manager.handle(ws, {
|
|
2004
|
+
action: 'session.resume',
|
|
2005
|
+
id: 'req1',
|
|
2006
|
+
sessionId: 'fail-session',
|
|
2007
|
+
agent: 'codex',
|
|
2008
|
+
});
|
|
2009
|
+
|
|
2010
|
+
const reply = parseReply(ws);
|
|
2011
|
+
assert.equal(reply.ok, false);
|
|
2012
|
+
assert.match(reply.error, /resume failed/);
|
|
2013
|
+
});
|
|
2014
|
+
|
|
2015
|
+
// listDirs tests removed — dirs.list is now handled directly in index.ts
|
|
2016
|
+
|
|
2017
|
+
// ── Additional coverage tests ─────────────────────────────────────────
|
|
2018
|
+
|
|
2019
|
+
test('createSession: expands fullwidth tilde ~ in cwd', async () => {
|
|
2020
|
+
const manager = createManager();
|
|
2021
|
+
const ws = mockWs();
|
|
2022
|
+
const driver = mockDriver();
|
|
2023
|
+
let capturedCwd = '';
|
|
2024
|
+
driver.start = async (cwd: string) => { capturedCwd = cwd; return 'sess-1'; };
|
|
2025
|
+
(manager as any).createDriver = () => driver;
|
|
2026
|
+
|
|
2027
|
+
await manager.handle(ws, {
|
|
2028
|
+
action: 'session.create',
|
|
2029
|
+
id: 'req1',
|
|
2030
|
+
agent: 'codex',
|
|
2031
|
+
cwd: '~', // fullwidth tilde
|
|
2032
|
+
});
|
|
2033
|
+
|
|
2034
|
+
assert.ok(!capturedCwd.startsWith('~'));
|
|
2035
|
+
assert.ok(!capturedCwd.startsWith('~'));
|
|
2036
|
+
assert.ok(capturedCwd.startsWith('/'));
|
|
2037
|
+
});
|
|
2038
|
+
|
|
2039
|
+
test('touchSession: updates record when session is not in active sessions map', () => {
|
|
2040
|
+
const manager = createManager();
|
|
2041
|
+
(manager as any).store.records = [
|
|
2042
|
+
{
|
|
2043
|
+
sessionId: 'rec-only',
|
|
2044
|
+
agent: 'codex' as AgentType,
|
|
2045
|
+
cwd: '/tmp',
|
|
2046
|
+
title: 'T',
|
|
2047
|
+
createdAt: '2026-03-01T00:00:00.000Z',
|
|
2048
|
+
lastActivityAt: '2026-03-01T00:00:00.000Z',
|
|
2049
|
+
},
|
|
2050
|
+
];
|
|
2051
|
+
// No active session for 'rec-only'
|
|
2052
|
+
|
|
2053
|
+
(manager as any).touchSession('rec-only');
|
|
2054
|
+
|
|
2055
|
+
const updated = (manager as any).store.find('rec-only');
|
|
2056
|
+
assert.notEqual(updated.lastActivityAt, '2026-03-01T00:00:00.000Z');
|
|
2057
|
+
});
|
|
2058
|
+
|
|
2059
|
+
test('touchSession: does nothing when session not found anywhere', () => {
|
|
2060
|
+
const manager = createManager();
|
|
2061
|
+
(manager as any).store.records = [];
|
|
2062
|
+
// Should not throw
|
|
2063
|
+
(manager as any).touchSession('nonexistent');
|
|
2064
|
+
});
|
|
2065
|
+
|
|
2066
|
+
test('sendMessage: auto-reconnect uses agent from client message as last resort', async () => {
|
|
2067
|
+
const manager = createManager();
|
|
2068
|
+
const ws = mockWs();
|
|
2069
|
+
const driver = mockDriver();
|
|
2070
|
+
(manager as any).createDriver = () => driver;
|
|
2071
|
+
// No sessionRecords, no scanner results, but agent is provided
|
|
2072
|
+
(manager as any).store.records = [];
|
|
2073
|
+
|
|
2074
|
+
await manager.handle(ws, {
|
|
2075
|
+
action: 'session.send',
|
|
2076
|
+
id: 'req1',
|
|
2077
|
+
sessionId: 'unknown-session',
|
|
2078
|
+
message: 'hello',
|
|
2079
|
+
agent: 'codex',
|
|
2080
|
+
});
|
|
2081
|
+
|
|
2082
|
+
// Should have attempted to reconnect using the provided agent
|
|
2083
|
+
const reply = parseReply(ws);
|
|
2084
|
+
// It might fail at driver.start() but it should try
|
|
2085
|
+
// mock driver.start returns 'mock-session-id', so it should succeed
|
|
2086
|
+
assert.equal(reply.ok, true);
|
|
2087
|
+
assert.equal((manager as any).sessions.has('unknown-session'), true);
|
|
2088
|
+
});
|
|
2089
|
+
|
|
2090
|
+
test('resumeSession: uses cwd and approvalMode from persisted record', async () => {
|
|
2091
|
+
const manager = createManager();
|
|
2092
|
+
const ws = mockWs();
|
|
2093
|
+
const driver = mockDriver();
|
|
2094
|
+
let startCwd = '';
|
|
2095
|
+
let startApproval = '';
|
|
2096
|
+
driver.start = async (cwd: string, _id?: string, approval?: string) => {
|
|
2097
|
+
startCwd = cwd;
|
|
2098
|
+
startApproval = approval ?? '';
|
|
2099
|
+
return 'resumed-id';
|
|
2100
|
+
};
|
|
2101
|
+
(manager as any).createDriver = () => driver;
|
|
2102
|
+
(manager as any).store.records = [
|
|
2103
|
+
{
|
|
2104
|
+
sessionId: 'persisted-1',
|
|
2105
|
+
agent: 'claude',
|
|
2106
|
+
cwd: '/saved-cwd',
|
|
2107
|
+
approvalMode: 'acceptEdits',
|
|
2108
|
+
title: 'Saved',
|
|
2109
|
+
createdAt: '2026-03-01T00:00:00.000Z',
|
|
2110
|
+
lastActivityAt: '2026-03-20T00:00:00.000Z',
|
|
2111
|
+
},
|
|
2112
|
+
];
|
|
2113
|
+
|
|
2114
|
+
await manager.handle(ws, {
|
|
2115
|
+
action: 'session.resume',
|
|
2116
|
+
id: 'req1',
|
|
2117
|
+
sessionId: 'persisted-1',
|
|
2118
|
+
agent: 'claude',
|
|
2119
|
+
});
|
|
2120
|
+
|
|
2121
|
+
assert.equal(startCwd, '/saved-cwd');
|
|
2122
|
+
assert.equal(startApproval, 'acceptEdits');
|
|
2123
|
+
const reply = parseReply(ws);
|
|
2124
|
+
assert.equal(reply.ok, true);
|
|
2125
|
+
});
|
|
2126
|
+
|
|
2127
|
+
// ── setApprovalMode ──────────────────────────────────────────────────
|
|
2128
|
+
|
|
2129
|
+
test('setApprovalMode: updates session approvalMode and persists', async () => {
|
|
2130
|
+
const manager = createManager();
|
|
2131
|
+
const ws = mockWs();
|
|
2132
|
+
const driver = mockDriver();
|
|
2133
|
+
(driver as any).setApprovalMode = (mode: string) => {
|
|
2134
|
+
(driver as any).calls.setApprovalMode = (driver as any).calls.setApprovalMode || [];
|
|
2135
|
+
(driver as any).calls.setApprovalMode.push(mode);
|
|
2136
|
+
};
|
|
2137
|
+
injectSession(manager, {
|
|
2138
|
+
sessionId: 'am-1',
|
|
2139
|
+
agent: 'claude',
|
|
2140
|
+
approvalMode: 'normal',
|
|
2141
|
+
driver,
|
|
2142
|
+
});
|
|
2143
|
+
|
|
2144
|
+
await manager.handle(ws, {
|
|
2145
|
+
action: 'session.setApprovalMode',
|
|
2146
|
+
id: 'req1',
|
|
2147
|
+
sessionId: 'am-1',
|
|
2148
|
+
approvalMode: 'autoApprove',
|
|
2149
|
+
});
|
|
2150
|
+
|
|
2151
|
+
const reply = parseReply(ws);
|
|
2152
|
+
assert.equal(reply.ok, true);
|
|
2153
|
+
assert.equal(reply.data.approvalMode, 'autoApprove');
|
|
2154
|
+
|
|
2155
|
+
const session = (manager as any).sessions.get('am-1');
|
|
2156
|
+
assert.equal(session.approvalMode, 'autoApprove');
|
|
2157
|
+
assert.deepEqual((driver as any).calls.setApprovalMode, ['autoApprove']);
|
|
2158
|
+
});
|
|
2159
|
+
|
|
2160
|
+
test('setApprovalMode: succeeds for session not in memory and not in store (untracked)', async () => {
|
|
2161
|
+
const manager = createManager();
|
|
2162
|
+
const ws = mockWs();
|
|
2163
|
+
|
|
2164
|
+
await manager.handle(ws, {
|
|
2165
|
+
action: 'session.setApprovalMode',
|
|
2166
|
+
id: 'req1',
|
|
2167
|
+
sessionId: 'nonexistent',
|
|
2168
|
+
approvalMode: 'autoApprove',
|
|
2169
|
+
});
|
|
2170
|
+
|
|
2171
|
+
const reply = parseReply(ws);
|
|
2172
|
+
assert.equal(reply.ok, true);
|
|
2173
|
+
assert.equal(reply.data.approvalMode, 'autoApprove');
|
|
2174
|
+
});
|
|
2175
|
+
|
|
2176
|
+
test('setApprovalMode: updates store record for session not in memory but in store', async () => {
|
|
2177
|
+
const manager = createManager();
|
|
2178
|
+
const ws = mockWs();
|
|
2179
|
+
const store = (manager as any).store;
|
|
2180
|
+
store.records.push({
|
|
2181
|
+
sessionId: 'stored-only',
|
|
2182
|
+
agent: 'claude',
|
|
2183
|
+
cwd: '/tmp',
|
|
2184
|
+
title: 'Stored session',
|
|
2185
|
+
createdAt: '2026-03-20T00:00:00.000Z',
|
|
2186
|
+
lastActivityAt: '2026-03-20T00:00:00.000Z',
|
|
2187
|
+
approvalMode: 'normal',
|
|
2188
|
+
});
|
|
2189
|
+
|
|
2190
|
+
await manager.handle(ws, {
|
|
2191
|
+
action: 'session.setApprovalMode',
|
|
2192
|
+
id: 'req1',
|
|
2193
|
+
sessionId: 'stored-only',
|
|
2194
|
+
approvalMode: 'autoApprove',
|
|
2195
|
+
});
|
|
2196
|
+
|
|
2197
|
+
const reply = parseReply(ws);
|
|
2198
|
+
assert.equal(reply.ok, true);
|
|
2199
|
+
assert.equal(reply.data.approvalMode, 'autoApprove');
|
|
2200
|
+
const record = store.records.find((r: any) => r.sessionId === 'stored-only');
|
|
2201
|
+
assert.equal(record.approvalMode, 'autoApprove');
|
|
2202
|
+
});
|
|
2203
|
+
|
|
2204
|
+
test('setApprovalMode: clears store override when switching back to Follow Remote', async () => {
|
|
2205
|
+
const manager = createManager();
|
|
2206
|
+
const ws = mockWs();
|
|
2207
|
+
const store = (manager as any).store;
|
|
2208
|
+
store.records.push({
|
|
2209
|
+
sessionId: 'stored-only',
|
|
2210
|
+
agent: 'claude',
|
|
2211
|
+
cwd: '/tmp',
|
|
2212
|
+
title: 'Stored session',
|
|
2213
|
+
createdAt: '2026-03-20T00:00:00.000Z',
|
|
2214
|
+
lastActivityAt: '2026-03-20T00:00:00.000Z',
|
|
2215
|
+
approvalMode: 'autoApprove',
|
|
2216
|
+
});
|
|
2217
|
+
|
|
2218
|
+
await manager.handle(ws, {
|
|
2219
|
+
action: 'session.setApprovalMode',
|
|
2220
|
+
id: 'req1',
|
|
2221
|
+
sessionId: 'stored-only',
|
|
2222
|
+
approvalMode: null,
|
|
2223
|
+
});
|
|
2224
|
+
|
|
2225
|
+
const reply = parseReply(ws);
|
|
2226
|
+
assert.equal(reply.ok, true);
|
|
2227
|
+
assert.equal(reply.data.approvalMode, null);
|
|
2228
|
+
const record = store.records.find((r: any) => r.sessionId === 'stored-only');
|
|
2229
|
+
assert.equal(record.approvalMode, undefined);
|
|
2230
|
+
});
|
|
2231
|
+
|
|
2232
|
+
test('setApprovalMode: updates active codex driver without restarting it', async () => {
|
|
2233
|
+
const manager = createManager();
|
|
2234
|
+
const ws = mockWs();
|
|
2235
|
+
const driver = mockDriver();
|
|
2236
|
+
|
|
2237
|
+
injectSession(manager, {
|
|
2238
|
+
sessionId: 'am-codex-1',
|
|
2239
|
+
agent: 'codex',
|
|
2240
|
+
approvalMode: 'normal',
|
|
2241
|
+
driver,
|
|
2242
|
+
});
|
|
2243
|
+
|
|
2244
|
+
await manager.handle(ws, {
|
|
2245
|
+
action: 'session.setApprovalMode',
|
|
2246
|
+
id: 'req1',
|
|
2247
|
+
sessionId: 'am-codex-1',
|
|
2248
|
+
approvalMode: 'autoApprove',
|
|
2249
|
+
});
|
|
2250
|
+
|
|
2251
|
+
const reply = parseReply(ws);
|
|
2252
|
+
assert.equal(reply.ok, true);
|
|
2253
|
+
assert.equal(driver.calls.stop.length, 0, 'driver should not be restarted');
|
|
2254
|
+
assert.deepEqual(driver.calls.setApprovalMode, ['autoApprove']);
|
|
2255
|
+
});
|
|
2256
|
+
|
|
2257
|
+
test('setApprovalMode: does not restart codex if mode unchanged', async () => {
|
|
2258
|
+
const manager = createManager();
|
|
2259
|
+
const ws = mockWs();
|
|
2260
|
+
const driver = mockDriver();
|
|
2261
|
+
(driver as any).setApprovalMode = () => {};
|
|
2262
|
+
|
|
2263
|
+
injectSession(manager, {
|
|
2264
|
+
sessionId: 'am-codex-2',
|
|
2265
|
+
agent: 'codex',
|
|
2266
|
+
approvalMode: 'autoApprove',
|
|
2267
|
+
driver,
|
|
2268
|
+
});
|
|
2269
|
+
|
|
2270
|
+
await manager.handle(ws, {
|
|
2271
|
+
action: 'session.setApprovalMode',
|
|
2272
|
+
id: 'req1',
|
|
2273
|
+
sessionId: 'am-codex-2',
|
|
2274
|
+
approvalMode: 'autoApprove',
|
|
2275
|
+
});
|
|
2276
|
+
|
|
2277
|
+
assert.equal(driver.calls.stop.length, 0, 'driver should not be restarted');
|
|
2278
|
+
});
|
|
2279
|
+
|
|
2280
|
+
// ── pendingApproval resend ───────────────────────────────────────────
|
|
2281
|
+
|
|
2282
|
+
test('pending approval is tracked and resent on resume attach', async () => {
|
|
2283
|
+
const manager = createManager();
|
|
2284
|
+
const ws1 = mockWs();
|
|
2285
|
+
const driver = mockDriver();
|
|
2286
|
+
const session = injectSession(manager, {
|
|
2287
|
+
sessionId: 'pa-1',
|
|
2288
|
+
agent: 'claude',
|
|
2289
|
+
driver,
|
|
2290
|
+
clients: new Set<WebSocket>([ws1]),
|
|
2291
|
+
isResponding: true,
|
|
2292
|
+
});
|
|
2293
|
+
|
|
2294
|
+
// Simulate driver emitting an approval.request
|
|
2295
|
+
(manager as any).bindDriverLifecycle(session, 'claude', '');
|
|
2296
|
+
driver._emitMessage({
|
|
2297
|
+
type: 'approval.request',
|
|
2298
|
+
sessionId: 'pa-1',
|
|
2299
|
+
requestId: 'req-abc',
|
|
2300
|
+
toolName: 'Bash',
|
|
2301
|
+
input: { command: 'rm -rf /' },
|
|
2302
|
+
description: 'Run dangerous command',
|
|
2303
|
+
});
|
|
2304
|
+
|
|
2305
|
+
// Verify pendingApproval is tracked
|
|
2306
|
+
assert.ok(session.pendingApproval);
|
|
2307
|
+
assert.equal(session.pendingApproval!.requestId, 'req-abc');
|
|
2308
|
+
assert.equal(session.isResponding, false, 'approval.request should clear isResponding');
|
|
2309
|
+
|
|
2310
|
+
// New client reconnects via resumeSession (attach to active)
|
|
2311
|
+
const ws2 = mockWs();
|
|
2312
|
+
await manager.handle(ws2, {
|
|
2313
|
+
action: 'session.resume',
|
|
2314
|
+
id: 'resume-1',
|
|
2315
|
+
sessionId: 'pa-1',
|
|
2316
|
+
agent: 'claude',
|
|
2317
|
+
});
|
|
2318
|
+
|
|
2319
|
+
// ws2 should receive the pending approval inside session.history
|
|
2320
|
+
const historyMsg = ws2.sent
|
|
2321
|
+
.map((s: string) => JSON.parse(s))
|
|
2322
|
+
.find((m: any) => m.type === 'session.history');
|
|
2323
|
+
assert.ok(historyMsg, 'reconnected client should receive session.history');
|
|
2324
|
+
assert.equal(historyMsg.pendingApproval?.requestId, 'req-abc');
|
|
2325
|
+
assert.equal(historyMsg.pendingApproval?.toolName, 'Bash');
|
|
2326
|
+
});
|
|
2327
|
+
|
|
2328
|
+
test('autoApprove mode immediately resolves follow-up codex approvals without surfacing another sheet', async () => {
|
|
2329
|
+
const manager = createManager();
|
|
2330
|
+
const ws = mockWs();
|
|
2331
|
+
const driver = mockDriver();
|
|
2332
|
+
const session = injectSession(manager, {
|
|
2333
|
+
sessionId: 'codex-auto-approval',
|
|
2334
|
+
agent: 'codex',
|
|
2335
|
+
driver,
|
|
2336
|
+
approvalMode: 'autoApprove',
|
|
2337
|
+
clients: new Set<WebSocket>([ws]),
|
|
2338
|
+
isResponding: true,
|
|
2339
|
+
});
|
|
2340
|
+
|
|
2341
|
+
(manager as any).bindDriverLifecycle(session, 'codex', '');
|
|
2342
|
+
driver._emitMessage({
|
|
2343
|
+
type: 'approval.request',
|
|
2344
|
+
sessionId: 'codex-auto-approval',
|
|
2345
|
+
requestId: 'req-auto',
|
|
2346
|
+
toolName: 'Bash',
|
|
2347
|
+
input: { command: 'git status' },
|
|
2348
|
+
description: 'Run git status',
|
|
2349
|
+
});
|
|
2350
|
+
|
|
2351
|
+
assert.deepEqual(driver.calls.respondApproval, [{ requestId: 'req-auto', approved: true }]);
|
|
2352
|
+
assert.equal(session.pendingApproval, undefined);
|
|
2353
|
+
assert.equal(session.isResponding, true);
|
|
2354
|
+
assert.equal(ws.sent.length, 0, 'approval request should not be broadcast when the session is already autoApprove');
|
|
2355
|
+
});
|
|
2356
|
+
|
|
2357
|
+
test('approval.request sends a push notification with session routing data', async () => {
|
|
2358
|
+
const pushCalls: Array<{ title: string; body: string; data?: Record<string, unknown> }> = [];
|
|
2359
|
+
const manager = createManager((title, body, data) => {
|
|
2360
|
+
pushCalls.push({ title, body, data });
|
|
2361
|
+
});
|
|
2362
|
+
const ws = mockWs();
|
|
2363
|
+
const driver = mockDriver();
|
|
2364
|
+
const session = injectSession(manager, {
|
|
2365
|
+
sessionId: 'push-approval',
|
|
2366
|
+
agent: 'codex',
|
|
2367
|
+
title: 'Deploy context',
|
|
2368
|
+
driver,
|
|
2369
|
+
clients: new Set<WebSocket>([ws]),
|
|
2370
|
+
});
|
|
2371
|
+
|
|
2372
|
+
(manager as any).bindDriverLifecycle(session, 'codex', '');
|
|
2373
|
+
driver._emitMessage({
|
|
2374
|
+
type: 'approval.request',
|
|
2375
|
+
sessionId: 'push-approval',
|
|
2376
|
+
requestId: 'req-push',
|
|
2377
|
+
toolName: 'Bash',
|
|
2378
|
+
input: { command: 'ps -Ao pid,ppid,etime,command' },
|
|
2379
|
+
description: 'Inspect the build process',
|
|
2380
|
+
});
|
|
2381
|
+
|
|
2382
|
+
assert.deepEqual(pushCalls, [{
|
|
2383
|
+
title: 'Approval required',
|
|
2384
|
+
body: 'Deploy context: Inspect the build process',
|
|
2385
|
+
data: {
|
|
2386
|
+
sessionId: 'push-approval',
|
|
2387
|
+
agent: 'codex',
|
|
2388
|
+
eventType: 'approval_request',
|
|
2389
|
+
requestId: 'req-push',
|
|
2390
|
+
},
|
|
2391
|
+
}]);
|
|
2392
|
+
});
|
|
2393
|
+
|
|
2394
|
+
test('pending approval is cleared after user responds', async () => {
|
|
2395
|
+
const manager = createManager();
|
|
2396
|
+
const ws = mockWs();
|
|
2397
|
+
const driver = mockDriver();
|
|
2398
|
+
const session = injectSession(manager, {
|
|
2399
|
+
sessionId: 'pa-2',
|
|
2400
|
+
agent: 'codex',
|
|
2401
|
+
driver,
|
|
2402
|
+
clients: new Set<WebSocket>([ws]),
|
|
2403
|
+
});
|
|
2404
|
+
|
|
2405
|
+
(manager as any).bindDriverLifecycle(session, 'codex', '');
|
|
2406
|
+
driver._emitMessage({
|
|
2407
|
+
type: 'approval.request',
|
|
2408
|
+
sessionId: 'pa-2',
|
|
2409
|
+
requestId: 'req-xyz',
|
|
2410
|
+
toolName: 'Write',
|
|
2411
|
+
input: {},
|
|
2412
|
+
description: 'Write file',
|
|
2413
|
+
});
|
|
2414
|
+
assert.ok(session.pendingApproval);
|
|
2415
|
+
|
|
2416
|
+
await manager.handle(ws, {
|
|
2417
|
+
action: 'session.approve',
|
|
2418
|
+
id: 'approve-1',
|
|
2419
|
+
sessionId: 'pa-2',
|
|
2420
|
+
requestId: 'req-xyz',
|
|
2421
|
+
approved: true,
|
|
2422
|
+
});
|
|
2423
|
+
|
|
2424
|
+
assert.equal(session.pendingApproval, undefined, 'pendingApproval should be cleared after response');
|
|
2425
|
+
});
|
|
2426
|
+
|
|
2427
|
+
test('session.approve: revives an inactive codex session before sending the approval response', async () => {
|
|
2428
|
+
const manager = createManager();
|
|
2429
|
+
const ws = mockWs();
|
|
2430
|
+
const revivedDriver = mockDriver();
|
|
2431
|
+
injectSession(manager, {
|
|
2432
|
+
sessionId: 'pa-revive',
|
|
2433
|
+
agent: 'codex',
|
|
2434
|
+
driver: null,
|
|
2435
|
+
active: false,
|
|
2436
|
+
pendingApproval: {
|
|
2437
|
+
requestId: 'req-revive',
|
|
2438
|
+
toolName: 'Bash',
|
|
2439
|
+
input: { command: 'ps -Ao pid,ppid,etime,command' },
|
|
2440
|
+
description: 'Inspect process list',
|
|
2441
|
+
},
|
|
2442
|
+
});
|
|
2443
|
+
|
|
2444
|
+
(manager as any).createDriver = () => revivedDriver;
|
|
2445
|
+
|
|
2446
|
+
await manager.handle(ws, {
|
|
2447
|
+
action: 'session.approve',
|
|
2448
|
+
id: 'approve-revive',
|
|
2449
|
+
sessionId: 'pa-revive',
|
|
2450
|
+
requestId: 'req-revive',
|
|
2451
|
+
approved: true,
|
|
2452
|
+
});
|
|
2453
|
+
|
|
2454
|
+
const reply = parseReply(ws);
|
|
2455
|
+
const session = (manager as any).sessions.get('pa-revive');
|
|
2456
|
+
assert.equal(reply.ok, true);
|
|
2457
|
+
assert.equal(revivedDriver.calls.start.length, 1);
|
|
2458
|
+
assert.deepEqual(revivedDriver.calls.respondApproval, [{ requestId: 'req-revive', approved: true }]);
|
|
2459
|
+
assert.equal(session.pendingApproval, undefined);
|
|
2460
|
+
assert.equal(session.driver, revivedDriver);
|
|
2461
|
+
});
|
|
2462
|
+
|
|
2463
|
+
test('pending approval is cleared on session.done', async () => {
|
|
2464
|
+
const manager = createManager();
|
|
2465
|
+
const ws = mockWs();
|
|
2466
|
+
const driver = mockDriver();
|
|
2467
|
+
const session = injectSession(manager, {
|
|
2468
|
+
sessionId: 'pa-3',
|
|
2469
|
+
agent: 'claude',
|
|
2470
|
+
driver,
|
|
2471
|
+
clients: new Set<WebSocket>([ws]),
|
|
2472
|
+
});
|
|
2473
|
+
|
|
2474
|
+
(manager as any).bindDriverLifecycle(session, 'claude', '');
|
|
2475
|
+
driver._emitMessage({
|
|
2476
|
+
type: 'approval.request',
|
|
2477
|
+
sessionId: 'pa-3',
|
|
2478
|
+
requestId: 'req-done',
|
|
2479
|
+
toolName: 'Edit',
|
|
2480
|
+
input: {},
|
|
2481
|
+
description: 'Edit file',
|
|
2482
|
+
});
|
|
2483
|
+
assert.ok(session.pendingApproval);
|
|
2484
|
+
|
|
2485
|
+
driver._emitMessage({
|
|
2486
|
+
type: 'session.done',
|
|
2487
|
+
sessionId: 'pa-3',
|
|
2488
|
+
});
|
|
2489
|
+
assert.equal(session.pendingApproval, undefined, 'pendingApproval should be cleared on session.done');
|
|
2490
|
+
});
|
|
2491
|
+
|
|
2492
|
+
test('synthetic pending approval survives session.done', async () => {
|
|
2493
|
+
const manager = createManager();
|
|
2494
|
+
const ws = mockWs();
|
|
2495
|
+
const driver = mockDriver();
|
|
2496
|
+
const session = injectSession(manager, {
|
|
2497
|
+
sessionId: 'pa-3-synth',
|
|
2498
|
+
agent: 'claude',
|
|
2499
|
+
driver,
|
|
2500
|
+
clients: new Set<WebSocket>([ws]),
|
|
2501
|
+
});
|
|
2502
|
+
|
|
2503
|
+
(manager as any).bindDriverLifecycle(session, 'claude', '');
|
|
2504
|
+
driver._emitMessage({
|
|
2505
|
+
type: 'approval.request',
|
|
2506
|
+
sessionId: 'pa-3-synth',
|
|
2507
|
+
requestId: 'claude-permission-denial:tu-1',
|
|
2508
|
+
toolName: 'Edit',
|
|
2509
|
+
input: {},
|
|
2510
|
+
description: 'Edit file',
|
|
2511
|
+
});
|
|
2512
|
+
assert.ok(session.pendingApproval);
|
|
2513
|
+
|
|
2514
|
+
driver._emitMessage({
|
|
2515
|
+
type: 'session.done',
|
|
2516
|
+
sessionId: 'pa-3-synth',
|
|
2517
|
+
});
|
|
2518
|
+
assert.ok(session.pendingApproval, 'synthetic pendingApproval should survive session.done');
|
|
2519
|
+
});
|
|
2520
|
+
|
|
2521
|
+
test('pending approval is cleared on driver exit', async () => {
|
|
2522
|
+
const manager = createManager();
|
|
2523
|
+
const ws = mockWs();
|
|
2524
|
+
const driver = mockDriver();
|
|
2525
|
+
const session = injectSession(manager, {
|
|
2526
|
+
sessionId: 'pa-4',
|
|
2527
|
+
agent: 'claude',
|
|
2528
|
+
driver,
|
|
2529
|
+
clients: new Set<WebSocket>([ws]),
|
|
2530
|
+
});
|
|
2531
|
+
|
|
2532
|
+
(manager as any).bindDriverLifecycle(session, 'claude', '');
|
|
2533
|
+
driver._emitMessage({
|
|
2534
|
+
type: 'approval.request',
|
|
2535
|
+
sessionId: 'pa-4',
|
|
2536
|
+
requestId: 'req-exit',
|
|
2537
|
+
toolName: 'Bash',
|
|
2538
|
+
input: {},
|
|
2539
|
+
description: 'Run command',
|
|
2540
|
+
});
|
|
2541
|
+
assert.ok(session.pendingApproval);
|
|
2542
|
+
|
|
2543
|
+
driver._emitExit(0);
|
|
2544
|
+
assert.equal(session.pendingApproval, undefined, 'pendingApproval should be cleared on driver exit');
|
|
2545
|
+
});
|
|
2546
|
+
|
|
2547
|
+
test('synthetic pending approval survives a clean driver exit', async () => {
|
|
2548
|
+
const manager = createManager();
|
|
2549
|
+
const ws = mockWs();
|
|
2550
|
+
const driver = mockDriver();
|
|
2551
|
+
const session = injectSession(manager, {
|
|
2552
|
+
sessionId: 'pa-4-synth',
|
|
2553
|
+
agent: 'claude',
|
|
2554
|
+
driver,
|
|
2555
|
+
clients: new Set<WebSocket>([ws]),
|
|
2556
|
+
});
|
|
2557
|
+
|
|
2558
|
+
(manager as any).bindDriverLifecycle(session, 'claude', '');
|
|
2559
|
+
driver._emitMessage({
|
|
2560
|
+
type: 'approval.request',
|
|
2561
|
+
sessionId: 'pa-4-synth',
|
|
2562
|
+
requestId: 'claude-permission-denial:tu-2',
|
|
2563
|
+
toolName: 'Bash',
|
|
2564
|
+
input: {},
|
|
2565
|
+
description: 'Run command',
|
|
2566
|
+
});
|
|
2567
|
+
assert.ok(session.pendingApproval);
|
|
2568
|
+
|
|
2569
|
+
driver._emitExit(0);
|
|
2570
|
+
assert.ok(session.pendingApproval, 'synthetic pendingApproval should survive a clean driver exit');
|
|
2571
|
+
});
|
|
2572
|
+
|
|
2573
|
+
// ── auto-reconnect approvalMode recovery ─────────────────────────────
|
|
2574
|
+
|
|
2575
|
+
test('auto-reconnect recovers approvalMode from existing in-memory session', async () => {
|
|
2576
|
+
const manager = createManager();
|
|
2577
|
+
const ws = mockWs();
|
|
2578
|
+
let startApproval = '';
|
|
2579
|
+
const driver = mockDriver();
|
|
2580
|
+
driver.start = async (_cwd: string, _id?: string, approval?: string) => {
|
|
2581
|
+
startApproval = approval ?? '';
|
|
2582
|
+
return 'reconnect-1';
|
|
2583
|
+
};
|
|
2584
|
+
|
|
2585
|
+
// Create a session that's inactive (driver null) but still in memory
|
|
2586
|
+
injectSession(manager, {
|
|
2587
|
+
sessionId: 'reconnect-1',
|
|
2588
|
+
agent: 'claude',
|
|
2589
|
+
approvalMode: 'autoApprove',
|
|
2590
|
+
driver: null,
|
|
2591
|
+
active: false,
|
|
2592
|
+
});
|
|
2593
|
+
|
|
2594
|
+
(manager as any).createDriver = () => driver;
|
|
2595
|
+
|
|
2596
|
+
await manager.handle(ws, {
|
|
2597
|
+
action: 'session.send',
|
|
2598
|
+
id: 'req1',
|
|
2599
|
+
sessionId: 'reconnect-1',
|
|
2600
|
+
message: 'hello',
|
|
2601
|
+
agent: 'claude',
|
|
2602
|
+
});
|
|
2603
|
+
|
|
2604
|
+
assert.equal(startApproval, 'autoApprove', 'auto-reconnect should recover approvalMode from in-memory session');
|
|
2605
|
+
});
|
|
2606
|
+
|
|
2607
|
+
// ── additional edge cases ────────────────────────────────────────────
|
|
2608
|
+
|
|
2609
|
+
test('setApprovalMode: works on session with no active driver', async () => {
|
|
2610
|
+
const manager = createManager();
|
|
2611
|
+
const ws = mockWs();
|
|
2612
|
+
injectSession(manager, {
|
|
2613
|
+
sessionId: 'am-nodriver',
|
|
2614
|
+
agent: 'claude',
|
|
2615
|
+
approvalMode: 'normal',
|
|
2616
|
+
driver: null,
|
|
2617
|
+
active: false,
|
|
2618
|
+
});
|
|
2619
|
+
|
|
2620
|
+
await manager.handle(ws, {
|
|
2621
|
+
action: 'session.setApprovalMode',
|
|
2622
|
+
id: 'req1',
|
|
2623
|
+
sessionId: 'am-nodriver',
|
|
2624
|
+
approvalMode: 'autoApprove',
|
|
2625
|
+
});
|
|
2626
|
+
|
|
2627
|
+
const reply = parseReply(ws);
|
|
2628
|
+
assert.equal(reply.ok, true);
|
|
2629
|
+
const session = (manager as any).sessions.get('am-nodriver');
|
|
2630
|
+
assert.equal(session.approvalMode, 'autoApprove');
|
|
2631
|
+
});
|
|
2632
|
+
|
|
2633
|
+
test('setApprovalMode: persists to session store', async () => {
|
|
2634
|
+
const manager = createManager();
|
|
2635
|
+
const ws = mockWs();
|
|
2636
|
+
injectSession(manager, {
|
|
2637
|
+
sessionId: 'am-persist',
|
|
2638
|
+
agent: 'claude',
|
|
2639
|
+
approvalMode: 'normal',
|
|
2640
|
+
driver: null,
|
|
2641
|
+
active: false,
|
|
2642
|
+
});
|
|
2643
|
+
|
|
2644
|
+
await manager.handle(ws, {
|
|
2645
|
+
action: 'session.setApprovalMode',
|
|
2646
|
+
id: 'req1',
|
|
2647
|
+
sessionId: 'am-persist',
|
|
2648
|
+
approvalMode: 'acceptEdits',
|
|
2649
|
+
});
|
|
2650
|
+
|
|
2651
|
+
const record = (manager as any).store.find('am-persist');
|
|
2652
|
+
assert.ok(record, 'store should have the session record');
|
|
2653
|
+
assert.equal(record.approvalMode, 'acceptEdits', 'store record should reflect updated mode');
|
|
2654
|
+
});
|
|
2655
|
+
|
|
2656
|
+
test('pending approval resent via sendHistory', async () => {
|
|
2657
|
+
const manager = createManager();
|
|
2658
|
+
const ws = mockWs();
|
|
2659
|
+
const driver = mockDriver();
|
|
2660
|
+
const session = injectSession(manager, {
|
|
2661
|
+
sessionId: 'pa-history',
|
|
2662
|
+
agent: 'claude',
|
|
2663
|
+
driver,
|
|
2664
|
+
clients: new Set<WebSocket>([ws]),
|
|
2665
|
+
pendingApproval: {
|
|
2666
|
+
requestId: 'req-hist',
|
|
2667
|
+
toolName: 'Grep',
|
|
2668
|
+
input: { pattern: 'foo' },
|
|
2669
|
+
description: 'Search for foo',
|
|
2670
|
+
},
|
|
2671
|
+
});
|
|
2672
|
+
|
|
2673
|
+
const ws2 = mockWs();
|
|
2674
|
+
await manager.handle(ws2, {
|
|
2675
|
+
action: 'session.history',
|
|
2676
|
+
id: 'hist-1',
|
|
2677
|
+
sessionId: 'pa-history',
|
|
2678
|
+
agent: 'claude',
|
|
2679
|
+
});
|
|
2680
|
+
|
|
2681
|
+
const historyMsg = ws2.sent
|
|
2682
|
+
.map((s: string) => JSON.parse(s))
|
|
2683
|
+
.find((m: any) => m.type === 'session.history');
|
|
2684
|
+
assert.ok(historyMsg, 'sendHistory should return session.history');
|
|
2685
|
+
assert.equal(historyMsg.pendingApproval?.requestId, 'req-hist');
|
|
2686
|
+
assert.equal(historyMsg.pendingApproval?.toolName, 'Grep');
|
|
2687
|
+
});
|
|
2688
|
+
|
|
2689
|
+
test('sendHistory skips closed WebSocket clients', async () => {
|
|
2690
|
+
const manager = createManager();
|
|
2691
|
+
const ws = mockWs();
|
|
2692
|
+
const driver = mockDriver();
|
|
2693
|
+
injectSession(manager, {
|
|
2694
|
+
sessionId: 'pa-closed',
|
|
2695
|
+
agent: 'claude',
|
|
2696
|
+
driver,
|
|
2697
|
+
clients: new Set<WebSocket>([ws]),
|
|
2698
|
+
pendingApproval: {
|
|
2699
|
+
requestId: 'req-closed',
|
|
2700
|
+
toolName: 'Bash',
|
|
2701
|
+
input: {},
|
|
2702
|
+
description: 'Run command',
|
|
2703
|
+
},
|
|
2704
|
+
});
|
|
2705
|
+
|
|
2706
|
+
const closedWs = mockWs(false);
|
|
2707
|
+
await manager.handle(closedWs, {
|
|
2708
|
+
action: 'session.history',
|
|
2709
|
+
id: 'hist-closed',
|
|
2710
|
+
sessionId: 'pa-closed',
|
|
2711
|
+
agent: 'claude',
|
|
2712
|
+
});
|
|
2713
|
+
|
|
2714
|
+
assert.equal(closedWs.sent.length, 0, 'should not send to closed WebSocket');
|
|
2715
|
+
});
|
|
2716
|
+
|
|
2717
|
+
test('later approval.request overwrites previous pendingApproval', async () => {
|
|
2718
|
+
const manager = createManager();
|
|
2719
|
+
const ws = mockWs();
|
|
2720
|
+
const driver = mockDriver();
|
|
2721
|
+
const session = injectSession(manager, {
|
|
2722
|
+
sessionId: 'pa-overwrite',
|
|
2723
|
+
agent: 'claude',
|
|
2724
|
+
driver,
|
|
2725
|
+
clients: new Set<WebSocket>([ws]),
|
|
2726
|
+
});
|
|
2727
|
+
|
|
2728
|
+
(manager as any).bindDriverLifecycle(session, 'claude', '');
|
|
2729
|
+
|
|
2730
|
+
driver._emitMessage({
|
|
2731
|
+
type: 'approval.request',
|
|
2732
|
+
sessionId: 'pa-overwrite',
|
|
2733
|
+
requestId: 'req-first',
|
|
2734
|
+
toolName: 'Bash',
|
|
2735
|
+
input: {},
|
|
2736
|
+
description: 'First request',
|
|
2737
|
+
});
|
|
2738
|
+
assert.equal(session.pendingApproval!.requestId, 'req-first');
|
|
2739
|
+
|
|
2740
|
+
driver._emitMessage({
|
|
2741
|
+
type: 'approval.request',
|
|
2742
|
+
sessionId: 'pa-overwrite',
|
|
2743
|
+
requestId: 'req-second',
|
|
2744
|
+
toolName: 'Write',
|
|
2745
|
+
input: {},
|
|
2746
|
+
description: 'Second request',
|
|
2747
|
+
});
|
|
2748
|
+
assert.equal(session.pendingApproval!.requestId, 'req-second', 'should track only the latest approval');
|
|
2749
|
+
});
|
|
2750
|
+
|
|
2751
|
+
test('pending approval cleared on session.interrupted', async () => {
|
|
2752
|
+
const manager = createManager();
|
|
2753
|
+
const ws = mockWs();
|
|
2754
|
+
const driver = mockDriver();
|
|
2755
|
+
const session = injectSession(manager, {
|
|
2756
|
+
sessionId: 'pa-interrupt',
|
|
2757
|
+
agent: 'claude',
|
|
2758
|
+
driver,
|
|
2759
|
+
clients: new Set<WebSocket>([ws]),
|
|
2760
|
+
});
|
|
2761
|
+
|
|
2762
|
+
(manager as any).bindDriverLifecycle(session, 'claude', '');
|
|
2763
|
+
driver._emitMessage({
|
|
2764
|
+
type: 'approval.request',
|
|
2765
|
+
sessionId: 'pa-interrupt',
|
|
2766
|
+
requestId: 'req-int',
|
|
2767
|
+
toolName: 'Bash',
|
|
2768
|
+
input: {},
|
|
2769
|
+
description: 'Run command',
|
|
2770
|
+
});
|
|
2771
|
+
assert.ok(session.pendingApproval);
|
|
2772
|
+
|
|
2773
|
+
driver._emitMessage({
|
|
2774
|
+
type: 'session.interrupted',
|
|
2775
|
+
sessionId: 'pa-interrupt',
|
|
2776
|
+
});
|
|
2777
|
+
assert.equal(session.pendingApproval, undefined, 'pendingApproval should be cleared on session.interrupted');
|
|
2778
|
+
});
|
|
2779
|
+
|
|
2780
|
+
test('setApprovalMode: codex mode change keeps the active session intact', async () => {
|
|
2781
|
+
const manager = createManager();
|
|
2782
|
+
const ws = mockWs();
|
|
2783
|
+
const driver = mockDriver();
|
|
2784
|
+
|
|
2785
|
+
injectSession(manager, {
|
|
2786
|
+
sessionId: 'am-fail',
|
|
2787
|
+
agent: 'codex',
|
|
2788
|
+
approvalMode: 'normal',
|
|
2789
|
+
driver,
|
|
2790
|
+
});
|
|
2791
|
+
|
|
2792
|
+
(manager as any).createDriver = () => {
|
|
2793
|
+
throw new Error('createDriver should not be called when switching codex approval mode');
|
|
2794
|
+
};
|
|
2795
|
+
|
|
2796
|
+
await manager.handle(ws, {
|
|
2797
|
+
action: 'session.setApprovalMode',
|
|
2798
|
+
id: 'req1',
|
|
2799
|
+
sessionId: 'am-fail',
|
|
2800
|
+
approvalMode: 'autoApprove',
|
|
2801
|
+
});
|
|
2802
|
+
|
|
2803
|
+
const reply = parseReply(ws);
|
|
2804
|
+
assert.equal(reply.ok, true);
|
|
2805
|
+
const session = (manager as any).sessions.get('am-fail');
|
|
2806
|
+
assert.equal(session.active, true);
|
|
2807
|
+
assert.equal(session.driver, driver);
|
|
2808
|
+
});
|
|
2809
|
+
|
|
2810
|
+
// ── createSession via handle() ───────────────────────────────────────
|
|
2811
|
+
|
|
2812
|
+
test('createSession: full flow via handle() creates session and replies', async () => {
|
|
2813
|
+
const manager = createManager();
|
|
2814
|
+
const ws = mockWs();
|
|
2815
|
+
const driver = mockDriver();
|
|
2816
|
+
(manager as any).createDriver = () => driver;
|
|
2817
|
+
|
|
2818
|
+
await manager.handle(ws, {
|
|
2819
|
+
action: 'session.create',
|
|
2820
|
+
id: 'req1',
|
|
2821
|
+
agent: 'claude',
|
|
2822
|
+
cwd: '/tmp',
|
|
2823
|
+
});
|
|
2824
|
+
|
|
2825
|
+
const reply = parseReply(ws);
|
|
2826
|
+
assert.equal(reply.ok, true);
|
|
2827
|
+
assert.equal(reply.data.sessionId, 'mock-session-id');
|
|
2828
|
+
|
|
2829
|
+
const session = (manager as any).sessions.get('mock-session-id');
|
|
2830
|
+
assert.ok(session, 'session should be stored');
|
|
2831
|
+
assert.equal(session.agent, 'claude');
|
|
2832
|
+
assert.equal(session.cwd, '/tmp');
|
|
2833
|
+
assert.equal(session.active, true);
|
|
2834
|
+
});
|
|
2835
|
+
|
|
2836
|
+
test('createSession: passes approvalMode to driver.start', async () => {
|
|
2837
|
+
const manager = createManager();
|
|
2838
|
+
const ws = mockWs();
|
|
2839
|
+
const driver = mockDriver();
|
|
2840
|
+
(manager as any).createDriver = () => driver;
|
|
2841
|
+
|
|
2842
|
+
await manager.handle(ws, {
|
|
2843
|
+
action: 'session.create',
|
|
2844
|
+
id: 'req1',
|
|
2845
|
+
agent: 'claude',
|
|
2846
|
+
cwd: '/tmp',
|
|
2847
|
+
approvalMode: 'autoApprove',
|
|
2848
|
+
});
|
|
2849
|
+
|
|
2850
|
+
assert.deepEqual(driver.calls.start[0], { cwd: '/tmp', resumeSessionId: undefined, approvalMode: 'autoApprove' });
|
|
2851
|
+
});
|
|
2852
|
+
|
|
2853
|
+
test('createSession: driver.start failure returns error reply', async () => {
|
|
2854
|
+
const manager = createManager();
|
|
2855
|
+
const ws = mockWs();
|
|
2856
|
+
const driver = mockDriver();
|
|
2857
|
+
driver.start = async () => { throw new Error('spawn failed'); };
|
|
2858
|
+
(manager as any).createDriver = () => driver;
|
|
2859
|
+
|
|
2860
|
+
await manager.handle(ws, {
|
|
2861
|
+
action: 'session.create',
|
|
2862
|
+
id: 'req1',
|
|
2863
|
+
agent: 'claude',
|
|
2864
|
+
cwd: '/tmp',
|
|
2865
|
+
});
|
|
2866
|
+
|
|
2867
|
+
const reply = parseReply(ws);
|
|
2868
|
+
assert.equal(reply.ok, false);
|
|
2869
|
+
assert.match(reply.error, /spawn failed/);
|
|
2870
|
+
});
|
|
2871
|
+
|
|
2872
|
+
// ── sendMessage via handle() ─────────────────────────────────────────
|
|
2873
|
+
|
|
2874
|
+
test('sendMessage: sends prompt to active driver', async () => {
|
|
2875
|
+
const manager = createManager();
|
|
2876
|
+
const ws = mockWs();
|
|
2877
|
+
const driver = mockDriver();
|
|
2878
|
+
injectSession(manager, {
|
|
2879
|
+
sessionId: 'sm-1',
|
|
2880
|
+
agent: 'claude',
|
|
2881
|
+
driver,
|
|
2882
|
+
clients: new Set<WebSocket>([ws]),
|
|
2883
|
+
});
|
|
2884
|
+
|
|
2885
|
+
await manager.handle(ws, {
|
|
2886
|
+
action: 'session.send',
|
|
2887
|
+
id: 'req1',
|
|
2888
|
+
sessionId: 'sm-1',
|
|
2889
|
+
message: 'hello world',
|
|
2890
|
+
});
|
|
2891
|
+
|
|
2892
|
+
const reply = parseReply(ws);
|
|
2893
|
+
assert.equal(reply.ok, true);
|
|
2894
|
+
assert.deepEqual(driver.calls.sendPrompt, ['hello world']);
|
|
2895
|
+
});
|
|
2896
|
+
|
|
2897
|
+
test('sendMessage: sets isResponding and lastUserMessage', async () => {
|
|
2898
|
+
const manager = createManager();
|
|
2899
|
+
const ws = mockWs();
|
|
2900
|
+
const driver = mockDriver();
|
|
2901
|
+
injectSession(manager, {
|
|
2902
|
+
sessionId: 'sm-2',
|
|
2903
|
+
agent: 'claude',
|
|
2904
|
+
driver,
|
|
2905
|
+
clients: new Set<WebSocket>([ws]),
|
|
2906
|
+
});
|
|
2907
|
+
|
|
2908
|
+
await manager.handle(ws, {
|
|
2909
|
+
action: 'session.send',
|
|
2910
|
+
id: 'req1',
|
|
2911
|
+
sessionId: 'sm-2',
|
|
2912
|
+
message: 'test prompt',
|
|
2913
|
+
});
|
|
2914
|
+
|
|
2915
|
+
const session = (manager as any).sessions.get('sm-2');
|
|
2916
|
+
assert.equal(session.isResponding, true);
|
|
2917
|
+
assert.equal(session.lastUserMessage, 'test prompt');
|
|
2918
|
+
});
|
|
2919
|
+
|
|
2920
|
+
test('sendMessage: deduplicates by clientMessageId', async () => {
|
|
2921
|
+
const manager = createManager();
|
|
2922
|
+
const ws = mockWs();
|
|
2923
|
+
const driver = mockDriver();
|
|
2924
|
+
injectSession(manager, {
|
|
2925
|
+
sessionId: 'sm-dedup',
|
|
2926
|
+
agent: 'claude',
|
|
2927
|
+
driver,
|
|
2928
|
+
clients: new Set<WebSocket>([ws]),
|
|
2929
|
+
});
|
|
2930
|
+
|
|
2931
|
+
await manager.handle(ws, {
|
|
2932
|
+
action: 'session.send',
|
|
2933
|
+
id: 'req1',
|
|
2934
|
+
sessionId: 'sm-dedup',
|
|
2935
|
+
message: 'hello',
|
|
2936
|
+
clientMessageId: 'cmid-1',
|
|
2937
|
+
});
|
|
2938
|
+
await manager.handle(ws, {
|
|
2939
|
+
action: 'session.send',
|
|
2940
|
+
id: 'req2',
|
|
2941
|
+
sessionId: 'sm-dedup',
|
|
2942
|
+
message: 'hello',
|
|
2943
|
+
clientMessageId: 'cmid-1',
|
|
2944
|
+
});
|
|
2945
|
+
|
|
2946
|
+
assert.equal(driver.calls.sendPrompt.length, 1, 'duplicate message should not be sent');
|
|
2947
|
+
const reply2 = parseReply(ws, 1);
|
|
2948
|
+
assert.equal(reply2.data.duplicate, true);
|
|
2949
|
+
});
|
|
2950
|
+
|
|
2951
|
+
test('sendMessage: rejects deleted sessions before reconnecting', async () => {
|
|
2952
|
+
const manager = createManager();
|
|
2953
|
+
const ws = mockWs();
|
|
2954
|
+
(manager as any).store.remove('deleted-send');
|
|
2955
|
+
|
|
2956
|
+
await manager.handle(ws, {
|
|
2957
|
+
action: 'session.send',
|
|
2958
|
+
id: 'req-deleted-send',
|
|
2959
|
+
sessionId: 'deleted-send',
|
|
2960
|
+
agent: 'codex',
|
|
2961
|
+
message: 'hello',
|
|
2962
|
+
});
|
|
2963
|
+
|
|
2964
|
+
const reply = parseReply(ws);
|
|
2965
|
+
assert.equal(reply.ok, false);
|
|
2966
|
+
assert.equal(reply.error, 'Session not found');
|
|
2967
|
+
});
|
|
2968
|
+
|
|
2969
|
+
test('sessions.list: filters deleted sessions at manager layer', async () => {
|
|
2970
|
+
const manager = createManager();
|
|
2971
|
+
const ws = mockWs();
|
|
2972
|
+
const cwd = '/tmp/session-list-test';
|
|
2973
|
+
(manager as any).store.records = [
|
|
2974
|
+
{
|
|
2975
|
+
sessionId: 'deleted-list',
|
|
2976
|
+
agent: 'codex',
|
|
2977
|
+
cwd,
|
|
2978
|
+
title: 'Deleted list',
|
|
2979
|
+
createdAt: '2026-03-20T00:00:00.000Z',
|
|
2980
|
+
lastActivityAt: '2026-03-20T00:00:00.000Z',
|
|
2981
|
+
},
|
|
2982
|
+
{
|
|
2983
|
+
sessionId: 'kept-list',
|
|
2984
|
+
agent: 'codex',
|
|
2985
|
+
cwd,
|
|
2986
|
+
title: 'Kept list',
|
|
2987
|
+
createdAt: '2026-03-19T00:00:00.000Z',
|
|
2988
|
+
lastActivityAt: '2026-03-19T00:00:00.000Z',
|
|
2989
|
+
},
|
|
2990
|
+
];
|
|
2991
|
+
(manager as any).store.deletedSessionIds.add('deleted-list');
|
|
2992
|
+
|
|
2993
|
+
await manager.handle(ws, {
|
|
2994
|
+
action: 'sessions.list',
|
|
2995
|
+
id: 'req-list',
|
|
2996
|
+
cwd,
|
|
2997
|
+
limit: 20,
|
|
2998
|
+
});
|
|
2999
|
+
|
|
3000
|
+
const reply = parseReply(ws);
|
|
3001
|
+
assert.equal(reply.ok, true);
|
|
3002
|
+
const ids = reply.data.sessions.map((session: { sessionId: string }) => session.sessionId);
|
|
3003
|
+
assert.ok(ids.includes('kept-list'));
|
|
3004
|
+
assert.ok(!ids.includes('deleted-list'));
|
|
3005
|
+
assert.equal(reply.data.inventoryVersion, 0);
|
|
3006
|
+
});
|
|
3007
|
+
|
|
3008
|
+
// ── stopSession ──────────────────────────────────────────────────────
|
|
3009
|
+
|
|
3010
|
+
test('stopSession: stops driver and marks session inactive', async () => {
|
|
3011
|
+
const manager = createManager();
|
|
3012
|
+
const ws = mockWs();
|
|
3013
|
+
const driver = mockDriver();
|
|
3014
|
+
injectSession(manager, {
|
|
3015
|
+
sessionId: 'stop-1',
|
|
3016
|
+
agent: 'codex',
|
|
3017
|
+
driver,
|
|
3018
|
+
});
|
|
3019
|
+
|
|
3020
|
+
await manager.handle(ws, {
|
|
3021
|
+
action: 'session.stop',
|
|
3022
|
+
id: 'req1',
|
|
3023
|
+
sessionId: 'stop-1',
|
|
3024
|
+
});
|
|
3025
|
+
|
|
3026
|
+
const reply = parseReply(ws);
|
|
3027
|
+
assert.equal(reply.ok, true);
|
|
3028
|
+
assert.equal(driver.calls.stop.length, 1);
|
|
3029
|
+
const session = (manager as any).sessions.get('stop-1');
|
|
3030
|
+
assert.equal(session.active, false);
|
|
3031
|
+
assert.equal(session.driver, null);
|
|
3032
|
+
});
|
|
3033
|
+
|
|
3034
|
+
test('stopSession: returns error for unknown session', async () => {
|
|
3035
|
+
const manager = createManager();
|
|
3036
|
+
const ws = mockWs();
|
|
3037
|
+
|
|
3038
|
+
await manager.handle(ws, {
|
|
3039
|
+
action: 'session.stop',
|
|
3040
|
+
id: 'req1',
|
|
3041
|
+
sessionId: 'nonexistent',
|
|
3042
|
+
});
|
|
3043
|
+
|
|
3044
|
+
const reply = parseReply(ws);
|
|
3045
|
+
assert.equal(reply.ok, false);
|
|
3046
|
+
assert.match(reply.error, /not found/i);
|
|
3047
|
+
});
|
|
3048
|
+
|
|
3049
|
+
// ── deleteSession ────────────────────────────────────────────────────
|
|
3050
|
+
|
|
3051
|
+
test('deleteSession: removes session from map and store', async () => {
|
|
3052
|
+
const manager = createManager();
|
|
3053
|
+
const ws = mockWs();
|
|
3054
|
+
const driver = mockDriver();
|
|
3055
|
+
injectSession(manager, {
|
|
3056
|
+
sessionId: 'del-1',
|
|
3057
|
+
agent: 'claude',
|
|
3058
|
+
driver,
|
|
3059
|
+
});
|
|
3060
|
+
|
|
3061
|
+
await manager.handle(ws, {
|
|
3062
|
+
action: 'session.delete',
|
|
3063
|
+
id: 'req1',
|
|
3064
|
+
sessionId: 'del-1',
|
|
3065
|
+
});
|
|
3066
|
+
|
|
3067
|
+
const reply = parseReply(ws);
|
|
3068
|
+
assert.equal(reply.ok, true);
|
|
3069
|
+
assert.equal(driver.calls.stop.length, 1);
|
|
3070
|
+
assert.equal((manager as any).sessions.has('del-1'), false, 'session should be removed from map');
|
|
3071
|
+
});
|
|
3072
|
+
|
|
3073
|
+
test('deleteSession: succeeds even for unknown session', async () => {
|
|
3074
|
+
const manager = createManager();
|
|
3075
|
+
const ws = mockWs();
|
|
3076
|
+
|
|
3077
|
+
await manager.handle(ws, {
|
|
3078
|
+
action: 'session.delete',
|
|
3079
|
+
id: 'req1',
|
|
3080
|
+
sessionId: 'nonexistent',
|
|
3081
|
+
});
|
|
3082
|
+
|
|
3083
|
+
const reply = parseReply(ws);
|
|
3084
|
+
assert.equal(reply.ok, true);
|
|
3085
|
+
});
|
|
3086
|
+
|
|
3087
|
+
test('resumeSession: rejects deleted sessions', async () => {
|
|
3088
|
+
const manager = createManager();
|
|
3089
|
+
const ws = mockWs();
|
|
3090
|
+
(manager as any).store.remove('deleted-resume');
|
|
3091
|
+
|
|
3092
|
+
await manager.handle(ws, {
|
|
3093
|
+
action: 'session.resume',
|
|
3094
|
+
id: 'req1',
|
|
3095
|
+
sessionId: 'deleted-resume',
|
|
3096
|
+
agent: 'codex',
|
|
3097
|
+
});
|
|
3098
|
+
|
|
3099
|
+
const reply = parseReply(ws);
|
|
3100
|
+
assert.equal(reply.ok, false);
|
|
3101
|
+
assert.equal(reply.error, 'Session not found');
|
|
3102
|
+
});
|
|
3103
|
+
|
|
3104
|
+
// ── interrupt ────────────────────────────────────────────────────────
|
|
3105
|
+
|
|
3106
|
+
test('interrupt: calls driver.interrupt()', async () => {
|
|
3107
|
+
const manager = createManager();
|
|
3108
|
+
const ws = mockWs();
|
|
3109
|
+
const driver = mockDriver();
|
|
3110
|
+
injectSession(manager, {
|
|
3111
|
+
sessionId: 'int-1',
|
|
3112
|
+
agent: 'claude',
|
|
3113
|
+
driver,
|
|
3114
|
+
});
|
|
3115
|
+
|
|
3116
|
+
await manager.handle(ws, {
|
|
3117
|
+
action: 'session.interrupt',
|
|
3118
|
+
id: 'req1',
|
|
3119
|
+
sessionId: 'int-1',
|
|
3120
|
+
});
|
|
3121
|
+
|
|
3122
|
+
const reply = parseReply(ws);
|
|
3123
|
+
assert.equal(reply.ok, true);
|
|
3124
|
+
assert.equal(driver.calls.interrupt.length, 1);
|
|
3125
|
+
});
|
|
3126
|
+
|
|
3127
|
+
test('interrupt: returns error for session without driver', async () => {
|
|
3128
|
+
const manager = createManager();
|
|
3129
|
+
const ws = mockWs();
|
|
3130
|
+
injectSession(manager, {
|
|
3131
|
+
sessionId: 'int-2',
|
|
3132
|
+
agent: 'claude',
|
|
3133
|
+
driver: null,
|
|
3134
|
+
active: false,
|
|
3135
|
+
});
|
|
3136
|
+
|
|
3137
|
+
await manager.handle(ws, {
|
|
3138
|
+
action: 'session.interrupt',
|
|
3139
|
+
id: 'req1',
|
|
3140
|
+
sessionId: 'int-2',
|
|
3141
|
+
});
|
|
3142
|
+
|
|
3143
|
+
const reply = parseReply(ws);
|
|
3144
|
+
assert.equal(reply.ok, false);
|
|
3145
|
+
});
|
|
3146
|
+
|
|
3147
|
+
// ── sweepIdleSessions ────────────────────────────────────────────────
|
|
3148
|
+
|
|
3149
|
+
test('sweepIdleSessions: stops idle drivers beyond timeout', () => {
|
|
3150
|
+
const manager = createManager();
|
|
3151
|
+
const driver = mockDriver();
|
|
3152
|
+
injectSession(manager, {
|
|
3153
|
+
sessionId: 'idle-1',
|
|
3154
|
+
agent: 'claude',
|
|
3155
|
+
driver,
|
|
3156
|
+
lastActivityTs: Date.now() - 60 * 60 * 1000, // 1 hour ago
|
|
3157
|
+
isResponding: false,
|
|
3158
|
+
});
|
|
3159
|
+
|
|
3160
|
+
(manager as any).sweepIdleSessions();
|
|
3161
|
+
|
|
3162
|
+
assert.equal(driver.calls.stop.length, 1, 'idle driver should be stopped');
|
|
3163
|
+
const session = (manager as any).sessions.get('idle-1');
|
|
3164
|
+
assert.equal(session.active, false);
|
|
3165
|
+
assert.equal(session.driver, null);
|
|
3166
|
+
});
|
|
3167
|
+
|
|
3168
|
+
test('sweepIdleSessions: does not stop sessions within timeout', () => {
|
|
3169
|
+
const manager = createManager();
|
|
3170
|
+
const driver = mockDriver();
|
|
3171
|
+
injectSession(manager, {
|
|
3172
|
+
sessionId: 'active-1',
|
|
3173
|
+
agent: 'claude',
|
|
3174
|
+
driver,
|
|
3175
|
+
lastActivityTs: Date.now() - 1000, // 1 second ago
|
|
3176
|
+
isResponding: false,
|
|
3177
|
+
});
|
|
3178
|
+
|
|
3179
|
+
(manager as any).sweepIdleSessions();
|
|
3180
|
+
|
|
3181
|
+
assert.equal(driver.calls.stop.length, 0, 'recent session should not be stopped');
|
|
3182
|
+
});
|
|
3183
|
+
|
|
3184
|
+
test('sweepIdleSessions: stops stalled responding sessions beyond timeout', () => {
|
|
3185
|
+
const manager = createManager();
|
|
3186
|
+
const driver = mockDriver();
|
|
3187
|
+
const ws = mockWs();
|
|
3188
|
+
injectSession(manager, {
|
|
3189
|
+
sessionId: 'responding-stalled-1',
|
|
3190
|
+
agent: 'claude',
|
|
3191
|
+
driver,
|
|
3192
|
+
clients: new Set<WebSocket>([ws]),
|
|
3193
|
+
lastActivityTs: Date.now() - 60 * 60 * 1000, // 1 hour ago
|
|
3194
|
+
isResponding: true,
|
|
3195
|
+
});
|
|
3196
|
+
|
|
3197
|
+
(manager as any).sweepIdleSessions();
|
|
3198
|
+
|
|
3199
|
+
assert.equal(driver.calls.stop.length, 1, 'stalled responding session should be stopped');
|
|
3200
|
+
const session = (manager as any).sessions.get('responding-stalled-1');
|
|
3201
|
+
assert.equal(session.active, false);
|
|
3202
|
+
assert.equal(session.driver, null);
|
|
3203
|
+
const errorMsg = ws.sent
|
|
3204
|
+
.map((entry: string) => JSON.parse(entry))
|
|
3205
|
+
.find((msg: any) => msg.type === 'error');
|
|
3206
|
+
assert.ok(errorMsg, 'stalled turn should broadcast an error');
|
|
3207
|
+
assert.match(errorMsg.message, /stopped responding/i);
|
|
3208
|
+
});
|
|
3209
|
+
|
|
3210
|
+
test('sweepIdleSessions: does not stop responding sessions waiting for approval', () => {
|
|
3211
|
+
const manager = createManager();
|
|
3212
|
+
const driver = mockDriver();
|
|
3213
|
+
injectSession(manager, {
|
|
3214
|
+
sessionId: 'responding-approval-1',
|
|
3215
|
+
agent: 'claude',
|
|
3216
|
+
driver,
|
|
3217
|
+
lastActivityTs: Date.now() - 60 * 60 * 1000, // 1 hour ago
|
|
3218
|
+
isResponding: true,
|
|
3219
|
+
pendingApproval: {
|
|
3220
|
+
requestId: 'req-pending',
|
|
3221
|
+
toolName: 'Write',
|
|
3222
|
+
input: {},
|
|
3223
|
+
description: 'Write file',
|
|
3224
|
+
},
|
|
3225
|
+
});
|
|
3226
|
+
|
|
3227
|
+
(manager as any).sweepIdleSessions();
|
|
3228
|
+
|
|
3229
|
+
assert.equal(driver.calls.stop.length, 0, 'pending approval should not be treated as a stalled turn');
|
|
3230
|
+
});
|
|
3231
|
+
|
|
3232
|
+
test('sweepIdleSessions: does not stop idle sessions waiting for approval', () => {
|
|
3233
|
+
const manager = createManager();
|
|
3234
|
+
const driver = mockDriver();
|
|
3235
|
+
injectSession(manager, {
|
|
3236
|
+
sessionId: 'idle-approval-1',
|
|
3237
|
+
agent: 'codex',
|
|
3238
|
+
driver,
|
|
3239
|
+
lastActivityTs: Date.now() - 60 * 60 * 1000,
|
|
3240
|
+
isResponding: false,
|
|
3241
|
+
pendingApproval: {
|
|
3242
|
+
requestId: 'req-idle',
|
|
3243
|
+
toolName: 'Bash',
|
|
3244
|
+
input: { command: 'pwd' },
|
|
3245
|
+
description: 'Run pwd',
|
|
3246
|
+
approvalContext: {
|
|
3247
|
+
provider: 'codex',
|
|
3248
|
+
kind: 'command-execution',
|
|
3249
|
+
rpcId: 88,
|
|
3250
|
+
},
|
|
3251
|
+
},
|
|
3252
|
+
});
|
|
3253
|
+
|
|
3254
|
+
(manager as any).sweepIdleSessions();
|
|
3255
|
+
|
|
3256
|
+
assert.equal(driver.calls.stop.length, 0, 'pending approval should bypass idle timeout');
|
|
3257
|
+
const session = (manager as any).sessions.get('idle-approval-1');
|
|
3258
|
+
assert.equal(session.active, true);
|
|
3259
|
+
});
|
|
3260
|
+
|
|
3261
|
+
test('sweepIdleSessions: honors disabled stall timeout', () => {
|
|
3262
|
+
const originalTurnStallTimeoutMs = config.turnStallTimeoutMs;
|
|
3263
|
+
(config as { turnStallTimeoutMs: number }).turnStallTimeoutMs = 0;
|
|
3264
|
+
try {
|
|
3265
|
+
const manager = createManager();
|
|
3266
|
+
const driver = mockDriver();
|
|
3267
|
+
injectSession(manager, {
|
|
3268
|
+
sessionId: 'responding-disabled-1',
|
|
3269
|
+
agent: 'claude',
|
|
3270
|
+
driver,
|
|
3271
|
+
lastActivityTs: Date.now() - 60 * 60 * 1000, // 1 hour ago
|
|
3272
|
+
isResponding: true,
|
|
3273
|
+
});
|
|
3274
|
+
|
|
3275
|
+
(manager as any).sweepIdleSessions();
|
|
3276
|
+
|
|
3277
|
+
assert.equal(driver.calls.stop.length, 0, 'disabled stall timeout should skip responding sessions');
|
|
3278
|
+
} finally {
|
|
3279
|
+
(config as { turnStallTimeoutMs: number }).turnStallTimeoutMs = originalTurnStallTimeoutMs;
|
|
3280
|
+
}
|
|
3281
|
+
});
|
|
3282
|
+
|
|
3283
|
+
test('sweepIdleSessions: skips sessions without driver', () => {
|
|
3284
|
+
const manager = createManager();
|
|
3285
|
+
injectSession(manager, {
|
|
3286
|
+
sessionId: 'nodriver-1',
|
|
3287
|
+
agent: 'claude',
|
|
3288
|
+
driver: null,
|
|
3289
|
+
active: false,
|
|
3290
|
+
lastActivityTs: Date.now() - 60 * 60 * 1000,
|
|
3291
|
+
});
|
|
3292
|
+
|
|
3293
|
+
// Should not throw
|
|
3294
|
+
(manager as any).sweepIdleSessions();
|
|
3295
|
+
});
|
|
3296
|
+
|
|
3297
|
+
test('sweepIdleSessions: removes inactive sessions from map after 1 hour', () => {
|
|
3298
|
+
const manager = createManager();
|
|
3299
|
+
injectSession(manager, {
|
|
3300
|
+
sessionId: 'inactive-1',
|
|
3301
|
+
agent: 'claude',
|
|
3302
|
+
active: false,
|
|
3303
|
+
driver: null,
|
|
3304
|
+
lastActivityTs: Date.now() - 2 * 60 * 60 * 1000, // 2 hours ago
|
|
3305
|
+
clients: new Set(),
|
|
3306
|
+
});
|
|
3307
|
+
|
|
3308
|
+
assert.ok((manager as any).sessions.has('inactive-1'), 'session should exist before sweep');
|
|
3309
|
+
|
|
3310
|
+
(manager as any).sweepIdleSessions();
|
|
3311
|
+
|
|
3312
|
+
assert.ok(!(manager as any).sessions.has('inactive-1'), 'inactive session should be removed from map');
|
|
3313
|
+
});
|
|
3314
|
+
|
|
3315
|
+
test('sweepIdleSessions: does not remove inactive sessions with connected clients', () => {
|
|
3316
|
+
const manager = createManager();
|
|
3317
|
+
const ws = mockWs();
|
|
3318
|
+
injectSession(manager, {
|
|
3319
|
+
sessionId: 'inactive-with-client',
|
|
3320
|
+
agent: 'claude',
|
|
3321
|
+
active: false,
|
|
3322
|
+
driver: null,
|
|
3323
|
+
lastActivityTs: Date.now() - 2 * 60 * 60 * 1000, // 2 hours ago
|
|
3324
|
+
clients: new Set([ws as any]),
|
|
3325
|
+
});
|
|
3326
|
+
|
|
3327
|
+
(manager as any).sweepIdleSessions();
|
|
3328
|
+
|
|
3329
|
+
assert.ok(
|
|
3330
|
+
(manager as any).sessions.has('inactive-with-client'),
|
|
3331
|
+
'inactive session with connected clients should NOT be removed',
|
|
3332
|
+
);
|
|
3333
|
+
});
|
|
3334
|
+
|
|
3335
|
+
test('sweepIdleSessions: stalled driver resolves pending claude hook approvals', () => {
|
|
3336
|
+
const manager = createManager();
|
|
3337
|
+
const driver = mockDriver();
|
|
3338
|
+
const ws = mockWs();
|
|
3339
|
+
const deferred = createDeferred<unknown>();
|
|
3340
|
+
const session = injectSession(manager, {
|
|
3341
|
+
sessionId: 'stall-hooks-1',
|
|
3342
|
+
agent: 'claude',
|
|
3343
|
+
driver,
|
|
3344
|
+
clients: new Set<WebSocket>([ws]),
|
|
3345
|
+
lastActivityTs: Date.now() - 60 * 60 * 1000,
|
|
3346
|
+
isResponding: true,
|
|
3347
|
+
pendingClaudeHookApprovals: new Map([
|
|
3348
|
+
['hook-req-1', { requestId: 'hook-req-1', promise: deferred.promise, resolve: deferred.resolve }],
|
|
3349
|
+
]) as any,
|
|
3350
|
+
});
|
|
3351
|
+
|
|
3352
|
+
(manager as any).sweepIdleSessions();
|
|
3353
|
+
|
|
3354
|
+
assert.equal(driver.calls.stop.length, 1, 'stalled driver should be stopped');
|
|
3355
|
+
assert.equal(session.pendingClaudeHookApprovals.size, 0, 'pending hook approvals should be resolved');
|
|
3356
|
+
assert.equal(session.active, false);
|
|
3357
|
+
assert.equal(session.driver, null);
|
|
3358
|
+
assert.equal(session.isResponding, false);
|
|
3359
|
+
});
|
|
3360
|
+
|
|
3361
|
+
test('sweepIdleSessions: stalled session stays in map for reconnect', () => {
|
|
3362
|
+
const manager = createManager();
|
|
3363
|
+
const driver = mockDriver();
|
|
3364
|
+
injectSession(manager, {
|
|
3365
|
+
sessionId: 'stall-reconnect-1',
|
|
3366
|
+
agent: 'claude',
|
|
3367
|
+
driver,
|
|
3368
|
+
lastActivityTs: Date.now() - 60 * 60 * 1000,
|
|
3369
|
+
isResponding: true,
|
|
3370
|
+
});
|
|
3371
|
+
|
|
3372
|
+
(manager as any).sweepIdleSessions();
|
|
3373
|
+
|
|
3374
|
+
assert.ok(
|
|
3375
|
+
(manager as any).sessions.has('stall-reconnect-1'),
|
|
3376
|
+
'stalled session should remain in map so next message can auto-reconnect',
|
|
3377
|
+
);
|
|
3378
|
+
const session = (manager as any).sessions.get('stall-reconnect-1');
|
|
3379
|
+
assert.equal(session.active, false);
|
|
3380
|
+
assert.equal(session.driver, null);
|
|
3381
|
+
});
|
|
3382
|
+
|
|
3383
|
+
// ── buildPushBody ────────────────────────────────────────────────────
|
|
3384
|
+
|
|
3385
|
+
test('buildPushBody: returns default for empty text', () => {
|
|
3386
|
+
const manager = createManager();
|
|
3387
|
+
assert.equal((manager as any).buildPushBody(''), 'Done.');
|
|
3388
|
+
assert.equal((manager as any).buildPushBody(' '), 'Done.');
|
|
3389
|
+
});
|
|
3390
|
+
|
|
3391
|
+
test('buildPushBody: returns short text as-is', () => {
|
|
3392
|
+
const manager = createManager();
|
|
3393
|
+
assert.equal((manager as any).buildPushBody('Hello world'), 'Hello world');
|
|
3394
|
+
});
|
|
3395
|
+
|
|
3396
|
+
test('buildPushBody: collapses whitespace', () => {
|
|
3397
|
+
const manager = createManager();
|
|
3398
|
+
assert.equal((manager as any).buildPushBody('hello\n world\t!'), 'hello world !');
|
|
3399
|
+
});
|
|
3400
|
+
|
|
3401
|
+
test('buildPushBody: truncates long text with ellipsis', () => {
|
|
3402
|
+
const manager = createManager();
|
|
3403
|
+
const longText = 'a'.repeat(300);
|
|
3404
|
+
const result = (manager as any).buildPushBody(longText);
|
|
3405
|
+
assert.ok(result.endsWith('...'), 'should end with ellipsis');
|
|
3406
|
+
assert.ok(result.length <= 183, `should be at most 183 chars, got ${result.length}`);
|
|
3407
|
+
});
|
|
3408
|
+
|
|
3409
|
+
// ── getActiveSessionCount / getDriverCounts ──────────────────────────
|
|
3410
|
+
|
|
3411
|
+
test('getActiveSessionCount: counts only active sessions', () => {
|
|
3412
|
+
const manager = createManager();
|
|
3413
|
+
injectSession(manager, { sessionId: 'a1', active: true });
|
|
3414
|
+
injectSession(manager, { sessionId: 'a2', active: true });
|
|
3415
|
+
injectSession(manager, { sessionId: 'a3', active: false, driver: null });
|
|
3416
|
+
|
|
3417
|
+
assert.equal(manager.getActiveSessionCount(), 2);
|
|
3418
|
+
});
|
|
3419
|
+
|
|
3420
|
+
test('getDriverCounts: groups by agent type', () => {
|
|
3421
|
+
const manager = createManager();
|
|
3422
|
+
injectSession(manager, { sessionId: 'c1', agent: 'claude', active: true });
|
|
3423
|
+
injectSession(manager, { sessionId: 'c2', agent: 'claude', active: true });
|
|
3424
|
+
injectSession(manager, { sessionId: 'x1', agent: 'codex', active: true });
|
|
3425
|
+
injectSession(manager, { sessionId: 'x2', agent: 'codex', active: false, driver: null });
|
|
3426
|
+
|
|
3427
|
+
const counts = manager.getDriverCounts();
|
|
3428
|
+
assert.equal(counts.claude, 2);
|
|
3429
|
+
assert.equal(counts.codex, 1);
|
|
3430
|
+
});
|