@elvatis_com/openclaw-cli-bridge-elvatis 2.5.0 → 2.6.1

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.
@@ -0,0 +1,264 @@
1
+ /**
2
+ * provider-sessions.ts
3
+ *
4
+ * Persistent session registry for CLI bridge provider sessions.
5
+ *
6
+ * A "provider session" represents a long-lived context with a CLI provider
7
+ * (Claude, Gemini, Codex, etc.). Sessions survive across individual runs:
8
+ * when a run times out, the session persists so that follow-up runs can
9
+ * resume in the same context.
10
+ *
11
+ * Session vs Run:
12
+ * - Session: long-lived unit (provider context, profile, remote session ID)
13
+ * - Run: single request within a session (messages, tools, timeout)
14
+ *
15
+ * Storage: in-memory Map + periodic flush to ~/.openclaw/cli-bridge/sessions.json.
16
+ */
17
+
18
+ import { randomBytes } from "node:crypto";
19
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
20
+ import { dirname } from "node:path";
21
+ import {
22
+ PROVIDER_SESSIONS_FILE,
23
+ PROVIDER_SESSION_TTL_MS,
24
+ PROVIDER_SESSION_SWEEP_MS,
25
+ } from "./config.js";
26
+
27
+ // ──────────────────────────────────────────────────────────────────────────────
28
+ // Types
29
+ // ──────────────────────────────────────────────────────────────────────────────
30
+
31
+ export type ProviderAlias = "claude" | "gemini" | "grok" | "codex" | "opencode" | "pi" | "bitnet" | string;
32
+
33
+ export type SessionState = "active" | "idle" | "expired";
34
+
35
+ export interface ProviderSession {
36
+ /** Unique session ID, e.g. "claude:session-a1b2c3d4". */
37
+ id: string;
38
+ /** Provider type. */
39
+ provider: ProviderAlias;
40
+ /** Full model alias, e.g. "cli-claude/claude-sonnet-4-6". */
41
+ modelAlias: string;
42
+ /** Unix timestamp when the session was created. */
43
+ createdAt: number;
44
+ /** Unix timestamp of the last activity (run start, touch). */
45
+ updatedAt: number;
46
+ /** Current session state. */
47
+ state: SessionState;
48
+ /** Total runs executed in this session. */
49
+ runCount: number;
50
+ /** Number of runs that timed out. */
51
+ timeoutCount: number;
52
+ /** Provider-specific state (profile path, remote session ID, etc.). */
53
+ meta: Record<string, unknown>;
54
+ }
55
+
56
+ export interface CreateSessionOptions {
57
+ /** Provider-specific metadata. */
58
+ meta?: Record<string, unknown>;
59
+ }
60
+
61
+ // ──────────────────────────────────────────────────────────────────────────────
62
+ // Registry
63
+ // ──────────────────────────────────────────────────────────────────────────────
64
+
65
+ /** Serialized format of the sessions file. */
66
+ interface SessionStore {
67
+ version: 1;
68
+ sessions: ProviderSession[];
69
+ }
70
+
71
+ export class ProviderSessionRegistry {
72
+ private sessions = new Map<string, ProviderSession>();
73
+ private sweepTimer: ReturnType<typeof setInterval> | null = null;
74
+ private dirty = false;
75
+
76
+ constructor() {
77
+ this.load();
78
+ this.sweepTimer = setInterval(() => this.sweep(), PROVIDER_SESSION_SWEEP_MS);
79
+ if (this.sweepTimer.unref) this.sweepTimer.unref();
80
+ }
81
+
82
+ // ── CRUD ─────────────────────────────────────────────────────────────────
83
+
84
+ /**
85
+ * Create a new provider session.
86
+ * Returns the session with a unique ID.
87
+ */
88
+ createSession(
89
+ provider: ProviderAlias,
90
+ modelAlias: string,
91
+ opts: CreateSessionOptions = {}
92
+ ): ProviderSession {
93
+ const now = Date.now();
94
+ const id = `${provider}:session-${randomBytes(6).toString("hex")}`;
95
+ const session: ProviderSession = {
96
+ id,
97
+ provider,
98
+ modelAlias,
99
+ createdAt: now,
100
+ updatedAt: now,
101
+ state: "active",
102
+ runCount: 0,
103
+ timeoutCount: 0,
104
+ meta: opts.meta ?? {},
105
+ };
106
+ this.sessions.set(id, session);
107
+ this.dirty = true;
108
+ this.flush();
109
+ return session;
110
+ }
111
+
112
+ /** Get a session by ID. Returns undefined if not found. */
113
+ getSession(id: string): ProviderSession | undefined {
114
+ return this.sessions.get(id);
115
+ }
116
+
117
+ /**
118
+ * Find an existing active session for the given provider+model.
119
+ * Returns the most recently updated match, or undefined.
120
+ */
121
+ findSession(provider: ProviderAlias, modelAlias: string): ProviderSession | undefined {
122
+ let best: ProviderSession | undefined;
123
+ for (const s of this.sessions.values()) {
124
+ if (s.provider !== provider || s.modelAlias !== modelAlias) continue;
125
+ if (s.state === "expired") continue;
126
+ if (!best || s.updatedAt > best.updatedAt) best = s;
127
+ }
128
+ return best;
129
+ }
130
+
131
+ /**
132
+ * Get or create a session for the given provider+model.
133
+ * Reuses existing active session if available.
134
+ */
135
+ ensureSession(
136
+ provider: ProviderAlias,
137
+ modelAlias: string,
138
+ opts: CreateSessionOptions = {}
139
+ ): ProviderSession {
140
+ const existing = this.findSession(provider, modelAlias);
141
+ if (existing) {
142
+ this.touchSession(existing.id);
143
+ return existing;
144
+ }
145
+ return this.createSession(provider, modelAlias, opts);
146
+ }
147
+
148
+ /**
149
+ * Update the session's last-activity timestamp and set state to active.
150
+ * Call this at the start of every run.
151
+ */
152
+ touchSession(id: string): boolean {
153
+ const session = this.sessions.get(id);
154
+ if (!session) return false;
155
+ session.updatedAt = Date.now();
156
+ if (session.state === "idle") session.state = "active";
157
+ this.dirty = true;
158
+ return true;
159
+ }
160
+
161
+ /** Record that a run completed in this session. */
162
+ recordRun(id: string, timedOut: boolean): void {
163
+ const session = this.sessions.get(id);
164
+ if (!session) return;
165
+ session.runCount++;
166
+ if (timedOut) session.timeoutCount++;
167
+ session.updatedAt = Date.now();
168
+ session.state = "idle"; // run finished, session stays alive
169
+ this.dirty = true;
170
+ this.flush();
171
+ }
172
+
173
+ /** Delete a session by ID. */
174
+ deleteSession(id: string): boolean {
175
+ const deleted = this.sessions.delete(id);
176
+ if (deleted) {
177
+ this.dirty = true;
178
+ this.flush();
179
+ }
180
+ return deleted;
181
+ }
182
+
183
+ /** List all sessions. */
184
+ listSessions(): ProviderSession[] {
185
+ return [...this.sessions.values()];
186
+ }
187
+
188
+ /** Get summary stats for logging/status. */
189
+ stats(): { total: number; active: number; idle: number; expired: number } {
190
+ let active = 0, idle = 0, expired = 0;
191
+ for (const s of this.sessions.values()) {
192
+ if (s.state === "active") active++;
193
+ else if (s.state === "idle") idle++;
194
+ else expired++;
195
+ }
196
+ return { total: this.sessions.size, active, idle, expired };
197
+ }
198
+
199
+ // ── Lifecycle ────────────────────────────────────────────────────────────
200
+
201
+ /** Sweep stale sessions (older than PROVIDER_SESSION_TTL_MS without activity). */
202
+ sweep(): void {
203
+ const now = Date.now();
204
+ let changed = false;
205
+ for (const [id, session] of this.sessions) {
206
+ if (now - session.updatedAt > PROVIDER_SESSION_TTL_MS) {
207
+ session.state = "expired";
208
+ this.sessions.delete(id);
209
+ changed = true;
210
+ }
211
+ }
212
+ if (changed) {
213
+ this.dirty = true;
214
+ this.flush();
215
+ }
216
+ }
217
+
218
+ /** Stop the sweep timer (for graceful shutdown). */
219
+ stop(): void {
220
+ if (this.sweepTimer) {
221
+ clearInterval(this.sweepTimer);
222
+ this.sweepTimer = null;
223
+ }
224
+ this.flush();
225
+ }
226
+
227
+ // ── Persistence ──────────────────────────────────────────────────────────
228
+
229
+ /** Load sessions from disk. */
230
+ private load(): void {
231
+ try {
232
+ const raw = readFileSync(PROVIDER_SESSIONS_FILE, "utf-8");
233
+ const store = JSON.parse(raw) as SessionStore;
234
+ if (store.version === 1 && Array.isArray(store.sessions)) {
235
+ for (const s of store.sessions) {
236
+ // Skip expired sessions on load
237
+ if (Date.now() - s.updatedAt > PROVIDER_SESSION_TTL_MS) continue;
238
+ this.sessions.set(s.id, s);
239
+ }
240
+ }
241
+ } catch {
242
+ // No file yet or corrupt — start fresh
243
+ }
244
+ }
245
+
246
+ /** Flush dirty sessions to disk. */
247
+ private flush(): void {
248
+ if (!this.dirty) return;
249
+ try {
250
+ mkdirSync(dirname(PROVIDER_SESSIONS_FILE), { recursive: true });
251
+ const store: SessionStore = {
252
+ version: 1,
253
+ sessions: [...this.sessions.values()],
254
+ };
255
+ writeFileSync(PROVIDER_SESSIONS_FILE, JSON.stringify(store, null, 2) + "\n", "utf-8");
256
+ this.dirty = false;
257
+ } catch {
258
+ // Non-fatal — sessions are still in memory
259
+ }
260
+ }
261
+ }
262
+
263
+ /** Shared singleton instance. */
264
+ export const providerSessions = new ProviderSessionRegistry();
@@ -20,6 +20,17 @@ import type { BrowserContext } from "playwright";
20
20
  import { renderStatusPage, type StatusProvider } from "./status-template.js";
21
21
  import { sessionManager } from "./session-manager.js";
22
22
  import { metrics } from "./metrics.js";
23
+ import { providerSessions } from "./provider-sessions.js";
24
+ import {
25
+ DEFAULT_PROXY_TIMEOUT_MS,
26
+ MAX_EFFECTIVE_TIMEOUT_MS,
27
+ TIMEOUT_PER_EXTRA_MSG_MS,
28
+ TIMEOUT_PER_TOOL_MS,
29
+ SSE_KEEPALIVE_INTERVAL_MS,
30
+ DEFAULT_BITNET_SERVER_URL,
31
+ BITNET_MAX_MESSAGES,
32
+ BITNET_SYSTEM_PROMPT,
33
+ } from "./config.js";
23
34
 
24
35
  export type GrokCompleteOptions = Parameters<typeof grokComplete>[1];
25
36
  export type GrokCompleteStreamOptions = Parameters<typeof grokCompleteStream>[1];
@@ -153,10 +164,11 @@ export function startProxyServer(opts: ProxyServerOptions): Promise<http.Server>
153
164
  });
154
165
  });
155
166
 
156
- // Stop the token refresh interval and session manager when the server closes (timer-leak prevention)
167
+ // Stop timers and flush state when the server closes (timer-leak prevention)
157
168
  server.on("close", () => {
158
169
  stopTokenRefresh();
159
170
  sessionManager.stop();
171
+ providerSessions.stop();
160
172
  });
161
173
 
162
174
  server.on("error", (err: NodeJS.ErrnoException) => {
@@ -547,7 +559,7 @@ async function handleRequest(
547
559
 
548
560
  // ── BitNet local inference routing ────────────────────────────────────────
549
561
  if (model.startsWith("local-bitnet/")) {
550
- const bitnetUrl = opts.getBitNetServerUrl?.() ?? "http://127.0.0.1:8082";
562
+ const bitnetUrl = opts.getBitNetServerUrl?.() ?? DEFAULT_BITNET_SERVER_URL;
551
563
  const timeoutMs = opts.timeoutMs ?? 120_000;
552
564
  // llama-server (BitNet build) crashes with std::runtime_error on multi-part
553
565
  // content arrays (ref: https://github.com/ggerganov/llama.cpp/issues/8367).
@@ -564,18 +576,14 @@ async function handleRequest(
564
576
  };
565
577
  // BitNet has a 4096 token context window. Long sessions blow it up and
566
578
  // cause a hard C++ crash (no graceful error). Truncate to system prompt +
567
- // last 10 messages (~2k tokens max) to stay safely within the limit.
568
- const BITNET_MAX_MESSAGES = 6;
569
- // Replace the full system prompt (MEMORY.md etc, ~2k+ tokens) with a
570
- // minimal one so BitNet's 4096-token context isn't blown by the system msg alone.
571
- const BITNET_SYSTEM = "You are Akido, a concise AI assistant. Answer briefly and directly. Current user: Emre. Timezone: Europe/Berlin.";
579
+ // last N messages (~2k tokens max) to stay safely within the limit.
572
580
  const allFlat = parsed.messages.map((m) => ({
573
581
  role: m.role,
574
582
  content: flattenContent(m.content),
575
583
  }));
576
584
  const nonSystemMsgs = allFlat.filter((m) => m.role !== "system");
577
585
  const truncated = nonSystemMsgs.slice(-BITNET_MAX_MESSAGES);
578
- const bitnetMessages = [{ role: "system", content: BITNET_SYSTEM }, ...truncated];
586
+ const bitnetMessages = [{ role: "system", content: BITNET_SYSTEM_PROMPT }, ...truncated];
579
587
  const requestBody = JSON.stringify({ ...parsed, messages: bitnetMessages, tools: undefined });
580
588
 
581
589
  const bitnetStart = Date.now();
@@ -639,14 +647,23 @@ async function handleRequest(
639
647
  let usedModel = model;
640
648
  const routeOpts = { workdir, tools: hasTools ? tools : undefined, mediaFiles: mediaFiles.length ? mediaFiles : undefined, log: opts.log };
641
649
 
650
+ // ── Provider session: ensure a persistent session for this model ────────
651
+ // Extract provider prefix from model (e.g. "cli-claude" from "cli-claude/claude-sonnet-4-6")
652
+ const providerPrefix = model.split("/")[0];
653
+ const incomingSessionId = (parsed as { providerSessionId?: string }).providerSessionId;
654
+ const session = incomingSessionId
655
+ ? (providerSessions.getSession(incomingSessionId) ?? providerSessions.ensureSession(providerPrefix, model))
656
+ : providerSessions.ensureSession(providerPrefix, model);
657
+ providerSessions.touchSession(session.id);
658
+
642
659
  // ── Dynamic timeout: scale with conversation size ────────────────────────
643
660
  // Per-model timeout takes precedence, then global proxyTimeoutMs, then 300s default.
644
661
  const perModelTimeout = opts.modelTimeouts?.[model];
645
- const baseTimeout = perModelTimeout ?? opts.timeoutMs ?? 300_000;
646
- const msgExtra = Math.max(0, cleanMessages.length - 10) * 2_000;
647
- const toolExtra = (tools?.length ?? 0) * 5_000;
648
- const effectiveTimeout = Math.min(baseTimeout + msgExtra + toolExtra, 600_000);
649
- opts.log(`[cli-bridge] ${model} timeout: ${Math.round(effectiveTimeout / 1000)}s (base=${Math.round(baseTimeout / 1000)}s${perModelTimeout ? " per-model" : ""}, +${Math.round(msgExtra / 1000)}s msgs, +${Math.round(toolExtra / 1000)}s tools)`);
662
+ const baseTimeout = perModelTimeout ?? opts.timeoutMs ?? DEFAULT_PROXY_TIMEOUT_MS;
663
+ const msgExtra = Math.max(0, cleanMessages.length - 10) * TIMEOUT_PER_EXTRA_MSG_MS;
664
+ const toolExtra = (tools?.length ?? 0) * TIMEOUT_PER_TOOL_MS;
665
+ const effectiveTimeout = Math.min(baseTimeout + msgExtra + toolExtra, MAX_EFFECTIVE_TIMEOUT_MS);
666
+ opts.log(`[cli-bridge] ${model} session=${session.id} timeout: ${Math.round(effectiveTimeout / 1000)}s (base=${Math.round(baseTimeout / 1000)}s${perModelTimeout ? " per-model" : ""}, +${Math.round(msgExtra / 1000)}s msgs, +${Math.round(toolExtra / 1000)}s tools)`);
650
667
 
651
668
  // ── SSE keepalive: send headers early so OpenClaw doesn't read-timeout ──
652
669
  let sseHeadersSent = false;
@@ -660,22 +677,25 @@ async function handleRequest(
660
677
  });
661
678
  sseHeadersSent = true;
662
679
  res.write(": keepalive\n\n");
663
- keepaliveInterval = setInterval(() => { res.write(": keepalive\n\n"); }, 15_000);
680
+ keepaliveInterval = setInterval(() => { res.write(": keepalive\n\n"); }, SSE_KEEPALIVE_INTERVAL_MS);
664
681
  }
665
682
 
666
683
  const cliStart = Date.now();
667
684
  try {
668
685
  result = await routeToCliRunner(model, cleanMessages, effectiveTimeout, routeOpts);
669
686
  metrics.recordRequest(model, Date.now() - cliStart, true);
687
+ providerSessions.recordRun(session.id, false);
670
688
  } catch (err) {
671
689
  const primaryDuration = Date.now() - cliStart;
672
690
  const msg = (err as Error).message;
673
691
  // ── Model fallback: retry once with a lighter model if configured ────
674
692
  const isTimeout = msg.includes("timeout:") || msg.includes("exit 143") || msg.includes("exited 143");
693
+ // Record the run (with timeout flag) — session is preserved, not deleted
694
+ providerSessions.recordRun(session.id, isTimeout);
675
695
  const fallbackModel = opts.modelFallbacks?.[model];
676
696
  if (fallbackModel) {
677
697
  metrics.recordRequest(model, primaryDuration, false);
678
- const reason = isTimeout ? "timeout by supervisor" : msg;
698
+ const reason = isTimeout ? `timeout by supervisor, session=${session.id} preserved` : msg;
679
699
  opts.warn(`[cli-bridge] ${model} failed (${reason}), falling back to ${fallbackModel}`);
680
700
  const fallbackStart = Date.now();
681
701
  try {
@@ -787,6 +807,8 @@ async function handleRequest(
787
807
  },
788
808
  ],
789
809
  usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
810
+ // Propagate session ID so callers can resume in the same session
811
+ provider_session_id: session.id,
790
812
  };
791
813
 
792
814
  res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
@@ -906,6 +928,26 @@ async function handleRequest(
906
928
  return;
907
929
  }
908
930
 
931
+ // ── Provider session endpoints ──────────────────────────────────────────────
932
+
933
+ // GET /v1/provider-sessions — list all provider sessions with stats
934
+ if (url === "/v1/provider-sessions" && req.method === "GET") {
935
+ const sessions = providerSessions.listSessions();
936
+ const stats = providerSessions.stats();
937
+ res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
938
+ res.end(JSON.stringify({ sessions, stats }));
939
+ return;
940
+ }
941
+
942
+ // DELETE /v1/provider-sessions/:id — delete a specific provider session
943
+ const provSessionMatch = url.match(/^\/v1\/provider-sessions\/([a-zA-Z0-9:_-]+)$/);
944
+ if (provSessionMatch && req.method === "DELETE") {
945
+ const ok = providerSessions.deleteSession(decodeURIComponent(provSessionMatch[1]));
946
+ res.writeHead(ok ? 200 : 404, { "Content-Type": "application/json", ...corsHeaders() });
947
+ res.end(JSON.stringify({ ok }));
948
+ return;
949
+ }
950
+
909
951
  // 404
910
952
  res.writeHead(404, { "Content-Type": "application/json" });
911
953
  res.end(JSON.stringify({ error: { message: `Not found: ${url}`, type: "not_found" } }));
@@ -16,6 +16,11 @@ import { join } from "node:path";
16
16
  import { execSync } from "node:child_process";
17
17
  import { formatPrompt, type ChatMessage } from "./cli-runner.js";
18
18
  import { createIsolatedWorkdir, cleanupWorkdir, sweepOrphanedWorkdirs } from "./workdir.js";
19
+ import {
20
+ SESSION_TTL_MS,
21
+ CLEANUP_INTERVAL_MS,
22
+ SESSION_KILL_GRACE_MS,
23
+ } from "./config.js";
19
24
 
20
25
  // ──────────────────────────────────────────────────────────────────────────────
21
26
  // Types
@@ -92,11 +97,7 @@ function buildMinimalEnv(): Record<string, string> {
92
97
  // Session Manager
93
98
  // ──────────────────────────────────────────────────────────────────────────────
94
99
 
95
- /** Auto-cleanup interval: 30 minutes. */
96
- const SESSION_TTL_MS = 30 * 60 * 1000;
97
- const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
98
- /** Grace period between SIGTERM and SIGKILL for session termination. */
99
- const KILL_GRACE_MS = 5_000;
100
+ // SESSION_TTL_MS, CLEANUP_INTERVAL_MS, SESSION_KILL_GRACE_MS imported from config.ts
100
101
 
101
102
  export class SessionManager {
102
103
  private sessions = new Map<string, SessionEntry>();
@@ -227,7 +228,7 @@ export class SessionManager {
227
228
  // If the process doesn't exit within the grace period, force-kill it
228
229
  setTimeout(() => {
229
230
  try { if (!entry.proc.killed) entry.proc.kill("SIGKILL"); } catch { /* already dead */ }
230
- }, KILL_GRACE_MS);
231
+ }, SESSION_KILL_GRACE_MS);
231
232
  return true;
232
233
  }
233
234
 
@@ -258,7 +259,7 @@ export class SessionManager {
258
259
  // Escalate to SIGKILL after grace period
259
260
  setTimeout(() => {
260
261
  try { if (!entry.proc.killed) entry.proc.kill("SIGKILL"); } catch { /* already dead */ }
261
- }, KILL_GRACE_MS);
262
+ }, SESSION_KILL_GRACE_MS);
262
263
  }
263
264
  // Clean up isolated workdir if it wasn't cleaned on exit
264
265
  if (entry.isolatedWorkdir) {
@@ -284,7 +285,7 @@ export class SessionManager {
284
285
  entry.status = "killed";
285
286
  setTimeout(() => {
286
287
  try { if (!entry.proc.killed) entry.proc.kill("SIGKILL"); } catch { /* already dead */ }
287
- }, KILL_GRACE_MS);
288
+ }, SESSION_KILL_GRACE_MS);
288
289
  }
289
290
  if (entry.isolatedWorkdir) {
290
291
  cleanupWorkdir(entry.isolatedWorkdir);
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Tests for the centralized config module.
3
+ * No mocks — tests the real exported values.
4
+ */
5
+
6
+ import { describe, it, expect } from "vitest";
7
+ import {
8
+ DEFAULT_PROXY_PORT,
9
+ DEFAULT_PROXY_API_KEY,
10
+ DEFAULT_PROXY_TIMEOUT_MS,
11
+ DEFAULT_CLI_TIMEOUT_MS,
12
+ TIMEOUT_GRACE_MS,
13
+ MAX_MESSAGES,
14
+ MAX_MSG_CHARS,
15
+ SESSION_TTL_MS,
16
+ CLEANUP_INTERVAL_MS,
17
+ SESSION_KILL_GRACE_MS,
18
+ DEFAULT_MODEL_TIMEOUTS,
19
+ DEFAULT_MODEL_FALLBACKS,
20
+ OPENCLAW_DIR,
21
+ CLI_TEST_DEFAULT_MODEL,
22
+ MAX_EFFECTIVE_TIMEOUT_MS,
23
+ TIMEOUT_PER_EXTRA_MSG_MS,
24
+ TIMEOUT_PER_TOOL_MS,
25
+ PROVIDER_SESSION_TTL_MS,
26
+ MEDIA_TMP_DIR,
27
+ PROFILE_DIRS,
28
+ STATE_FILE,
29
+ PENDING_FILE,
30
+ PROVIDER_SESSIONS_FILE,
31
+ DEFAULT_BITNET_SERVER_URL,
32
+ BITNET_MAX_MESSAGES,
33
+ BITNET_SYSTEM_PROMPT,
34
+ } from "../src/config.js";
35
+
36
+ describe("config.ts exports", () => {
37
+ it("exports sensible timeout defaults", () => {
38
+ expect(DEFAULT_PROXY_TIMEOUT_MS).toBe(300_000);
39
+ expect(DEFAULT_CLI_TIMEOUT_MS).toBe(120_000);
40
+ expect(TIMEOUT_GRACE_MS).toBe(5_000);
41
+ expect(MAX_EFFECTIVE_TIMEOUT_MS).toBe(600_000);
42
+ expect(SESSION_TTL_MS).toBe(30 * 60 * 1000);
43
+ expect(CLEANUP_INTERVAL_MS).toBe(5 * 60 * 1000);
44
+ expect(SESSION_KILL_GRACE_MS).toBe(5_000);
45
+ expect(PROVIDER_SESSION_TTL_MS).toBe(2 * 60 * 60 * 1000);
46
+ });
47
+
48
+ it("exports dynamic timeout scaling factors", () => {
49
+ expect(TIMEOUT_PER_EXTRA_MSG_MS).toBe(2_000);
50
+ expect(TIMEOUT_PER_TOOL_MS).toBe(5_000);
51
+ });
52
+
53
+ it("exports message limits", () => {
54
+ expect(MAX_MESSAGES).toBe(20);
55
+ expect(MAX_MSG_CHARS).toBe(4_000);
56
+ });
57
+
58
+ it("exports proxy defaults", () => {
59
+ expect(DEFAULT_PROXY_PORT).toBe(31337);
60
+ expect(DEFAULT_PROXY_API_KEY).toBe("cli-bridge");
61
+ });
62
+
63
+ it("exports per-model timeouts for all major models", () => {
64
+ expect(DEFAULT_MODEL_TIMEOUTS["cli-claude/claude-opus-4-6"]).toBe(300_000);
65
+ expect(DEFAULT_MODEL_TIMEOUTS["cli-claude/claude-sonnet-4-6"]).toBe(180_000);
66
+ expect(DEFAULT_MODEL_TIMEOUTS["cli-claude/claude-haiku-4-5"]).toBe(90_000);
67
+ expect(DEFAULT_MODEL_TIMEOUTS["cli-gemini/gemini-2.5-pro"]).toBe(180_000);
68
+ expect(DEFAULT_MODEL_TIMEOUTS["cli-gemini/gemini-2.5-flash"]).toBe(90_000);
69
+ expect(DEFAULT_MODEL_TIMEOUTS["openai-codex/gpt-5.4"]).toBe(300_000);
70
+ });
71
+
72
+ it("exports model fallback chains", () => {
73
+ expect(DEFAULT_MODEL_FALLBACKS["cli-claude/claude-sonnet-4-6"]).toBe("cli-claude/claude-haiku-4-5");
74
+ expect(DEFAULT_MODEL_FALLBACKS["cli-claude/claude-opus-4-6"]).toBe("cli-claude/claude-sonnet-4-6");
75
+ expect(DEFAULT_MODEL_FALLBACKS["cli-gemini/gemini-2.5-pro"]).toBe("cli-gemini/gemini-2.5-flash");
76
+ });
77
+
78
+ it("exports path constants rooted in ~/.openclaw", () => {
79
+ expect(OPENCLAW_DIR).toContain(".openclaw");
80
+ expect(STATE_FILE).toContain("cli-bridge-state.json");
81
+ expect(PENDING_FILE).toContain("cli-bridge-pending.json");
82
+ expect(PROVIDER_SESSIONS_FILE).toContain("sessions.json");
83
+ expect(MEDIA_TMP_DIR).toContain("cli-bridge-media");
84
+ });
85
+
86
+ it("exports browser profile directories", () => {
87
+ expect(PROFILE_DIRS.grok).toContain("grok-profile");
88
+ expect(PROFILE_DIRS.gemini).toContain("gemini-profile");
89
+ expect(PROFILE_DIRS.claude).toContain("claude-profile");
90
+ expect(PROFILE_DIRS.chatgpt).toContain("chatgpt-profile");
91
+ });
92
+
93
+ it("exports BitNet defaults", () => {
94
+ expect(DEFAULT_BITNET_SERVER_URL).toBe("http://127.0.0.1:8082");
95
+ expect(BITNET_MAX_MESSAGES).toBe(6);
96
+ expect(BITNET_SYSTEM_PROMPT).toContain("Akido");
97
+ });
98
+
99
+ it("exports CLI test default model", () => {
100
+ expect(CLI_TEST_DEFAULT_MODEL).toBe("cli-claude/claude-sonnet-4-6");
101
+ });
102
+ });