@clinebot/core 0.0.7 → 0.0.11

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 (67) hide show
  1. package/dist/auth/cline.d.ts +2 -0
  2. package/dist/auth/codex.d.ts +5 -1
  3. package/dist/auth/oca.d.ts +7 -1
  4. package/dist/auth/types.d.ts +2 -0
  5. package/dist/index.d.ts +3 -1
  6. package/dist/index.node.js +124 -122
  7. package/dist/input/mention-enricher.d.ts +1 -0
  8. package/dist/providers/local-provider-service.d.ts +1 -1
  9. package/dist/runtime/session-runtime.d.ts +1 -1
  10. package/dist/session/default-session-manager.d.ts +13 -17
  11. package/dist/session/runtime-oauth-token-manager.d.ts +4 -2
  12. package/dist/session/session-agent-events.d.ts +15 -0
  13. package/dist/session/session-config-builder.d.ts +13 -0
  14. package/dist/session/session-manager.d.ts +2 -2
  15. package/dist/session/session-team-coordination.d.ts +12 -0
  16. package/dist/session/session-telemetry.d.ts +9 -0
  17. package/dist/session/unified-session-persistence-service.d.ts +12 -16
  18. package/dist/session/utils/helpers.d.ts +2 -2
  19. package/dist/session/utils/types.d.ts +2 -1
  20. package/dist/telemetry/core-events.d.ts +122 -0
  21. package/dist/tools/definitions.d.ts +1 -1
  22. package/dist/tools/executors/file-read.d.ts +1 -1
  23. package/dist/tools/index.d.ts +1 -1
  24. package/dist/tools/presets.d.ts +1 -1
  25. package/dist/tools/schemas.d.ts +46 -3
  26. package/dist/tools/types.d.ts +3 -3
  27. package/dist/types/config.d.ts +1 -1
  28. package/dist/types/provider-settings.d.ts +4 -4
  29. package/dist/types.d.ts +1 -1
  30. package/package.json +4 -3
  31. package/src/auth/cline.ts +35 -1
  32. package/src/auth/codex.ts +27 -2
  33. package/src/auth/oca.ts +31 -4
  34. package/src/auth/types.ts +3 -0
  35. package/src/index.ts +27 -0
  36. package/src/input/mention-enricher.test.ts +3 -0
  37. package/src/input/mention-enricher.ts +3 -0
  38. package/src/providers/local-provider-service.ts +6 -7
  39. package/src/runtime/hook-file-hooks.ts +11 -10
  40. package/src/runtime/session-runtime.ts +1 -1
  41. package/src/session/default-session-manager.e2e.test.ts +2 -1
  42. package/src/session/default-session-manager.test.ts +131 -0
  43. package/src/session/default-session-manager.ts +372 -602
  44. package/src/session/runtime-oauth-token-manager.ts +21 -14
  45. package/src/session/session-agent-events.ts +159 -0
  46. package/src/session/session-config-builder.ts +111 -0
  47. package/src/session/session-host.ts +13 -0
  48. package/src/session/session-manager.ts +2 -2
  49. package/src/session/session-team-coordination.ts +198 -0
  50. package/src/session/session-telemetry.ts +95 -0
  51. package/src/session/unified-session-persistence-service.test.ts +81 -0
  52. package/src/session/unified-session-persistence-service.ts +470 -469
  53. package/src/session/utils/helpers.ts +14 -4
  54. package/src/session/utils/types.ts +2 -1
  55. package/src/storage/provider-settings-legacy-migration.ts +3 -3
  56. package/src/telemetry/core-events.ts +344 -0
  57. package/src/tools/definitions.test.ts +121 -7
  58. package/src/tools/definitions.ts +60 -24
  59. package/src/tools/executors/file-read.test.ts +29 -5
  60. package/src/tools/executors/file-read.ts +17 -6
  61. package/src/tools/index.ts +2 -0
  62. package/src/tools/presets.ts +1 -1
  63. package/src/tools/schemas.ts +65 -5
  64. package/src/tools/types.ts +7 -3
  65. package/src/types/config.ts +1 -1
  66. package/src/types/provider-settings.ts +6 -6
  67. package/src/types.ts +1 -1
package/src/auth/cline.ts CHANGED
@@ -1,3 +1,11 @@
1
+ import type { ITelemetryService } from "@clinebot/shared";
2
+ import {
3
+ captureAuthFailed,
4
+ captureAuthLoggedOut,
5
+ captureAuthStarted,
6
+ captureAuthSucceeded,
7
+ identifyAccount,
8
+ } from "../telemetry/core-events";
1
9
  import { startLocalOAuthServer } from "./server.js";
2
10
  import type {
3
11
  OAuthCredentials,
@@ -63,6 +71,7 @@ export interface ClineOAuthProviderOptions {
63
71
  callbackPath?: string;
64
72
  callbackPorts?: number[];
65
73
  requestTimeoutMs?: number;
74
+ telemetry?: ITelemetryService;
66
75
  /**
67
76
  * Optional identity provider name for token exchange.
68
77
  */
@@ -253,6 +262,7 @@ export async function loginClineOAuth(
253
262
  callbacks: OAuthLoginCallbacks;
254
263
  },
255
264
  ): Promise<ClineOAuthCredentials> {
265
+ captureAuthStarted(options.telemetry, options.provider ?? "cline");
256
266
  const callbackPorts = options.callbackPorts?.length
257
267
  ? options.callbackPorts
258
268
  : DEFAULT_CALLBACK_PORTS;
@@ -312,7 +322,26 @@ export async function loginClineOAuth(
312
322
  throw new Error("Missing authorization code");
313
323
  }
314
324
 
315
- return exchangeAuthorizationCode(code, callbackUrl, options, provider);
325
+ const credentials = await exchangeAuthorizationCode(
326
+ code,
327
+ callbackUrl,
328
+ options,
329
+ provider,
330
+ );
331
+ captureAuthSucceeded(options.telemetry, provider ?? "cline");
332
+ identifyAccount(options.telemetry, {
333
+ id: credentials.accountId,
334
+ email: credentials.email,
335
+ provider: provider ?? "cline",
336
+ });
337
+ return credentials;
338
+ } catch (error) {
339
+ captureAuthFailed(
340
+ options.telemetry,
341
+ options.provider ?? "cline",
342
+ error instanceof Error ? error.message : String(error),
343
+ );
344
+ throw error;
316
345
  } finally {
317
346
  localServer.close();
318
347
  }
@@ -384,6 +413,11 @@ export async function getValidClineCredentials(
384
413
  return await refreshClineToken(currentCredentials, providerOptions);
385
414
  } catch (error) {
386
415
  if (error instanceof ClineOAuthTokenError && error.isLikelyInvalidGrant()) {
416
+ captureAuthLoggedOut(
417
+ providerOptions.telemetry,
418
+ providerOptions.provider ?? "cline",
419
+ "invalid_grant",
420
+ );
387
421
  return null;
388
422
  }
389
423
  if (currentCredentials.expires - Date.now() > retryableTokenGraceMs) {
package/src/auth/codex.ts CHANGED
@@ -5,7 +5,15 @@
5
5
  * It is only intended for CLI use, not browser environments.
6
6
  */
7
7
 
8
+ import type { ITelemetryService } from "@clinebot/shared";
8
9
  import { nanoid } from "nanoid";
10
+ import {
11
+ captureAuthFailed,
12
+ captureAuthLoggedOut,
13
+ captureAuthStarted,
14
+ captureAuthSucceeded,
15
+ identifyAccount,
16
+ } from "../telemetry/core-events";
9
17
  import { startLocalOAuthServer } from "./server.js";
10
18
  import type {
11
19
  OAuthCredentials,
@@ -295,7 +303,9 @@ export async function loginOpenAICodex(options: {
295
303
  onProgress?: (message: string) => void;
296
304
  onManualCodeInput?: () => Promise<string>;
297
305
  originator?: string;
306
+ telemetry?: ITelemetryService;
298
307
  }): Promise<OAuthCredentials> {
308
+ captureAuthStarted(options.telemetry, "openai-codex");
299
309
  const callbackConfig = resolveCallbackServerConfig();
300
310
  const { verifier, state, url } = await createAuthorizationFlow(
301
311
  options.originator,
@@ -352,7 +362,21 @@ export async function loginOpenAICodex(options: {
352
362
  throw new Error("Token exchange failed");
353
363
  }
354
364
 
355
- return toCodexCredentials(tokenResult);
365
+ const credentials = toCodexCredentials(tokenResult);
366
+ captureAuthSucceeded(options.telemetry, "openai-codex");
367
+ identifyAccount(options.telemetry, {
368
+ id: credentials.accountId,
369
+ email: credentials.email,
370
+ provider: "openai-codex",
371
+ });
372
+ return credentials;
373
+ } catch (error) {
374
+ captureAuthFailed(
375
+ options.telemetry,
376
+ "openai-codex",
377
+ error instanceof Error ? error.message : String(error),
378
+ );
379
+ throw error;
356
380
  } finally {
357
381
  server.close();
358
382
  }
@@ -378,7 +402,7 @@ export async function refreshOpenAICodexToken(
378
402
 
379
403
  export async function getValidOpenAICodexCredentials(
380
404
  currentCredentials: OAuthCredentials | null,
381
- options?: RefreshTokenResolution,
405
+ options?: RefreshTokenResolution & { telemetry?: ITelemetryService },
382
406
  ): Promise<OAuthCredentials | null> {
383
407
  if (!currentCredentials) {
384
408
  return null;
@@ -409,6 +433,7 @@ export async function getValidOpenAICodexCredentials(
409
433
  error instanceof OpenAICodexOAuthTokenError &&
410
434
  error.isLikelyInvalidGrant()
411
435
  ) {
436
+ captureAuthLoggedOut(options?.telemetry, "openai-codex", "invalid_grant");
412
437
  return null;
413
438
  }
414
439
  if (currentCredentials.expires - Date.now() > retryableTokenGraceMs) {
package/src/auth/oca.ts CHANGED
@@ -1,4 +1,12 @@
1
+ import type { ITelemetryService } from "@clinebot/shared";
1
2
  import { nanoid } from "nanoid";
3
+ import {
4
+ captureAuthFailed,
5
+ captureAuthLoggedOut,
6
+ captureAuthStarted,
7
+ captureAuthSucceeded,
8
+ identifyAccount,
9
+ } from "../telemetry/core-events";
2
10
  import { startLocalOAuthServer } from "./server.js";
3
11
  import type {
4
12
  OAuthCredentials,
@@ -325,8 +333,12 @@ function buildAuthorizationUrl(input: {
325
333
  }
326
334
 
327
335
  export async function loginOcaOAuth(
328
- options: OcaOAuthProviderOptions & { callbacks: OAuthLoginCallbacks },
336
+ options: OcaOAuthProviderOptions & {
337
+ callbacks: OAuthLoginCallbacks;
338
+ telemetry?: ITelemetryService;
339
+ },
329
340
  ): Promise<OAuthCredentials> {
341
+ captureAuthStarted(options.telemetry, "oca");
330
342
  const config = resolveConfig(options.config);
331
343
  const mode = resolveMode(options.mode);
332
344
  const callbackPorts = options.callbackPorts?.length
@@ -391,13 +403,27 @@ export async function loginOcaOAuth(
391
403
  throw new Error("State mismatch");
392
404
  }
393
405
 
394
- return await exchangeAuthorizationCode({
406
+ const credentials = await exchangeAuthorizationCode({
395
407
  code,
396
408
  state: returnedState,
397
409
  mode,
398
410
  config,
399
411
  requestTimeoutMs,
400
412
  });
413
+ captureAuthSucceeded(options.telemetry, "oca");
414
+ identifyAccount(options.telemetry, {
415
+ id: credentials.accountId,
416
+ email: credentials.email,
417
+ provider: "oca",
418
+ });
419
+ return credentials;
420
+ } catch (error) {
421
+ captureAuthFailed(
422
+ options.telemetry,
423
+ "oca",
424
+ error instanceof Error ? error.message : String(error),
425
+ );
426
+ throw error;
401
427
  } finally {
402
428
  localServer.close();
403
429
  }
@@ -447,8 +473,8 @@ export async function refreshOcaToken(
447
473
 
448
474
  export async function getValidOcaCredentials(
449
475
  currentCredentials: OAuthCredentials | null,
450
- options?: OcaTokenResolution,
451
- providerOptions?: OcaOAuthProviderOptions,
476
+ options?: OcaTokenResolution & { telemetry?: ITelemetryService },
477
+ providerOptions?: OcaOAuthProviderOptions & { telemetry?: ITelemetryService },
452
478
  ): Promise<OAuthCredentials | null> {
453
479
  if (!currentCredentials) {
454
480
  return null;
@@ -475,6 +501,7 @@ export async function getValidOcaCredentials(
475
501
  return await refreshOcaToken(currentCredentials, providerOptions);
476
502
  } catch (error) {
477
503
  if (error instanceof OcaOAuthTokenError && error.isLikelyInvalidGrant()) {
504
+ captureAuthLoggedOut(providerOptions?.telemetry, "oca", "invalid_grant");
478
505
  return null;
479
506
  }
480
507
  if (currentCredentials.expires - Date.now() > retryableTokenGraceMs) {
package/src/auth/types.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { ITelemetryService } from "@clinebot/shared";
2
+
1
3
  export interface OAuthPrompt {
2
4
  message: string;
3
5
  defaultValue?: string;
@@ -69,6 +71,7 @@ export interface OcaOAuthProviderOptions {
69
71
  requestTimeoutMs?: number;
70
72
  refreshBufferMs?: number;
71
73
  retryableTokenGraceMs?: number;
74
+ telemetry?: ITelemetryService;
72
75
  }
73
76
 
74
77
  export interface OcaTokenResolution {
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ export {
13
13
  type ToolApprovalResult,
14
14
  type ToolContext,
15
15
  } from "@clinebot/agents";
16
+ export { LlmsModels, LlmsProviders } from "@clinebot/llms";
16
17
  // Shared contracts and path helpers re-exported for app consumers.
17
18
  export type {
18
19
  AgentMode,
@@ -57,7 +58,10 @@ export {
57
58
  ensureHookLogDir,
58
59
  ensureParentDir,
59
60
  resolveClineDataDir,
61
+ resolveClineDir,
60
62
  resolveSessionDataDir,
63
+ setClineDir,
64
+ setClineDirIfUnset,
61
65
  setHomeDir,
62
66
  setHomeDirIfUnset,
63
67
  } from "@clinebot/shared/storage";
@@ -123,6 +127,29 @@ export {
123
127
  buildTeamProgressSummary,
124
128
  toTeamProgressLifecycleEvent,
125
129
  } from "./team";
130
+ export {
131
+ captureAuthFailed,
132
+ captureAuthLoggedOut,
133
+ captureAuthStarted,
134
+ captureAuthSucceeded,
135
+ captureConversationTurnEvent,
136
+ captureDiffEditFailure,
137
+ captureHookDiscovery,
138
+ captureMentionFailed,
139
+ captureMentionSearchResults,
140
+ captureMentionUsed,
141
+ captureModeSwitch,
142
+ captureProviderApiError,
143
+ captureSkillUsed,
144
+ captureSubagentExecution,
145
+ captureTaskCompleted,
146
+ captureTaskCreated,
147
+ captureTaskRestarted,
148
+ captureTokenUsage,
149
+ captureToolUsage,
150
+ identifyAccount,
151
+ LegacyTelemetryEvents,
152
+ } from "./telemetry/core-events";
126
153
  export type { ITelemetryAdapter } from "./telemetry/ITelemetryAdapter";
127
154
  export {
128
155
  LoggerTelemetryAdapter,
@@ -32,6 +32,7 @@ describe("enrichPromptWithMentions", () => {
32
32
  cwd,
33
33
  );
34
34
 
35
+ expect(result.mentions).toEqual(["src/index.ts"]);
35
36
  expect(result.matchedFiles).toEqual(["src/index.ts"]);
36
37
  expect(result.ignoredMentions).toEqual([]);
37
38
  expect(result.prompt).toBe("Review @src/index.ts");
@@ -50,6 +51,7 @@ describe("enrichPromptWithMentions", () => {
50
51
  cwd,
51
52
  );
52
53
 
54
+ expect(result.mentions).toEqual(["missing/file.ts"]);
53
55
  expect(result.matchedFiles).toEqual([]);
54
56
  expect(result.ignoredMentions).toEqual(["missing/file.ts"]);
55
57
  expect(result.prompt).toBe(
@@ -72,6 +74,7 @@ describe("enrichPromptWithMentions", () => {
72
74
  { maxTotalBytes: 5, maxFiles: 2, maxFileBytes: 5 },
73
75
  );
74
76
 
77
+ expect(result.mentions).toEqual(["a.ts", "b.ts"]);
75
78
  expect(result.matchedFiles).toEqual(["a.ts"]);
76
79
  expect(result.ignoredMentions).toEqual(["b.ts"]);
77
80
  expect(result.prompt).toBe("Use @a.ts and @b.ts");
@@ -13,6 +13,7 @@ export interface MentionEnricherOptions extends FastFileIndexOptions {
13
13
 
14
14
  export interface MentionEnrichmentResult {
15
15
  prompt: string;
16
+ mentions: string[];
16
17
  matchedFiles: string[];
17
18
  ignoredMentions: string[];
18
19
  }
@@ -60,6 +61,7 @@ export async function enrichPromptWithMentions(
60
61
  if (mentions.length === 0) {
61
62
  return {
62
63
  prompt: input,
64
+ mentions: [],
63
65
  matchedFiles: [],
64
66
  ignoredMentions: [],
65
67
  };
@@ -113,6 +115,7 @@ export async function enrichPromptWithMentions(
113
115
 
114
116
  return {
115
117
  prompt: input,
118
+ mentions,
116
119
  matchedFiles: matched,
117
120
  ignoredMentions: ignored,
118
121
  };
@@ -1,7 +1,6 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
- import type { providers as LlmsProviders } from "@clinebot/llms";
4
- import { models } from "@clinebot/llms";
3
+ import { LlmsModels, type LlmsProviders } from "@clinebot/llms";
5
4
  import type {
6
5
  RpcAddProviderActionRequest,
7
6
  RpcOAuthProviderId,
@@ -215,7 +214,7 @@ function registerCustomProvider(
215
214
  ]),
216
215
  );
217
216
 
218
- models.registerProvider({
217
+ LlmsModels.registerProvider({
219
218
  provider: {
220
219
  id: providerId,
221
220
  name: entry.provider.name.trim() || titleCaseFromId(providerId),
@@ -318,7 +317,7 @@ export async function addLocalProvider(
318
317
  }> {
319
318
  const providerId = request.providerId.trim().toLowerCase();
320
319
  if (!providerId) throw new Error("providerId is required");
321
- if (models.hasProvider(providerId)) {
320
+ if (LlmsModels.hasProvider(providerId)) {
322
321
  throw new Error(`provider "${providerId}" already exists`);
323
322
  }
324
323
  const providerName = request.name.trim();
@@ -411,10 +410,10 @@ export async function listLocalProviders(
411
410
  settingsPath: string;
412
411
  }> {
413
412
  const state = manager.read();
414
- const ids = models.getProviderIds().sort((a, b) => a.localeCompare(b));
413
+ const ids = LlmsModels.getProviderIds().sort((a, b) => a.localeCompare(b));
415
414
  const providerItems = await Promise.all(
416
415
  ids.map(async (id): Promise<RpcProviderListItem> => {
417
- const info = await models.getProvider(id);
416
+ const info = await LlmsModels.getProvider(id);
418
417
  const providerModels = await getLocalProviderModels(id);
419
418
  const persistedSettings = state.providers[id]?.settings;
420
419
  const providerName = info?.name ?? titleCaseFromId(id);
@@ -450,7 +449,7 @@ export async function getLocalProviderModels(
450
449
  providerId: string,
451
450
  ): Promise<{ providerId: string; models: RpcProviderModel[] }> {
452
451
  const id = providerId.trim();
453
- const modelMap = await models.getModelsForProvider(id);
452
+ const modelMap = await LlmsModels.getModelsForProvider(id);
454
453
  const items = Object.entries(modelMap)
455
454
  .sort(([a], [b]) => a.localeCompare(b))
456
455
  .map(([modelId, info]) => toRpcProviderModel(modelId, info));
@@ -214,18 +214,14 @@ async function writeToChildStdin(
214
214
  }
215
215
 
216
216
  await new Promise<void>((resolve, reject) => {
217
- const onError = (error: Error) => {
218
- stdin.off("error", onError);
219
- const code = (error as Error & { code?: string }).code;
220
- if (code === "EPIPE" || code === "ERR_STREAM_DESTROYED") {
221
- resolve();
217
+ let settled = false;
218
+ const finish = (error?: Error | null) => {
219
+ if (settled) {
222
220
  return;
223
221
  }
224
- reject(error);
225
- };
226
- stdin.once("error", onError);
227
- stdin.end(body, (error?: Error | null) => {
222
+ settled = true;
228
223
  stdin.off("error", onError);
224
+ stdin.off("close", onClose);
229
225
  if (error) {
230
226
  const code = (error as Error & { code?: string }).code;
231
227
  if (code === "EPIPE" || code === "ERR_STREAM_DESTROYED") {
@@ -236,7 +232,12 @@ async function writeToChildStdin(
236
232
  return;
237
233
  }
238
234
  resolve();
239
- });
235
+ };
236
+ const onError = (error: Error) => finish(error);
237
+ const onClose = () => finish();
238
+ stdin.on("error", onError);
239
+ stdin.once("close", onClose);
240
+ stdin.end(body, (error?: Error | null) => finish(error));
240
241
  });
241
242
  }
242
243
 
@@ -40,7 +40,7 @@ export interface RuntimeBuilder {
40
40
  export interface SessionRuntime {
41
41
  start(config: CoreSessionConfig): Promise<{ sessionId: string }>;
42
42
  send(sessionId: string, prompt: string): Promise<AgentResult | undefined>;
43
- abort(sessionId: string): Promise<void>;
43
+ abort(sessionId: string, reason?: unknown): Promise<void>;
44
44
  stop(sessionId: string): Promise<void>;
45
45
  poll(): Promise<string[]>;
46
46
  }
@@ -10,7 +10,7 @@ import {
10
10
  import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import type { AgentResult } from "@clinebot/agents";
13
- import type { providers as LlmsProviders } from "@clinebot/llms";
13
+ import type { LlmsProviders } from "@clinebot/llms";
14
14
  import { nanoid } from "nanoid";
15
15
  import { afterEach, describe, expect, it, vi } from "vitest";
16
16
  import type { SessionSource, SessionStatus } from "../types/common";
@@ -348,6 +348,7 @@ describe("DefaultSessionManager e2e", () => {
348
348
  await manager.stop(started.sessionId);
349
349
  const stopped = await manager.get(started.sessionId);
350
350
  expect(stopped?.status).toBe("cancelled");
351
+ expect(stopped?.exitCode).toBe(0);
351
352
  expect(agentShutdown).toHaveBeenCalledTimes(1);
352
353
  expect(runtimeShutdown).toHaveBeenCalledTimes(1);
353
354
  const parsedManifest = JSON.parse(
@@ -309,6 +309,137 @@ describe("DefaultSessionManager", () => {
309
309
  });
310
310
  });
311
311
 
312
+ it("preserves per-turn metadata on prior assistant messages across turns", async () => {
313
+ const sessionId = "sess-meta-multi";
314
+ const manifest = createManifest(sessionId);
315
+ const persistSessionMessages = vi.fn();
316
+ const runtimeBuilder = {
317
+ build: vi.fn().mockReturnValue({
318
+ tools: [],
319
+ shutdown: vi.fn(),
320
+ }),
321
+ };
322
+ const firstTurnMessages = [
323
+ {
324
+ role: "user" as const,
325
+ content: [{ type: "text" as const, text: "hello" }],
326
+ },
327
+ {
328
+ role: "assistant" as const,
329
+ content: [{ type: "text" as const, text: "world" }],
330
+ },
331
+ ];
332
+ const secondTurnMessages = [
333
+ ...firstTurnMessages,
334
+ {
335
+ role: "user" as const,
336
+ content: [{ type: "text" as const, text: "again" }],
337
+ },
338
+ {
339
+ role: "assistant" as const,
340
+ content: [{ type: "text" as const, text: "still here" }],
341
+ },
342
+ ];
343
+ const run = vi.fn().mockResolvedValue(
344
+ createResult({
345
+ usage: {
346
+ inputTokens: 33,
347
+ outputTokens: 12,
348
+ cacheReadTokens: 4,
349
+ cacheWriteTokens: 1,
350
+ totalCost: 0.42,
351
+ },
352
+ model: {
353
+ id: "claude-sonnet-4-6",
354
+ provider: "anthropic",
355
+ },
356
+ endedAt: new Date("2026-01-01T00:00:02.000Z"),
357
+ messages: firstTurnMessages,
358
+ }),
359
+ );
360
+ const continueFn = vi.fn().mockResolvedValue(
361
+ createResult({
362
+ usage: {
363
+ inputTokens: 10,
364
+ outputTokens: 5,
365
+ cacheReadTokens: 2,
366
+ cacheWriteTokens: 0,
367
+ totalCost: 0.12,
368
+ },
369
+ model: {
370
+ id: "claude-sonnet-4-6",
371
+ provider: "anthropic",
372
+ },
373
+ endedAt: new Date("2026-01-01T00:00:03.000Z"),
374
+ messages: secondTurnMessages,
375
+ }),
376
+ );
377
+ const agent = {
378
+ run,
379
+ continue: continueFn,
380
+ abort: vi.fn(),
381
+ shutdown: vi.fn().mockResolvedValue(undefined),
382
+ restore: vi.fn(),
383
+ getMessages: vi.fn().mockReturnValue([]),
384
+ messages: [],
385
+ };
386
+ const sessionService = {
387
+ ensureSessionsDir: vi.fn().mockReturnValue("/tmp/sessions"),
388
+ createRootSessionWithArtifacts: vi.fn().mockResolvedValue({
389
+ manifestPath: "/tmp/manifest-meta-multi.json",
390
+ transcriptPath: "/tmp/transcript-meta-multi.log",
391
+ hookPath: "/tmp/hook-meta-multi.log",
392
+ messagesPath: "/tmp/messages-meta-multi.json",
393
+ manifest,
394
+ }),
395
+ persistSessionMessages,
396
+ updateSessionStatus: vi.fn().mockResolvedValue({ updated: true }),
397
+ writeSessionManifest: vi.fn(),
398
+ listSessions: vi.fn().mockResolvedValue([]),
399
+ deleteSession: vi.fn().mockResolvedValue({ deleted: true }),
400
+ };
401
+ const manager = new DefaultSessionManager({
402
+ distinctId,
403
+ sessionService: sessionService as never,
404
+ runtimeBuilder,
405
+ createAgent: () => agent as never,
406
+ });
407
+
408
+ await manager.start({
409
+ config: createConfig({
410
+ sessionId,
411
+ providerId: "anthropic",
412
+ modelId: "claude-sonnet-4-6",
413
+ }),
414
+ interactive: true,
415
+ });
416
+
417
+ await manager.send({ sessionId, prompt: "hello" });
418
+ await manager.send({ sessionId, prompt: "again" });
419
+
420
+ const persisted = persistSessionMessages.mock.calls[1]?.[1];
421
+ expect(persisted?.[1]).toMatchObject({
422
+ role: "assistant",
423
+ metrics: {
424
+ inputTokens: 33,
425
+ outputTokens: 12,
426
+ cacheReadTokens: 4,
427
+ cacheWriteTokens: 1,
428
+ cost: 0.42,
429
+ },
430
+ });
431
+ expect(persisted?.[3]).toMatchObject({
432
+ role: "assistant",
433
+ metrics: {
434
+ inputTokens: 10,
435
+ outputTokens: 5,
436
+ cacheReadTokens: 2,
437
+ cacheWriteTokens: 0,
438
+ cost: 0.12,
439
+ },
440
+ });
441
+ });
442
+
312
443
  it("persists rendered messages when a turn fails", async () => {
313
444
  const sessionId = "sess-failed-turn";
314
445
  const manifest = createManifest(sessionId);