Skip to content

Commit 64936a3

Browse files
rajbosCopilot
andcommitted
refactor: Phases 4+5 — clean SessionDiscoveryDeps and fix last direct DA calls
Phase 4: Drop 7 individual DataAccess fields from SessionDiscoveryDeps. SessionDiscovery now only needs ecosystems + log/warn/error + optional override. Updated constructor calls in extension.ts and cli/src/helpers.ts. Phase 5 (partial): Fix last 3 direct this.openCode.* calls in extension.ts. - getEditorTypeFromPath(): use findEcosystem(p)?.id === 'opencode' instead of this.openCode.isOpenCodeSessionFile() - Folder counting loop: use adapter.getEditorRoot() for all adapter-owned sessions (was only handling OpenCode; now handles Crush and other virtual-path sessions too) Phase 3 tests: Add 24 unit tests covering adapter discovery: - isDiscoverable() type guard (all 7 adapters + negative case) - handles() path recognition for OpenCode/Continue/ClaudeCode/MistralVibe - getCandidatePaths() structure validation for all adapters - getEditorRoot() returns non-empty string for all adapters - discover() returns valid DiscoveryResult shape (graceful empty dirs) - getCandidatePaths/discover consistency invariant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 55f61b5 commit 64936a3

4 files changed

Lines changed: 261 additions & 26 deletions

File tree

cli/src/helpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ const _ecosystems: IEcosystemAdapter[] = [
102102

103103
/** Create session discovery instance for CLI */
104104
function createSessionDiscovery(): SessionDiscovery {
105-
return new SessionDiscovery({ log, warn, error, ecosystems: _ecosystems, openCode: _openCodeInstance, crush: _crushInstance, continue_: _continueInstance, visualStudio: _visualStudioInstance, claudeCode: _claudeCodeInstance, claudeDesktopCowork: _claudeDesktopCoworkInstance, mistralVibe: _mistralVibeInstance });
105+
return new SessionDiscovery({ log, warn, error, ecosystems: _ecosystems });
106106
}
107107

108108
/** Discover all session files on this machine */

vscode-extension/src/extension.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ class CopilotTokenTracker implements vscode.Disposable {
308308
* Returns the filename without the .agent.md extension.
309309
*/
310310
private getEditorTypeFromPath(filePath: string): string {
311-
return _getEditorTypeFromPath(filePath, (p) => this.openCode.isOpenCodeSessionFile(p));
311+
return _getEditorTypeFromPath(filePath, (p) => this.findEcosystem(p)?.id === 'opencode');
312312
}
313313

314314
/** Returns the first adapter that claims this session file, or null for Copilot Chat sessions. */
@@ -850,13 +850,6 @@ class CopilotTokenTracker implements vscode.Disposable {
850850
warn: (m) => this.warn(m),
851851
error: (m, e) => this.error(m, e),
852852
ecosystems: this.ecosystems,
853-
openCode: this.openCode,
854-
crush: this.crush,
855-
visualStudio: this.visualStudio,
856-
continue_: this.continue_,
857-
claudeCode: this.claudeCode,
858-
claudeDesktopCowork: this.claudeDesktopCowork,
859-
mistralVibe: this.mistralVibe,
860853
sampleDataDirectoryOverride: () => this.localRegressionSampleDataDir,
861854
});
862855
this.context = context;
@@ -6838,9 +6831,10 @@ ${hashtag}`;
68386831
"session-state",
68396832
);
68406833
for (const file of sessionFiles) {
6841-
// Handle OpenCode DB virtual paths (opencode.db#ses_<id>)
6842-
if (this.openCode.isOpenCodeDbSession(file)) {
6843-
const editorRoot = this.openCode.getOpenCodeDataDir();
6834+
// Handle virtual/adapter-owned paths (e.g. opencode.db#ses_<id>, crush.db#<uuid>)
6835+
const eco = this.findEcosystem(file);
6836+
if (eco) {
6837+
const editorRoot = eco.getEditorRoot(file);
68446838
dirCounts.set(editorRoot, (dirCounts.get(editorRoot) || 0) + 1);
68456839
continue;
68466840
}

vscode-extension/src/sessionDiscovery.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,6 @@ import * as vscode from 'vscode';
55
import * as fs from 'fs';
66
import * as path from 'path';
77
import * as os from 'os';
8-
import type { OpenCodeDataAccess } from './opencode';
9-
import type { CrushDataAccess } from './crush';
10-
import type { ContinueDataAccess } from './continue';
11-
import type { VisualStudioDataAccess } from "./visualstudio";
12-
import type { ClaudeCodeDataAccess } from './claudecode';
13-
import type { ClaudeDesktopCoworkDataAccess } from './claudedesktop';
14-
import type { MistralVibeDataAccess } from './mistralvibe';
158
import type { IEcosystemAdapter } from './ecosystemAdapter';
169
import { isDiscoverable } from './ecosystemAdapter';
1710

@@ -20,13 +13,6 @@ export interface SessionDiscoveryDeps {
2013
warn: (message: string) => void;
2114
error: (message: string, error?: any) => void;
2215
ecosystems: IEcosystemAdapter[];
23-
openCode: OpenCodeDataAccess;
24-
crush: CrushDataAccess;
25-
continue_: ContinueDataAccess;
26-
visualStudio: VisualStudioDataAccess;
27-
claudeCode: ClaudeCodeDataAccess;
28-
claudeDesktopCowork: ClaudeDesktopCoworkDataAccess;
29-
mistralVibe: MistralVibeDataAccess;
3016
sampleDataDirectoryOverride?: () => string | undefined;
3117
}
3218

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
/**
2+
* Unit tests for ecosystem adapters and the IDiscoverableEcosystem interface.
3+
* Tests cover: isDiscoverable type guard, handles(), getCandidatePaths(),
4+
* getEditorRoot(), and discover() adapter loop behavior.
5+
*/
6+
import test from 'node:test';
7+
import * as assert from 'node:assert/strict';
8+
import * as path from 'node:path';
9+
import * as os from 'node:os';
10+
11+
import { isDiscoverable } from '../../src/ecosystemAdapter';
12+
import type { IEcosystemAdapter } from '../../src/ecosystemAdapter';
13+
14+
import { OpenCodeAdapter } from '../../src/adapters/openCodeAdapter';
15+
import { CrushAdapter } from '../../src/adapters/crushAdapter';
16+
import { ContinueAdapter } from '../../src/adapters/continueAdapter';
17+
import { ClaudeCodeAdapter } from '../../src/adapters/claudeCodeAdapter';
18+
import { ClaudeDesktopAdapter } from '../../src/adapters/claudeDesktopAdapter';
19+
import { VisualStudioAdapter } from '../../src/adapters/visualStudioAdapter';
20+
import { MistralVibeAdapter } from '../../src/adapters/mistralVibeAdapter';
21+
22+
import { OpenCodeDataAccess } from '../../src/opencode';
23+
import { CrushDataAccess } from '../../src/crush';
24+
import { ContinueDataAccess } from '../../src/continue';
25+
import { ClaudeCodeDataAccess } from '../../src/claudecode';
26+
import { ClaudeDesktopCoworkDataAccess } from '../../src/claudedesktop';
27+
import { VisualStudioDataAccess } from '../../src/visualstudio';
28+
import { MistralVibeDataAccess } from '../../src/mistralvibe';
29+
30+
// Stub functions for adapters requiring callbacks
31+
const noopEstimateTokens = (_text: string, _model?: string) => 0;
32+
const noopIsMcpTool = (_name: string) => false;
33+
const noopExtractMcpServerName = (_name: string) => '';
34+
35+
// Adapter instances
36+
const openCodeDA = new OpenCodeDataAccess(null as any);
37+
const crushDA = new CrushDataAccess(null as any);
38+
const continueDA = new ContinueDataAccess();
39+
const claudeCodeDA = new ClaudeCodeDataAccess();
40+
const claudeDesktopDA = new ClaudeDesktopCoworkDataAccess();
41+
const visualStudioDA = new VisualStudioDataAccess();
42+
const mistralVibeDA = new MistralVibeDataAccess();
43+
44+
const openCodeAdapter = new OpenCodeAdapter(openCodeDA);
45+
const crushAdapter = new CrushAdapter(crushDA);
46+
const continueAdapter = new ContinueAdapter(continueDA);
47+
const claudeCodeAdapter = new ClaudeCodeAdapter(claudeCodeDA);
48+
const claudeDesktopAdapter = new ClaudeDesktopAdapter(claudeDesktopDA, noopIsMcpTool, noopExtractMcpServerName, noopEstimateTokens);
49+
const visualStudioAdapter = new VisualStudioAdapter(visualStudioDA, noopEstimateTokens);
50+
const mistralVibeAdapter = new MistralVibeAdapter(mistralVibeDA);
51+
52+
const allAdapters: IEcosystemAdapter[] = [
53+
openCodeAdapter, crushAdapter, continueAdapter,
54+
claudeCodeAdapter, claudeDesktopAdapter, visualStudioAdapter, mistralVibeAdapter,
55+
];
56+
57+
// ---------------------------------------------------------------------------
58+
// isDiscoverable type guard
59+
// ---------------------------------------------------------------------------
60+
61+
test('isDiscoverable: returns true for all 7 adapters', () => {
62+
for (const adapter of allAdapters) {
63+
assert.ok(isDiscoverable(adapter), `Expected ${adapter.id} to be discoverable`);
64+
}
65+
});
66+
67+
test('isDiscoverable: returns false for plain IEcosystemAdapter without discover()', () => {
68+
const plainAdapter: IEcosystemAdapter = {
69+
id: 'plain', displayName: 'Plain',
70+
handles: () => false, getBackingPath: (f) => f, stat: async (f) => { throw new Error(); },
71+
getTokens: async () => ({ tokens: 0, thinkingTokens: 0, actualTokens: 0 }),
72+
countInteractions: async () => 0, getModelUsage: async () => ({}),
73+
getMeta: async () => ({ title: undefined, firstInteraction: null, lastInteraction: null }),
74+
getEditorRoot: () => '',
75+
};
76+
assert.ok(!isDiscoverable(plainAdapter));
77+
});
78+
79+
// ---------------------------------------------------------------------------
80+
// Adapter IDs and display names
81+
// ---------------------------------------------------------------------------
82+
83+
test('adapter IDs are stable lowercase identifiers', () => {
84+
assert.equal(openCodeAdapter.id, 'opencode');
85+
assert.equal(crushAdapter.id, 'crush');
86+
assert.equal(continueAdapter.id, 'continue');
87+
assert.equal(claudeCodeAdapter.id, 'claudecode');
88+
assert.equal(claudeDesktopAdapter.id, 'claudedesktop');
89+
assert.equal(visualStudioAdapter.id, 'visualstudio');
90+
assert.equal(mistralVibeAdapter.id, 'mistralvibe');
91+
});
92+
93+
// ---------------------------------------------------------------------------
94+
// handles() — path recognition
95+
// ---------------------------------------------------------------------------
96+
97+
test('OpenCodeAdapter.handles: recognises JSON session paths', () => {
98+
const p = path.join(openCodeDA.getOpenCodeDataDir(), 'storage', 'session', 'ses_abc123.json');
99+
assert.ok(openCodeAdapter.handles(p));
100+
});
101+
102+
test('OpenCodeAdapter.handles: recognises DB virtual paths', () => {
103+
const p = path.join(openCodeDA.getOpenCodeDataDir(), 'opencode.db#ses_abc123');
104+
assert.ok(openCodeAdapter.handles(p));
105+
});
106+
107+
test('OpenCodeAdapter.handles: rejects unrelated paths', () => {
108+
assert.ok(!openCodeAdapter.handles(path.join(os.homedir(), '.continue', 'sessions', 'abc.json')));
109+
assert.ok(!openCodeAdapter.handles(path.join(os.homedir(), '.claude', 'projects', 'hash', 'abc.jsonl')));
110+
});
111+
112+
test('ContinueAdapter.handles: recognises ~/.continue/sessions paths', () => {
113+
const p = path.join(os.homedir(), '.continue', 'sessions', 'abc123.json');
114+
assert.ok(continueAdapter.handles(p));
115+
});
116+
117+
test('ContinueAdapter.handles: rejects unrelated paths', () => {
118+
assert.ok(!continueAdapter.handles(path.join(os.homedir(), '.claude', 'projects', 'hash', 'abc.jsonl')));
119+
});
120+
121+
test('ClaudeCodeAdapter.handles: recognises ~/.claude/projects paths', () => {
122+
const p = path.join(os.homedir(), '.claude', 'projects', 'my-project', 'abc123.jsonl');
123+
assert.ok(claudeCodeAdapter.handles(p));
124+
});
125+
126+
test('ClaudeCodeAdapter.handles: rejects ~/.claude/stats-cache.json', () => {
127+
assert.ok(!claudeCodeAdapter.handles(path.join(os.homedir(), '.claude', 'stats-cache.json')));
128+
});
129+
130+
test('MistralVibeAdapter.handles: recognises ~/.vibe/logs/session paths', () => {
131+
const p = path.join(os.homedir(), '.vibe', 'logs', 'session', 'session_20240101_120000_abc12345', 'meta.json');
132+
assert.ok(mistralVibeAdapter.handles(p));
133+
});
134+
135+
test('MistralVibeAdapter.handles: rejects unrelated paths', () => {
136+
assert.ok(!mistralVibeAdapter.handles(path.join(os.homedir(), '.claude', 'projects', 'hash', 'abc.jsonl')));
137+
});
138+
139+
// ---------------------------------------------------------------------------
140+
// getCandidatePaths() — structure validation
141+
// ---------------------------------------------------------------------------
142+
143+
test('getCandidatePaths: all adapters return array of {path, source}', () => {
144+
for (const adapter of allAdapters) {
145+
if (!isDiscoverable(adapter)) { continue; }
146+
const paths = adapter.getCandidatePaths();
147+
assert.ok(Array.isArray(paths), `${adapter.id}: expected array`);
148+
for (const cp of paths) {
149+
assert.ok(typeof cp.path === 'string' && cp.path.length > 0, `${adapter.id}: path should be non-empty string`);
150+
assert.ok(typeof cp.source === 'string' && cp.source.length > 0, `${adapter.id}: source should be non-empty string`);
151+
}
152+
}
153+
});
154+
155+
test('OpenCodeAdapter.getCandidatePaths: returns both JSON dir and DB paths', () => {
156+
const paths = openCodeAdapter.getCandidatePaths();
157+
const sources = paths.map(p => p.source);
158+
assert.ok(sources.some(s => s.includes('JSON')), 'Should include JSON path');
159+
assert.ok(sources.some(s => s.includes('DB')), 'Should include DB path');
160+
assert.equal(paths.length, 2);
161+
});
162+
163+
test('CrushAdapter.getCandidatePaths: always includes projects.json path', () => {
164+
const paths = crushAdapter.getCandidatePaths();
165+
assert.ok(paths.length >= 1);
166+
assert.ok(paths[0].path.endsWith('projects.json'));
167+
assert.ok(paths[0].source.includes('Crush'));
168+
});
169+
170+
test('ContinueAdapter.getCandidatePaths: returns sessions directory path', () => {
171+
const paths = continueAdapter.getCandidatePaths();
172+
assert.equal(paths.length, 1);
173+
assert.ok(paths[0].path.length > 0);
174+
assert.equal(paths[0].source, 'Continue');
175+
});
176+
177+
test('ClaudeCodeAdapter.getCandidatePaths: returns Claude Code projects directory', () => {
178+
const paths = claudeCodeAdapter.getCandidatePaths();
179+
assert.equal(paths.length, 1);
180+
assert.ok(paths[0].path.includes('.claude'));
181+
assert.equal(paths[0].source, 'Claude Code');
182+
});
183+
184+
test('MistralVibeAdapter.getCandidatePaths: returns session log directory', () => {
185+
const paths = mistralVibeAdapter.getCandidatePaths();
186+
assert.equal(paths.length, 1);
187+
assert.ok(paths[0].path.includes('.vibe'));
188+
assert.equal(paths[0].source, 'Mistral Vibe');
189+
});
190+
191+
// ---------------------------------------------------------------------------
192+
// getEditorRoot() — returns non-empty string
193+
// ---------------------------------------------------------------------------
194+
195+
test('getEditorRoot: all adapters return non-empty string', () => {
196+
const dummyFile = '/dummy/path/session.json';
197+
for (const adapter of allAdapters) {
198+
const root = adapter.getEditorRoot(dummyFile);
199+
assert.ok(typeof root === 'string' && root.length > 0, `${adapter.id}: getEditorRoot should return non-empty string`);
200+
}
201+
});
202+
203+
test('OpenCodeAdapter.getEditorRoot: returns opencode data directory', () => {
204+
const root = openCodeAdapter.getEditorRoot('/any/path');
205+
assert.ok(root.includes('opencode'));
206+
});
207+
208+
test('ContinueAdapter.getEditorRoot: returns continue data directory', () => {
209+
const root = continueAdapter.getEditorRoot('/any/path');
210+
assert.ok(root.includes('.continue'));
211+
});
212+
213+
test('ClaudeCodeAdapter.getEditorRoot: returns claude data directory', () => {
214+
const root = claudeCodeAdapter.getEditorRoot('/any/path');
215+
assert.ok(root.includes('.claude'));
216+
});
217+
218+
// ---------------------------------------------------------------------------
219+
// discover() — adapter loop behavior (empty directories)
220+
// ---------------------------------------------------------------------------
221+
222+
test('discover: returns DiscoveryResult shape when no sessions exist', async () => {
223+
// All adapters should gracefully return empty sessionFiles when data dirs don't exist
224+
for (const adapter of allAdapters) {
225+
if (!isDiscoverable(adapter)) { continue; }
226+
const result = await adapter.discover(() => { /* noop */ });
227+
assert.ok(typeof result === 'object', `${adapter.id}: should return object`);
228+
assert.ok(Array.isArray(result.sessionFiles), `${adapter.id}: sessionFiles should be array`);
229+
assert.ok(Array.isArray(result.candidatePaths), `${adapter.id}: candidatePaths should be array`);
230+
// candidatePaths from discover() should match getCandidatePaths() (or be a superset for crush multi-project)
231+
const syncPaths = adapter.getCandidatePaths();
232+
for (const cp of syncPaths) {
233+
const found = result.candidatePaths.some(rp => rp.path === cp.path && rp.source === cp.source);
234+
assert.ok(found, `${adapter.id}: discover() candidatePaths should include getCandidatePaths() entry: ${cp.path}`);
235+
}
236+
}
237+
});
238+
239+
// ---------------------------------------------------------------------------
240+
// getCandidatePaths() / discover() consistency
241+
// ---------------------------------------------------------------------------
242+
243+
test('getCandidatePaths paths are consistent with discover candidatePaths', async () => {
244+
// For all adapters, getCandidatePaths() should be a subset of discover().candidatePaths
245+
// (Crush can return MORE paths from discover if projects are found)
246+
for (const adapter of allAdapters) {
247+
if (!isDiscoverable(adapter)) { continue; }
248+
const syncPaths = adapter.getCandidatePaths().map(cp => cp.path);
249+
const discoverResult = await adapter.discover(() => { /* noop */ });
250+
const discoverPaths = discoverResult.candidatePaths.map(cp => cp.path);
251+
for (const sp of syncPaths) {
252+
assert.ok(discoverPaths.includes(sp), `${adapter.id}: sync path '${sp}' missing from discover() result`);
253+
}
254+
}
255+
});

0 commit comments

Comments
 (0)