@tracebench/adapter-cursor 0.2.2 → 0.2.5

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 (58) hide show
  1. package/README.md +9 -7
  2. package/dist/db-fixture.d.ts +2 -0
  3. package/dist/db-fixture.d.ts.map +1 -0
  4. package/dist/db-fixture.js +75 -0
  5. package/dist/db-fixture.js.map +1 -0
  6. package/dist/db-read.d.ts +26 -0
  7. package/dist/db-read.d.ts.map +1 -0
  8. package/dist/db-read.js +142 -0
  9. package/dist/db-read.js.map +1 -0
  10. package/dist/db-snapshot.d.ts +13 -0
  11. package/dist/db-snapshot.d.ts.map +1 -0
  12. package/dist/db-snapshot.js +34 -0
  13. package/dist/db-snapshot.js.map +1 -0
  14. package/dist/db-types.d.ts +70 -0
  15. package/dist/db-types.d.ts.map +1 -0
  16. package/dist/db-types.js +2 -0
  17. package/dist/db-types.js.map +1 -0
  18. package/dist/db-uri.d.ts +8 -0
  19. package/dist/db-uri.d.ts.map +1 -0
  20. package/dist/db-uri.js +22 -0
  21. package/dist/db-uri.js.map +1 -0
  22. package/dist/discover-db.d.ts +11 -0
  23. package/dist/discover-db.d.ts.map +1 -0
  24. package/dist/discover-db.js +19 -0
  25. package/dist/discover-db.js.map +1 -0
  26. package/dist/discover.d.ts +13 -1
  27. package/dist/discover.d.ts.map +1 -1
  28. package/dist/discover.js +38 -3
  29. package/dist/discover.js.map +1 -1
  30. package/dist/index.d.ts +8 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +8 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/load-db.d.ts +5 -0
  35. package/dist/load-db.d.ts.map +1 -0
  36. package/dist/load-db.js +19 -0
  37. package/dist/load-db.js.map +1 -0
  38. package/dist/normalize-db.d.ts +10 -0
  39. package/dist/normalize-db.d.ts.map +1 -0
  40. package/dist/normalize-db.js +292 -0
  41. package/dist/normalize-db.js.map +1 -0
  42. package/dist/normalize.d.ts.map +1 -1
  43. package/dist/normalize.js +5 -0
  44. package/dist/normalize.js.map +1 -1
  45. package/package.json +3 -3
  46. package/src/db-fixture.ts +97 -0
  47. package/src/db-read.ts +199 -0
  48. package/src/db-snapshot.ts +41 -0
  49. package/src/db-types.ts +60 -0
  50. package/src/db-uri.ts +25 -0
  51. package/src/discover-db.ts +33 -0
  52. package/src/discover.test.ts +4 -2
  53. package/src/discover.ts +55 -3
  54. package/src/index.ts +13 -1
  55. package/src/load-db.ts +25 -0
  56. package/src/normalize-db.test.ts +88 -0
  57. package/src/normalize-db.ts +332 -0
  58. package/src/normalize.ts +6 -0
package/src/db-read.ts ADDED
@@ -0,0 +1,199 @@
1
+ // Read Composer sessions from Cursor's global state.vscdb.
2
+
3
+ import { statSync } from 'node:fs';
4
+ import { SqliteDatabase } from '@tracebench/core';
5
+ import type {
6
+ ComposerDataRow,
7
+ ComposerHeaderEntry,
8
+ ConversationHeader,
9
+ CursorBubble,
10
+ } from './db-types.js';
11
+ import { snapshotCursorDb, releaseDbSnapshot } from './db-snapshot.js';
12
+
13
+ export interface ComposerListEntry {
14
+ composerId: string;
15
+ name: string | null;
16
+ subtitle: string | null;
17
+ projectPath: string | null;
18
+ createdAtMs: number | null;
19
+ lastUpdatedAtMs: number | null;
20
+ unifiedMode: string | null;
21
+ modelName: string | null;
22
+ bubbleCount: number;
23
+ /** Number of bubbles listed in fullConversationHeadersOnly (may be 0 for agent sessions). */
24
+ headerBubbleCount: number;
25
+ }
26
+
27
+ function parseJson<T>(raw: string | Buffer | null | undefined): T | null {
28
+ if (raw == null) return null;
29
+ const s = typeof raw === 'string' ? raw : raw.toString('utf8');
30
+ if (!s) return null;
31
+ try {
32
+ return JSON.parse(s) as T;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ function workspacePath(
39
+ ws?: { uri?: { fsPath?: string; path?: string } },
40
+ ): string | null {
41
+ return ws?.uri?.fsPath ?? ws?.uri?.path ?? null;
42
+ }
43
+
44
+ function openReadonly(dbPath: string) {
45
+ return new SqliteDatabase(dbPath, { readonly: true, fileMustExist: true });
46
+ }
47
+
48
+ /** List composers that have at least one stored bubble. */
49
+ export function listComposersWithBubbles(dbPath: string): ComposerListEntry[] {
50
+ const snap = snapshotCursorDb(dbPath);
51
+ if (!snap) return [];
52
+
53
+ try {
54
+ const db = openReadonly(snap.dbPath);
55
+
56
+ const headersRaw = db
57
+ .prepare('SELECT value FROM ItemTable WHERE key = ?')
58
+ .get('composer.composerHeaders') as { value: string } | undefined;
59
+ const headers = parseJson<{ allComposers?: ComposerHeaderEntry[] }>(
60
+ headersRaw?.value,
61
+ );
62
+ const headerById = new Map<string, ComposerHeaderEntry>();
63
+ for (const h of headers?.allComposers ?? []) {
64
+ if (h.composerId) headerById.set(h.composerId, h);
65
+ }
66
+
67
+ const bubbleCounts = new Map<string, number>();
68
+ const rows = db
69
+ .prepare(
70
+ `SELECT key FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'`,
71
+ )
72
+ .all() as { key: string }[];
73
+ for (const { key } of rows) {
74
+ const parts = key.split(':');
75
+ if (parts.length < 3) continue;
76
+ const composerId = parts[1]!;
77
+ bubbleCounts.set(composerId, (bubbleCounts.get(composerId) ?? 0) + 1);
78
+ }
79
+
80
+ const composerIds = [...bubbleCounts.keys()];
81
+ const out: ComposerListEntry[] = [];
82
+
83
+ const dataStmt = db.prepare(
84
+ 'SELECT value FROM cursorDiskKV WHERE key = ?',
85
+ );
86
+
87
+ for (const composerId of composerIds) {
88
+ const dataRow = dataStmt.get(`composerData:${composerId}`) as
89
+ | { value: string }
90
+ | undefined;
91
+ const data = parseJson<ComposerDataRow>(dataRow?.value);
92
+ const header = headerById.get(composerId);
93
+
94
+ const name =
95
+ data?.name ?? header?.name ?? header?.subtitle ?? data?.subtitle ?? null;
96
+ const projectPath =
97
+ workspacePath(data?.workspaceIdentifier) ??
98
+ workspacePath(header?.workspaceIdentifier);
99
+
100
+ out.push({
101
+ composerId,
102
+ name: name ?? null,
103
+ subtitle: data?.subtitle ?? header?.subtitle ?? null,
104
+ projectPath,
105
+ createdAtMs: data?.createdAt ?? header?.createdAt ?? null,
106
+ lastUpdatedAtMs: data?.lastUpdatedAt ?? header?.lastUpdatedAt ?? null,
107
+ unifiedMode: data?.unifiedMode ?? header?.unifiedMode ?? null,
108
+ modelName: data?.modelConfig?.modelName ?? null,
109
+ bubbleCount: bubbleCounts.get(composerId) ?? 0,
110
+ headerBubbleCount: data?.fullConversationHeadersOnly?.length ?? 0,
111
+ });
112
+ }
113
+
114
+ db.close();
115
+ out.sort((a, b) => (b.lastUpdatedAtMs ?? 0) - (a.lastUpdatedAtMs ?? 0));
116
+ return out;
117
+ } finally {
118
+ releaseDbSnapshot(snap);
119
+ }
120
+ }
121
+
122
+ export interface LoadedComposer {
123
+ composerId: string;
124
+ data: ComposerDataRow | null;
125
+ headers: ConversationHeader[];
126
+ bubbles: CursorBubble[];
127
+ }
128
+
129
+ export function loadComposerFromDb(
130
+ dbPath: string,
131
+ composerId: string,
132
+ ): LoadedComposer | null {
133
+ const snap = snapshotCursorDb(dbPath);
134
+ if (!snap) return null;
135
+
136
+ try {
137
+ const db = openReadonly(snap.dbPath);
138
+
139
+ const dataRow = db
140
+ .prepare('SELECT value FROM cursorDiskKV WHERE key = ?')
141
+ .get(`composerData:${composerId}`) as { value: string } | undefined;
142
+ const data = parseJson<ComposerDataRow>(dataRow?.value);
143
+
144
+ const headerList = data?.fullConversationHeadersOnly ?? [];
145
+ const bubbles: CursorBubble[] = [];
146
+
147
+ const bubbleStmt = db.prepare(
148
+ 'SELECT value FROM cursorDiskKV WHERE key = ?',
149
+ );
150
+ const prefix = `bubbleId:${composerId}:`;
151
+
152
+ if (headerList.length > 0) {
153
+ for (const h of headerList) {
154
+ const row = bubbleStmt.get(`${prefix}${h.bubbleId}`) as
155
+ | { value: string }
156
+ | undefined;
157
+ const bubble = parseJson<CursorBubble>(row?.value);
158
+ if (bubble) bubbles.push(bubble);
159
+ }
160
+ } else {
161
+ const all = db
162
+ .prepare(
163
+ `SELECT key, value FROM cursorDiskKV WHERE key LIKE ?`,
164
+ )
165
+ .all(`${prefix}%`) as { key: string; value: string }[];
166
+ for (const row of all) {
167
+ const bubble = parseJson<CursorBubble>(row.value);
168
+ if (bubble) bubbles.push(bubble);
169
+ }
170
+ bubbles.sort((a, b) => {
171
+ const ta = a.createdAt ? Date.parse(a.createdAt) : 0;
172
+ const tb = b.createdAt ? Date.parse(b.createdAt) : 0;
173
+ return ta - tb;
174
+ });
175
+ }
176
+
177
+ db.close();
178
+
179
+ if (bubbles.length === 0 && !data) return null;
180
+
181
+ return {
182
+ composerId,
183
+ data,
184
+ headers: headerList,
185
+ bubbles,
186
+ };
187
+ } finally {
188
+ releaseDbSnapshot(snap);
189
+ }
190
+ }
191
+
192
+ /** DB mtime for incremental indexing (main file only). */
193
+ export function cursorDbMtimeMs(dbPath: string): number {
194
+ try {
195
+ return statSync(dbPath).mtimeMs;
196
+ } catch {
197
+ return 0;
198
+ }
199
+ }
@@ -0,0 +1,41 @@
1
+ // Copy Cursor's state.vscdb (+ WAL sidecars) for consistent reads while Cursor is running.
2
+
3
+ import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { basename, join } from 'node:path';
6
+
7
+ export interface DbSnapshot {
8
+ /** Path to the copied main DB file (inside a temp dir). */
9
+ dbPath: string;
10
+ /** Temp directory holding the snapshot; call `releaseDbSnapshot` when done. */
11
+ tempDir: string;
12
+ }
13
+
14
+ /**
15
+ * Snapshot `state.vscdb` (and `-wal`/`-shm` when present) into a temp directory.
16
+ * Returns null when the main DB file does not exist.
17
+ */
18
+ export function snapshotCursorDb(mainDbPath: string): DbSnapshot | null {
19
+ if (!existsSync(mainDbPath)) return null;
20
+
21
+ const tempDir = mkdtempSync(join(tmpdir(), 'tracebench-cursor-db-'));
22
+ const base = basename(mainDbPath);
23
+ const dir = join(mainDbPath, '..');
24
+
25
+ copyFileSync(mainDbPath, join(tempDir, base));
26
+ const wal = join(dir, `${base}-wal`);
27
+ const shm = join(dir, `${base}-shm`);
28
+ if (existsSync(wal)) copyFileSync(wal, join(tempDir, `${base}-wal`));
29
+ if (existsSync(shm)) copyFileSync(shm, join(tempDir, `${base}-shm`));
30
+
31
+ return { dbPath: join(tempDir, base), tempDir };
32
+ }
33
+
34
+ export function releaseDbSnapshot(snapshot: DbSnapshot | null): void {
35
+ if (!snapshot) return;
36
+ try {
37
+ rmSync(snapshot.tempDir, { recursive: true, force: true });
38
+ } catch {
39
+ // ignore cleanup failures
40
+ }
41
+ }
@@ -0,0 +1,60 @@
1
+ export interface ComposerHeaderEntry {
2
+ type?: string;
3
+ composerId: string;
4
+ name?: string;
5
+ subtitle?: string;
6
+ lastUpdatedAt?: number;
7
+ createdAt?: number;
8
+ unifiedMode?: string;
9
+ isArchived?: boolean;
10
+ workspaceIdentifier?: {
11
+ uri?: { fsPath?: string; path?: string };
12
+ };
13
+ }
14
+
15
+ export interface ConversationHeader {
16
+ bubbleId: string;
17
+ type: number; // 1 = user, 2 = assistant
18
+ }
19
+
20
+ export interface ComposerDataRow {
21
+ composerId: string;
22
+ name?: string;
23
+ subtitle?: string;
24
+ createdAt?: number;
25
+ lastUpdatedAt?: number;
26
+ unifiedMode?: string;
27
+ status?: string;
28
+ fullConversationHeadersOnly?: ConversationHeader[];
29
+ workspaceIdentifier?: {
30
+ uri?: { fsPath?: string; path?: string };
31
+ };
32
+ modelConfig?: { modelName?: string };
33
+ isArchived?: boolean;
34
+ isDraft?: boolean;
35
+ }
36
+
37
+ export interface CursorBubble {
38
+ bubbleId?: string;
39
+ type?: number;
40
+ text?: string;
41
+ richText?: string;
42
+ createdAt?: string;
43
+ capabilityType?: number;
44
+ thinking?: { text?: string };
45
+ thinkingDurationMs?: number;
46
+ modelInfo?: { modelName?: string };
47
+ tokenCount?: {
48
+ inputTokens?: number;
49
+ outputTokens?: number;
50
+ };
51
+ toolFormerData?: {
52
+ toolCallId?: string;
53
+ name?: string;
54
+ status?: string;
55
+ rawArgs?: string;
56
+ params?: string;
57
+ result?: string;
58
+ additionalData?: Record<string, unknown>;
59
+ };
60
+ }
package/src/db-uri.ts ADDED
@@ -0,0 +1,25 @@
1
+ // Virtual paths for Composer DB sessions (indexed by tracebench as raw_path).
2
+
3
+ const PREFIX = 'cursor-db:';
4
+
5
+ /** Stable raw_path for a Composer session loaded from SQLite. */
6
+ export function composerDbUri(composerId: string, globalDbPath: string): string {
7
+ return `${PREFIX}${composerId}@${globalDbPath}`;
8
+ }
9
+
10
+ export function parseComposerDbUri(
11
+ rawPath: string,
12
+ ): { composerId: string; globalDbPath: string } | null {
13
+ if (!rawPath.startsWith(PREFIX)) return null;
14
+ const rest = rawPath.slice(PREFIX.length);
15
+ const at = rest.lastIndexOf('@');
16
+ if (at <= 0) return null;
17
+ return {
18
+ composerId: rest.slice(0, at),
19
+ globalDbPath: rest.slice(at + 1),
20
+ };
21
+ }
22
+
23
+ export function isComposerDbUri(rawPath: string): boolean {
24
+ return rawPath.startsWith(PREFIX);
25
+ }
@@ -0,0 +1,33 @@
1
+ // Discover Cursor Composer sessions from global state.vscdb.
2
+
3
+ import { defaultCursorGlobalDbPath } from './paths.js';
4
+ import { listComposersWithBubbles, cursorDbMtimeMs } from './db-read.js';
5
+ import { composerDbUri } from './db-uri.js';
6
+
7
+ export interface DiscoveredComposerSession {
8
+ session_id: string;
9
+ file_path: string;
10
+ global_db_path: string;
11
+ project_path: string | null;
12
+ name: string | null;
13
+ size: number;
14
+ mtime_ms: number;
15
+ }
16
+
17
+ export function discoverComposerSessions(
18
+ globalDbPath?: string,
19
+ ): DiscoveredComposerSession[] {
20
+ const dbPath = globalDbPath ?? defaultCursorGlobalDbPath();
21
+ const dbMtime = cursorDbMtimeMs(dbPath);
22
+ const composers = listComposersWithBubbles(dbPath);
23
+
24
+ return composers.map((c) => ({
25
+ session_id: c.composerId,
26
+ file_path: composerDbUri(c.composerId, dbPath),
27
+ global_db_path: dbPath,
28
+ project_path: c.projectPath,
29
+ name: c.name,
30
+ size: c.bubbleCount,
31
+ mtime_ms: c.lastUpdatedAtMs ?? dbMtime,
32
+ }));
33
+ }
@@ -22,13 +22,15 @@ describe('discoverSessions', () => {
22
22
  writeFileSync(nested, '{"role":"user","message":{"content":[]}}\n');
23
23
  writeFileSync(subagent, '{"role":"user","message":{"content":[]}}\n');
24
24
 
25
- const found = discoverSessions(root);
25
+ const found = discoverSessions({ projectsRoot: root, globalDbPath: false });
26
26
  const ids = found.map((f) => f.session_id).sort();
27
27
  expect(ids).toEqual(['sess-main', 'sess-sub']);
28
28
  expect(found.every((f) => f.encoded_project_dir === 'Users-me-testproj')).toBe(true);
29
29
  });
30
30
 
31
31
  it('returns empty when root is missing', () => {
32
- expect(discoverSessions('/nonexistent/tracebench-cursor-root')).toEqual([]);
32
+ expect(
33
+ discoverSessions({ projectsRoot: '/nonexistent/tracebench-cursor-root', globalDbPath: false }),
34
+ ).toEqual([]);
33
35
  });
34
36
  });
package/src/discover.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  import { readdirSync, statSync } from 'node:fs';
10
10
  import { join, basename } from 'node:path';
11
11
  import { defaultCursorProjectsRoot } from './paths.js';
12
+ import { discoverComposerSessions } from './discover-db.js';
12
13
 
13
14
  export interface DiscoveredCursorSession {
14
15
  session_id: string;
@@ -16,6 +17,8 @@ export interface DiscoveredCursorSession {
16
17
  encoded_project_dir: string;
17
18
  size: number;
18
19
  mtime_ms: number;
20
+ /** Present when loaded from Composer SQLite instead of JSONL. */
21
+ source?: 'jsonl' | 'composer_db';
19
22
  }
20
23
 
21
24
  export { defaultCursorProjectsRoot as defaultProjectsRoot };
@@ -56,8 +59,14 @@ function walkJsonl(
56
59
  }
57
60
  }
58
61
 
59
- export function discoverSessions(root?: string): DiscoveredCursorSession[] {
60
- const base = root ?? defaultCursorProjectsRoot();
62
+ export interface DiscoverSessionsOptions {
63
+ /** Override ~/.cursor/projects */
64
+ projectsRoot?: string;
65
+ /** Override global state.vscdb path; set to false to skip DB discovery. */
66
+ globalDbPath?: string | false;
67
+ }
68
+
69
+ function discoverJsonlSessions(base: string): DiscoveredCursorSession[] {
61
70
  const out: DiscoveredCursorSession[] = [];
62
71
  let projectDirs: string[];
63
72
  try {
@@ -71,6 +80,49 @@ export function discoverSessions(root?: string): DiscoveredCursorSession[] {
71
80
  const transcriptsDir = join(base, dir, 'agent-transcripts');
72
81
  walkJsonl(transcriptsDir, dir, out);
73
82
  }
74
- out.sort((a, b) => b.mtime_ms - a.mtime_ms);
83
+ for (const s of out) s.source = 'jsonl';
75
84
  return out;
76
85
  }
86
+
87
+ /**
88
+ * Discover agent-transcript JSONL sessions and Composer DB sessions.
89
+ * When the same composerId exists in both, the DB entry wins (richer events).
90
+ */
91
+ export function discoverSessions(
92
+ rootOrOpts?: string | DiscoverSessionsOptions,
93
+ ): DiscoveredCursorSession[] {
94
+ const opts: DiscoverSessionsOptions =
95
+ typeof rootOrOpts === 'string' ? { projectsRoot: rootOrOpts } : (rootOrOpts ?? {});
96
+
97
+ const base = opts.projectsRoot ?? defaultCursorProjectsRoot();
98
+ const jsonl = discoverJsonlSessions(base);
99
+
100
+ if (opts.globalDbPath === false) {
101
+ jsonl.sort((a, b) => b.mtime_ms - a.mtime_ms);
102
+ return jsonl;
103
+ }
104
+
105
+ let dbSessions: DiscoveredCursorSession[] = [];
106
+ try {
107
+ dbSessions = discoverComposerSessions(
108
+ opts.globalDbPath === undefined ? undefined : opts.globalDbPath,
109
+ ).map((d) => ({
110
+ session_id: d.session_id,
111
+ file_path: d.file_path,
112
+ encoded_project_dir: '',
113
+ size: d.size,
114
+ mtime_ms: d.mtime_ms,
115
+ source: 'composer_db' as const,
116
+ }));
117
+ } catch {
118
+ dbSessions = [];
119
+ }
120
+
121
+ const dbIds = new Set(dbSessions.map((s) => s.session_id));
122
+ const merged = [
123
+ ...dbSessions,
124
+ ...jsonl.filter((s) => !dbIds.has(s.session_id)),
125
+ ];
126
+ merged.sort((a, b) => b.mtime_ms - a.mtime_ms);
127
+ return merged;
128
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export const HARNESS_NAME = 'cursor' as const;
2
+ /** JSONL agent-transcript format version. */
2
3
  export const FORMAT_VERSION = '2026-q1';
4
+ /** Composer SQLite (state.vscdb) format version. */
5
+ export const FORMAT_VERSION_COMPOSER = '2026-q1-composer';
3
6
 
4
7
  export {
5
8
  defaultCursorProjectsRoot,
@@ -7,7 +10,13 @@ export {
7
10
  defaultCursorGlobalDbPath,
8
11
  decodeProjectPath,
9
12
  } from './paths.js';
10
- export { defaultProjectsRoot, discoverSessions, type DiscoveredCursorSession } from './discover.js';
13
+ export {
14
+ defaultProjectsRoot,
15
+ discoverSessions,
16
+ type DiscoveredCursorSession,
17
+ type DiscoverSessionsOptions,
18
+ } from './discover.js';
19
+ export { discoverComposerSessions, type DiscoveredComposerSession } from './discover-db.js';
11
20
  export { parseSession, streamSession, type RawCursorEvent } from './parse.js';
12
21
  export {
13
22
  loadSession,
@@ -15,3 +24,6 @@ export {
15
24
  parseTranscriptPath,
16
25
  type NormalizeResult,
17
26
  } from './normalize.js';
27
+ export { loadComposerSession } from './load-db.js';
28
+ export { normalizeComposerSession, FORMAT_VERSION_DB } from './normalize-db.js';
29
+ export { composerDbUri, parseComposerDbUri, isComposerDbUri } from './db-uri.js';
package/src/load-db.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { loadComposerFromDb } from './db-read.js';
2
+ import { parseComposerDbUri } from './db-uri.js';
3
+ import { normalizeComposerSession } from './normalize-db.js';
4
+ import type { NormalizeResult } from './normalize.js';
5
+
6
+ export async function loadComposerSession(
7
+ rawPath: string,
8
+ opts: { formatVersion?: string } = {},
9
+ ): Promise<NormalizeResult> {
10
+ const parsed = parseComposerDbUri(rawPath);
11
+ if (!parsed) {
12
+ throw new Error(`not a composer DB path: ${rawPath}`);
13
+ }
14
+
15
+ const loaded = loadComposerFromDb(parsed.globalDbPath, parsed.composerId);
16
+ if (!loaded) {
17
+ throw new Error(`composer not found in DB: ${parsed.composerId}`);
18
+ }
19
+
20
+ return normalizeComposerSession(loaded, {
21
+ rawPath,
22
+ globalDbPath: parsed.globalDbPath,
23
+ formatVersion: opts.formatVersion,
24
+ });
25
+ }
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { createMinimalCursorDb } from './db-fixture.js';
6
+ import { listComposersWithBubbles, loadComposerFromDb } from './db-read.js';
7
+ import { discoverComposerSessions } from './discover-db.js';
8
+ import { composerDbUri } from './db-uri.js';
9
+ import { loadComposerSession } from './load-db.js';
10
+ import { discoverSessions } from './discover.js';
11
+
12
+ const COMPOSER_ID = 'aaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
13
+
14
+ let dbPath: string;
15
+ let tempDir: string;
16
+
17
+ beforeAll(() => {
18
+ tempDir = mkdtempSync(join(tmpdir(), 'tb-cursor-db-fixture-'));
19
+ dbPath = join(tempDir, 'state.vscdb');
20
+ createMinimalCursorDb(dbPath);
21
+ });
22
+
23
+ afterAll(() => {
24
+ rmSync(tempDir, { recursive: true, force: true });
25
+ });
26
+
27
+ describe('Composer DB reader', () => {
28
+ it('lists composers with bubbles', () => {
29
+ const list = listComposersWithBubbles(dbPath);
30
+ expect(list.length).toBe(1);
31
+ expect(list[0]!.composerId).toBe(COMPOSER_ID);
32
+ expect(list[0]!.bubbleCount).toBe(3);
33
+ expect(list[0]!.projectPath).toBe('/Users/me/code/fixture');
34
+ });
35
+
36
+ it('loads ordered bubbles from fullConversationHeadersOnly', () => {
37
+ const loaded = loadComposerFromDb(dbPath, COMPOSER_ID);
38
+ expect(loaded).not.toBeNull();
39
+ expect(loaded!.bubbles.length).toBe(3);
40
+ expect(loaded!.bubbles[0]!.type).toBe(1);
41
+ expect(loaded!.bubbles[1]!.capabilityType).toBe(30);
42
+ expect(loaded!.bubbles[2]!.toolFormerData?.toolCallId).toBe('tool_fixture_call_001');
43
+ });
44
+ });
45
+
46
+ describe('normalize Composer DB session', () => {
47
+ it('emits tool_call + tool_result with stable call id', async () => {
48
+ const uri = composerDbUri(COMPOSER_ID, dbPath);
49
+ const { session, events } = await loadComposerSession(uri);
50
+ expect(session.harness).toBe('cursor');
51
+ expect(session.session_id).toBe(COMPOSER_ID);
52
+ expect(session.model).toBe('composer-2.5');
53
+ expect(session.title).toMatch(/Summarize the fixture/);
54
+
55
+ const toolCall = events.find((e) => e.event_type === 'tool_call');
56
+ const toolResult = events.find((e) => e.event_type === 'tool_result');
57
+ expect(toolCall).toBeDefined();
58
+ expect(toolResult).toBeDefined();
59
+ expect(toolCall!.event_id).toBe('tool_fixture_call_001');
60
+ expect(toolResult!.parent_event_id).toBe('tool_fixture_call_001');
61
+ expect(toolCall!.tool.name).toBe('Read');
62
+ expect(toolResult!.tool.output).toContain('Fixture');
63
+ });
64
+
65
+ it('emits thinking events from capabilityType 30 bubbles', async () => {
66
+ const { events } = await loadComposerSession(composerDbUri(COMPOSER_ID, dbPath));
67
+ expect(events.some((e) => e.event_type === 'thinking')).toBe(true);
68
+ });
69
+ });
70
+
71
+ describe('discover merge', () => {
72
+ it('prefers DB session over JSONL with same composer id', () => {
73
+ const merged = discoverSessions({
74
+ projectsRoot: join(tempDir, 'empty-projects'),
75
+ globalDbPath: dbPath,
76
+ });
77
+ const match = merged.find((s) => s.session_id === COMPOSER_ID);
78
+ expect(match).toBeDefined();
79
+ expect(match!.source).toBe('composer_db');
80
+ expect(match!.file_path).toContain('cursor-db:');
81
+ });
82
+
83
+ it('discovers composer sessions via discoverComposerSessions', () => {
84
+ const dbOnly = discoverComposerSessions(dbPath);
85
+ expect(dbOnly.length).toBe(1);
86
+ expect(dbOnly[0]!.session_id).toBe(COMPOSER_ID);
87
+ });
88
+ });