@clinebot/core 0.0.5 → 0.0.7

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.
Files changed (57) hide show
  1. package/dist/agents/hooks-config-loader.d.ts +1 -0
  2. package/dist/index.d.ts +4 -1
  3. package/dist/index.node.d.ts +2 -0
  4. package/dist/index.node.js +134 -107
  5. package/dist/runtime/session-runtime.d.ts +3 -1
  6. package/dist/session/default-session-manager.d.ts +4 -0
  7. package/dist/session/rpc-spawn-lease.d.ts +7 -0
  8. package/dist/session/session-host.d.ts +2 -0
  9. package/dist/session/session-manager.d.ts +1 -0
  10. package/dist/storage/provider-settings-legacy-migration.d.ts +25 -0
  11. package/dist/telemetry/ITelemetryAdapter.d.ts +54 -0
  12. package/dist/telemetry/LoggerTelemetryAdapter.d.ts +21 -0
  13. package/dist/telemetry/OpenTelemetryAdapter.d.ts +43 -0
  14. package/dist/telemetry/OpenTelemetryProvider.d.ts +41 -0
  15. package/dist/telemetry/TelemetryService.d.ts +34 -0
  16. package/dist/telemetry/opentelemetry.d.ts +3 -0
  17. package/dist/telemetry/opentelemetry.js +27 -0
  18. package/dist/tools/schemas.d.ts +8 -8
  19. package/dist/types/config.d.ts +2 -1
  20. package/dist/types/events.d.ts +1 -1
  21. package/package.json +16 -3
  22. package/src/agents/hooks-config-loader.ts +21 -1
  23. package/src/index.node.ts +7 -0
  24. package/src/index.ts +16 -0
  25. package/src/input/file-indexer.test.ts +40 -0
  26. package/src/input/file-indexer.ts +21 -0
  27. package/src/runtime/hook-file-hooks.test.ts +98 -1
  28. package/src/runtime/hook-file-hooks.ts +93 -11
  29. package/src/runtime/runtime-builder.test.ts +20 -0
  30. package/src/runtime/runtime-builder.ts +1 -0
  31. package/src/runtime/session-runtime.ts +3 -1
  32. package/src/session/default-session-manager.test.ts +72 -0
  33. package/src/session/default-session-manager.ts +59 -1
  34. package/src/session/rpc-spawn-lease.test.ts +49 -0
  35. package/src/session/rpc-spawn-lease.ts +122 -0
  36. package/src/session/session-graph.ts +2 -0
  37. package/src/session/session-host.ts +14 -1
  38. package/src/session/session-manager.ts +1 -0
  39. package/src/storage/provider-settings-legacy-migration.test.ts +133 -1
  40. package/src/storage/provider-settings-legacy-migration.ts +60 -8
  41. package/src/telemetry/ITelemetryAdapter.ts +94 -0
  42. package/src/telemetry/LoggerTelemetryAdapter.test.ts +42 -0
  43. package/src/telemetry/LoggerTelemetryAdapter.ts +114 -0
  44. package/src/telemetry/OpenTelemetryAdapter.test.ts +157 -0
  45. package/src/telemetry/OpenTelemetryAdapter.ts +348 -0
  46. package/src/telemetry/OpenTelemetryProvider.test.ts +113 -0
  47. package/src/telemetry/OpenTelemetryProvider.ts +322 -0
  48. package/src/telemetry/TelemetryService.test.ts +134 -0
  49. package/src/telemetry/TelemetryService.ts +141 -0
  50. package/src/telemetry/opentelemetry.ts +20 -0
  51. package/src/tools/definitions.test.ts +82 -29
  52. package/src/tools/definitions.ts +41 -32
  53. package/src/tools/executors/editor.test.ts +35 -0
  54. package/src/tools/executors/editor.ts +33 -46
  55. package/src/tools/schemas.ts +34 -35
  56. package/src/types/config.ts +2 -0
  57. package/src/types/events.ts +6 -1
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import type { AgentResult } from "@clinebot/agents";
5
5
  import { describe, expect, it, vi } from "vitest";
6
+ import { TelemetryService } from "../telemetry/TelemetryService";
6
7
  import { SessionSource } from "../types/common";
7
8
  import type { CoreSessionConfig } from "../types/config";
8
9
  import { DefaultSessionManager } from "./default-session-manager";
@@ -70,6 +71,77 @@ function createConfig(
70
71
  }
71
72
 
72
73
  describe("DefaultSessionManager", () => {
74
+ it("emits session lifecycle telemetry when configured", async () => {
75
+ const sessionId = "sess-telemetry";
76
+ const manifest = createManifest(sessionId);
77
+ const adapter = {
78
+ name: "test",
79
+ emit: vi.fn(),
80
+ emitRequired: vi.fn(),
81
+ recordCounter: vi.fn(),
82
+ recordHistogram: vi.fn(),
83
+ recordGauge: vi.fn(),
84
+ isEnabled: vi.fn(() => true),
85
+ flush: vi.fn().mockResolvedValue(undefined),
86
+ dispose: vi.fn().mockResolvedValue(undefined),
87
+ };
88
+ const telemetry = new TelemetryService({
89
+ adapters: [adapter],
90
+ distinctId: distinctId,
91
+ });
92
+ const sessionService = {
93
+ ensureSessionsDir: vi.fn().mockReturnValue("/tmp/sessions"),
94
+ createRootSessionWithArtifacts: vi.fn().mockResolvedValue({
95
+ manifestPath: "/tmp/manifest.json",
96
+ transcriptPath: "/tmp/transcript.log",
97
+ hookPath: "/tmp/hook.log",
98
+ messagesPath: "/tmp/messages.json",
99
+ manifest,
100
+ }),
101
+ persistSessionMessages: vi.fn(),
102
+ updateSessionStatus: vi.fn().mockResolvedValue({
103
+ updated: true,
104
+ endedAt: "2026-01-01T00:00:05.000Z",
105
+ }),
106
+ writeSessionManifest: vi.fn(),
107
+ listSessions: vi.fn().mockResolvedValue([]),
108
+ deleteSession: vi.fn().mockResolvedValue({ deleted: true }),
109
+ };
110
+ const runtimeBuilder = {
111
+ build: vi.fn().mockReturnValue({
112
+ tools: [],
113
+ shutdown: vi.fn(),
114
+ }),
115
+ };
116
+ const agent = {
117
+ run: vi.fn().mockResolvedValue(createResult()),
118
+ continue: vi.fn().mockResolvedValue(createResult()),
119
+ getMessages: vi.fn().mockReturnValue([]),
120
+ abort: vi.fn(),
121
+ shutdown: vi.fn().mockResolvedValue(undefined),
122
+ };
123
+ const manager = new DefaultSessionManager({
124
+ distinctId,
125
+ sessionService: sessionService as never,
126
+ runtimeBuilder: runtimeBuilder as never,
127
+ createAgent: () => agent as never,
128
+ telemetry,
129
+ });
130
+
131
+ await manager.start({
132
+ config: createConfig({ telemetry, sessionId }),
133
+ prompt: "hello",
134
+ });
135
+
136
+ expect(adapter.emit).toHaveBeenCalledWith(
137
+ "session.started",
138
+ expect.objectContaining({
139
+ sessionId,
140
+ distinct_id: distinctId,
141
+ }),
142
+ );
143
+ });
144
+
73
145
  it("runs a non-interactive prompt and persists messages/status", async () => {
74
146
  const sessionId = "sess-1";
75
147
  const manifest = createManifest(sessionId);
@@ -14,7 +14,11 @@ import {
14
14
  type ToolApprovalResult,
15
15
  } from "@clinebot/agents";
16
16
  import type { providers as LlmsProviders } from "@clinebot/llms";
17
- import { formatUserInputBlock, normalizeUserInput } from "@clinebot/shared";
17
+ import {
18
+ formatUserInputBlock,
19
+ type ITelemetryService,
20
+ normalizeUserInput,
21
+ } from "@clinebot/shared";
18
22
  import { setHomeDirIfUnset } from "@clinebot/shared/storage";
19
23
  import { nanoid } from "nanoid";
20
24
  import { resolveAndLoadAgentPlugins } from "../agents/plugin-config-loader";
@@ -89,6 +93,7 @@ export interface DefaultSessionManagerOptions {
89
93
  toolPolicies?: AgentConfig["toolPolicies"];
90
94
  providerSettingsManager?: ProviderSettingsManager;
91
95
  oauthTokenManager?: RuntimeOAuthTokenManager;
96
+ telemetry?: ITelemetryService;
92
97
  requestToolApproval?: (
93
98
  request: ToolApprovalRequest,
94
99
  ) => Promise<ToolApprovalResult>;
@@ -120,6 +125,7 @@ export class DefaultSessionManager implements SessionManager {
120
125
  private readonly defaultToolPolicies?: AgentConfig["toolPolicies"];
121
126
  private readonly providerSettingsManager: ProviderSettingsManager;
122
127
  private readonly oauthTokenManager: RuntimeOAuthTokenManager;
128
+ private readonly defaultTelemetry?: ITelemetryService;
123
129
  private readonly defaultRequestToolApproval?: (
124
130
  request: ToolApprovalRequest,
125
131
  ) => Promise<ToolApprovalResult>;
@@ -143,6 +149,7 @@ export class DefaultSessionManager implements SessionManager {
143
149
  new RuntimeOAuthTokenManager({
144
150
  providerSettingsManager: this.providerSettingsManager,
145
151
  });
152
+ this.defaultTelemetry = options.telemetry;
146
153
  this.defaultRequestToolApproval = options.requestToolApproval;
147
154
  }
148
155
 
@@ -263,6 +270,7 @@ export class DefaultSessionManager implements SessionManager {
263
270
  ...input.config,
264
271
  hooks: effectiveHooks,
265
272
  extensions: effectiveExtensions,
273
+ telemetry: input.config.telemetry ?? this.defaultTelemetry,
266
274
  };
267
275
  const providerConfig =
268
276
  this.buildResolvedProviderConfig(effectiveConfigBase);
@@ -276,6 +284,7 @@ export class DefaultSessionManager implements SessionManager {
276
284
  hooks: effectiveHooks,
277
285
  extensions: effectiveExtensions,
278
286
  logger: effectiveConfig.logger,
287
+ telemetry: effectiveConfig.telemetry,
279
288
  onTeamEvent: (event: TeamEvent) => {
280
289
  void this.handleTeamEvent(sessionId, event);
281
290
  effectiveConfig.onTeamEvent?.(event);
@@ -287,6 +296,18 @@ export class DefaultSessionManager implements SessionManager {
287
296
  input.defaultToolExecutors ?? this.defaultToolExecutors,
288
297
  });
289
298
  const tools = [...runtime.tools, ...(effectiveConfig.extraTools ?? [])];
299
+ effectiveConfig.telemetry?.capture({
300
+ event: "session.started",
301
+ properties: {
302
+ sessionId,
303
+ source,
304
+ providerId: effectiveConfig.providerId,
305
+ modelId: effectiveConfig.modelId,
306
+ enableTools: effectiveConfig.enableTools,
307
+ enableSpawnAgent: effectiveConfig.enableSpawnAgent,
308
+ enableAgentTeams: effectiveConfig.enableAgentTeams,
309
+ },
310
+ });
290
311
  const agent = this.createAgentInstance({
291
312
  providerId: providerConfig.providerId,
292
313
  modelId: providerConfig.modelId,
@@ -326,6 +347,14 @@ export class DefaultSessionManager implements SessionManager {
326
347
  }),
327
348
  );
328
349
  }
350
+ if (event.type === "iteration_end") {
351
+ void this.invoke<void>(
352
+ "persistSessionMessages",
353
+ sessionId,
354
+ liveSession?.agent.getMessages() ?? [],
355
+ liveSession?.config.systemPrompt,
356
+ );
357
+ }
329
358
  this.emit({
330
359
  type: "agent_event",
331
360
  payload: {
@@ -397,6 +426,15 @@ export class DefaultSessionManager implements SessionManager {
397
426
  if (!session) {
398
427
  throw new Error(`session not found: ${input.sessionId}`);
399
428
  }
429
+ session.config.telemetry?.capture({
430
+ event: "session.input_sent",
431
+ properties: {
432
+ sessionId: input.sessionId,
433
+ promptLength: input.prompt.length,
434
+ userImageCount: input.userImages?.length ?? 0,
435
+ userFileCount: input.userFiles?.length ?? 0,
436
+ },
437
+ });
400
438
  try {
401
439
  const result = await this.runTurn(session, {
402
440
  prompt: input.prompt,
@@ -428,6 +466,10 @@ export class DefaultSessionManager implements SessionManager {
428
466
  if (!session) {
429
467
  return;
430
468
  }
469
+ session.config.telemetry?.capture({
470
+ event: "session.aborted",
471
+ properties: { sessionId },
472
+ });
431
473
  session.aborting = true;
432
474
  session.agent.abort();
433
475
  }
@@ -437,6 +479,10 @@ export class DefaultSessionManager implements SessionManager {
437
479
  if (!session) {
438
480
  return;
439
481
  }
482
+ session.config.telemetry?.capture({
483
+ event: "session.stopped",
484
+ properties: { sessionId },
485
+ });
440
486
  await this.shutdownSession(session, {
441
487
  status: "cancelled",
442
488
  exitCode: null,
@@ -1200,4 +1246,16 @@ export class DefaultSessionManager implements SessionManager {
1200
1246
  apiKey: resolved.apiKey,
1201
1247
  });
1202
1248
  }
1249
+
1250
+ async updateSessionModel(sessionId: string, modelId: string): Promise<void> {
1251
+ const session = this.sessions.get(sessionId);
1252
+ if (!session) {
1253
+ throw new Error(`session not found: ${sessionId}`);
1254
+ }
1255
+ session.config.modelId = modelId;
1256
+ const agentWithConnection = session.agent as Agent & {
1257
+ updateConnection?: (overrides: { modelId?: string }) => void;
1258
+ };
1259
+ agentWithConnection.updateConnection?.({ modelId });
1260
+ }
1203
1261
  }
@@ -0,0 +1,49 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { tryAcquireRpcSpawnLease } from "./rpc-spawn-lease";
6
+
7
+ describe("tryAcquireRpcSpawnLease", () => {
8
+ const tempDirs: string[] = [];
9
+
10
+ afterEach(() => {
11
+ delete process.env.CLINE_DATA_DIR;
12
+ for (const dir of tempDirs.splice(0)) {
13
+ rmSync(dir, { recursive: true, force: true });
14
+ }
15
+ });
16
+
17
+ it("allows only one active lease per address", () => {
18
+ const dataDir = mkdtempSync(path.join(os.tmpdir(), "rpc-spawn-lease-"));
19
+ tempDirs.push(dataDir);
20
+ process.env.CLINE_DATA_DIR = dataDir;
21
+
22
+ const first = tryAcquireRpcSpawnLease("127.0.0.1:4317");
23
+ const second = tryAcquireRpcSpawnLease("127.0.0.1:4317");
24
+
25
+ expect(first).toBeDefined();
26
+ expect(second).toBeUndefined();
27
+
28
+ first?.release();
29
+
30
+ const third = tryAcquireRpcSpawnLease("127.0.0.1:4317");
31
+ expect(third).toBeDefined();
32
+ third?.release();
33
+ });
34
+
35
+ it("lets different addresses acquire independent leases", () => {
36
+ const dataDir = mkdtempSync(path.join(os.tmpdir(), "rpc-spawn-lease-"));
37
+ tempDirs.push(dataDir);
38
+ process.env.CLINE_DATA_DIR = dataDir;
39
+
40
+ const first = tryAcquireRpcSpawnLease("127.0.0.1:4317");
41
+ const second = tryAcquireRpcSpawnLease("127.0.0.1:4318");
42
+
43
+ expect(first).toBeDefined();
44
+ expect(second).toBeDefined();
45
+
46
+ first?.release();
47
+ second?.release();
48
+ });
49
+ });
@@ -0,0 +1,122 @@
1
+ import {
2
+ closeSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ openSync,
6
+ readFileSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { dirname, resolve } from "node:path";
11
+ import { resolveSessionDataDir } from "@clinebot/shared/storage";
12
+
13
+ const DEFAULT_LEASE_TTL_MS = 15_000;
14
+
15
+ interface RpcSpawnLeaseRecord {
16
+ address: string;
17
+ pid: number;
18
+ createdAt: number;
19
+ }
20
+
21
+ export interface RpcSpawnLease {
22
+ path: string;
23
+ release: () => void;
24
+ }
25
+
26
+ function encodeAddress(address: string): string {
27
+ return Buffer.from(address).toString("base64url");
28
+ }
29
+
30
+ function getLeasePath(address: string): string {
31
+ return resolve(
32
+ resolveSessionDataDir(),
33
+ "rpc",
34
+ "spawn-leases",
35
+ `${encodeAddress(address)}.lock`,
36
+ );
37
+ }
38
+
39
+ function isProcessAlive(pid: number): boolean {
40
+ if (!Number.isInteger(pid) || pid <= 0) {
41
+ return false;
42
+ }
43
+ try {
44
+ process.kill(pid, 0);
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ function shouldClearLease(path: string, ttlMs: number): boolean {
52
+ try {
53
+ const raw = readFileSync(path, "utf8");
54
+ const parsed = JSON.parse(raw) as Partial<RpcSpawnLeaseRecord>;
55
+ const createdAt = Number(parsed.createdAt ?? 0);
56
+ if (!Number.isFinite(createdAt) || createdAt <= 0) {
57
+ return true;
58
+ }
59
+ if (Date.now() - createdAt > ttlMs) {
60
+ return true;
61
+ }
62
+ return !isProcessAlive(Number(parsed.pid ?? 0));
63
+ } catch {
64
+ return true;
65
+ }
66
+ }
67
+
68
+ export function tryAcquireRpcSpawnLease(
69
+ address: string,
70
+ options?: { ttlMs?: number },
71
+ ): RpcSpawnLease | undefined {
72
+ const ttlMs = Math.max(1_000, options?.ttlMs ?? DEFAULT_LEASE_TTL_MS);
73
+ const path = getLeasePath(address);
74
+ mkdirSync(dirname(path), { recursive: true });
75
+
76
+ if (existsSync(path) && shouldClearLease(path, ttlMs)) {
77
+ rmSync(path, { force: true });
78
+ }
79
+
80
+ let fd: number | undefined;
81
+ try {
82
+ fd = openSync(path, "wx");
83
+ const record: RpcSpawnLeaseRecord = {
84
+ address,
85
+ pid: process.pid,
86
+ createdAt: Date.now(),
87
+ };
88
+ writeFileSync(fd, JSON.stringify(record), "utf8");
89
+ } catch {
90
+ if (typeof fd === "number") {
91
+ try {
92
+ closeSync(fd);
93
+ } catch {
94
+ // Best effort.
95
+ }
96
+ }
97
+ return undefined;
98
+ }
99
+
100
+ let released = false;
101
+ return {
102
+ path,
103
+ release: () => {
104
+ if (released) {
105
+ return;
106
+ }
107
+ released = true;
108
+ try {
109
+ if (typeof fd === "number") {
110
+ closeSync(fd);
111
+ }
112
+ } catch {
113
+ // Best effort.
114
+ }
115
+ try {
116
+ rmSync(path, { force: true });
117
+ } catch {
118
+ // Best effort.
119
+ }
120
+ },
121
+ };
122
+ }
@@ -70,6 +70,8 @@ export function deriveSubsessionStatus(event: HookEventPayload): SessionStatus {
70
70
  switch (event.hookName) {
71
71
  case "agent_end":
72
72
  return "completed";
73
+ case "agent_error":
74
+ return "failed";
73
75
  case "session_shutdown": {
74
76
  const reason = String(event.reason ?? "").toLowerCase();
75
77
  if (
@@ -7,12 +7,14 @@ import type {
7
7
  ToolApprovalResult,
8
8
  } from "@clinebot/agents";
9
9
  import { getRpcServerDefaultAddress, getRpcServerHealth } from "@clinebot/rpc";
10
+ import type { ITelemetryService } from "@clinebot/shared";
10
11
  import { resolveSessionDataDir } from "@clinebot/shared/storage";
11
12
  import { nanoid } from "nanoid";
12
13
  import { SqliteSessionStore } from "../storage/sqlite-session-store";
13
14
  import type { ToolExecutors } from "../tools";
14
15
  import { DefaultSessionManager } from "./default-session-manager";
15
16
  import { RpcCoreSessionService } from "./rpc-session-service";
17
+ import { tryAcquireRpcSpawnLease } from "./rpc-spawn-lease";
16
18
  import type { SessionManager } from "./session-manager";
17
19
  import { CoreSessionService } from "./session-service";
18
20
 
@@ -33,6 +35,7 @@ export interface CreateSessionHostOptions {
33
35
  rpcConnectAttempts?: number;
34
36
  rpcConnectDelayMs?: number;
35
37
  defaultToolExecutors?: Partial<ToolExecutors>;
38
+ telemetry?: ITelemetryService;
36
39
  toolPolicies?: AgentConfig["toolPolicies"];
37
40
  requestToolApproval?: (
38
41
  request: ToolApprovalRequest,
@@ -42,13 +45,19 @@ export interface CreateSessionHostOptions {
42
45
  export type SessionHost = SessionManager;
43
46
 
44
47
  function startRpcServerInBackground(address: string): void {
48
+ const lease = tryAcquireRpcSpawnLease(address);
49
+ if (!lease) {
50
+ return;
51
+ }
45
52
  const launcher = process.execPath;
46
53
  const entryArg = process.argv[1]?.trim();
47
54
  if (!entryArg) {
55
+ lease.release();
48
56
  return;
49
57
  }
50
58
  const entry = resolve(process.cwd(), entryArg);
51
59
  if (!existsSync(entry)) {
60
+ lease.release();
52
61
  return;
53
62
  }
54
63
  const conditionsArg = process.execArgv.find((arg) =>
@@ -73,6 +82,7 @@ function startRpcServerInBackground(address: string): void {
73
82
  cwd: process.cwd(),
74
83
  });
75
84
  child.unref();
85
+ setTimeout(() => lease.release(), 10_000).unref();
76
86
  }
77
87
 
78
88
  async function tryConnectRpcBackend(
@@ -190,13 +200,16 @@ export async function resolveSessionBackend(
190
200
  export async function createSessionHost(
191
201
  options: CreateSessionHostOptions,
192
202
  ): Promise<SessionHost> {
203
+ const distinctId = resolveHostDistinctId(options.distinctId);
204
+ options.telemetry?.setDistinctId(distinctId);
193
205
  const backend =
194
206
  options.sessionService ?? (await resolveSessionBackend(options));
195
207
  return new DefaultSessionManager({
196
208
  sessionService: backend,
197
209
  defaultToolExecutors: options.defaultToolExecutors,
210
+ telemetry: options.telemetry,
198
211
  toolPolicies: options.toolPolicies,
199
212
  requestToolApproval: options.requestToolApproval,
200
- distinctId: resolveHostDistinctId(options.distinctId),
213
+ distinctId,
201
214
  });
202
215
  }
@@ -64,4 +64,5 @@ export interface SessionManager {
64
64
  readTranscript(sessionId: string, maxChars?: number): Promise<string>;
65
65
  readHooks(sessionId: string, limit?: number): Promise<unknown[]>;
66
66
  subscribe(listener: (event: CoreSessionEvent) => void): () => void;
67
+ updateSessionModel?(sessionId: string, modelId: string): Promise<void>;
67
68
  }
@@ -2,7 +2,11 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { afterEach, describe, expect, it } from "vitest";
5
- import { migrateLegacyProviderSettings } from "./provider-settings-legacy-migration";
5
+ import {
6
+ type LegacyClineUserInfo,
7
+ migrateLegacyProviderSettings,
8
+ resolveLegacyClineAuth,
9
+ } from "./provider-settings-legacy-migration";
6
10
  import { ProviderSettingsManager } from "./provider-settings-manager";
7
11
 
8
12
  describe("migrateLegacyProviderSettings", () => {
@@ -173,3 +177,131 @@ describe("migrateLegacyProviderSettings", () => {
173
177
  );
174
178
  });
175
179
  });
180
+
181
+ // =============================================================================
182
+ // resolveLegacyClineAuth – pure in-memory tests
183
+ // =============================================================================
184
+
185
+ /** Builds a realistic LegacyClineUserInfo JSON string. */
186
+ function makeClineAccountJson(
187
+ overrides: Partial<LegacyClineUserInfo> & { userId?: string } = {},
188
+ ): string {
189
+ return JSON.stringify({
190
+ idToken: overrides.idToken ?? "id-token-abc",
191
+ expiresAt: overrides.expiresAt ?? 1750000000000,
192
+ refreshToken: overrides.refreshToken ?? "refresh-token-xyz",
193
+ userInfo: overrides.userInfo ?? {
194
+ id: overrides.userId ?? "user-42",
195
+ email: "test@example.com",
196
+ displayName: "Test User",
197
+ termsAcceptedAt: "2025-01-01T00:00:00Z",
198
+ clineBenchConsent: false,
199
+ createdAt: "2025-01-01T00:00:00Z",
200
+ updatedAt: "2025-01-01T00:00:00Z",
201
+ },
202
+ provider: overrides.provider ?? "google",
203
+ startedAt: overrides.startedAt ?? Date.now(),
204
+ } satisfies LegacyClineUserInfo);
205
+ }
206
+
207
+ describe("resolveLegacyClineAuth", () => {
208
+ it("extracts all auth fields from a complete legacy account JSON", () => {
209
+ const result = resolveLegacyClineAuth(
210
+ makeClineAccountJson({
211
+ idToken: "my-id-token",
212
+ expiresAt: 1750000000000,
213
+ refreshToken: "my-refresh",
214
+ userId: "user-123",
215
+ }),
216
+ );
217
+
218
+ expect(result).toEqual({
219
+ accessToken: "my-id-token",
220
+ refreshToken: "my-refresh",
221
+ expiresAt: 1750000000000,
222
+ accountId: "user-123",
223
+ });
224
+ });
225
+
226
+ it("maps idToken to accessToken", () => {
227
+ const result = resolveLegacyClineAuth(
228
+ makeClineAccountJson({ idToken: "tok-abc" }),
229
+ );
230
+ expect(result?.accessToken).toBe("tok-abc");
231
+ });
232
+
233
+ it("preserves expiresAt as a number", () => {
234
+ const result = resolveLegacyClineAuth(
235
+ makeClineAccountJson({ expiresAt: 9999999999999 }),
236
+ );
237
+ expect(result?.expiresAt).toBe(9999999999999);
238
+ expect(typeof result?.expiresAt).toBe("number");
239
+ });
240
+
241
+ it("maps userInfo.id to accountId", () => {
242
+ const result = resolveLegacyClineAuth(
243
+ makeClineAccountJson({ userId: "uid-xyz" }),
244
+ );
245
+ expect(result?.accountId).toBe("uid-xyz");
246
+ });
247
+
248
+ it("returns undefined accountId when userInfo is missing entirely", () => {
249
+ const raw = JSON.stringify({
250
+ idToken: "tok",
251
+ expiresAt: 1000,
252
+ refreshToken: "ref",
253
+ provider: "google",
254
+ startedAt: 1,
255
+ });
256
+
257
+ const result = resolveLegacyClineAuth(raw);
258
+ expect(result).toBeDefined();
259
+ expect(result?.accessToken).toBe("tok");
260
+ expect(result?.accountId).toBeUndefined();
261
+ });
262
+
263
+ it("returns undefined accountId when userInfo.id is missing", () => {
264
+ const raw = JSON.stringify({
265
+ idToken: "tok",
266
+ expiresAt: 1000,
267
+ refreshToken: "ref",
268
+ userInfo: {
269
+ email: "x@y.com",
270
+ displayName: "X",
271
+ termsAcceptedAt: "2025-01-01T00:00:00Z",
272
+ clineBenchConsent: false,
273
+ createdAt: "2025-01-01T00:00:00Z",
274
+ updatedAt: "2025-01-01T00:00:00Z",
275
+ },
276
+ provider: "google",
277
+ startedAt: 1,
278
+ });
279
+
280
+ const result = resolveLegacyClineAuth(raw);
281
+ expect(result).toBeDefined();
282
+ expect(result?.accountId).toBeUndefined();
283
+ });
284
+
285
+ it("returns undefined for invalid json", () => {
286
+ expect(resolveLegacyClineAuth(undefined)).toBeUndefined();
287
+ expect(resolveLegacyClineAuth("")).toBeUndefined();
288
+ expect(resolveLegacyClineAuth(" \n\t ")).toBeUndefined();
289
+ expect(resolveLegacyClineAuth("not-json{{{")).toBeUndefined();
290
+ expect(resolveLegacyClineAuth("null")).toBeUndefined();
291
+ });
292
+
293
+ it("returns undefined fields when idToken/refreshToken are missing from JSON", () => {
294
+ const raw = JSON.stringify({
295
+ userInfo: { id: "uid" },
296
+ provider: "google",
297
+ startedAt: 1,
298
+ });
299
+
300
+ const result = resolveLegacyClineAuth(raw);
301
+ expect(result).toBeDefined();
302
+ expect(result?.accessToken).toBeUndefined();
303
+ expect(result?.refreshToken).toBeUndefined();
304
+ expect(result?.expiresAt).toBeUndefined();
305
+ expect(result?.accountId).toBe("uid");
306
+ });
307
+ });