From 5ac2c698fd1634457b65c792ef78bfdb6918703a Mon Sep 17 00:00:00 2001 From: Prakhar Khatri Date: Thu, 7 May 2026 13:05:43 +0000 Subject: [PATCH 1/2] Improve VS Code extension diagnostics Add structured AgentDiff extension logging, multi-root and WSL-aware capture handling, and tests/docs so Copilot capture failures are visible and easier to diagnose. Co-authored-by: Cursor --- README.md | 39 ++- scripts/tests/test_extension.js | 343 ++++++++++++++++--------- scripts/vscode-extension/extension.js | 352 ++++++++++++++++++++++---- scripts/vscode-extension/package.json | 4 + 4 files changed, 566 insertions(+), 172 deletions(-) diff --git a/README.md b/README.md index ac7167a..819f45d 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ agentdiff status --remote --no-fetch # fast: show refs + SHAs only, skip trace |-------|---------------|----------| | **Claude Code** | `PostToolUse` hook (`~/.claude/settings.json`) | Edit, Write, MultiEdit | | **Cursor** | `afterFileEdit`, `afterTabFileEdit` hooks | Agent edits + Tab completions | -| **GitHub Copilot** | VS Code extension (`~/.vscode/extensions/`) | Inline completions, chat edits | +| **GitHub Copilot** | VS Code extension (`~/.vscode/extensions/`) | Large inline insertions, saved AI edits, manual captures | | **Windsurf** | `post_write_code` hook (`~/.codeium/windsurf/hooks.json`) | Cascade agent writes | | **OpenCode** | `tool.execute.after` plugin (`~/.config/opencode/plugins/`) | All tool writes | | **Codex CLI** | `notify` hook (`~/.codex/config.toml`) | Task-level file changes | @@ -390,7 +390,7 @@ Installs Python capture scripts to `~/.agentdiff/scripts/` and registers hooks w - Gemini → `~/.gemini/settings.json` (BeforeTool, AfterTool) - Windsurf → `~/.codeium/windsurf/hooks.json` (post_write_code) - OpenCode → `~/.config/opencode/plugins/agentdiff.ts` (tool.execute.after) -- Copilot → VS Code extension in `~/.vscode/extensions/agentdiff-copilot-0.1.0/` +- Copilot → VS Code extension in `~/.vscode/extensions/agentdiff-copilot-0.1.0/` or the matching VS Code Server extensions directory for WSL/remote workspaces **2. `agentdiff init` — per-repo setup** @@ -413,6 +413,8 @@ When an AI agent makes an edit, its hook fires and writes a JSON entry to ` { + origExecFile = childProcess.execFile; + origSpawn = childProcess.spawn; + origExistsSync = fs.existsSync; + origAppendFileSync = fs.appendFileSync; + origMkdirSync = fs.mkdirSync; + fs.appendFileSync = () => {}; + fs.mkdirSync = () => {}; +}); + +afterEach(() => { + childProcess.execFile = origExecFile; + childProcess.spawn = origSpawn; + fs.existsSync = origExistsSync; + fs.appendFileSync = origAppendFileSync; + fs.mkdirSync = origMkdirSync; + delete require.cache[EXT_PATH]; + delete require.cache.__vscode_mock__; +}); + +function makeUri(fsPath, scheme = 'file') { + return { scheme, fsPath }; +} + +function makeDocument(filePath, { scheme = 'file', version = 1, lineCount = 1 } = {}) { + return { uri: makeUri(filePath, scheme), version, lineCount }; +} + +function makeVscodeMock({ copilotInstalled = true, copilotActive = true, workspaceFolders } = {}) { const subscriptions = []; const registeredCommands = {}; + const outputLines = []; + const folders = (workspaceFolders || ['/tmp/repo-a', '/tmp/repo-b']).map((folderPath) => ({ + uri: makeUri(folderPath), + name: path.basename(folderPath), + })); - // EventEmitter-style helpers function makeEvent() { let _handler = null; const event = (handler) => { @@ -35,21 +72,31 @@ function makeVscodeMock({ copilotInstalled = true, copilotActive = true } = {}) return event; } + function folderFor(uri) { + return folders + .filter((folder) => { + const rel = path.relative(folder.uri.fsPath, uri.fsPath); + return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel)); + }) + .sort((a, b) => b.uri.fsPath.length - a.uri.fsPath.length)[0]; + } + const onDidChangeTextDocument = makeEvent(); const onDidSaveTextDocument = makeEvent(); + const copilotExt = copilotInstalled ? { isActive: copilotActive } : undefined; - const copilotExt = copilotInstalled - ? { isActive: copilotActive } - : undefined; - - const mock = { + return { workspace: { + workspaceFolders: folders, onDidChangeTextDocument, onDidSaveTextDocument, - asRelativePath: (fsPath) => fsPath.replace(/^\/tmp\/[^/]+\//, ''), - getConfiguration: (_section) => ({ - get: (_key) => null, - }), + getWorkspaceFolder: folderFor, + asRelativePath: (uriOrPath) => { + const uri = typeof uriOrPath === 'string' ? makeUri(uriOrPath) : uriOrPath; + const folder = folderFor(uri); + return folder ? path.relative(folder.uri.fsPath, uri.fsPath) : uri.fsPath; + }, + getConfiguration: () => ({ get: () => null }), }, extensions: { getExtension: (id) => { @@ -62,6 +109,11 @@ function makeVscodeMock({ copilotInstalled = true, copilotActive = true } = {}) window: { activeTextEditor: null, showInformationMessage: () => {}, + createOutputChannel: () => ({ + appendLine: (line) => outputLines.push(line), + show: () => { outputLines.push('__shown__'); }, + dispose: () => {}, + }), }, commands: { registerCommand: (id, fn) => { @@ -69,36 +121,25 @@ function makeVscodeMock({ copilotInstalled = true, copilotActive = true } = {}) return { dispose: () => {} }; }, }, - Uri: { parse: (s) => ({ scheme: 'file', fsPath: s }) }, - - // Test helpers (not part of real vscode API) + lm: { + selectChatModels: async () => [{ id: 'copilot-test-model' }], + }, + Uri: { parse: (s) => makeUri(s) }, _fire: { change: onDidChangeTextDocument.fire, save: onDidSaveTextDocument.fire }, _commands: registeredCommands, _subscriptions: subscriptions, + _outputLines: outputLines, }; - - return mock; } -// ─── Load extension with a given vscode mock ───────────────────────────────── - -const EXT_PATH = path.resolve(__dirname, '../../scripts/vscode-extension/extension.js'); - function loadExtension(vscodeMock) { - // Patch require.cache with the mock before loading. - // The module key must match what extension.js resolves 'vscode' to. - const fakeVscodeId = require.resolve('path'); // any stable key — we overwrite below - const vscodeKey = 'vscode'; // extension does: require('vscode') - - // Node resolves built-in modules differently; inject via _resolveFilename hook. const origResolve = Module._resolveFilename; Module._resolveFilename = function (request, parent, isMain, options) { if (request === 'vscode') return '__vscode_mock__'; return origResolve.call(this, request, parent, isMain, options); }; - // Place the mock in require.cache under our fake id. - require.cache['__vscode_mock__'] = { + require.cache.__vscode_mock__ = { id: '__vscode_mock__', filename: '__vscode_mock__', loaded: true, @@ -107,17 +148,13 @@ function loadExtension(vscodeMock) { paths: [], }; - // Clear the extension from cache so it re-executes with the new mock. delete require.cache[EXT_PATH]; - let ext; try { - ext = require(EXT_PATH); + return require(EXT_PATH); } finally { Module._resolveFilename = origResolve; } - - return ext; } function activateExt(vscodeMock) { @@ -127,139 +164,182 @@ function activateExt(vscodeMock) { return { ext, vscode: vscodeMock }; } -// ─── Helper: build a fake text document change event ──────────────────────── - -function makeChangeEvent(filePath, changes, { scheme = 'file', isActive = true } = {}) { +function makeChangeEvent(filePath, changes, { scheme = 'file', version = 1 } = {}) { return { - document: { uri: { scheme, fsPath: filePath } }, + document: makeDocument(filePath, { scheme, version }), contentChanges: changes.map(({ text, startLine = 0 }) => ({ text, range: { start: { line: startLine } }, })), - // stub — vscode extension checks copilotExt.isActive, not this }; } -// ─── Tests ─────────────────────────────────────────────────────────────────── +function installRepoStubs({ initialized = true } = {}) { + const spawned = []; + + childProcess.execFile = (_cmd, args, opts, cb) => { + const cwd = opts.cwd; + const repo = cwd.startsWith('/tmp/repo-a') ? '/tmp/repo-a' + : cwd.startsWith('/tmp/repo-b') ? '/tmp/repo-b' + : null; + if (!repo) { + cb(new Error('not a repo'), '', 'fatal'); + return; + } + if (args.join(' ') === 'rev-parse --show-toplevel') { + cb(null, `${repo}\n`, ''); + return; + } + if (args.join(' ') === 'rev-parse --git-common-dir') { + cb(null, `${repo}/.git\n`, ''); + return; + } + cb(new Error('unexpected git args'), '', ''); + }; + + fs.existsSync = (p) => { + if (p === '__AGENTDIFF_CAPTURE_COPILOT__') return true; + if (String(p).endsWith('/.git/agentdiff')) return initialized; + return origExistsSync(p); + }; -describe('Extension: Copilot detection threshold (MIN_COPILOT_CHANGE_LEN = 50)', () => { - test('does not capture a short single-line insertion (<50 chars)', (t, done) => { + childProcess.spawn = (command, args) => { + const proc = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.stdin = { + chunks: [], + write(chunk) { this.chunks.push(String(chunk)); }, + end() { + spawned.push({ + command, + args, + payload: JSON.parse(this.chunks.join('')), + }); + }, + }; + return proc; + }; + + return spawned; +} + +describe('Extension: edit capture flow', () => { + test('captures a large insertion on save with repo, workspace, version, and lines', async () => { + const spawned = installRepoStubs(); const vscode = makeVscodeMock(); activateExt(vscode); - // 49 chars — below threshold - const shortText = 'x'.repeat(49); - vscode._fire.change(makeChangeEvent('/tmp/repo/src/main.rs', [{ text: shortText }])); + const filePath = '/tmp/repo-a/src/main.rs'; + vscode._fire.change(makeChangeEvent(filePath, [{ text: 'x'.repeat(80), startLine: 2 }], { version: 7 })); + await vscode._fire.save(makeDocument(filePath, { version: 7 })); + + assert.equal(spawned.length, 1); + assert.equal(spawned[0].command, 'python3'); + assert.deepEqual(spawned[0].payload.lines, [3]); + assert.equal(spawned[0].payload.event, 'save'); + assert.equal(spawned[0].payload.cwd, '/tmp/repo-a'); + assert.equal(spawned[0].payload.repo_root, '/tmp/repo-a'); + assert.equal(spawned[0].payload.workspace_folder, '/tmp/repo-a'); + assert.equal(spawned[0].payload.document_version, 7); + assert.equal(spawned[0].payload.model, 'copilot-test-model'); + }); - // Wait longer than the 2-second debounce to confirm nothing fires. - // We can't easily intercept the spawn, so we just check no error is thrown - // and the handler runs without crashing. - setTimeout(() => done(), 50); + test('captures multi-line insertions below the single-line threshold', async () => { + const spawned = installRepoStubs(); + const vscode = makeVscodeMock(); + activateExt(vscode); + + const filePath = '/tmp/repo-a/src/main.rs'; + vscode._fire.change(makeChangeEvent(filePath, [{ text: 'a\nb', startLine: 4 }], { version: 2 })); + await vscode._fire.save(makeDocument(filePath, { version: 2 })); + + assert.equal(spawned.length, 1); + assert.deepEqual(spawned[0].payload.lines, [5, 6]); }); - test('handles multi-line insertion (>1 newline) regardless of length', (t, done) => { + test('does not capture a short single-line insertion', async () => { + const spawned = installRepoStubs(); const vscode = makeVscodeMock(); activateExt(vscode); - // Only 5 chars but spans 2 lines - vscode._fire.change(makeChangeEvent('/tmp/repo/src/main.rs', [{ text: 'a\nb' }])); + const filePath = '/tmp/repo-a/src/main.rs'; + vscode._fire.change(makeChangeEvent(filePath, [{ text: 'x'.repeat(49) }])); + await vscode._fire.save(makeDocument(filePath)); - // Extension should not throw - setTimeout(() => done(), 50); + assert.equal(spawned.length, 0); }); - test('ignores changes from non-file URI schemes', (t, done) => { + test('skips non-file documents before buffering capture state', async () => { + const spawned = installRepoStubs(); const vscode = makeVscodeMock(); activateExt(vscode); - // git scheme — should be ignored immediately - vscode._fire.change(makeChangeEvent('/tmp/repo/src/main.rs', [{ text: 'x'.repeat(100) }], { scheme: 'git' })); + vscode._fire.change(makeChangeEvent('/tmp/repo-a/src/main.rs', [{ text: 'x'.repeat(80) }], { scheme: 'git' })); - setTimeout(() => done(), 50); + assert.equal(spawned.length, 0); }); }); -describe('Extension: EXCLUDED_PATHS filtering', () => { - test('ignores changes to .agentdiff/ paths', (t, done) => { +describe('Extension: multi-root workspace and path filtering', () => { + test('uses the owning workspace folder in a multi-root workspace', async () => { + const spawned = installRepoStubs(); const vscode = makeVscodeMock(); activateExt(vscode); - // This path resolves to something that starts with .agentdiff/ after asRelativePath - const event = { - document: { uri: { scheme: 'file', fsPath: '/tmp/repo/.agentdiff/ledger.jsonl' } }, - contentChanges: [{ text: 'x'.repeat(200), range: { start: { line: 0 } } }], - }; - // asRelativePath will return '.agentdiff/ledger.jsonl' — should be filtered - const vscodeReal = require.__spy || vscode; - vscode.workspace.asRelativePath = (_p) => '.agentdiff/ledger.jsonl'; + const filePath = '/tmp/repo-b/pkg/lib.js'; + vscode._fire.change(makeChangeEvent(filePath, [{ text: 'y'.repeat(80), startLine: 0 }])); + await vscode._fire.save(makeDocument(filePath)); - vscode._fire.change(event); - setTimeout(() => done(), 50); + assert.equal(spawned.length, 1); + assert.equal(spawned[0].payload.cwd, '/tmp/repo-b'); + assert.equal(spawned[0].payload.repo_root, '/tmp/repo-b'); + assert.equal(spawned[0].payload.workspace_folder, '/tmp/repo-b'); }); - test('ignores changes to .git/ paths', (t, done) => { + test('ignores .agentdiff and .git paths relative to the owning root', async () => { + const spawned = installRepoStubs(); const vscode = makeVscodeMock(); activateExt(vscode); - const event = { - document: { uri: { scheme: 'file', fsPath: '/tmp/repo/.git/COMMIT_EDITMSG' } }, - contentChanges: [{ text: 'x'.repeat(200), range: { start: { line: 0 } } }], - }; - vscode.workspace.asRelativePath = (_p) => '.git/COMMIT_EDITMSG'; + vscode._fire.change(makeChangeEvent('/tmp/repo-b/.agentdiff/ledger.jsonl', [{ text: 'x'.repeat(80) }])); + vscode._fire.change(makeChangeEvent('/tmp/repo-b/.git/COMMIT_EDITMSG', [{ text: 'x'.repeat(80) }])); - vscode._fire.change(event); - setTimeout(() => done(), 50); + assert.equal(spawned.length, 0); }); }); -describe('Extension: Copilot not installed', () => { - test('activate() returns early without registering listeners when Copilot is absent', () => { - const vscode = makeVscodeMock({ copilotInstalled: false }); - const { ext } = activateExt(vscode); +describe('Extension: diagnostics and commands', () => { + test('logs a structured warning when the repo has not been initialized', async () => { + const spawned = installRepoStubs({ initialized: false }); + const vscode = makeVscodeMock(); + activateExt(vscode); - // If Copilot is not installed, no subscriptions should be registered - assert.equal(vscode._subscriptions.length, 0, - 'Should not register any listeners when Copilot extension is absent'); - }); -}); + const filePath = '/tmp/repo-a/src/main.rs'; + vscode._fire.change(makeChangeEvent(filePath, [{ text: 'x'.repeat(80) }])); + await vscode._fire.save(makeDocument(filePath)); -describe('Extension: getCopilotModel() fallback', () => { - test('returns "gpt-4o" when copilot-chat extension is present', () => { - // Load module and inspect getCopilotModel directly by activating - // and checking what model ends up in the capture payload. - // We do this indirectly — if chat ext is present without advanced config, - // getCopilotModel returns 'gpt-4o'. We verify activate() doesn't throw. - const vscode = makeVscodeMock({ copilotInstalled: true, copilotActive: true }); - // Make copilot-chat also present - vscode.extensions.getExtension = (id) => { - if (id === 'GitHub.copilot') return { isActive: true }; - if (id === 'GitHub.copilot-chat') return { isActive: true }; - return undefined; - }; - assert.doesNotThrow(() => activateExt(vscode)); + assert.equal(spawned.length, 0); + assert.ok(vscode._outputLines.some((line) => line.includes('"event":"capture.skipped_repo_not_initialized"'))); }); - test('returns "copilot" fallback when no config and no chat ext', () => { + test('registers capture and log commands', () => { const vscode = makeVscodeMock(); - vscode.extensions.getExtension = (id) => { - if (id === 'GitHub.copilot') return { isActive: true }; - // no chat ext - return undefined; - }; - assert.doesNotThrow(() => activateExt(vscode)); + activateExt(vscode); + + assert.ok('agentdiff.captureNow' in vscode._commands); + assert.ok('agentdiff.openLogs' in vscode._commands); }); -}); -describe('Extension: agentdiff.captureNow command', () => { - test('command is registered after activation', () => { + test('open logs command shows the output channel', () => { const vscode = makeVscodeMock(); activateExt(vscode); - assert.ok('agentdiff.captureNow' in vscode._commands, - 'agentdiff.captureNow command should be registered'); + vscode._commands['agentdiff.openLogs'](); + + assert.ok(vscode._outputLines.includes('__shown__')); }); - test('captureNow shows message when no active editor', async () => { + test('captureNow shows a message when no active editor exists', async () => { const vscode = makeVscodeMock(); activateExt(vscode); @@ -271,10 +351,29 @@ describe('Extension: agentdiff.captureNow command', () => { assert.ok(shown && shown.includes('agentdiff'), `Expected info message, got: ${shown}`); }); + + test('activate returns early without listeners when Copilot is absent', () => { + const vscode = makeVscodeMock({ copilotInstalled: false }); + activateExt(vscode); + + assert.equal(vscode._subscriptions.length, 0); + assert.ok(vscode._outputLines.some((line) => line.includes('"event":"extension.inactive_copilot_missing"'))); + }); +}); + +describe('Extension: WSL path helpers', () => { + test('translates common Windows and WSL UNC paths for WSL capture scripts', () => { + const vscode = makeVscodeMock(); + const { ext } = activateExt(vscode); + + assert.equal(ext._test.windowsPathToWsl('C:\\Users\\me\\repo\\file.js'), '/mnt/c/Users/me/repo/file.js'); + assert.equal(ext._test.windowsPathToWsl('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo\\file.js'), '/home/me/repo/file.js'); + assert.equal(ext._test.windowsPathToWsl('/home/me/repo/file.js'), '/home/me/repo/file.js'); + }); }); describe('Extension: deactivate', () => { - test('deactivate() does not throw', () => { + test('deactivate does not throw', () => { const vscode = makeVscodeMock(); const { ext } = activateExt(vscode); assert.doesNotThrow(() => ext.deactivate()); diff --git a/scripts/vscode-extension/extension.js b/scripts/vscode-extension/extension.js index cd14ab2..5a0cb8c 100644 --- a/scripts/vscode-extension/extension.js +++ b/scripts/vscode-extension/extension.js @@ -12,116 +12,331 @@ const CAPTURE_SCRIPT = '__AGENTDIFF_CAPTURE_COPILOT__'; // Minimum insertion length to be considered a Copilot-originated change. // Must be high enough to avoid capturing human typing, copy-paste, and edits // from other agents (Claude, Cursor, Codex) that also trigger VS Code's -// onDidChangeTextDocument events. 50 chars catches multi-line Copilot +// onDidChangeTextDocument events. 50 chars catches multi-line Copilot // completions while filtering out most false positives. const MIN_COPILOT_CHANGE_LEN = 50; +const DEFAULT_FLUSH_DELAY_MS = 2000; +const FLUSH_DELAY_MS = Number(process.env.AGENTDIFF_EXT_FLUSH_DELAY_MS || DEFAULT_FLUSH_DELAY_MS); // Paths that should never be attributed to Copilot (auto-generated metadata). const EXCLUDED_PATHS = ['.agentdiff/', '.git/']; +let outputChannel = null; + function isDebug() { const v = process.env.AGENTDIFF_DEBUG || ''; - return v === '1' || v.toLowerCase() === 'true' || v.toLowerCase() === 'yes'; + return ['1', 'true', 'yes', 'on'].includes(v.toLowerCase()); } -function debugLog(msg) { - if (!isDebug()) return; +function getOutputChannel() { + if (!outputChannel && vscode.window && typeof vscode.window.createOutputChannel === 'function') { + outputChannel = vscode.window.createOutputChannel('AgentDiff'); + } + return outputChannel; +} + +function writeFileLog(line, force = false) { + if (!force && !isDebug()) return; try { const logDir = path.join(os.homedir(), '.agentdiff', 'logs'); fs.mkdirSync(logDir, { recursive: true }); - const ts = new Date().toISOString(); - fs.appendFileSync(path.join(logDir, 'capture-copilot-ext.log'), `${ts} ${msg}\n`); + fs.appendFileSync(path.join(logDir, 'capture-copilot-ext.log'), `${line}\n`); } catch (_) {} } -function findRepoRoot(filePath) { +function logEvent(level, event, fields = {}) { + const entry = { + ts: new Date().toISOString(), + level, + component: 'vscode-copilot-extension', + event, + ...fields, + }; + const line = JSON.stringify(entry); + const channel = getOutputChannel(); + if (channel && (level !== 'debug' || isDebug())) { + channel.appendLine(line); + } + writeFileLog(line, level !== 'debug'); +} + +function normalizeSlashes(value) { + return String(value || '').replace(/\\/g, '/'); +} + +function isExcludedRelativePath(relPath) { + const normalized = normalizeSlashes(relPath).replace(/^\/+/, ''); + return EXCLUDED_PATHS.some((excluded) => normalized === excluded.slice(0, -1) || normalized.startsWith(excluded)); +} + +function isPathInside(child, parent) { + if (!child || !parent) return false; + const relative = path.relative(parent, child); + return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative)); +} + +function getWorkspaceFolder(uri) { + if (vscode.workspace && typeof vscode.workspace.getWorkspaceFolder === 'function') { + const folder = vscode.workspace.getWorkspaceFolder(uri); + if (folder) return folder; + } + + const folders = (vscode.workspace && vscode.workspace.workspaceFolders) || []; + return folders.find((folder) => isPathInside(uri.fsPath, folder.uri.fsPath)) || null; +} + +function getRelativePath(uri) { + const folder = getWorkspaceFolder(uri); + if (folder) { + return normalizeSlashes(path.relative(folder.uri.fsPath, uri.fsPath)); + } + if (vscode.workspace && typeof vscode.workspace.asRelativePath === 'function') { + return normalizeSlashes(vscode.workspace.asRelativePath(uri, false)); + } + return normalizeSlashes(uri.fsPath); +} + +function execGit(args, cwd) { + return new Promise((resolve) => { + const useWsl = looksLikeWslScript(CAPTURE_SCRIPT); + const command = useWsl ? 'wsl.exe' : 'git'; + const commandArgs = useWsl ? ['--cd', windowsPathToWsl(cwd), '-e', 'git', ...args] : args; + const options = useWsl ? {} : { cwd }; + + cp.execFile(command, commandArgs, options, (err, stdout, stderr) => { + if (err) { + logEvent('debug', 'git.rev_parse.failed', { + cwd, + args, + error: err.message, + stderr: String(stderr || '').trim(), + }); + resolve(null); + return; + } + resolve(String(stdout || '').trim() || null); + }); + }); +} + +function pathExistsDir(dirPath) { + if (!dirPath) return Promise.resolve(false); + if (!looksLikeWslScript(CAPTURE_SCRIPT)) { + return Promise.resolve(fs.existsSync(dirPath)); + } return new Promise((resolve) => { - const dir = path.dirname(filePath); - cp.exec('git rev-parse --show-toplevel', { cwd: dir }, (err, stdout) => { - resolve(err ? null : stdout.trim()); + cp.execFile('wsl.exe', ['-e', 'test', '-d', dirPath], {}, (err) => { + resolve(!err); }); }); } +function resolveGitDir(gitDir, cwd) { + if (!gitDir) return null; + if (looksLikeWslScript(CAPTURE_SCRIPT)) { + return gitDir.startsWith('/') ? gitDir : path.posix.resolve(windowsPathToWsl(cwd), gitDir); + } + return path.isAbsolute(gitDir) ? gitDir : path.resolve(cwd, gitDir); +} + +async function findRepoInfo(uri) { + const filePath = uri.fsPath; + const workspaceFolder = getWorkspaceFolder(uri); + const startDir = path.dirname(filePath); + const repoRoot = await execGit(['rev-parse', '--show-toplevel'], startDir); + const gitDirRaw = await execGit(['rev-parse', '--git-common-dir'], startDir); + const gitDir = resolveGitDir(gitDirRaw, startDir); + const agentdiffDir = gitDir + ? (looksLikeWslScript(CAPTURE_SCRIPT) ? path.posix.join(gitDir, 'agentdiff') : path.join(gitDir, 'agentdiff')) + : null; + const initialized = await pathExistsDir(agentdiffDir); + + return { + cwd: repoRoot || (workspaceFolder && workspaceFolder.uri.fsPath) || startDir, + repoRoot, + gitDir, + agentdiffDir, + initialized, + workspaceFolder: workspaceFolder ? workspaceFolder.uri.fsPath : null, + }; +} + async function getCopilotModel() { try { + if (!vscode.lm || typeof vscode.lm.selectChatModels !== 'function') { + return 'copilot'; + } const models = await vscode.lm.selectChatModels({ vendor: 'copilot' }); if (models && models.length > 0) { return models[0].id; } - } catch (_) {} + } catch (err) { + logEvent('debug', 'copilot.model.lookup_failed', { error: err.message }); + } return 'copilot'; } -function fireCapture(payload) { - if (!fs.existsSync(CAPTURE_SCRIPT)) { - debugLog(`capture script not found: ${CAPTURE_SCRIPT}`); - return; +function looksLikeWslScript(scriptPath) { + return process.platform === 'win32' && /^\/(home|mnt|opt|usr|var|tmp)\//.test(scriptPath); +} + +function windowsPathToWsl(value) { + if (!value) return value; + const normalized = normalizeSlashes(value); + const drive = normalized.match(/^([A-Za-z]):\/(.*)$/); + if (drive) { + return `/mnt/${drive[1].toLowerCase()}/${drive[2]}`; + } + const unc = normalized.match(/^\/\/wsl(?:\.localhost|\$)\/[^/]+(\/.*)$/i); + if (unc) { + return unc[1]; + } + return value; +} + +function buildCaptureProcess(payload) { + if (looksLikeWslScript(CAPTURE_SCRIPT)) { + const translatedPayload = { ...payload }; + for (const key of ['cwd', 'file_path', 'repo_root', 'workspace_folder']) { + translatedPayload[key] = windowsPathToWsl(translatedPayload[key]); + } + return { + command: 'wsl.exe', + args: ['-e', 'python3', CAPTURE_SCRIPT], + payload: translatedPayload, + allowMissingScript: true, + }; } + const python = process.platform === 'win32' ? 'python' : 'python3'; - const proc = cp.spawn(python, [CAPTURE_SCRIPT], { stdio: ['pipe', 'ignore', 'ignore'] }); - proc.stdin.write(JSON.stringify(payload)); + return { + command: python, + args: [CAPTURE_SCRIPT], + payload, + allowMissingScript: false, + }; +} + +function fireCapture(payload) { + const procSpec = buildCaptureProcess(payload); + if (!procSpec.allowMissingScript && !fs.existsSync(CAPTURE_SCRIPT)) { + logEvent('error', 'capture.script_missing', { captureScript: CAPTURE_SCRIPT }); + return false; + } + + const proc = cp.spawn(procSpec.command, procSpec.args, { stdio: ['pipe', 'ignore', 'pipe'] }); + proc.stdin.write(JSON.stringify(procSpec.payload)); proc.stdin.end(); - proc.on('error', (err) => debugLog(`spawn error: ${err.message}`)); - debugLog(`fired capture: file=${payload.file_path} lines=${JSON.stringify(payload.lines)}`); + proc.stderr.on('data', (chunk) => { + logEvent('error', 'capture.stderr', { message: String(chunk).trim() }); + }); + proc.on('error', (err) => logEvent('error', 'capture.spawn_failed', { error: err.message })); + logEvent('debug', 'capture.spawned', { + command: procSpec.command, + args: procSpec.args, + filePath: procSpec.payload.file_path, + lines: procSpec.payload.lines, + repoRoot: procSpec.payload.repo_root, + }); + return true; } -async function captureFile(filePath, pending) { - const repoRoot = await findRepoRoot(filePath); - const cwd = repoRoot || path.dirname(filePath); - fireCapture({ +async function captureDocument(document, pending) { + const uri = document.uri; + const filePath = uri.fsPath; + const repoInfo = await findRepoInfo(uri); + + if (!repoInfo.initialized) { + logEvent('warn', 'capture.skipped_repo_not_initialized', { + filePath, + repoRoot: repoInfo.repoRoot, + gitDir: repoInfo.gitDir, + workspaceFolder: repoInfo.workspaceFolder, + }); + return false; + } + + const payload = { event: pending.tool, - cwd, + cwd: repoInfo.cwd, file_path: filePath, + repo_root: repoInfo.repoRoot, + workspace_folder: repoInfo.workspaceFolder, + document_version: pending.documentVersion || document.version || null, + captured_at: new Date().toISOString(), + changed_at: pending.changedAt || null, model: await getCopilotModel(), session_id: `vscode-${Date.now()}`, prompt: null, lines: Array.from(pending.lines).sort((a, b) => a - b), - }); + }; + + return fireCapture(payload); } function activate(context) { + const channel = getOutputChannel(); const copilotExt = vscode.extensions.getExtension('GitHub.copilot') || vscode.extensions.getExtension('GitHub.copilot-chat'); if (!copilotExt) { - debugLog('GitHub Copilot extension not found — agentdiff Copilot capture inactive'); + logEvent('warn', 'extension.inactive_copilot_missing'); return; } - debugLog('agentdiff Copilot extension activated'); + logEvent('info', 'extension.activated', { + copilotActive: !!copilotExt.isActive, + workspaceFolders: ((vscode.workspace && vscode.workspace.workspaceFolders) || []).map((f) => f.uri.fsPath), + }); - // pendingChanges: filePath -> { lines: Set, tool: string } + // pendingChanges: filePath -> { document, lines, tool, documentVersion, changedAt } const pendingChanges = new Map(); let flushTimer; async function flushAll() { - for (const [filePath, pending] of pendingChanges) { + flushTimer = null; + const entries = Array.from(pendingChanges.entries()); + pendingChanges.clear(); + for (const [, pending] of entries) { if (pending.lines.size > 0) { - await captureFile(filePath, pending); + await captureDocument(pending.document, pending); } } - pendingChanges.clear(); } // Track document changes and attribute "large" insertions to Copilot. const changeDisposable = vscode.workspace.onDidChangeTextDocument((event) => { - if (event.document.uri.scheme !== 'file') return; - if (!copilotExt.isActive) return; + if (event.document.uri.scheme !== 'file') { + logEvent('debug', 'change.ignored_non_file', { scheme: event.document.uri.scheme }); + return; + } + if (!copilotExt.isActive) { + logEvent('debug', 'change.ignored_copilot_inactive'); + return; + } const filePath = event.document.uri.fsPath; + const relPath = getRelativePath(event.document.uri); + if (isExcludedRelativePath(relPath)) { + logEvent('debug', 'change.ignored_excluded_path', { filePath, relPath }); + return; + } - // Skip metadata paths that are auto-generated. - const relPath = vscode.workspace.asRelativePath(filePath, false); - if (EXCLUDED_PATHS.some((p) => relPath.startsWith(p))) return; - const pending = pendingChanges.get(filePath) || { lines: new Set(), tool: 'inline' }; + const pending = pendingChanges.get(filePath) || { + document: event.document, + lines: new Set(), + tool: 'inline', + }; + pending.document = event.document; + pending.documentVersion = event.document.version || null; + pending.changedAt = new Date().toISOString(); let changed = false; for (const change of event.contentChanges) { const insertedLen = change.text.length; const insertedLineCount = change.text.split('\n').length; - // Treat as Copilot if multi-line insertion or single-line >= threshold + // Treat as Copilot if multi-line insertion or single-line >= threshold. if (insertedLen >= MIN_COPILOT_CHANGE_LEN || insertedLineCount > 1) { const startLine = change.range.start.line + 1; // 1-based for (let l = 0; l < insertedLineCount; l++) { @@ -131,11 +346,20 @@ function activate(context) { } } - if (!changed) return; + if (!changed) { + logEvent('debug', 'change.ignored_below_threshold', { filePath, relPath }); + return; + } pendingChanges.set(filePath, pending); + logEvent('debug', 'change.buffered', { + filePath, + relPath, + documentVersion: pending.documentVersion, + lines: Array.from(pending.lines).sort((a, b) => a - b), + }); if (flushTimer) clearTimeout(flushTimer); - flushTimer = setTimeout(flushAll, 2000); + flushTimer = setTimeout(flushAll, FLUSH_DELAY_MS); }); // On save, flush pending changes for that file immediately. @@ -144,8 +368,15 @@ function activate(context) { const filePath = doc.uri.fsPath; const pending = pendingChanges.get(filePath); if (!pending || pending.lines.size === 0) return; - await captureFile(filePath, { lines: pending.lines, tool: 'save' }); + pending.tool = 'save'; + pending.document = doc; + pending.documentVersion = doc.version || pending.documentVersion || null; + await captureDocument(doc, pending); pendingChanges.delete(filePath); + if (pendingChanges.size === 0 && flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } }); // Command: manually record all lines of the current file as Copilot-authored. @@ -156,16 +387,43 @@ function activate(context) { vscode.window.showInformationMessage('agentdiff: No active editor'); return; } - const filePath = editor.document.uri.fsPath; const lines = new Set(); for (let i = 1; i <= editor.document.lineCount; i++) lines.add(i); - await captureFile(filePath, { lines, tool: 'manual' }); - vscode.window.showInformationMessage('agentdiff: Copilot capture recorded'); + const ok = await captureDocument(editor.document, { + lines, + tool: 'manual', + documentVersion: editor.document.version || null, + changedAt: null, + }); + vscode.window.showInformationMessage(ok + ? 'agentdiff: Copilot capture recorded' + : 'agentdiff: Copilot capture skipped; see AgentDiff output'); }); - context.subscriptions.push(changeDisposable, saveDisposable, captureCmd); + const openLogsCmd = vscode.commands.registerCommand('agentdiff.openLogs', () => { + if (channel && typeof channel.show === 'function') { + channel.show(); + } + }); + + context.subscriptions.push(changeDisposable, saveDisposable, captureCmd, openLogsCmd); } -function deactivate() {} +function deactivate() { + logEvent('info', 'extension.deactivated'); + if (outputChannel && typeof outputChannel.dispose === 'function') { + outputChannel.dispose(); + outputChannel = null; + } +} -module.exports = { activate, deactivate }; +module.exports = { + activate, + deactivate, + _test: { + buildCaptureProcess, + getRelativePath, + isExcludedRelativePath, + windowsPathToWsl, + }, +}; diff --git a/scripts/vscode-extension/package.json b/scripts/vscode-extension/package.json index a7f946e..da0632f 100644 --- a/scripts/vscode-extension/package.json +++ b/scripts/vscode-extension/package.json @@ -13,6 +13,10 @@ { "command": "agentdiff.captureNow", "title": "agentdiff: Capture Copilot edits for current file" + }, + { + "command": "agentdiff.openLogs", + "title": "agentdiff: Open Logs" } ] } From da559314e75238bf6a0f252997665e4529c23c68 Mon Sep 17 00:00:00 2001 From: Prakhar Khatri Date: Tue, 12 May 2026 12:45:20 +0000 Subject: [PATCH 2/2] fix: register openLogs before Copilot guard; delete pending before await Two bugs in extension.js: 1. agentdiff.openLogs was registered after the copilot guard so it was silently a no-op when capture was inactive (the most useful time). Moved registration before the guard and pushed to subscriptions early. 2. pendingChanges.delete() happened after await captureDocument(), leaving the flush timer able to fire a duplicate capture during the async gap. Delete and cancel the timer before awaiting. Co-Authored-By: Claude Sonnet 4.6 --- scripts/vscode-extension/extension.js | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/scripts/vscode-extension/extension.js b/scripts/vscode-extension/extension.js index 5a0cb8c..be56159 100644 --- a/scripts/vscode-extension/extension.js +++ b/scripts/vscode-extension/extension.js @@ -276,6 +276,15 @@ async function captureDocument(document, pending) { function activate(context) { const channel = getOutputChannel(); + + // Register openLogs before the Copilot guard so it works even when capture is inactive. + const openLogsCmd = vscode.commands.registerCommand('agentdiff.openLogs', () => { + if (channel && typeof channel.show === 'function') { + channel.show(); + } + }); + context.subscriptions.push(openLogsCmd); + const copilotExt = vscode.extensions.getExtension('GitHub.copilot') || vscode.extensions.getExtension('GitHub.copilot-chat'); @@ -371,12 +380,14 @@ function activate(context) { pending.tool = 'save'; pending.document = doc; pending.documentVersion = doc.version || pending.documentVersion || null; - await captureDocument(doc, pending); + // Remove before awaiting so the flush timer cannot fire a duplicate capture + // for the same save event while captureDocument is in flight. pendingChanges.delete(filePath); if (pendingChanges.size === 0 && flushTimer) { clearTimeout(flushTimer); flushTimer = null; } + await captureDocument(doc, pending); }); // Command: manually record all lines of the current file as Copilot-authored. @@ -400,13 +411,7 @@ function activate(context) { : 'agentdiff: Copilot capture skipped; see AgentDiff output'); }); - const openLogsCmd = vscode.commands.registerCommand('agentdiff.openLogs', () => { - if (channel && typeof channel.show === 'function') { - channel.show(); - } - }); - - context.subscriptions.push(changeDisposable, saveDisposable, captureCmd, openLogsCmd); + context.subscriptions.push(changeDisposable, saveDisposable, captureCmd); } function deactivate() {