From 327b80d8651176dce4d53dec20081083a162e56e Mon Sep 17 00:00:00 2001 From: vemv Date: Mon, 31 Aug 2026 20:40:18 +0200 Subject: [PATCH 01/39] feat: integrate cs-agent for refactoring Replace the old ACE backend with the local cs-agent binary for refactoring operations. This eliminates dependency on the ACE backend service while keeping the existing CWF webview UI. - Add AgentRefactoringService to invoke cs-agent binary - Add agent types and configuration modules - Add VS Code settings for agent model and AWS config - Add platform-specific package targets in Makefile - Update .vscodeignore to include bin/ directory - Add unit tests for mapping logic and config building - Add integration test for real binary invocation --- .vscodeignore | 3 + Makefile | 30 ++- package.json | 18 ++ src/configuration.ts | 12 + src/cs-extension-state.ts | 5 + src/devtools-api/index.ts | 49 +--- src/refactoring/agent-config.ts | 43 ++++ src/refactoring/agent-service.ts | 240 ++++++++++++++++++ src/refactoring/agent-types.ts | 56 ++++ .../fixtures/agent-output/fix-proposed.json | 23 ++ .../agent-output/needs-human-review.json | 23 ++ .../fixtures/agent-output/unable-to-fix.json | 11 + src/test/suite/agent-config.test.ts | 92 +++++++ src/test/suite/agent-integration.test.ts | 131 ++++++++++ src/test/suite/agent-service.test.ts | 202 +++++++++++++++ src/test/suite/post-refactoring.test.ts | 6 + 16 files changed, 901 insertions(+), 43 deletions(-) create mode 100644 src/refactoring/agent-config.ts create mode 100644 src/refactoring/agent-service.ts create mode 100644 src/refactoring/agent-types.ts create mode 100644 src/test/fixtures/agent-output/fix-proposed.json create mode 100644 src/test/fixtures/agent-output/needs-human-review.json create mode 100644 src/test/fixtures/agent-output/unable-to-fix.json create mode 100644 src/test/suite/agent-config.test.ts create mode 100644 src/test/suite/agent-integration.test.ts create mode 100644 src/test/suite/agent-service.test.ts diff --git a/.vscodeignore b/.vscodeignore index bab81d20..7642bf7f 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -17,6 +17,9 @@ out/test/** cs-* *.zip +## Include the agent binary +!bin/ + ## Misc dotfiles .mocharc.json Makefile diff --git a/Makefile b/Makefile index 44af03e5..4937fb17 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build package tsc clean lint watch test pretest pretest-e2e test-e2e updatedocs +.PHONY: build package package-darwin-arm64 package-darwin-x64 package-linux-arm64 package-linux-x64 package-win32-x64 package-all tsc clean lint watch test pretest pretest-e2e test-e2e updatedocs .DEFAULT_GOAL := build @@ -14,6 +14,34 @@ package: lint pretest npx @vscode/vsce@3.7.1 package; \ git checkout .vscodeignore; \ +package-darwin-arm64: lint pretest + npm i + cd ~/refactoring-agent/agent && $(MAKE) build-all + mkdir -p ./bin + cp ~/refactoring-agent/agent/target/aarch64-apple-darwin/release/cs-agent ./bin/cs-agent + node ./scripts/package-platform.js darwin arm64 + +package-darwin-x64: lint pretest + npm i + cd ~/refactoring-agent/agent && $(MAKE) build-all + mkdir -p ./bin + cp ~/refactoring-agent/agent/target/x86_64-apple-darwin/release/cs-agent ./bin/cs-agent + node ./scripts/package-platform.js darwin x64 + +package-linux-arm64: lint pretest + npm i + node ./scripts/package-platform.js linux arm64 + +package-linux-x64: lint pretest + npm i + node ./scripts/package-platform.js linux x64 + +package-win32-x64: lint pretest + npm i + node ./scripts/package-platform.js win32 x64 + +package-all: package-darwin-arm64 package-darwin-x64 package-linux-arm64 package-linux-x64 package-win32-x64 + tsc: npx tsc --noEmit diff --git a/package.json b/package.json index 907fb732..804a8335 100644 --- a/package.json +++ b/package.json @@ -151,6 +151,24 @@ "description": "Authentication token for CodeScene ACE. Has priority over the Sign In with CodeScene command.", "order": 19.5 }, + "codescene.agentModel": { + "type": "string", + "default": "amazon-bedrock/eu.anthropic.claude-sonnet-4-6", + "description": "Model identifier for refactoring agent", + "order": 16 + }, + "codescene.awsProfile": { + "type": "string", + "default": "", + "description": "AWS profile for Bedrock (uses default credential chain if empty)", + "order": 17 + }, + "codescene.awsRegion": { + "type": "string", + "default": "eu-west-1", + "description": "AWS region for Bedrock", + "order": 18 + }, "codescene.enableTelemetry": { "type": "boolean", "default": true, diff --git a/src/configuration.ts b/src/configuration.ts index 61c0dd1a..f388ad72 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -71,3 +71,15 @@ export function toggleReviewCodeLenses() { export function getAuthToken() { return getConfiguration('authToken', ''); } + +export function getAgentModel() { + return getConfiguration('agentModel', 'amazon-bedrock/eu.anthropic.claude-sonnet-4-6'); +} + +export function getAwsProfile() { + return getConfiguration('awsProfile', ''); +} + +export function getAwsRegion() { + return getConfiguration('awsRegion', 'eu-west-1'); +} diff --git a/src/cs-extension-state.ts b/src/cs-extension-state.ts index a46ea7fb..28b0efe7 100644 --- a/src/cs-extension-state.ts +++ b/src/cs-extension-state.ts @@ -227,3 +227,8 @@ function featureState(feature: CsFeature) { if (isDefined(feature.error)) return 'error'; return feature.state; } + +export function getExtensionPath(): string | undefined { + if (!CsExtensionState.hasInstance) return undefined; + return CsExtensionState.extensionUri.fsPath; +} diff --git a/src/devtools-api/index.ts b/src/devtools-api/index.ts index 32c1e4ac..ed387211 100644 --- a/src/devtools-api/index.ts +++ b/src/devtools-api/index.ts @@ -20,6 +20,7 @@ import { getAuthToken } from '../configuration'; import { CsExtensionState, CsFeature } from '../cs-extension-state'; import { logOutputChannel } from '../log'; import { RefactoringRequest } from '../refactoring/request'; +import { AgentRefactoringService } from '../refactoring/agent-service'; import { vscodeRange } from '../review/utils'; import { StatsCollector } from '../stats'; import { Delta } from './delta-model'; @@ -387,24 +388,6 @@ export class DevtoolsAPI { private static readonly refactoringErrorEmitter = new vscode.EventEmitter(); public static readonly onDidRefactoringFail = DevtoolsAPI.refactoringErrorEmitter.event; - private static buildRefactoringPayload(fnToRefactor: FnToRefactor, skipCache: boolean, token: string) { - const payload: Record = { - 'token': token, - }; - - if (fnToRefactor['nippy-b64']) { - payload['fn-to-refactor-nippy-b64'] = fnToRefactor['nippy-b64']; - } else { - payload['fn-to-refactor'] = fnToRefactor; - } - - if (skipCache) { - payload['skip-cache'] = true; - } - - return payload; - } - /** * Posts a refactoring using devtools binary * @@ -417,17 +400,13 @@ export class DevtoolsAPI { this.checkAceEnabled(); const { document, fnToRefactor, skipCache, signal } = request; - const token = this.getAuthToken(); + this.getAuthToken(); DevtoolsAPI.refactoringRequestEmitter.fire({ document, request, type: 'start' }); try { - const payload = DevtoolsAPI.buildRefactoringPayload(fnToRefactor, skipCache, token); - this.logRefactorRequested(fnToRefactor, skipCache); - const fp = this.getValidatedFileParts(document); - - const response = await this.executeRefactor(payload, signal, fp); + const response = await this.executeRefactor(document, fnToRefactor, signal); this.logRefactorDone(fnToRefactor, skipCache, response); @@ -436,7 +415,6 @@ export class DevtoolsAPI { return response; } catch (e) { this.handleRefactorError(e); - // Some general error reporting above, but pass along the error for further handling throw e; } finally { DevtoolsAPI.refactoringRequestEmitter.fire({ document, request, type: 'end' }); @@ -463,25 +441,12 @@ export class DevtoolsAPI { ); } - private static getValidatedFileParts(document: any) { - const fp = fileParts(document); - if (!validateFileParts('postRefactoring', fp, document)) { - throw new Error('Invalid file parts: document directory is missing or does not exist'); - } - return fp; - } - private static async executeRefactor( - payload: any, - signal: AbortSignal, - fp: { documentDirectory: string } + document: TextDocument, + fnToRefactor: FnToRefactor, + signal: AbortSignal ): Promise { - return DevtoolsAPI.instance.executeAsJson({ - args: ['run-command', 'refactor'], - input: JSON.stringify(payload), - execOptions: { signal, cwd: fp.documentDirectory }, - taskId: REFACTOR_TASK_ID, // Limit to only 1 refactoring at a time - }); + return AgentRefactoringService.runRefactoring(document, fnToRefactor, signal); } private static logRefactorDone(fnToRefactor: any, skipCache: boolean, response: RefactorResponse): void { diff --git a/src/refactoring/agent-config.ts b/src/refactoring/agent-config.ts new file mode 100644 index 00000000..6f2c9eb2 --- /dev/null +++ b/src/refactoring/agent-config.ts @@ -0,0 +1,43 @@ +import { getAgentModel, getAwsProfile, getAwsRegion, getAuthToken } from '../configuration'; +import { AgentConfig, OpencodeConfig } from './agent-types'; + +export { getAgentModel } from '../configuration'; + +export function buildAgentConfigWithToken(token: string): AgentConfig { + if (!token) { + throw new Error('No authentication token available for agent'); + } + + const model = getAgentModel() ?? 'amazon-bedrock/eu.anthropic.claude-sonnet-4-6'; + const awsProfile = getAwsProfile() ?? ''; + const awsRegion = getAwsRegion() ?? 'eu-west-1'; + + const opencodeConfig: OpencodeConfig = { + model, + }; + + if (model.startsWith('amazon-bedrock/')) { + if (awsProfile) { + opencodeConfig.aws_profile = awsProfile; + } + opencodeConfig.aws_region = awsRegion; + } + + return { + codescene_access_token: token, + plugins: ['render-code-fix-reporter'], + opencode_config: opencodeConfig, + }; +} + +export function buildAgentConfig(): AgentConfig { + const token = getAuthToken(); + if (!token) { + throw new Error('No authentication token available for agent'); + } + return buildAgentConfigWithToken(token); +} + +export function buildAgentConfigJson(): string { + return JSON.stringify(buildAgentConfig()); +} diff --git a/src/refactoring/agent-service.ts b/src/refactoring/agent-service.ts new file mode 100644 index 00000000..c37f6015 --- /dev/null +++ b/src/refactoring/agent-service.ts @@ -0,0 +1,240 @@ +import { spawn, ChildProcess } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { v4 as uuidv4 } from 'uuid'; +import { TextDocument } from 'vscode'; +import { FnToRefactor, RefactorResponse, Confidence, Reason } from '../devtools-api/refactor-models'; +import { logOutputChannel } from '../log'; +import { buildAgentConfigWithToken, getAgentModel } from './agent-config'; +import { AgentInput, AgentOutput, ConfidenceLevel, AgentChange } from './agent-types'; +import { getExtensionPath } from '../cs-extension-state'; +import { getEffectiveToken } from '../devtools-api'; + +const INPUT_FILE = 'render-code-fix-input.json'; +const OUTPUT_FILE = 'render-code-fix-output.json'; + +export class AgentRefactoringService { + private static getAgentBinaryPath(): string { + const extensionPath = getExtensionPath(); + if (!extensionPath) { + throw new Error('Extension path not available'); + } + const binaryName = process.platform === 'win32' ? 'cs-agent.exe' : 'cs-agent'; + return path.join(extensionPath, 'bin', binaryName); + } + + static async runRefactoring( + document: TextDocument, + fnToRefactor: FnToRefactor, + signal?: AbortSignal + ): Promise { + const taskId = `refactor-${uuidv4()}`; + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cs-agent-')); + const inputPath = path.join(workDir, INPUT_FILE); + const outputPath = path.join(workDir, OUTPUT_FILE); + + try { + const input = AgentRefactoringService.buildInput(document, fnToRefactor, taskId); + fs.writeFileSync(inputPath, JSON.stringify(input, null, 2)); + + logOutputChannel.info(`Agent refactoring started for task ${taskId}`); + + await AgentRefactoringService.invokeAgent(workDir, signal); + + if (!fs.existsSync(outputPath)) { + throw new Error('Agent did not produce output file'); + } + + const outputContent = fs.readFileSync(outputPath, 'utf-8'); + const output: AgentOutput = JSON.parse(outputContent); + + logOutputChannel.info(`Agent refactoring completed for task ${taskId}: ${output.fix_result}`); + + return AgentRefactoringService.mapOutputToResponse(output, document, fnToRefactor); + } finally { + AgentRefactoringService.cleanup(workDir); + } + } + + private static buildInput(document: TextDocument, fnToRefactor: FnToRefactor, taskId: string): AgentInput { + const target = fnToRefactor['refactoring-targets'][0]; + if (!target) { + throw new Error('No refactoring target found'); + } + + return { + '@context': { '@vocab': 'https://codescene.io/schemas/code-health-fix#' }, + task_id: taskId, + file: document.fileName, + line: target.line, + smell: target.category, + }; + } + + private static invokeAgent(workDir: string, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const binaryPath = AgentRefactoringService.getAgentBinaryPath(); + const model = getAgentModel() ?? 'amazon-bedrock/eu.anthropic.claude-sonnet-4-6'; + const token = getEffectiveToken(); + if (!token) { + reject(new Error('No authentication token available for agent')); + return; + } + const config = buildAgentConfigWithToken(token); + const configJson = JSON.stringify(config); + + const args: string[] = ['run', 'skill:render-code-fix', '--model', model]; + const env: NodeJS.ProcessEnv = { + ...process.env, + CS_AGENT_CONFIG: configJson, + }; + + logOutputChannel.debug(`Spawning agent: ${binaryPath} ${args.join(' ')}`); + + const proc: ChildProcess = spawn(binaryPath, args, { + cwd: workDir, + env, + }); + + let stderr = ''; + proc.stderr?.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + proc.stdout?.on('data', (data: Buffer) => { + logOutputChannel.debug(`Agent stdout: ${data.toString()}`); + }); + + const abortHandler = () => { + proc.kill('SIGTERM'); + reject(new Error('Agent invocation aborted')); + }; + + if (signal) { + signal.addEventListener('abort', abortHandler); + } + + proc.on('close', (code: number | null) => { + if (signal) { + signal.removeEventListener('abort', abortHandler); + } + + if (code === 0) { + resolve(); + } else { + logOutputChannel.error(`Agent exited with code ${code}: ${stderr}`); + reject(new Error(`Agent exited with code ${code}: ${stderr}`)); + } + }); + + proc.on('error', (err: Error) => { + if (signal) { + signal.removeEventListener('abort', abortHandler); + } + reject(err); + }); + }); + } + + static mapOutputToResponse( + output: AgentOutput, + document: TextDocument, + fnToRefactor: FnToRefactor + ): RefactorResponse { + const code = AgentRefactoringService.applyChanges(document, fnToRefactor, output.changes); + const confidence = AgentRefactoringService.mapConfidence(output); + const reasons = AgentRefactoringService.mapReasons(output); + + return { + code, + confidence, + 'credits-info': undefined, + declarations: undefined, + metadata: { 'cached?': false }, + reasons, + 'refactoring-properties': { + 'added-code-smells': [], + 'removed-code-smells': fnToRefactor['refactoring-targets'].map((t) => t.category), + }, + 'trace-id': output.task_id, + }; + } + + private static applyChanges( + document: TextDocument, + fnToRefactor: FnToRefactor, + changes: AgentChange[] + ): string { + let code = fnToRefactor.body; + + for (const change of changes) { + if (path.basename(change.file) !== path.basename(document.fileName)) { + continue; + } + + for (const replacement of change.replacements) { + code = code.replace(replacement.search, replacement.replace); + } + } + + return code; + } + + private static mapConfidence(output: AgentOutput): Confidence { + const levelMap: Record = { + high: 3, + medium: 2, + low: 1, + }; + + let title: string; + let description: string; + + switch (output.fix_result) { + case 'fix_proposed': + title = output.summary; + description = 'Review the suggested changes and apply if appropriate.'; + break; + case 'unable_to_fix': + title = 'Unable to refactor'; + description = 'The agent could not find a suitable refactoring for this code.'; + break; + case 'needs_human_review': + title = 'Needs review'; + description = 'The suggested changes require careful human review before applying.'; + break; + } + + return { + level: levelMap[output.confidence] ?? 2, + title, + 'review-header': output.fix_result === 'fix_proposed' ? undefined : 'Review required', + 'recommended-action': { + description, + details: output.reasoning, + }, + }; + } + + private static mapReasons(output: AgentOutput): Reason[] { + if (output.fix_result === 'fix_proposed') { + return []; + } + + return [ + { + summary: output.summary, + details: undefined, + }, + ]; + } + + private static cleanup(workDir: string): void { + try { + fs.rmSync(workDir, { recursive: true, force: true }); + } catch (err) { + logOutputChannel.warn(`Failed to cleanup work directory ${workDir}: ${err}`); + } + } +} diff --git a/src/refactoring/agent-types.ts b/src/refactoring/agent-types.ts new file mode 100644 index 00000000..2dfcb37f --- /dev/null +++ b/src/refactoring/agent-types.ts @@ -0,0 +1,56 @@ +export interface AgentInputContext { + '@vocab': string; +} + +export interface AgentInput { + '@context': AgentInputContext; + task_id: string; + file: string; + line: number; + smell: string; +} + +export interface AgentOutputContext { + '@vocab': string; +} + +export type FixResult = 'fix_proposed' | 'unable_to_fix' | 'needs_human_review'; +export type ConfidenceLevel = 'high' | 'medium' | 'low'; +export type ChangeType = 'partial' | 'full'; + +export interface AgentReplacement { + search: string; + replace: string; +} + +export interface AgentChange { + file: string; + change_type: ChangeType; + description: string; + replacements: AgentReplacement[]; +} + +export interface AgentOutput { + '@context': AgentOutputContext; + schema_version: string; + task_id: string; + fix_result: FixResult; + confidence: ConfidenceLevel; + summary: string; + reasoning: string; + changes: AgentChange[]; + generated_at: string; +} + +export interface AgentConfig { + codescene_access_token: string; + plugins: string[]; + opencode_config: OpencodeConfig; +} + +export interface OpencodeConfig { + model: string; + provider?: string; + aws_profile?: string; + aws_region?: string; +} diff --git a/src/test/fixtures/agent-output/fix-proposed.json b/src/test/fixtures/agent-output/fix-proposed.json new file mode 100644 index 00000000..4f55a03c --- /dev/null +++ b/src/test/fixtures/agent-output/fix-proposed.json @@ -0,0 +1,23 @@ +{ + "@context": { "@vocab": "https://codescene.io/schemas/code-health-fix#" }, + "schema_version": "1.0", + "task_id": "refactor-test-123", + "fix_result": "fix_proposed", + "confidence": "high", + "summary": "Extracted helper function to reduce complexity", + "reasoning": "The original function had deep nesting which made it hard to read. By extracting the inner logic into a helper function, we reduce the cognitive load and improve maintainability.", + "changes": [ + { + "file": "test.ts", + "change_type": "partial", + "description": "Extract deeply nested condition to helper", + "replacements": [ + { + "search": "if (a > 0) {\n if (a > 1) {\n return a;\n }\n }", + "replace": "return processValue(a);" + } + ] + } + ], + "generated_at": "2026-08-31T12:00:00Z" +} diff --git a/src/test/fixtures/agent-output/needs-human-review.json b/src/test/fixtures/agent-output/needs-human-review.json new file mode 100644 index 00000000..2ae85642 --- /dev/null +++ b/src/test/fixtures/agent-output/needs-human-review.json @@ -0,0 +1,23 @@ +{ + "@context": { "@vocab": "https://codescene.io/schemas/code-health-fix#" }, + "schema_version": "1.0", + "task_id": "refactor-test-789", + "fix_result": "needs_human_review", + "confidence": "medium", + "summary": "Partial refactoring suggested - review recommended", + "reasoning": "The suggested refactoring simplifies the structure but may change error handling behavior. Please verify that the new error handling approach meets your requirements.", + "changes": [ + { + "file": "test.ts", + "change_type": "partial", + "description": "Simplify error handling", + "replacements": [ + { + "search": "try {\n doSomething();\n } catch (e) {\n handleError(e);\n }", + "replace": "await doSomethingSafe();" + } + ] + } + ], + "generated_at": "2026-08-31T12:00:00Z" +} diff --git a/src/test/fixtures/agent-output/unable-to-fix.json b/src/test/fixtures/agent-output/unable-to-fix.json new file mode 100644 index 00000000..98fd27f5 --- /dev/null +++ b/src/test/fixtures/agent-output/unable-to-fix.json @@ -0,0 +1,11 @@ +{ + "@context": { "@vocab": "https://codescene.io/schemas/code-health-fix#" }, + "schema_version": "1.0", + "task_id": "refactor-test-456", + "fix_result": "unable_to_fix", + "confidence": "low", + "summary": "Cannot refactor: code complexity is essential", + "reasoning": "The complexity in this function stems from essential business logic that cannot be simplified without changing the behavior. The nested conditions handle edge cases that must be preserved.", + "changes": [], + "generated_at": "2026-08-31T12:00:00Z" +} diff --git a/src/test/suite/agent-config.test.ts b/src/test/suite/agent-config.test.ts new file mode 100644 index 00000000..71dc0689 --- /dev/null +++ b/src/test/suite/agent-config.test.ts @@ -0,0 +1,92 @@ +import * as assert from 'assert'; +import { buildAgentConfigWithToken } from '../../refactoring/agent-config'; +import { mockConfiguration, restoreDefaultConfiguration } from '../setup'; +import { aceSuite } from '../ace-test-suite'; + +const testCases = [ + { + name: 'buildAgentConfigWithToken includes required fields', + config: {}, + token: 'test-token-123', + assertions: (result: any) => { + assert.strictEqual(result.codescene_access_token, 'test-token-123'); + assert.deepStrictEqual(result.plugins, ['render-code-fix-reporter']); + assert.ok(result.opencode_config); + assert.ok(result.opencode_config.model); + }, + }, + { + name: 'buildAgentConfigWithToken uses default model when not configured', + config: {}, + token: 'test-token', + assertions: (result: any) => { + assert.strictEqual(result.opencode_config.model, 'amazon-bedrock/eu.anthropic.claude-sonnet-4-6'); + }, + }, + { + name: 'buildAgentConfigWithToken uses configured model', + config: { agentModel: 'anthropic/claude-3-opus' }, + token: 'test-token', + assertions: (result: any) => { + assert.strictEqual(result.opencode_config.model, 'anthropic/claude-3-opus'); + }, + }, + { + name: 'buildAgentConfigWithToken includes AWS config for Bedrock models', + config: { + agentModel: 'amazon-bedrock/some-model', + awsProfile: 'my-profile', + awsRegion: 'us-east-1', + }, + token: 'test-token', + assertions: (result: any) => { + assert.strictEqual(result.opencode_config.aws_profile, 'my-profile'); + assert.strictEqual(result.opencode_config.aws_region, 'us-east-1'); + }, + }, + { + name: 'buildAgentConfigWithToken omits AWS profile when empty', + config: { + agentModel: 'amazon-bedrock/some-model', + awsProfile: '', + awsRegion: 'eu-west-1', + }, + token: 'test-token', + assertions: (result: any) => { + assert.strictEqual(result.opencode_config.aws_profile, undefined); + assert.strictEqual(result.opencode_config.aws_region, 'eu-west-1'); + }, + }, + { + name: 'buildAgentConfigWithToken does not include AWS config for non-Bedrock models', + config: { + agentModel: 'anthropic/claude-3-opus', + awsProfile: 'my-profile', + awsRegion: 'us-east-1', + }, + token: 'test-token', + assertions: (result: any) => { + assert.strictEqual(result.opencode_config.aws_profile, undefined); + assert.strictEqual(result.opencode_config.aws_region, undefined); + }, + }, +]; + +aceSuite('AgentConfig Test Suite', () => { + teardown(() => { + restoreDefaultConfiguration(); + }); + + for (const tc of testCases) { + test(tc.name, () => { + mockConfiguration('codescene', tc.config); + const result = buildAgentConfigWithToken(tc.token); + tc.assertions(result); + }); + } + + test('buildAgentConfigWithToken throws when token is empty', () => { + mockConfiguration('codescene', {}); + assert.throws(() => buildAgentConfigWithToken(''), /No authentication token/); + }); +}); diff --git a/src/test/suite/agent-integration.test.ts b/src/test/suite/agent-integration.test.ts new file mode 100644 index 00000000..63f0d905 --- /dev/null +++ b/src/test/suite/agent-integration.test.ts @@ -0,0 +1,131 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as vscode from 'vscode'; +import { mockWorkspaceFolders, createMockWorkspaceFolder, restoreDefaultWorkspaceFolders } from '../setup'; +import { TestTextDocument } from '../mocks/test-text-document'; +import { createTestDir } from '../integration_helper'; +import * as configModule from '../../configuration'; +import * as csExtensionState from '../../cs-extension-state'; +import { AgentRefactoringService } from '../../refactoring/agent-service'; +import { FnToRefactor } from '../../devtools-api/refactor-models'; + +import { aceSuite } from '../ace-test-suite'; + +aceSuite('Agent Integration Test Suite', () => { + const testDir = createTestDir('test-agent-integration'); + const testToken = process.env.CODESCENE_TEST_TOKEN; + let originalGetAuthToken: any; + let originalGetExtensionPath: any; + + if (!testToken) { + console.log('Skipping Agent Integration tests: CODESCENE_TEST_TOKEN environment variable not set'); + return; + } + + const projectRoot = path.join(__dirname, '..', '..', '..'); + const agentBinaryPath = path.join(projectRoot, 'bin', 'cs-agent'); + if (!fs.existsSync(agentBinaryPath)) { + console.log('Skipping Agent Integration tests: cs-agent binary not found at ' + agentBinaryPath); + return; + } + + function createTestFile(filename: string, content: string, languageId: string = 'cpp'): TestTextDocument { + const testFile = path.resolve(testDir, filename); + fs.writeFileSync(testFile, content); + return new TestTextDocument(testFile, content, languageId); + } + + setup(async function () { + this.timeout(60000); + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + fs.mkdirSync(testDir, { recursive: true }); + + mockWorkspaceFolders([createMockWorkspaceFolder(testDir)]); + + originalGetAuthToken = configModule.getAuthToken; + (configModule as any).getAuthToken = () => testToken; + + originalGetExtensionPath = csExtensionState.getExtensionPath; + (csExtensionState as any).getExtensionPath = () => projectRoot; + + Object.defineProperty(csExtensionState.CsExtensionState, 'stateProperties', { + get: () => ({ + session: undefined, + features: { + analysis: { state: 'enabled' }, + ace: { state: 'enabled' }, + }, + }), + configurable: true, + }); + }); + + teardown(() => { + if (originalGetAuthToken) { + (configModule as any).getAuthToken = originalGetAuthToken; + } + + if (originalGetExtensionPath) { + (csExtensionState as any).getExtensionPath = originalGetExtensionPath; + } + + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + + restoreDefaultWorkspaceFolders(); + }); + + test('runRefactoring returns valid response with code and confidence', async function () { + this.timeout(120000); + + const complexContent = `int f(int a) { + if (a > 0) { + if (a > 1) { + if (a > 2) { + if (a > 3) { + return a; + } + } + } + } + return 0; +} +`; + + const doc = createTestFile('complex.cpp', complexContent); + + const fnToRefactor: FnToRefactor = { + 'file-type': 'cpp', + name: 'f', + body: complexContent, + 'refactoring-targets': [ + { + category: 'Deep, Nested Complexity', + line: 2, + }, + ], + range: { + 'start-line': 1, + 'start-column': 1, + 'end-line': 13, + 'end-column': 2, + }, + vscodeRange: new vscode.Range(0, 0, 12, 1), + }; + + const response = await AgentRefactoringService.runRefactoring(doc, fnToRefactor); + + assert.ok(response, 'Response should be defined'); + assert.ok(response.code, 'Response should have code'); + assert.ok(response.confidence, 'Response should have confidence'); + assert.strictEqual(typeof response.confidence.level, 'number', 'Confidence level should be a number'); + assert.ok(response.confidence.title, 'Confidence should have a title'); + assert.ok(response.confidence['recommended-action'], 'Confidence should have recommended action'); + assert.ok(response['trace-id'], 'Response should have trace-id'); + assert.ok(response['refactoring-properties'], 'Response should have refactoring-properties'); + }); +}); diff --git a/src/test/suite/agent-service.test.ts b/src/test/suite/agent-service.test.ts new file mode 100644 index 00000000..13949956 --- /dev/null +++ b/src/test/suite/agent-service.test.ts @@ -0,0 +1,202 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; +import { AgentRefactoringService } from '../../refactoring/agent-service'; +import { AgentOutput, ConfidenceLevel } from '../../refactoring/agent-types'; +import { FnToRefactor } from '../../devtools-api/refactor-models'; +import { aceSuite } from '../ace-test-suite'; +import { Range, Position } from '../mocks/vscode'; + +const fixturesDir = path.join(__dirname, '..', '..', '..', 'src', 'test', 'fixtures', 'agent-output'); + +function loadFixture(name: string): AgentOutput { + const content = fs.readFileSync(path.join(fixturesDir, name), 'utf-8'); + return JSON.parse(content); +} + +function createMockFnToRefactor(body: string): FnToRefactor { + return { + name: 'testFunction', + body, + 'file-type': 'typescript', + range: { + 'start-line': 1, + 'start-column': 1, + 'end-line': 10, + 'end-column': 2, + }, + 'refactoring-targets': [{ category: 'Deep, Nested Complexity', line: 1 }], + vscodeRange: new Range(new Position(0, 0), new Position(9, 1)) as any, + }; +} + +function createMockDocument(fileName: string, content: string) { + return { + fileName, + getText: () => content, + uri: { fsPath: fileName }, + } as any; +} + +aceSuite('AgentRefactoringService Test Suite', () => { + const testCases = [ + { + name: 'mapOutputToResponse maps fix_proposed correctly', + fixture: 'fix-proposed.json', + fnBody: 'function test(a: number) {\n if (a > 0) {\n if (a > 1) {\n return a;\n }\n }\n return 0;\n}', + assertions: (response: any, output: AgentOutput) => { + assert.strictEqual(response['trace-id'], output.task_id); + assert.strictEqual(response.confidence.level, 3); + assert.strictEqual(response.confidence.title, output.summary); + assert.strictEqual(response.confidence['recommended-action'].details, output.reasoning); + assert.strictEqual(response.reasons.length, 0); + assert.deepStrictEqual(response['refactoring-properties']['removed-code-smells'], ['Deep, Nested Complexity']); + }, + }, + { + name: 'mapOutputToResponse maps unable_to_fix correctly', + fixture: 'unable-to-fix.json', + fnBody: 'function complex() { return 42; }', + assertions: (response: any, output: AgentOutput) => { + assert.strictEqual(response['trace-id'], output.task_id); + assert.strictEqual(response.confidence.level, 1); + assert.strictEqual(response.confidence.title, 'Unable to refactor'); + assert.strictEqual(response.confidence['review-header'], 'Review required'); + assert.strictEqual(response.reasons.length, 1); + assert.strictEqual(response.reasons[0].summary, output.summary); + }, + }, + { + name: 'mapOutputToResponse maps needs_human_review correctly', + fixture: 'needs-human-review.json', + fnBody: 'function test() {\n try {\n doSomething();\n } catch (e) {\n handleError(e);\n }\n}', + assertions: (response: any, output: AgentOutput) => { + assert.strictEqual(response['trace-id'], output.task_id); + assert.strictEqual(response.confidence.level, 2); + assert.strictEqual(response.confidence.title, 'Needs review'); + assert.strictEqual(response.confidence['review-header'], 'Review required'); + assert.strictEqual(response.reasons.length, 1); + }, + }, + ]; + + for (const tc of testCases) { + test(tc.name, () => { + const output = loadFixture(tc.fixture); + const fnToRefactor = createMockFnToRefactor(tc.fnBody); + const document = createMockDocument('/test/test.ts', tc.fnBody); + + const response = AgentRefactoringService.mapOutputToResponse(output, document, fnToRefactor); + + tc.assertions(response, output); + }); + } + + const confidenceLevelCases: Array<{ confidence: ConfidenceLevel; expectedLevel: number }> = [ + { confidence: 'high', expectedLevel: 3 }, + { confidence: 'medium', expectedLevel: 2 }, + { confidence: 'low', expectedLevel: 1 }, + ]; + + for (const tc of confidenceLevelCases) { + test(`mapOutputToResponse maps confidence level ${tc.confidence} to ${tc.expectedLevel}`, () => { + const output: AgentOutput = { + '@context': { '@vocab': 'https://codescene.io/schemas/code-health-fix#' }, + schema_version: '1.0', + task_id: 'test-123', + fix_result: 'fix_proposed', + confidence: tc.confidence, + summary: 'Test summary', + reasoning: 'Test reasoning', + changes: [], + generated_at: '2026-08-31T12:00:00Z', + }; + + const fnToRefactor = createMockFnToRefactor('function test() {}'); + const document = createMockDocument('/test/test.ts', 'function test() {}'); + + const response = AgentRefactoringService.mapOutputToResponse(output, document, fnToRefactor); + + assert.strictEqual(response.confidence.level, tc.expectedLevel); + }); + } + + test('mapOutputToResponse applies replacements to code', () => { + const output: AgentOutput = { + '@context': { '@vocab': 'https://codescene.io/schemas/code-health-fix#' }, + schema_version: '1.0', + task_id: 'test-replace', + fix_result: 'fix_proposed', + confidence: 'high', + summary: 'Applied change', + reasoning: 'Test', + changes: [ + { + file: 'test.ts', + change_type: 'partial', + description: 'Replace old with new', + replacements: [{ search: 'oldCode', replace: 'newCode' }], + }, + ], + generated_at: '2026-08-31T12:00:00Z', + }; + + const fnBody = 'function test() { oldCode; }'; + const fnToRefactor = createMockFnToRefactor(fnBody); + const document = createMockDocument('/test/test.ts', fnBody); + + const response = AgentRefactoringService.mapOutputToResponse(output, document, fnToRefactor); + + assert.strictEqual(response.code, 'function test() { newCode; }'); + }); + + test('mapOutputToResponse ignores changes for different files', () => { + const output: AgentOutput = { + '@context': { '@vocab': 'https://codescene.io/schemas/code-health-fix#' }, + schema_version: '1.0', + task_id: 'test-different-file', + fix_result: 'fix_proposed', + confidence: 'high', + summary: 'Applied change', + reasoning: 'Test', + changes: [ + { + file: 'other.ts', + change_type: 'partial', + description: 'Replace in other file', + replacements: [{ search: 'oldCode', replace: 'newCode' }], + }, + ], + generated_at: '2026-08-31T12:00:00Z', + }; + + const fnBody = 'function test() { oldCode; }'; + const fnToRefactor = createMockFnToRefactor(fnBody); + const document = createMockDocument('/test/test.ts', fnBody); + + const response = AgentRefactoringService.mapOutputToResponse(output, document, fnToRefactor); + + assert.strictEqual(response.code, fnBody); + }); + + test('mapOutputToResponse sets metadata cached to false', () => { + const output: AgentOutput = { + '@context': { '@vocab': 'https://codescene.io/schemas/code-health-fix#' }, + schema_version: '1.0', + task_id: 'test-metadata', + fix_result: 'fix_proposed', + confidence: 'high', + summary: 'Test', + reasoning: 'Test', + changes: [], + generated_at: '2026-08-31T12:00:00Z', + }; + + const fnToRefactor = createMockFnToRefactor('function test() {}'); + const document = createMockDocument('/test/test.ts', 'function test() {}'); + + const response = AgentRefactoringService.mapOutputToResponse(output, document, fnToRefactor); + + assert.strictEqual(response.metadata['cached?'], false); + }); +}); diff --git a/src/test/suite/post-refactoring.test.ts b/src/test/suite/post-refactoring.test.ts index 071eca43..0577b95a 100644 --- a/src/test/suite/post-refactoring.test.ts +++ b/src/test/suite/post-refactoring.test.ts @@ -29,6 +29,12 @@ aceSuite('PostRefactoring Integration Test Suite', () => { return; } + const agentBinaryPath = path.join(__dirname, '..', '..', '..', 'bin', 'cs-agent'); + if (!fs.existsSync(agentBinaryPath)) { + console.log('Skipping PostRefactoring tests: cs-agent binary not found at ' + agentBinaryPath); + return; + } + function createTestFile(filename: string, content: string, languageId: string = 'cpp'): TestTextDocument { const testFile = path.resolve(testDir, filename); fs.writeFileSync(testFile, content); From bd6f48e2a54553980212ca5e536639b519b4e878 Mon Sep 17 00:00:00 2001 From: vemv Date: Tue, 1 Sep 2026 12:35:21 +0200 Subject: [PATCH 02/39] refactor: rename ACE to CodeScene Refactoring Agent Aligns with the agentic-ace branch reference implementation: - Remove agentModel, awsProfile, awsRegion settings (agent reads its own config) - Update user-facing strings from "CodeScene ACE" to "CodeScene Agent" or "CodeScene Refactoring Agent" - Simplify agent config to only pass token and plugins --- package.json | 22 +------ src/code-health-monitor/codelens.ts | 2 +- src/codescene-tab/webview-panel.ts | 16 +++--- .../cwf-webview-ace-acknowledgement-panel.ts | 4 +- .../webview/ace/cwf-webview-ace-panel.ts | 4 +- .../webview/refactoring-components.ts | 2 +- src/configuration.ts | 12 ---- src/control-center/view-provider.ts | 4 +- src/cs-statusbar.ts | 2 +- src/refactoring/agent-config.ts | 22 +------ src/refactoring/agent-service.ts | 5 +- src/refactoring/agent-types.ts | 8 --- src/review/codeaction.ts | 6 +- src/test/suite/agent-config.test.ts | 57 ------------------- 14 files changed, 26 insertions(+), 140 deletions(-) diff --git a/package.json b/package.json index 804a8335..ab32c469 100644 --- a/package.json +++ b/package.json @@ -142,33 +142,15 @@ "codescene.enableAutoRefactor": { "type": "boolean", "default": true, - "description": "Enable CodeScene ACE", + "description": "Enable CodeScene Refactoring Agent", "order": 15 }, "codescene.authToken": { "type": "string", "default": "", - "description": "Authentication token for CodeScene ACE. Has priority over the Sign In with CodeScene command.", + "description": "Authentication token for CodeScene Refactoring Agent.", "order": 19.5 }, - "codescene.agentModel": { - "type": "string", - "default": "amazon-bedrock/eu.anthropic.claude-sonnet-4-6", - "description": "Model identifier for refactoring agent", - "order": 16 - }, - "codescene.awsProfile": { - "type": "string", - "default": "", - "description": "AWS profile for Bedrock (uses default credential chain if empty)", - "order": 17 - }, - "codescene.awsRegion": { - "type": "string", - "default": "eu-west-1", - "description": "AWS region for Bedrock", - "order": 18 - }, "codescene.enableTelemetry": { "type": "boolean", "default": true, diff --git a/src/code-health-monitor/codelens.ts b/src/code-health-monitor/codelens.ts index 56179c66..bf7c69ad 100644 --- a/src/code-health-monitor/codelens.ts +++ b/src/code-health-monitor/codelens.ts @@ -55,7 +55,7 @@ export class CodeHealthMonitorCodeLens implements vscode.CodeLensProvider -

CodeScene ACE - AI-Powered Refactoring

-

CodeScene ACE combines multiple LLMs with fact-based validation. - ACE chooses the best LLM for the job, validates its output, and proposes refactoring for cleaner code which is easier +

CodeScene Refactoring Agent - AI-Powered Refactoring

+

CodeScene Refactoring Agent combines multiple LLMs with fact-based validation. + The agent chooses the best LLM for the job, validates its output, and proposes refactoring for cleaner code which is easier to maintain.

-

CodeScene ACE is built on our CodeHealth™ Metric, the only code analysis metric with a +

CodeScene Refactoring Agent is built on our CodeHealth™ Metric, the only code analysis metric with a proven business impact.

@@ -278,19 +278,19 @@ export class CodeSceneTabPanel implements Disposable {
  • Your code is never stored by us or the LLMs
  • Your code snippets are shared only with select LLMs
  • Your code is not used to train any LLM
  • -
  • All communications with CodeScene ACE is fully encrypted
  • +
  • All communications with CodeScene Refactoring Agent is fully encrypted
  • View CodeScene's AI Privacy Principles
    - Show me CodeScene ACE + Show me CodeScene Refactoring Agent
    -

    You can disable CodeScene ACE anytime in settings.

    +

    You can disable CodeScene Refactoring Agent anytime in settings.

    `; renderHtmlTemplate(this.webViewPanel, { - title: 'CodeScene ACE - AI Powered Refactoring', + title: 'CodeScene Refactoring Agent - AI Powered Refactoring', bodyContent: [fnLocContent, ackContent], cssPaths: [['out', 'codescene-tab', 'webview', 'ace-acknowledgement-styles.css']], scriptPaths: [['out', 'codescene-tab', 'webview', 'ace-acknowledgement-script.js']], diff --git a/src/codescene-tab/webview/ace/acknowledgement/cwf-webview-ace-acknowledgement-panel.ts b/src/codescene-tab/webview/ace/acknowledgement/cwf-webview-ace-acknowledgement-panel.ts index 03c3a833..e2f8fb4a 100644 --- a/src/codescene-tab/webview/ace/acknowledgement/cwf-webview-ace-acknowledgement-panel.ts +++ b/src/codescene-tab/webview/ace/acknowledgement/cwf-webview-ace-acknowledgement-panel.ts @@ -35,7 +35,7 @@ export class CodeSceneCWFAceAcknowledgementTabPanel implements Disposable { constructor() { this.webViewPanel = vscode.window.createWebviewPanel( CodeSceneCWFAceAcknowledgementTabPanel.viewType, - 'CodeScene ACE - AI-Powered Refactoring', + 'CodeScene Refactoring Agent - AI-Powered Refactoring', { viewColumn: ViewColumn.Beside, preserveFocus: true }, { enableScripts: true, @@ -61,7 +61,7 @@ export class CodeSceneCWFAceAcknowledgementTabPanel implements Disposable { if (!this.state) return; await this.handleMessage(this.state.request, message); } catch (e) { - reportError({ context: 'An error occurred in the CodeScene ACE Acknowledgement panel', e }); + reportError({ context: 'An error occurred in the CodeScene Agent Acknowledgement panel', e }); } } diff --git a/src/codescene-tab/webview/ace/cwf-webview-ace-panel.ts b/src/codescene-tab/webview/ace/cwf-webview-ace-panel.ts index e583b1bf..ef1e5c66 100644 --- a/src/codescene-tab/webview/ace/cwf-webview-ace-panel.ts +++ b/src/codescene-tab/webview/ace/cwf-webview-ace-panel.ts @@ -53,7 +53,7 @@ export class CodeSceneCWFAceTabPanel implements Disposable { this.webViewPanel = vscode.window.createWebviewPanel( CodeSceneCWFAceTabPanel.viewType, - 'CodeScene ACE', + 'CodeScene Agent', { viewColumn: ViewColumn.Beside, preserveFocus: true }, { enableScripts: true, @@ -97,7 +97,7 @@ export class CodeSceneCWFAceTabPanel implements Disposable { if (!this.state) return; await this.handleAceMessage(this.state.request, message); } catch (e) { - reportError({ context: 'An error occurred in the CodeScene ACE panel', e }); + reportError({ context: 'An error occurred in the CodeScene Agent panel', e }); } } diff --git a/src/codescene-tab/webview/refactoring-components.ts b/src/codescene-tab/webview/refactoring-components.ts index e621e514..a987ee19 100644 --- a/src/codescene-tab/webview/refactoring-components.ts +++ b/src/codescene-tab/webview/refactoring-components.ts @@ -187,7 +187,7 @@ export function refactoringError(isAuthError = false) { default: return /*html*/ `
    -

    Unfortunately, we are unable to provide a CodeScene ACE refactoring recommendation or a code improvement +

    Unfortunately, we are unable to provide a CodeScene Refactoring Agent recommendation or a code improvement guide at this time. We recommend reviewing your code manually to identify potential areas for enhancement.

    For further assistance, please refer to the CodeScene documentation for best practices and guidance on improving your code.

    diff --git a/src/configuration.ts b/src/configuration.ts index f388ad72..61c0dd1a 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -71,15 +71,3 @@ export function toggleReviewCodeLenses() { export function getAuthToken() { return getConfiguration('authToken', ''); } - -export function getAgentModel() { - return getConfiguration('agentModel', 'amazon-bedrock/eu.anthropic.claude-sonnet-4-6'); -} - -export function getAwsProfile() { - return getConfiguration('awsProfile', ''); -} - -export function getAwsRegion() { - return getConfiguration('awsRegion', 'eu-west-1'); -} diff --git a/src/control-center/view-provider.ts b/src/control-center/view-provider.ts index 50920872..68aa1016 100644 --- a/src/control-center/view-provider.ts +++ b/src/control-center/view-provider.ts @@ -247,7 +247,7 @@ export class ControlCenterViewProvider implements WebviewViewProvider, Disposabl error: { iconClass: 'codicon-error', text: 'error', - tooltip: 'Click to retry connecting to CodeScene ACE', + tooltip: 'Click to retry connecting to CodeScene Agent', }, offline: { iconClass: 'codicon-error', @@ -301,7 +301,7 @@ export class ControlCenterViewProvider implements WebviewViewProvider, Disposabl return /*html*/ `
    -
    CodeScene ACE
    +
    CodeScene Agent
    ${status.text} diff --git a/src/cs-statusbar.ts b/src/cs-statusbar.ts index 657fe096..5ee3746a 100644 --- a/src/cs-statusbar.ts +++ b/src/cs-statusbar.ts @@ -150,7 +150,7 @@ export class CsStatusBar implements Disposable { text: `$(${hasToken ? 'cs-logo' : 'error'}) ACE`, command: 'codescene.openSettingsAndFocusToken', background: hasToken ? undefined : 'statusBarItem.warningBackground', - tooltip: hasToken ? 'CodeScene ACE is active' : 'Configure ACE auth token in extension Workspace settings', + tooltip: hasToken ? 'CodeScene Agent is active' : 'Configure Agent auth token in extension Workspace settings', }); }, }; diff --git a/src/refactoring/agent-config.ts b/src/refactoring/agent-config.ts index 6f2c9eb2..064fad52 100644 --- a/src/refactoring/agent-config.ts +++ b/src/refactoring/agent-config.ts @@ -1,32 +1,14 @@ -import { getAgentModel, getAwsProfile, getAwsRegion, getAuthToken } from '../configuration'; -import { AgentConfig, OpencodeConfig } from './agent-types'; - -export { getAgentModel } from '../configuration'; +import { getAuthToken } from '../configuration'; +import { AgentConfig } from './agent-types'; export function buildAgentConfigWithToken(token: string): AgentConfig { if (!token) { throw new Error('No authentication token available for agent'); } - const model = getAgentModel() ?? 'amazon-bedrock/eu.anthropic.claude-sonnet-4-6'; - const awsProfile = getAwsProfile() ?? ''; - const awsRegion = getAwsRegion() ?? 'eu-west-1'; - - const opencodeConfig: OpencodeConfig = { - model, - }; - - if (model.startsWith('amazon-bedrock/')) { - if (awsProfile) { - opencodeConfig.aws_profile = awsProfile; - } - opencodeConfig.aws_region = awsRegion; - } - return { codescene_access_token: token, plugins: ['render-code-fix-reporter'], - opencode_config: opencodeConfig, }; } diff --git a/src/refactoring/agent-service.ts b/src/refactoring/agent-service.ts index c37f6015..6346a8b7 100644 --- a/src/refactoring/agent-service.ts +++ b/src/refactoring/agent-service.ts @@ -6,7 +6,7 @@ import { v4 as uuidv4 } from 'uuid'; import { TextDocument } from 'vscode'; import { FnToRefactor, RefactorResponse, Confidence, Reason } from '../devtools-api/refactor-models'; import { logOutputChannel } from '../log'; -import { buildAgentConfigWithToken, getAgentModel } from './agent-config'; +import { buildAgentConfigWithToken } from './agent-config'; import { AgentInput, AgentOutput, ConfidenceLevel, AgentChange } from './agent-types'; import { getExtensionPath } from '../cs-extension-state'; import { getEffectiveToken } from '../devtools-api'; @@ -75,7 +75,6 @@ export class AgentRefactoringService { private static invokeAgent(workDir: string, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { const binaryPath = AgentRefactoringService.getAgentBinaryPath(); - const model = getAgentModel() ?? 'amazon-bedrock/eu.anthropic.claude-sonnet-4-6'; const token = getEffectiveToken(); if (!token) { reject(new Error('No authentication token available for agent')); @@ -84,7 +83,7 @@ export class AgentRefactoringService { const config = buildAgentConfigWithToken(token); const configJson = JSON.stringify(config); - const args: string[] = ['run', 'skill:render-code-fix', '--model', model]; + const args: string[] = ['run', 'skill:render-code-fix']; const env: NodeJS.ProcessEnv = { ...process.env, CS_AGENT_CONFIG: configJson, diff --git a/src/refactoring/agent-types.ts b/src/refactoring/agent-types.ts index 2dfcb37f..5ed55f8e 100644 --- a/src/refactoring/agent-types.ts +++ b/src/refactoring/agent-types.ts @@ -45,12 +45,4 @@ export interface AgentOutput { export interface AgentConfig { codescene_access_token: string; plugins: string[]; - opencode_config: OpencodeConfig; -} - -export interface OpencodeConfig { - model: string; - provider?: string; - aws_profile?: string; - aws_region?: string; } diff --git a/src/review/codeaction.ts b/src/review/codeaction.ts index e3dd34ed..e64d1caf 100644 --- a/src/review/codeaction.ts +++ b/src/review/codeaction.ts @@ -88,16 +88,16 @@ export class ReviewCodeActionProvider implements vscode.CodeActionProvider, vsco if (!fnToRefactor) return; const refactorHighligting = new vscode.Diagnostic(fnToRefactor.vscodeRange, 'Function to refactor'); - const refactorAction = new vscode.CodeAction('Refactor using CodeScene ACE', vscode.CodeActionKind.QuickFix); + const refactorAction = new vscode.CodeAction('Refactor using CodeScene Agent', vscode.CodeActionKind.QuickFix); refactorAction.diagnostics = [refactorHighligting]; refactorAction.command = { command: 'codescene.requestAndPresentRefactoring', - title: 'Refactor using CodeScene ACE', + title: 'Refactor using CodeScene Agent', arguments: [document, 'codeaction', fnToRefactor], }; refactorAction.disabled = !authToken ? { - reason: 'Refactoring is not available. Please verify your authentication token in Workspace settings.', + reason: 'Activate the refactoring agent by providing your authentication token in Workspace settings.', } : undefined; actions.push(refactorAction); diff --git a/src/test/suite/agent-config.test.ts b/src/test/suite/agent-config.test.ts index 71dc0689..a51ef18a 100644 --- a/src/test/suite/agent-config.test.ts +++ b/src/test/suite/agent-config.test.ts @@ -11,63 +11,6 @@ const testCases = [ assertions: (result: any) => { assert.strictEqual(result.codescene_access_token, 'test-token-123'); assert.deepStrictEqual(result.plugins, ['render-code-fix-reporter']); - assert.ok(result.opencode_config); - assert.ok(result.opencode_config.model); - }, - }, - { - name: 'buildAgentConfigWithToken uses default model when not configured', - config: {}, - token: 'test-token', - assertions: (result: any) => { - assert.strictEqual(result.opencode_config.model, 'amazon-bedrock/eu.anthropic.claude-sonnet-4-6'); - }, - }, - { - name: 'buildAgentConfigWithToken uses configured model', - config: { agentModel: 'anthropic/claude-3-opus' }, - token: 'test-token', - assertions: (result: any) => { - assert.strictEqual(result.opencode_config.model, 'anthropic/claude-3-opus'); - }, - }, - { - name: 'buildAgentConfigWithToken includes AWS config for Bedrock models', - config: { - agentModel: 'amazon-bedrock/some-model', - awsProfile: 'my-profile', - awsRegion: 'us-east-1', - }, - token: 'test-token', - assertions: (result: any) => { - assert.strictEqual(result.opencode_config.aws_profile, 'my-profile'); - assert.strictEqual(result.opencode_config.aws_region, 'us-east-1'); - }, - }, - { - name: 'buildAgentConfigWithToken omits AWS profile when empty', - config: { - agentModel: 'amazon-bedrock/some-model', - awsProfile: '', - awsRegion: 'eu-west-1', - }, - token: 'test-token', - assertions: (result: any) => { - assert.strictEqual(result.opencode_config.aws_profile, undefined); - assert.strictEqual(result.opencode_config.aws_region, 'eu-west-1'); - }, - }, - { - name: 'buildAgentConfigWithToken does not include AWS config for non-Bedrock models', - config: { - agentModel: 'anthropic/claude-3-opus', - awsProfile: 'my-profile', - awsRegion: 'us-east-1', - }, - token: 'test-token', - assertions: (result: any) => { - assert.strictEqual(result.opencode_config.aws_profile, undefined); - assert.strictEqual(result.opencode_config.aws_region, undefined); }, }, ]; From 430fff1936111bf331eacc399ce4e1012c1deb00 Mon Sep 17 00:00:00 2001 From: vemv Date: Tue, 1 Sep 2026 12:49:44 +0200 Subject: [PATCH 03/39] fix: handle open-settings message in ACE acknowledgement panel Clicking "Extension Settings" in the ACE acknowledgement panel was logging an error because the panel didn't have a handler for the open-settings message type. Reuse the existing handleOpenSettings function from cwf-message-handlers. --- src/code-health-monitor/home/cwf-message-handlers.ts | 2 +- .../acknowledgement/cwf-webview-ace-acknowledgement-panel.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/code-health-monitor/home/cwf-message-handlers.ts b/src/code-health-monitor/home/cwf-message-handlers.ts index 15e5e070..428fa86b 100644 --- a/src/code-health-monitor/home/cwf-message-handlers.ts +++ b/src/code-health-monitor/home/cwf-message-handlers.ts @@ -103,7 +103,7 @@ function handleOpenDocs(homeView: HomeView, payload: OpenDocsMessage['payload']) /** * Open settings in editor */ -function handleOpenSettings() { +export function handleOpenSettings() { Telemetry.logUsage('control-center/open-settings'); vscode.commands.executeCommand('workbench.action.openWorkspaceSettings', getExtensionSettingsFilter()).then( () => {}, diff --git a/src/codescene-tab/webview/ace/acknowledgement/cwf-webview-ace-acknowledgement-panel.ts b/src/codescene-tab/webview/ace/acknowledgement/cwf-webview-ace-acknowledgement-panel.ts index e2f8fb4a..6a166219 100644 --- a/src/codescene-tab/webview/ace/acknowledgement/cwf-webview-ace-acknowledgement-panel.ts +++ b/src/codescene-tab/webview/ace/acknowledgement/cwf-webview-ace-acknowledgement-panel.ts @@ -8,6 +8,7 @@ import { highlightCode } from '../../../../refactoring/utils'; import Telemetry from '../../../../telemetry'; import { showDocAtPosition } from '../../../../utils'; import { commonResourceRoots } from '../../../../webview-utils'; +import { handleOpenSettings } from '../../../../code-health-monitor/home/cwf-message-handlers'; import { CsExtensionState } from '../../../../cs-extension-state'; import { getAceAcknowledgeData } from './ace-acknowledgement-mapper'; @@ -72,6 +73,7 @@ export class CodeSceneCWFAceAcknowledgementTabPanel implements Disposable { await showDocAtPosition(request.document, request.fnToRefactor.vscodeRange.start); void highlightCode(request, false); }, + 'open-settings': handleOpenSettings, acknowledged: async () => { const document = this.state?.request.document; const fnToRefactor = this.state?.request.fnToRefactor; From cb4d860a9fa9e6547b3167a54615c64c638ad6ef Mon Sep 17 00:00:00 2001 From: vemv Date: Tue, 1 Sep 2026 13:25:16 +0200 Subject: [PATCH 04/39] fix: spawn refactoring agent with git repository root as cwd The agent was being spawned with a temp directory as cwd, causing "not inside a git repository" errors. Use the git repo root so the agent can access the entire project and run git commands. --- src/refactoring/agent-service.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/refactoring/agent-service.ts b/src/refactoring/agent-service.ts index 6346a8b7..280a60d7 100644 --- a/src/refactoring/agent-service.ts +++ b/src/refactoring/agent-service.ts @@ -10,6 +10,7 @@ import { buildAgentConfigWithToken } from './agent-config'; import { AgentInput, AgentOutput, ConfidenceLevel, AgentChange } from './agent-types'; import { getExtensionPath } from '../cs-extension-state'; import { getEffectiveToken } from '../devtools-api'; +import { acquireGitApi, getRepoRootPath } from '../git-utils'; const INPUT_FILE = 'render-code-fix-input.json'; const OUTPUT_FILE = 'render-code-fix-output.json'; @@ -40,7 +41,7 @@ export class AgentRefactoringService { logOutputChannel.info(`Agent refactoring started for task ${taskId}`); - await AgentRefactoringService.invokeAgent(workDir, signal); + await AgentRefactoringService.invokeAgent(workDir, document, signal); if (!fs.existsSync(outputPath)) { throw new Error('Agent did not produce output file'); @@ -72,7 +73,7 @@ export class AgentRefactoringService { }; } - private static invokeAgent(workDir: string, signal?: AbortSignal): Promise { + private static invokeAgent(workDir: string, document: TextDocument, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { const binaryPath = AgentRefactoringService.getAgentBinaryPath(); const token = getEffectiveToken(); @@ -89,10 +90,14 @@ export class AgentRefactoringService { CS_AGENT_CONFIG: configJson, }; + const gitApi = acquireGitApi(); + const repo = gitApi?.getRepository(document.uri); + const cwd = repo ? getRepoRootPath(repo) : path.dirname(document.fileName); + logOutputChannel.debug(`Spawning agent: ${binaryPath} ${args.join(' ')}`); const proc: ChildProcess = spawn(binaryPath, args, { - cwd: workDir, + cwd, env, }); From d66ae430971acbcfb5dc0f8a527a583936829cd0 Mon Sep 17 00:00:00 2001 From: vemv Date: Wed, 2 Sep 2026 11:24:05 +0200 Subject: [PATCH 05/39] fix: add Amazon Bedrock provider config for refactoring agent The agent was failing with "UnknownError" because the opencode_config with Amazon Bedrock provider settings was missing from the agent config. Also adds the --model argument to explicitly request the Bedrock model. --- src/refactoring/agent-config.ts | 10 ++++++++++ src/refactoring/agent-service.ts | 11 ++++++++++- src/refactoring/agent-types.ts | 10 ++++++++++ src/test/suite/agent-config.test.ts | 4 ++++ 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/refactoring/agent-config.ts b/src/refactoring/agent-config.ts index 064fad52..6937e6b9 100644 --- a/src/refactoring/agent-config.ts +++ b/src/refactoring/agent-config.ts @@ -9,6 +9,16 @@ export function buildAgentConfigWithToken(token: string): AgentConfig { return { codescene_access_token: token, plugins: ['render-code-fix-reporter'], + opencode_config: { + provider: { + 'amazon-bedrock': { + options: { + profile: 'codescene-dev', + region: 'eu-west-1', + }, + }, + }, + }, }; } diff --git a/src/refactoring/agent-service.ts b/src/refactoring/agent-service.ts index 280a60d7..5360a260 100644 --- a/src/refactoring/agent-service.ts +++ b/src/refactoring/agent-service.ts @@ -12,6 +12,11 @@ import { getExtensionPath } from '../cs-extension-state'; import { getEffectiveToken } from '../devtools-api'; import { acquireGitApi, getRepoRootPath } from '../git-utils'; +function stripAnsi(str: string): string { + // eslint-disable-next-line no-control-regex + return str.replace(/\x1b\[[0-9;]*m/g, ''); +} + const INPUT_FILE = 'render-code-fix-input.json'; const OUTPUT_FILE = 'render-code-fix-output.json'; @@ -84,7 +89,7 @@ export class AgentRefactoringService { const config = buildAgentConfigWithToken(token); const configJson = JSON.stringify(config); - const args: string[] = ['run', 'skill:render-code-fix']; + const args: string[] = ['run', 'skill:render-code-fix', '--model', 'amazon-bedrock/eu.anthropic.claude-sonnet-4-6']; const env: NodeJS.ProcessEnv = { ...process.env, CS_AGENT_CONFIG: configJson, @@ -104,6 +109,10 @@ export class AgentRefactoringService { let stderr = ''; proc.stderr?.on('data', (data: Buffer) => { stderr += data.toString(); + const lines = data.toString().split('\n').filter((line) => line.trim()); + for (const line of lines) { + logOutputChannel.debug(`[cs-agent:err] ${stripAnsi(line)}`); + } }); proc.stdout?.on('data', (data: Buffer) => { diff --git a/src/refactoring/agent-types.ts b/src/refactoring/agent-types.ts index 5ed55f8e..cd18e515 100644 --- a/src/refactoring/agent-types.ts +++ b/src/refactoring/agent-types.ts @@ -45,4 +45,14 @@ export interface AgentOutput { export interface AgentConfig { codescene_access_token: string; plugins: string[]; + opencode_config?: { + provider: { + 'amazon-bedrock': { + options: { + profile: string; + region: string; + }; + }; + }; + }; } diff --git a/src/test/suite/agent-config.test.ts b/src/test/suite/agent-config.test.ts index a51ef18a..49ca8d3e 100644 --- a/src/test/suite/agent-config.test.ts +++ b/src/test/suite/agent-config.test.ts @@ -11,6 +11,10 @@ const testCases = [ assertions: (result: any) => { assert.strictEqual(result.codescene_access_token, 'test-token-123'); assert.deepStrictEqual(result.plugins, ['render-code-fix-reporter']); + assert.ok(result.opencode_config, 'opencode_config should be present'); + assert.ok(result.opencode_config.provider['amazon-bedrock'], 'amazon-bedrock provider should be present'); + assert.strictEqual(result.opencode_config.provider['amazon-bedrock'].options.profile, 'codescene-dev'); + assert.strictEqual(result.opencode_config.provider['amazon-bedrock'].options.region, 'eu-west-1'); }, }, ]; From 2f304f8d980ffc8bee91e0019a34fbaa231575c9 Mon Sep 17 00:00:00 2001 From: vemv Date: Wed, 2 Sep 2026 11:51:48 +0200 Subject: [PATCH 06/39] fix: handle whole_file change type in agent output The agent outputs whole_file_content instead of replacements when change_type is "whole_file". Also fixes the integration test to initialize a git repo and adds assertions verifying the agent writes the expected output file. --- src/refactoring/agent-service.ts | 44 ++++++++++++++---------- src/refactoring/agent-types.ts | 5 +-- src/test/suite/agent-integration.test.ts | 16 ++++++++- 3 files changed, 44 insertions(+), 21 deletions(-) diff --git a/src/refactoring/agent-service.ts b/src/refactoring/agent-service.ts index 5360a260..af449301 100644 --- a/src/refactoring/agent-service.ts +++ b/src/refactoring/agent-service.ts @@ -1,7 +1,6 @@ import { spawn, ChildProcess } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { v4 as uuidv4 } from 'uuid'; import { TextDocument } from 'vscode'; import { FnToRefactor, RefactorResponse, Confidence, Reason } from '../devtools-api/refactor-models'; @@ -33,10 +32,13 @@ export class AgentRefactoringService { static async runRefactoring( document: TextDocument, fnToRefactor: FnToRefactor, - signal?: AbortSignal + signal?: AbortSignal, + skipCleanup?: boolean ): Promise { const taskId = `refactor-${uuidv4()}`; - const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cs-agent-')); + const gitApi = acquireGitApi(); + const repo = gitApi?.getRepository(document.uri); + const workDir = repo ? getRepoRootPath(repo) : path.dirname(document.fileName); const inputPath = path.join(workDir, INPUT_FILE); const outputPath = path.join(workDir, OUTPUT_FILE); @@ -46,7 +48,7 @@ export class AgentRefactoringService { logOutputChannel.info(`Agent refactoring started for task ${taskId}`); - await AgentRefactoringService.invokeAgent(workDir, document, signal); + await AgentRefactoringService.invokeAgent(workDir, signal); if (!fs.existsSync(outputPath)) { throw new Error('Agent did not produce output file'); @@ -59,7 +61,9 @@ export class AgentRefactoringService { return AgentRefactoringService.mapOutputToResponse(output, document, fnToRefactor); } finally { - AgentRefactoringService.cleanup(workDir); + if (!skipCleanup) { + AgentRefactoringService.cleanup(inputPath, outputPath); + } } } @@ -78,7 +82,7 @@ export class AgentRefactoringService { }; } - private static invokeAgent(workDir: string, document: TextDocument, signal?: AbortSignal): Promise { + private static invokeAgent(workDir: string, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { const binaryPath = AgentRefactoringService.getAgentBinaryPath(); const token = getEffectiveToken(); @@ -95,14 +99,10 @@ export class AgentRefactoringService { CS_AGENT_CONFIG: configJson, }; - const gitApi = acquireGitApi(); - const repo = gitApi?.getRepository(document.uri); - const cwd = repo ? getRepoRootPath(repo) : path.dirname(document.fileName); - logOutputChannel.debug(`Spawning agent: ${binaryPath} ${args.join(' ')}`); const proc: ChildProcess = spawn(binaryPath, args, { - cwd, + cwd: workDir, env, }); @@ -186,8 +186,12 @@ export class AgentRefactoringService { continue; } - for (const replacement of change.replacements) { - code = code.replace(replacement.search, replacement.replace); + if (change.change_type === 'whole_file' && change.whole_file_content) { + code = change.whole_file_content; + } else if (change.replacements) { + for (const replacement of change.replacements) { + code = code.replace(replacement.search, replacement.replace); + } } } @@ -243,11 +247,15 @@ export class AgentRefactoringService { ]; } - private static cleanup(workDir: string): void { - try { - fs.rmSync(workDir, { recursive: true, force: true }); - } catch (err) { - logOutputChannel.warn(`Failed to cleanup work directory ${workDir}: ${err}`); + private static cleanup(inputPath: string, outputPath: string): void { + for (const filePath of [inputPath, outputPath]) { + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + } catch (err) { + logOutputChannel.warn(`Failed to cleanup file ${filePath}: ${err}`); + } } } } diff --git a/src/refactoring/agent-types.ts b/src/refactoring/agent-types.ts index cd18e515..244ce538 100644 --- a/src/refactoring/agent-types.ts +++ b/src/refactoring/agent-types.ts @@ -16,7 +16,7 @@ export interface AgentOutputContext { export type FixResult = 'fix_proposed' | 'unable_to_fix' | 'needs_human_review'; export type ConfidenceLevel = 'high' | 'medium' | 'low'; -export type ChangeType = 'partial' | 'full'; +export type ChangeType = 'partial' | 'whole_file'; export interface AgentReplacement { search: string; @@ -27,7 +27,8 @@ export interface AgentChange { file: string; change_type: ChangeType; description: string; - replacements: AgentReplacement[]; + replacements?: AgentReplacement[]; + whole_file_content?: string; } export interface AgentOutput { diff --git a/src/test/suite/agent-integration.test.ts b/src/test/suite/agent-integration.test.ts index 63f0d905..8d3cf259 100644 --- a/src/test/suite/agent-integration.test.ts +++ b/src/test/suite/agent-integration.test.ts @@ -43,6 +43,11 @@ aceSuite('Agent Integration Test Suite', () => { } fs.mkdirSync(testDir, { recursive: true }); + const { execSync } = require('child_process'); + execSync('git init', { cwd: testDir }); + execSync('git config user.email "test@test.com"', { cwd: testDir }); + execSync('git config user.name "Test"', { cwd: testDir }); + mockWorkspaceFolders([createMockWorkspaceFolder(testDir)]); originalGetAuthToken = configModule.getAuthToken; @@ -117,7 +122,16 @@ aceSuite('Agent Integration Test Suite', () => { vscodeRange: new vscode.Range(0, 0, 12, 1), }; - const response = await AgentRefactoringService.runRefactoring(doc, fnToRefactor); + const outputFilePath = path.join(testDir, 'render-code-fix-output.json'); + + const response = await AgentRefactoringService.runRefactoring(doc, fnToRefactor, undefined, true); + + assert.ok(fs.existsSync(outputFilePath), 'Agent should have written output file'); + const outputContent = fs.readFileSync(outputFilePath, 'utf-8'); + const output = JSON.parse(outputContent); + assert.ok(output.fix_result, 'Output file should contain fix_result'); + assert.ok(output.changes, 'Output file should contain changes'); + assert.ok(output.schema_version, 'Output file should contain schema_version'); assert.ok(response, 'Response should be defined'); assert.ok(response.code, 'Response should have code'); From ef0c4d293486711ce6e2d9f55428614232ab6918 Mon Sep 17 00:00:00 2001 From: vemv Date: Wed, 2 Sep 2026 11:58:15 +0200 Subject: [PATCH 07/39] chore: remove debug log statements --- src/code-health-monitor/addon.ts | 4 ---- src/extension-impl.ts | 6 ------ src/simple-executor.ts | 9 --------- 3 files changed, 19 deletions(-) diff --git a/src/code-health-monitor/addon.ts b/src/code-health-monitor/addon.ts index c1b2a8e9..c4205b78 100644 --- a/src/code-health-monitor/addon.ts +++ b/src/code-health-monitor/addon.ts @@ -40,7 +40,6 @@ function removeStaleFilesFromMonitors(changedFiles: Set): void { export async function runScheduledGitChangeReview(): Promise { if (!isVSCodeWindowFocused()) { - logOutputChannel.debug('Skipping scheduled git change review: window not focused'); return; } if (!isGitAvailable()) return; @@ -48,7 +47,6 @@ export async function runScheduledGitChangeReview(): Promise { logOutputChannel.info('Skipping scheduled git change review: analysis in progress'); return; } - logOutputChannel.info('Starting scheduled git change review'); const startTime = Date.now(); const changedFiles = await gitChangeListerInstance!.start(); @@ -56,8 +54,6 @@ export async function runScheduledGitChangeReview(): Promise { const elapsedMs = Date.now() - startTime; const elapsedSeconds = Math.ceil(elapsedMs / 1000); - logOutputChannel.debug(`Scheduled git change review completed in ${elapsedSeconds}s`); - if (elapsedSeconds > GIT_CHANGE_LISTER_BASE_PERIOD_SECONDS && scheduledExecutorInstance) { const newPeriod = GIT_CHANGE_LISTER_BASE_PERIOD_SECONDS * 2 + elapsedSeconds; const currentPeriod = scheduledExecutorInstance.getIntervalSeconds(); diff --git a/src/extension-impl.ts b/src/extension-impl.ts index 0fa2bdcb..6dad03d1 100644 --- a/src/extension-impl.ts +++ b/src/extension-impl.ts @@ -120,12 +120,6 @@ const onCodesceneConfigChange = debounce(async (uri: vscode.Uri) => { function handleWindowStateChange(state: vscode.WindowState): void { const previousState = isWindowFocused; isWindowFocused = state.focused; - - if (state.focused && !previousState) { - logOutputChannel.debug('VSCode window gained focus'); - } else if (!state.focused && previousState) { - logOutputChannel.debug('VSCode window lost focus'); - } } async function updateCodeHealthRulesVersion(uri: vscode.Uri): Promise { diff --git a/src/simple-executor.ts b/src/simple-executor.ts index 283482ec..3ecda119 100644 --- a/src/simple-executor.ts +++ b/src/simple-executor.ts @@ -66,11 +66,6 @@ export class SimpleExecutor implements Executor { const logCommand = [command.command, ...trimmedArgsForLogging].join(' '); const allOptions = { maxBuffer: MAX_BUFFER, ...options }; - if (command.command === 'git') { // These can be frequently executed, so demote their log level - logOutputChannel.debug(`Executing: "${logCommand}" with options: ${JSON.stringify(allOptions)}`); - } else { - logOutputChannel.info(`Executing: "${logCommand}" with options: ${JSON.stringify(allOptions)}`); - } return new Promise((resolve, reject) => { const start = Date.now(); @@ -82,9 +77,6 @@ export class SimpleExecutor implements Executor { return; } const end = Date.now(); - logOutputChannel.trace( - `[pid ${childProcess?.pid}] "${logName}" took ${end - start} ms (exit ${error?.code || 0})` - ); this.stats.addRun(command, end - start); @@ -95,7 +87,6 @@ export class SimpleExecutor implements Executor { this.writeInput(childProcess, input); } - logOutputChannel.trace(`[pid ${childProcess.pid}] "${logName}" started`); }); } From d300b6309a16f42f9de26db7a7be725020f80e7f Mon Sep 17 00:00:00 2001 From: vemv Date: Thu, 3 Sep 2026 15:42:12 +0200 Subject: [PATCH 08/39] feat: forward agent stdout to CWF progress widget Stream progress messages from the refactoring agent binary to the loading view instead of showing hardcoded rotating messages. The most recent stdout line is displayed, with newer lines replacing older ones. --- .../types/index.d.ts | 1 + .../webview/ace/ace-data-mapper.ts | 4 +++- .../webview/ace/cwf-webview-ace-panel.ts | 8 ++++++++ src/devtools-api/index.ts | 11 ++++++----- src/refactoring/agent-service.ts | 18 ++++++++++++++---- src/refactoring/request.ts | 4 +++- 6 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/centralized-webview-framework/types/index.d.ts b/src/centralized-webview-framework/types/index.d.ts index 0124473f..335cf69d 100644 --- a/src/centralized-webview-framework/types/index.d.ts +++ b/src/centralized-webview-framework/types/index.d.ts @@ -115,6 +115,7 @@ export interface AceContextViewProps { error?: string; isStale: boolean; loading: boolean; + progressMessage?: string; }; } diff --git a/src/codescene-tab/webview/ace/ace-data-mapper.ts b/src/codescene-tab/webview/ace/ace-data-mapper.ts index 96226746..afad7d3d 100644 --- a/src/codescene-tab/webview/ace/ace-data-mapper.ts +++ b/src/codescene-tab/webview/ace/ace-data-mapper.ts @@ -11,10 +11,11 @@ interface CwfAceData { isStale: boolean; error?: string; loading: boolean; + progressMessage?: string; } export function getAceData(data: CwfAceData): AceContextViewProps { - const { request, result, isStale, error, loading } = data; + const { request, result, isStale, error, loading, progressMessage } = data; return { view: 'ace', @@ -40,6 +41,7 @@ export function getAceData(data: CwfAceData): AceContextViewProps { isStale, loading, error, + progressMessage, }, }; } diff --git a/src/codescene-tab/webview/ace/cwf-webview-ace-panel.ts b/src/codescene-tab/webview/ace/cwf-webview-ace-panel.ts index ef1e5c66..6c7242a2 100644 --- a/src/codescene-tab/webview/ace/cwf-webview-ace-panel.ts +++ b/src/codescene-tab/webview/ace/cwf-webview-ace-panel.ts @@ -25,6 +25,7 @@ export interface CwfAceTabParams { request: RefactoringRequest; cwfProps?: AceContextViewProps; isStale?: boolean; + progressMessage?: string; } export class CodeSceneCWFAceTabPanel implements Disposable { @@ -231,6 +232,13 @@ export class CodeSceneCWFAceTabPanel implements Disposable { private async updateWebView(request: RefactoringRequest) { const isStale = this.state?.isStale ?? false; + request.onProgress = (message: string) => { + if (this.state?.request === request) { + this.state.progressMessage = message; + void this.renderAce(request, getAceData({ request, isStale, loading: true, progressMessage: message })); + } + }; + await this.renderAce(request, getAceData({ request, isStale, loading: true })); try { diff --git a/src/devtools-api/index.ts b/src/devtools-api/index.ts index ed387211..c62a8a0a 100644 --- a/src/devtools-api/index.ts +++ b/src/devtools-api/index.ts @@ -20,7 +20,7 @@ import { getAuthToken } from '../configuration'; import { CsExtensionState, CsFeature } from '../cs-extension-state'; import { logOutputChannel } from '../log'; import { RefactoringRequest } from '../refactoring/request'; -import { AgentRefactoringService } from '../refactoring/agent-service'; +import { AgentRefactoringService, ProgressCallback } from '../refactoring/agent-service'; import { vscodeRange } from '../review/utils'; import { StatsCollector } from '../stats'; import { Delta } from './delta-model'; @@ -396,7 +396,7 @@ export class DevtoolsAPI { * @param request refactoring request * @returns refactoring response */ - static async postRefactoring(request: RefactoringRequest): Promise { + static async postRefactoring(request: RefactoringRequest, onProgress?: ProgressCallback): Promise { this.checkAceEnabled(); const { document, fnToRefactor, skipCache, signal } = request; @@ -406,7 +406,7 @@ export class DevtoolsAPI { try { this.logRefactorRequested(fnToRefactor, skipCache); - const response = await this.executeRefactor(document, fnToRefactor, signal); + const response = await this.executeRefactor(document, fnToRefactor, signal, onProgress); this.logRefactorDone(fnToRefactor, skipCache, response); @@ -444,9 +444,10 @@ export class DevtoolsAPI { private static async executeRefactor( document: TextDocument, fnToRefactor: FnToRefactor, - signal: AbortSignal + signal: AbortSignal, + onProgress?: ProgressCallback ): Promise { - return AgentRefactoringService.runRefactoring(document, fnToRefactor, signal); + return AgentRefactoringService.runRefactoring(document, fnToRefactor, signal, undefined, onProgress); } private static logRefactorDone(fnToRefactor: any, skipCache: boolean, response: RefactorResponse): void { diff --git a/src/refactoring/agent-service.ts b/src/refactoring/agent-service.ts index af449301..2d71cf82 100644 --- a/src/refactoring/agent-service.ts +++ b/src/refactoring/agent-service.ts @@ -19,6 +19,8 @@ function stripAnsi(str: string): string { const INPUT_FILE = 'render-code-fix-input.json'; const OUTPUT_FILE = 'render-code-fix-output.json'; +export type ProgressCallback = (message: string) => void; + export class AgentRefactoringService { private static getAgentBinaryPath(): string { const extensionPath = getExtensionPath(); @@ -33,7 +35,8 @@ export class AgentRefactoringService { document: TextDocument, fnToRefactor: FnToRefactor, signal?: AbortSignal, - skipCleanup?: boolean + skipCleanup?: boolean, + onProgress?: ProgressCallback ): Promise { const taskId = `refactor-${uuidv4()}`; const gitApi = acquireGitApi(); @@ -48,7 +51,7 @@ export class AgentRefactoringService { logOutputChannel.info(`Agent refactoring started for task ${taskId}`); - await AgentRefactoringService.invokeAgent(workDir, signal); + await AgentRefactoringService.invokeAgent(workDir, signal, onProgress); if (!fs.existsSync(outputPath)) { throw new Error('Agent did not produce output file'); @@ -82,7 +85,7 @@ export class AgentRefactoringService { }; } - private static invokeAgent(workDir: string, signal?: AbortSignal): Promise { + private static invokeAgent(workDir: string, signal?: AbortSignal, onProgress?: ProgressCallback): Promise { return new Promise((resolve, reject) => { const binaryPath = AgentRefactoringService.getAgentBinaryPath(); const token = getEffectiveToken(); @@ -116,7 +119,14 @@ export class AgentRefactoringService { }); proc.stdout?.on('data', (data: Buffer) => { - logOutputChannel.debug(`Agent stdout: ${data.toString()}`); + const output = data.toString(); + logOutputChannel.debug(`Agent stdout: ${output}`); + if (onProgress) { + const lines = output.split('\n').filter((line) => line.trim()); + if (lines.length > 0) { + onProgress(lines[lines.length - 1]); + } + } }); const abortHandler = () => { diff --git a/src/refactoring/request.ts b/src/refactoring/request.ts index c91b669b..97167ac5 100644 --- a/src/refactoring/request.ts +++ b/src/refactoring/request.ts @@ -3,18 +3,20 @@ import { TextDocument } from 'vscode'; import { DevtoolsAPI, logIdString } from '../devtools-api'; import { FnToRefactor, RefactorResponse } from '../devtools-api/refactor-models'; import { logOutputChannel } from '../log'; +import { ProgressCallback } from './agent-service'; export class RefactoringRequest { readonly traceId: string; promise: Promise; private abortController = new AbortController(); readonly signal = this.abortController.signal; + onProgress?: ProgressCallback; constructor(readonly fnToRefactor: FnToRefactor, readonly document: TextDocument, readonly skipCache: boolean = false) { this.document = document; this.fnToRefactor = fnToRefactor; this.traceId = uuidv4(); - this.promise = DevtoolsAPI.postRefactoring(this); + this.promise = DevtoolsAPI.postRefactoring(this, (message) => this.onProgress?.(message)); } get eventData() { From c6ff0c2ad9d1cad2b327d9f861fd2d749b37d530 Mon Sep 17 00:00:00 2001 From: vemv Date: Thu, 3 Sep 2026 15:44:45 +0200 Subject: [PATCH 09/39] chore: gitignore bin/cs-agent --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0737030a..719f61ce 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ test-git-repo-diff-utils/ test-cs-diagnostics/ coverage/ .cs-prod-build +bin/cs-agent From ad8501047f5495b03414662b893c19f64227692a Mon Sep 17 00:00:00 2001 From: vemv Date: Thu, 3 Sep 2026 15:45:21 +0200 Subject: [PATCH 10/39] chore: add updatecwf target for local CWF development Automatically build and copy CWF from ../cs-webview when running as vemv, otherwise download from GitHub releases. Make build depend on updatecwf to ensure CWF is always up to date. --- Makefile | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 4937fb17..a51e88e3 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,13 @@ -.PHONY: build package package-darwin-arm64 package-darwin-x64 package-linux-arm64 package-linux-x64 package-win32-x64 package-all tsc clean lint watch test pretest pretest-e2e test-e2e updatedocs +.PHONY: build package package-darwin-arm64 package-darwin-x64 package-linux-arm64 package-linux-x64 package-win32-x64 package-all tsc clean lint watch test pretest pretest-e2e test-e2e updatedocs updatecwf .DEFAULT_GOAL := build -build: +build: updatecwf npm run build package: lint pretest npm i - npm run updatecwf + $(MAKE) updatecwf test -z "$$(git status --porcelain)" || (echo "Error: Working directory must be clean (per git status)" && exit 1); \ sed -i '' '/^cs-\*/d' .vscodeignore; \ node ./scripts/bundle-cli-for-current-platform.js; \ @@ -79,5 +79,17 @@ test1: pretest updatedocs: npm run updatedocs +updatecwf: + @if [ "$$(whoami)" = "vemv" ] && [ -d "../cs-webview" ]; then \ + echo "Building CWF locally from ../cs-webview..."; \ + (cd ../cs-webview && npm run build); \ + rm -rf ./cs-cwf; \ + cp -r ../cs-webview/build ./cs-cwf; \ + echo "CWF built and copied to ./cs-cwf"; \ + else \ + echo "Downloading CWF from GitHub releases..."; \ + npm run updatecwf; \ + fi + clean: npm run clean From 82eda8bb90b8f7db72bb93fc75645374ea5beab5 Mon Sep 17 00:00:00 2001 From: vemv Date: Thu, 3 Sep 2026 15:54:05 +0200 Subject: [PATCH 11/39] chore: rename refactoring-agent directory to cs-agent Update Makefile package targets to reference ~/cs-agent instead of ~/refactoring-agent for darwin arm64 and x64 builds. --- Makefile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index a51e88e3..8494e510 100644 --- a/Makefile +++ b/Makefile @@ -16,16 +16,16 @@ package: lint pretest package-darwin-arm64: lint pretest npm i - cd ~/refactoring-agent/agent && $(MAKE) build-all + cd ~/cs-agent/agent && $(MAKE) build-all mkdir -p ./bin - cp ~/refactoring-agent/agent/target/aarch64-apple-darwin/release/cs-agent ./bin/cs-agent + cp ~/cs-agent/agent/target/aarch64-apple-darwin/release/cs-agent ./bin/cs-agent node ./scripts/package-platform.js darwin arm64 package-darwin-x64: lint pretest npm i - cd ~/refactoring-agent/agent && $(MAKE) build-all + cd ~/cs-agent/agent && $(MAKE) build-all mkdir -p ./bin - cp ~/refactoring-agent/agent/target/x86_64-apple-darwin/release/cs-agent ./bin/cs-agent + cp ~/cs-agent/agent/target/x86_64-apple-darwin/release/cs-agent ./bin/cs-agent node ./scripts/package-platform.js darwin x64 package-linux-arm64: lint pretest From 8d195b410b40c22df4526e17dfbbaa6e19691618 Mon Sep 17 00:00:00 2001 From: vemv Date: Thu, 3 Sep 2026 16:17:16 +0200 Subject: [PATCH 12/39] chore: fix local builds with local CWF --- Makefile | 2 +- scripts/package-platform.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 8494e510..7b030b75 100644 --- a/Makefile +++ b/Makefile @@ -80,7 +80,7 @@ updatedocs: npm run updatedocs updatecwf: - @if [ "$$(whoami)" = "vemv" ] && [ -d "../cs-webview" ]; then \ + @if [ -d "../cs-webview" ] && [ -z "$${CI}" ]; then \ echo "Building CWF locally from ../cs-webview..."; \ (cd ../cs-webview && npm run build); \ rm -rf ./cs-cwf; \ diff --git a/scripts/package-platform.js b/scripts/package-platform.js index 0539fb1e..97760fd4 100755 --- a/scripts/package-platform.js +++ b/scripts/package-platform.js @@ -89,7 +89,7 @@ function packageExtension(platform, arch, projectRoot, buildNoAce, originalVscod console.log('\nStep 4: Updating docs and webview...'); const tokenEnv = { ...process.env, GITHUB_TOKEN: process.env.GITHUB_TOKEN || '' }; execSync('npm run updatedocs', { stdio: 'inherit', env: tokenEnv }); - execSync('npm run updatecwf', { stdio: 'inherit', env: tokenEnv }); + execSync('make updatecwf', { stdio: 'inherit', env: tokenEnv }); console.log('\nStep 5: Building extension...'); execSync('npm run build', { From 9549438e257bb2b6dfb94608b5239b1dc8c1d139 Mon Sep 17 00:00:00 2001 From: vemv Date: Thu, 3 Sep 2026 16:41:25 +0200 Subject: [PATCH 13/39] refactor: reduce complexity in AgentRefactoringService Extract spawnAgentProcess, setupStderrHandler, setupStdoutHandler from invokeAgent to reduce cyclomatic complexity. Refactor applyChanges to use filter/reduce pattern to eliminate nested conditionals. Fix post-refactoring tests by initializing git repo and mocking extension path. --- src/refactoring/agent-service.ts | 128 ++++++++++++------------ src/test/suite/post-refactoring.test.ts | 14 +++ 2 files changed, 77 insertions(+), 65 deletions(-) diff --git a/src/refactoring/agent-service.ts b/src/refactoring/agent-service.ts index 2d71cf82..f3696772 100644 --- a/src/refactoring/agent-service.ts +++ b/src/refactoring/agent-service.ts @@ -85,76 +85,76 @@ export class AgentRefactoringService { }; } - private static invokeAgent(workDir: string, signal?: AbortSignal, onProgress?: ProgressCallback): Promise { - return new Promise((resolve, reject) => { - const binaryPath = AgentRefactoringService.getAgentBinaryPath(); - const token = getEffectiveToken(); - if (!token) { - reject(new Error('No authentication token available for agent')); - return; - } - const config = buildAgentConfigWithToken(token); - const configJson = JSON.stringify(config); + private static spawnAgentProcess(workDir: string): { proc: ChildProcess; binaryPath: string } { + const binaryPath = AgentRefactoringService.getAgentBinaryPath(); + const token = getEffectiveToken(); + if (!token) { + throw new Error('No authentication token available for agent'); + } + const config = buildAgentConfigWithToken(token); + const configJson = JSON.stringify(config); - const args: string[] = ['run', 'skill:render-code-fix', '--model', 'amazon-bedrock/eu.anthropic.claude-sonnet-4-6']; - const env: NodeJS.ProcessEnv = { - ...process.env, - CS_AGENT_CONFIG: configJson, - }; + const args: string[] = ['run', 'skill:render-code-fix', '--model', 'amazon-bedrock/eu.anthropic.claude-sonnet-4-6']; + const env: NodeJS.ProcessEnv = { + ...process.env, + CS_AGENT_CONFIG: configJson, + }; - logOutputChannel.debug(`Spawning agent: ${binaryPath} ${args.join(' ')}`); + logOutputChannel.debug(`Spawning agent: ${binaryPath} ${args.join(' ')}`); - const proc: ChildProcess = spawn(binaryPath, args, { - cwd: workDir, - env, - }); + const proc = spawn(binaryPath, args, { cwd: workDir, env }); + return { proc, binaryPath }; + } - let stderr = ''; - proc.stderr?.on('data', (data: Buffer) => { - stderr += data.toString(); - const lines = data.toString().split('\n').filter((line) => line.trim()); - for (const line of lines) { - logOutputChannel.debug(`[cs-agent:err] ${stripAnsi(line)}`); - } - }); + private static setupStderrHandler(proc: ChildProcess): { getStderr: () => string } { + let stderr = ''; + proc.stderr?.on('data', (data: Buffer) => { + stderr += data.toString(); + const lines = data.toString().split('\n').filter((line) => line.trim()); + for (const line of lines) { + logOutputChannel.debug(`[cs-agent:err] ${stripAnsi(line)}`); + } + }); + return { getStderr: () => stderr }; + } - proc.stdout?.on('data', (data: Buffer) => { - const output = data.toString(); - logOutputChannel.debug(`Agent stdout: ${output}`); - if (onProgress) { - const lines = output.split('\n').filter((line) => line.trim()); - if (lines.length > 0) { - onProgress(lines[lines.length - 1]); - } - } - }); + private static setupStdoutHandler(proc: ChildProcess, onProgress?: ProgressCallback): void { + proc.stdout?.on('data', (data: Buffer) => { + const output = data.toString(); + logOutputChannel.debug(`Agent stdout: ${output}`); + if (!onProgress) return; + const lines = output.split('\n').filter((line) => line.trim()); + const lastLine = lines[lines.length - 1]; + if (lastLine) onProgress(lastLine); + }); + } + + private static invokeAgent(workDir: string, signal?: AbortSignal, onProgress?: ProgressCallback): Promise { + return new Promise((resolve, reject) => { + const { proc } = AgentRefactoringService.spawnAgentProcess(workDir); + const { getStderr } = AgentRefactoringService.setupStderrHandler(proc); + AgentRefactoringService.setupStdoutHandler(proc, onProgress); const abortHandler = () => { proc.kill('SIGTERM'); reject(new Error('Agent invocation aborted')); }; - if (signal) { - signal.addEventListener('abort', abortHandler); - } + signal?.addEventListener('abort', abortHandler); proc.on('close', (code: number | null) => { - if (signal) { - signal.removeEventListener('abort', abortHandler); - } - + signal?.removeEventListener('abort', abortHandler); if (code === 0) { resolve(); } else { + const stderr = getStderr(); logOutputChannel.error(`Agent exited with code ${code}: ${stderr}`); reject(new Error(`Agent exited with code ${code}: ${stderr}`)); } }); proc.on('error', (err: Error) => { - if (signal) { - signal.removeEventListener('abort', abortHandler); - } + signal?.removeEventListener('abort', abortHandler); reject(err); }); }); @@ -184,28 +184,26 @@ export class AgentRefactoringService { }; } + private static applyChange(code: string, change: AgentChange): string { + if (change.change_type === 'whole_file' && change.whole_file_content) { + return change.whole_file_content; + } + if (!change.replacements) return code; + return change.replacements.reduce((c, r) => c.replace(r.search, r.replace), code); + } + private static applyChanges( document: TextDocument, fnToRefactor: FnToRefactor, changes: AgentChange[] ): string { - let code = fnToRefactor.body; - - for (const change of changes) { - if (path.basename(change.file) !== path.basename(document.fileName)) { - continue; - } - - if (change.change_type === 'whole_file' && change.whole_file_content) { - code = change.whole_file_content; - } else if (change.replacements) { - for (const replacement of change.replacements) { - code = code.replace(replacement.search, replacement.replace); - } - } - } - - return code; + const relevantChanges = changes.filter( + (change) => path.basename(change.file) === path.basename(document.fileName) + ); + return relevantChanges.reduce( + (code, change) => AgentRefactoringService.applyChange(code, change), + fnToRefactor.body + ); } private static mapConfidence(output: AgentOutput): Confidence { diff --git a/src/test/suite/post-refactoring.test.ts b/src/test/suite/post-refactoring.test.ts index 0577b95a..0e467c80 100644 --- a/src/test/suite/post-refactoring.test.ts +++ b/src/test/suite/post-refactoring.test.ts @@ -23,6 +23,8 @@ aceSuite('PostRefactoring Integration Test Suite', () => { let errorListener: vscode.Disposable; let refactoringErrorListener: vscode.Disposable; let originalGetAuthToken: any; + let originalGetExtensionPath: any; + const projectRoot = path.join(__dirname, '..', '..', '..'); if (!testToken) { console.log('Skipping PostRefactoring tests: CODESCENE_TEST_TOKEN environment variable not set'); @@ -60,6 +62,11 @@ aceSuite('PostRefactoring Integration Test Suite', () => { } fs.mkdirSync(testDir, { recursive: true }); + const { execSync } = require('child_process'); + execSync('git init', { cwd: testDir }); + execSync('git config user.email "test@test.com"', { cwd: testDir }); + execSync('git config user.name "Test"', { cwd: testDir }); + mockWorkspaceFolders([createMockWorkspaceFolder(testDir)]); const binaryPath = await ensureBinary(); @@ -68,6 +75,9 @@ aceSuite('PostRefactoring Integration Test Suite', () => { originalGetAuthToken = configModule.getAuthToken; (configModule as any).getAuthToken = () => testToken; + originalGetExtensionPath = csExtensionState.getExtensionPath; + (csExtensionState as any).getExtensionPath = () => projectRoot; + Object.defineProperty(csExtensionState.CsExtensionState, 'stateProperties', { get: () => ({ session: undefined, @@ -103,6 +113,10 @@ aceSuite('PostRefactoring Integration Test Suite', () => { (configModule as any).getAuthToken = originalGetAuthToken; } + if (originalGetExtensionPath) { + (csExtensionState as any).getExtensionPath = originalGetExtensionPath; + } + if (fs.existsSync(testDir)) { fs.rmSync(testDir, { recursive: true, force: true }); } From 3fe8b6630dd01c73d2038bee19b2a1bc7b9d4258 Mon Sep 17 00:00:00 2001 From: vemv Date: Thu, 3 Sep 2026 16:50:05 +0200 Subject: [PATCH 14/39] fix: clean up test-git-repo-integration dir reliably Add global mocha afterAll hook to clean up test directories after all tests complete. This catches async operations that recreate directories after per-suite teardowns run. --- src/test/setup.ts | 16 +++++++++ src/test/suite/git-change-lister.test.ts | 46 ++++++------------------ 2 files changed, 27 insertions(+), 35 deletions(-) diff --git a/src/test/setup.ts b/src/test/setup.ts index 6a664ec3..8050a1aa 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,5 +1,6 @@ import Module from 'module'; import * as fs from 'fs'; +import * as path from 'path'; import { DiagnosticStub } from './stubs/diagnostic-stub'; import { EventEmitterStub } from './stubs/event-emitter-stub'; import { RangeStub } from './stubs/range-stub'; @@ -499,3 +500,18 @@ const originalRequire = Module.prototype.require; } return originalRequire.apply(this, arguments as any); }; + +const testDirsToCleanup = [ + path.join(__dirname, '../../test-git-repo'), + path.join(__dirname, '../../test-git-repo-integration'), +]; + +export const mochaHooks = { + afterAll() { + for (const dir of testDirsToCleanup) { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + }, +}; diff --git a/src/test/suite/git-change-lister.test.ts b/src/test/suite/git-change-lister.test.ts index 49db7d6b..1b78acdf 100644 --- a/src/test/suite/git-change-lister.test.ts +++ b/src/test/suite/git-change-lister.test.ts @@ -19,6 +19,7 @@ import { FileWithIssues } from '../../code-health-monitor/file-with-issues'; suite('GitChangeLister Test Suite', () => { const testRepoPath = path.join(__dirname, '../../../test-git-repo'); + const integrationRepoPath = path.join(__dirname, '../../../test-git-repo-integration'); let gitChangeLister: GitChangeLister; let mockExecutor: MockExecutor; @@ -174,12 +175,14 @@ suite('GitChangeLister Test Suite', () => { }); suite('DeltaAnalysisTreeProvider integration', () => { - const integrationRepoPath = path.join(__dirname, '../../../test-git-repo-integration'); let treeProvider: DeltaAnalysisTreeProvider; let deltaSubscription: { dispose: () => void }; suiteSetup(async function () { this.timeout(60000); + if (fs.existsSync(integrationRepoPath)) { + fs.rmSync(integrationRepoPath, { recursive: true, force: true }); + } const binaryPath = await ensureBinary(); const mockContext = createMockExtensionContext(integrationRepoPath); if (!CsExtensionState.hasInstance) { @@ -189,17 +192,6 @@ suite('GitChangeLister Test Suite', () => { DevtoolsAPI.init(binaryPath, mockContext, async () => false); }); - suiteTeardown(async function () { - this.timeout(20000); - try { - if (fs.existsSync(integrationRepoPath)) { - fs.rmSync(integrationRepoPath, { recursive: true, force: true }); - } - } catch (error) { - console.error('Failed to clean up test-git-repo-integration:', error); - } - }); - setup(async function () { this.timeout(20000); @@ -230,29 +222,13 @@ suite('GitChangeLister Test Suite', () => { }); }); - teardown(async function () { - this.timeout(20000); - try { - deltaSubscription?.dispose(); - treeProvider.clearTree(); - Reviewer.instance.clearCache(); - setGitApiForTesting(undefined); - restoreDefaultWorkspaceFolders(); - clearMockGitRepositories(); - await new Promise((resolve) => setTimeout(resolve, 100)); - if (fs.existsSync(integrationRepoPath)) { - fs.rmSync(integrationRepoPath, { recursive: true, force: true }); - } - } catch (error) { - console.error('Error during test teardown:', error); - if (fs.existsSync(integrationRepoPath)) { - try { - fs.rmSync(integrationRepoPath, { recursive: true, force: true }); - } catch (cleanupError) { - console.error('Failed to clean up test-git-repo-integration in error handler:', cleanupError); - } - } - } + teardown(function () { + deltaSubscription?.dispose(); + treeProvider.clearTree(); + Reviewer.instance.clearCache(); + setGitApiForTesting(undefined); + restoreDefaultWorkspaceFolders(); + clearMockGitRepositories(); }); const testCases = [ From afdfa5a394f0f0b76bcb20fc214769f1a54ecb25 Mon Sep 17 00:00:00 2001 From: vemv Date: Thu, 3 Sep 2026 19:36:19 +0200 Subject: [PATCH 15/39] test: update agent output fixtures to use enum values Match the new cs-agent schema that uses fixed enum values for summary and reasoning instead of freeform strings. --- src/test/fixtures/agent-output/fix-proposed.json | 4 ++-- src/test/fixtures/agent-output/needs-human-review.json | 4 ++-- src/test/fixtures/agent-output/unable-to-fix.json | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/test/fixtures/agent-output/fix-proposed.json b/src/test/fixtures/agent-output/fix-proposed.json index 4f55a03c..e17f5639 100644 --- a/src/test/fixtures/agent-output/fix-proposed.json +++ b/src/test/fixtures/agent-output/fix-proposed.json @@ -4,8 +4,8 @@ "task_id": "refactor-test-123", "fix_result": "fix_proposed", "confidence": "high", - "summary": "Extracted helper function to reduce complexity", - "reasoning": "The original function had deep nesting which made it hard to read. By extracting the inner logic into a helper function, we reduce the cognitive load and improve maintainability.", + "summary": "Refactoring suggestion", + "reasoning": "The refactoring improves code health.", "changes": [ { "file": "test.ts", diff --git a/src/test/fixtures/agent-output/needs-human-review.json b/src/test/fixtures/agent-output/needs-human-review.json index 2ae85642..bfd32294 100644 --- a/src/test/fixtures/agent-output/needs-human-review.json +++ b/src/test/fixtures/agent-output/needs-human-review.json @@ -4,8 +4,8 @@ "task_id": "refactor-test-789", "fix_result": "needs_human_review", "confidence": "medium", - "summary": "Partial refactoring suggested - review recommended", - "reasoning": "The suggested refactoring simplifies the structure but may change error handling behavior. Please verify that the new error handling approach meets your requirements.", + "summary": "Refactoring results", + "reasoning": "The code needs to be inspected and verified carefully.", "changes": [ { "file": "test.ts", diff --git a/src/test/fixtures/agent-output/unable-to-fix.json b/src/test/fixtures/agent-output/unable-to-fix.json index 98fd27f5..f995b173 100644 --- a/src/test/fixtures/agent-output/unable-to-fix.json +++ b/src/test/fixtures/agent-output/unable-to-fix.json @@ -4,8 +4,8 @@ "task_id": "refactor-test-456", "fix_result": "unable_to_fix", "confidence": "low", - "summary": "Cannot refactor: code complexity is essential", - "reasoning": "The complexity in this function stems from essential business logic that cannot be simplified without changing the behavior. The nested conditions handle edge cases that must be preserved.", + "summary": "Code improvement guide", + "reasoning": "CodeScene Refactoring Agent was unable to generate a refactoring recommendation. Please refer to the manual steps for improving your code.", "changes": [], "generated_at": "2026-08-31T12:00:00Z" } From de24fe31fc90067f31426210fdb8f8bda0ca7c12 Mon Sep 17 00:00:00 2001 From: vemv Date: Thu, 3 Sep 2026 19:36:37 +0200 Subject: [PATCH 16/39] chore: expand CLAUDE.md --- CLAUDE.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index ce82caf2..33a46a74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,3 +11,8 @@ Testing practices: see [TESTING.md](.claude/TESTING.md). Validate your work using `make lint`. Satifying all linter faults is mandatory - they must be addressed before proceeding with other work. If you find an issue at any stage, you must fix it immediately, regardless of whether you think it's pre-existing. + +This project uses CWF for rendering views, and can typically be found at `../cs-webview`. +This project uses cs-agent for performing refactorings, and can typically be found at `../cs-agent`. +This project uses the CodeScene CLI for performing various code analysis tasks, and can typically be found at `../codescene/cli`. +This project used in the past ACE where cs-agent is now used, and can typically be found at `../automated-refactoring`. From 1eb249514ef1dd8485961b50a4a35398d259dbb3 Mon Sep 17 00:00:00 2001 From: vemv Date: Fri, 4 Sep 2026 09:46:37 +0200 Subject: [PATCH 17/39] feat: use temp directory for agent JSON I/O files Move render-code-fix input/output JSON files to a temp directory instead of the repository root to avoid cluttering user projects. The agent's io_json_dir config option specifies where the reporter plugin reads and writes these files. --- src/refactoring/agent-config.ts | 3 +- src/refactoring/agent-service.ts | 32 +++--- src/refactoring/agent-types.ts | 1 + src/test/suite/agent-config.test.ts | 16 ++- src/test/suite/agent-integration.test.ts | 125 +++++++++++++++++++++-- 5 files changed, 150 insertions(+), 27 deletions(-) diff --git a/src/refactoring/agent-config.ts b/src/refactoring/agent-config.ts index 6937e6b9..faa80f80 100644 --- a/src/refactoring/agent-config.ts +++ b/src/refactoring/agent-config.ts @@ -1,7 +1,7 @@ import { getAuthToken } from '../configuration'; import { AgentConfig } from './agent-types'; -export function buildAgentConfigWithToken(token: string): AgentConfig { +export function buildAgentConfigWithToken(token: string, ioDir?: string): AgentConfig { if (!token) { throw new Error('No authentication token available for agent'); } @@ -9,6 +9,7 @@ export function buildAgentConfigWithToken(token: string): AgentConfig { return { codescene_access_token: token, plugins: ['render-code-fix-reporter'], + ...(ioDir && { io_json_dir: ioDir }), opencode_config: { provider: { 'amazon-bedrock': { diff --git a/src/refactoring/agent-service.ts b/src/refactoring/agent-service.ts index f3696772..8c7f8090 100644 --- a/src/refactoring/agent-service.ts +++ b/src/refactoring/agent-service.ts @@ -1,5 +1,6 @@ import { spawn, ChildProcess } from 'child_process'; import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; import { v4 as uuidv4 } from 'uuid'; import { TextDocument } from 'vscode'; @@ -42,8 +43,9 @@ export class AgentRefactoringService { const gitApi = acquireGitApi(); const repo = gitApi?.getRepository(document.uri); const workDir = repo ? getRepoRootPath(repo) : path.dirname(document.fileName); - const inputPath = path.join(workDir, INPUT_FILE); - const outputPath = path.join(workDir, OUTPUT_FILE); + const ioDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cs-agent-io-')); + const inputPath = path.join(ioDir, INPUT_FILE); + const outputPath = path.join(ioDir, OUTPUT_FILE); try { const input = AgentRefactoringService.buildInput(document, fnToRefactor, taskId); @@ -51,7 +53,7 @@ export class AgentRefactoringService { logOutputChannel.info(`Agent refactoring started for task ${taskId}`); - await AgentRefactoringService.invokeAgent(workDir, signal, onProgress); + await AgentRefactoringService.invokeAgent(workDir, ioDir, signal, onProgress); if (!fs.existsSync(outputPath)) { throw new Error('Agent did not produce output file'); @@ -65,7 +67,7 @@ export class AgentRefactoringService { return AgentRefactoringService.mapOutputToResponse(output, document, fnToRefactor); } finally { if (!skipCleanup) { - AgentRefactoringService.cleanup(inputPath, outputPath); + AgentRefactoringService.cleanup(ioDir); } } } @@ -85,13 +87,13 @@ export class AgentRefactoringService { }; } - private static spawnAgentProcess(workDir: string): { proc: ChildProcess; binaryPath: string } { + private static spawnAgentProcess(workDir: string, ioDir: string): { proc: ChildProcess; binaryPath: string } { const binaryPath = AgentRefactoringService.getAgentBinaryPath(); const token = getEffectiveToken(); if (!token) { throw new Error('No authentication token available for agent'); } - const config = buildAgentConfigWithToken(token); + const config = buildAgentConfigWithToken(token, ioDir); const configJson = JSON.stringify(config); const args: string[] = ['run', 'skill:render-code-fix', '--model', 'amazon-bedrock/eu.anthropic.claude-sonnet-4-6']; @@ -129,9 +131,9 @@ export class AgentRefactoringService { }); } - private static invokeAgent(workDir: string, signal?: AbortSignal, onProgress?: ProgressCallback): Promise { + private static invokeAgent(workDir: string, ioDir: string, signal?: AbortSignal, onProgress?: ProgressCallback): Promise { return new Promise((resolve, reject) => { - const { proc } = AgentRefactoringService.spawnAgentProcess(workDir); + const { proc } = AgentRefactoringService.spawnAgentProcess(workDir, ioDir); const { getStderr } = AgentRefactoringService.setupStderrHandler(proc); AgentRefactoringService.setupStdoutHandler(proc, onProgress); @@ -255,15 +257,13 @@ export class AgentRefactoringService { ]; } - private static cleanup(inputPath: string, outputPath: string): void { - for (const filePath of [inputPath, outputPath]) { - try { - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath); - } - } catch (err) { - logOutputChannel.warn(`Failed to cleanup file ${filePath}: ${err}`); + private static cleanup(ioDir: string): void { + try { + if (fs.existsSync(ioDir)) { + fs.rmSync(ioDir, { recursive: true }); } + } catch (err) { + logOutputChannel.warn(`Failed to cleanup directory ${ioDir}: ${err}`); } } } diff --git a/src/refactoring/agent-types.ts b/src/refactoring/agent-types.ts index 244ce538..07e63b04 100644 --- a/src/refactoring/agent-types.ts +++ b/src/refactoring/agent-types.ts @@ -46,6 +46,7 @@ export interface AgentOutput { export interface AgentConfig { codescene_access_token: string; plugins: string[]; + io_json_dir?: string; opencode_config?: { provider: { 'amazon-bedrock': { diff --git a/src/test/suite/agent-config.test.ts b/src/test/suite/agent-config.test.ts index 49ca8d3e..7aff86f9 100644 --- a/src/test/suite/agent-config.test.ts +++ b/src/test/suite/agent-config.test.ts @@ -8,15 +8,29 @@ const testCases = [ name: 'buildAgentConfigWithToken includes required fields', config: {}, token: 'test-token-123', + ioDir: undefined, assertions: (result: any) => { assert.strictEqual(result.codescene_access_token, 'test-token-123'); assert.deepStrictEqual(result.plugins, ['render-code-fix-reporter']); + assert.strictEqual(result.io_json_dir, undefined, 'io_json_dir should not be present when not provided'); assert.ok(result.opencode_config, 'opencode_config should be present'); assert.ok(result.opencode_config.provider['amazon-bedrock'], 'amazon-bedrock provider should be present'); assert.strictEqual(result.opencode_config.provider['amazon-bedrock'].options.profile, 'codescene-dev'); assert.strictEqual(result.opencode_config.provider['amazon-bedrock'].options.region, 'eu-west-1'); }, }, + { + name: 'buildAgentConfigWithToken includes io_json_dir when provided', + config: {}, + token: 'test-token-456', + ioDir: '/tmp/cs-agent-io-test', + assertions: (result: any) => { + assert.strictEqual(result.codescene_access_token, 'test-token-456'); + assert.deepStrictEqual(result.plugins, ['render-code-fix-reporter']); + assert.strictEqual(result.io_json_dir, '/tmp/cs-agent-io-test', 'io_json_dir should match provided value'); + assert.ok(result.opencode_config, 'opencode_config should be present'); + }, + }, ]; aceSuite('AgentConfig Test Suite', () => { @@ -27,7 +41,7 @@ aceSuite('AgentConfig Test Suite', () => { for (const tc of testCases) { test(tc.name, () => { mockConfiguration('codescene', tc.config); - const result = buildAgentConfigWithToken(tc.token); + const result = buildAgentConfigWithToken(tc.token, tc.ioDir); tc.assertions(result); }); } diff --git a/src/test/suite/agent-integration.test.ts b/src/test/suite/agent-integration.test.ts index 8d3cf259..8d1ed572 100644 --- a/src/test/suite/agent-integration.test.ts +++ b/src/test/suite/agent-integration.test.ts @@ -1,6 +1,7 @@ import * as assert from 'assert'; import * as path from 'path'; import * as fs from 'fs'; +import * as os from 'os'; import * as vscode from 'vscode'; import { mockWorkspaceFolders, createMockWorkspaceFolder, restoreDefaultWorkspaceFolders } from '../setup'; import { TestTextDocument } from '../mocks/test-text-document'; @@ -122,16 +123,12 @@ aceSuite('Agent Integration Test Suite', () => { vscodeRange: new vscode.Range(0, 0, 12, 1), }; - const outputFilePath = path.join(testDir, 'render-code-fix-output.json'); - - const response = await AgentRefactoringService.runRefactoring(doc, fnToRefactor, undefined, true); + const response = await AgentRefactoringService.runRefactoring(doc, fnToRefactor); - assert.ok(fs.existsSync(outputFilePath), 'Agent should have written output file'); - const outputContent = fs.readFileSync(outputFilePath, 'utf-8'); - const output = JSON.parse(outputContent); - assert.ok(output.fix_result, 'Output file should contain fix_result'); - assert.ok(output.changes, 'Output file should contain changes'); - assert.ok(output.schema_version, 'Output file should contain schema_version'); + const inputFilePath = path.join(testDir, 'render-code-fix-input.json'); + const outputFilePath = path.join(testDir, 'render-code-fix-output.json'); + assert.ok(!fs.existsSync(inputFilePath), 'Input JSON file should NOT be in the repo directory'); + assert.ok(!fs.existsSync(outputFilePath), 'Output JSON file should NOT be in the repo directory'); assert.ok(response, 'Response should be defined'); assert.ok(response.code, 'Response should have code'); @@ -142,4 +139,114 @@ aceSuite('Agent Integration Test Suite', () => { assert.ok(response['trace-id'], 'Response should have trace-id'); assert.ok(response['refactoring-properties'], 'Response should have refactoring-properties'); }); + + test('runRefactoring writes JSON files to temp directory', async function () { + this.timeout(120000); + + const simpleContent = `int g(int x) { + if (x > 0) { + if (x > 1) { + if (x > 2) { + return x; + } + } + } + return 0; +} +`; + + const doc = createTestFile('simple.cpp', simpleContent); + + const fnToRefactor: FnToRefactor = { + 'file-type': 'cpp', + name: 'g', + body: simpleContent, + 'refactoring-targets': [ + { + category: 'Deep, Nested Complexity', + line: 2, + }, + ], + range: { + 'start-line': 1, + 'start-column': 1, + 'end-line': 11, + 'end-column': 2, + }, + vscodeRange: new vscode.Range(0, 0, 10, 1), + }; + + for (const dir of fs.readdirSync(os.tmpdir()).filter((d) => d.startsWith('cs-agent-io-'))) { + fs.rmSync(path.join(os.tmpdir(), dir), { recursive: true }); + } + + await AgentRefactoringService.runRefactoring(doc, fnToRefactor, undefined, true); + + const tmpDirsAfter = fs.readdirSync(os.tmpdir()).filter((d) => d.startsWith('cs-agent-io-')); + assert.strictEqual(tmpDirsAfter.length, 1, 'Exactly one temp directory should exist'); + + const ioDir = path.join(os.tmpdir(), tmpDirsAfter[0]); + const inputFile = path.join(ioDir, 'render-code-fix-input.json'); + const outputFile = path.join(ioDir, 'render-code-fix-output.json'); + + assert.ok(fs.existsSync(inputFile), 'Input JSON file should exist in temp directory'); + assert.ok(fs.existsSync(outputFile), 'Output JSON file should exist in temp directory'); + + const inputContent = JSON.parse(fs.readFileSync(inputFile, 'utf-8')); + assert.ok(inputContent.task_id, 'Input file should contain task_id'); + assert.ok(inputContent.file, 'Input file should contain file path'); + assert.strictEqual(inputContent.smell, 'Deep, Nested Complexity', 'Input file should contain smell'); + + const outputContent = JSON.parse(fs.readFileSync(outputFile, 'utf-8')); + assert.ok(outputContent.fix_result, 'Output file should contain fix_result'); + assert.ok(outputContent.changes, 'Output file should contain changes'); + + fs.rmSync(ioDir, { recursive: true }); + }); + + test('runRefactoring cleans up temp directory after completion', async function () { + this.timeout(120000); + + const simpleContent = `int h(int x) { + if (x > 0) { + if (x > 1) { + if (x > 2) { + return x; + } + } + } + return 0; +} +`; + + const doc = createTestFile('cleanup.cpp', simpleContent); + + const fnToRefactor: FnToRefactor = { + 'file-type': 'cpp', + name: 'h', + body: simpleContent, + 'refactoring-targets': [ + { + category: 'Deep, Nested Complexity', + line: 2, + }, + ], + range: { + 'start-line': 1, + 'start-column': 1, + 'end-line': 11, + 'end-column': 2, + }, + vscodeRange: new vscode.Range(0, 0, 10, 1), + }; + + for (const dir of fs.readdirSync(os.tmpdir()).filter((d) => d.startsWith('cs-agent-io-'))) { + fs.rmSync(path.join(os.tmpdir(), dir), { recursive: true }); + } + + await AgentRefactoringService.runRefactoring(doc, fnToRefactor); + + const tmpDirsAfter = fs.readdirSync(os.tmpdir()).filter((d) => d.startsWith('cs-agent-io-')); + assert.strictEqual(tmpDirsAfter.length, 0, 'Temp directory should be cleaned up after refactoring'); + }); }); From a996c35c199452621f6a31aa651b0f0b2503b269 Mon Sep 17 00:00:00 2001 From: vemv Date: Fri, 4 Sep 2026 10:35:26 +0200 Subject: [PATCH 18/39] test: verify agent reads input from io_json_dir Add assertions that prove the agent actually reads from the temp directory: task_id must match between input and output, and when fix_proposed, changes must reference the input file. --- src/test/suite/agent-integration.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/test/suite/agent-integration.test.ts b/src/test/suite/agent-integration.test.ts index 8d1ed572..c4d8478b 100644 --- a/src/test/suite/agent-integration.test.ts +++ b/src/test/suite/agent-integration.test.ts @@ -200,6 +200,19 @@ aceSuite('Agent Integration Test Suite', () => { const outputContent = JSON.parse(fs.readFileSync(outputFile, 'utf-8')); assert.ok(outputContent.fix_result, 'Output file should contain fix_result'); assert.ok(outputContent.changes, 'Output file should contain changes'); + assert.strictEqual( + outputContent.task_id, + inputContent.task_id, + 'Output task_id should match input task_id (proves agent read from temp directory)' + ); + if (outputContent.fix_result === 'fix_proposed') { + assert.ok(outputContent.changes.length > 0, 'fix_proposed should have at least one change'); + const changedFiles = outputContent.changes.map((c: { file: string }) => c.file); + assert.ok( + changedFiles.some((f: string) => f.includes('simple.cpp')), + `Changes should reference the input file (got: ${changedFiles.join(', ')})` + ); + } fs.rmSync(ioDir, { recursive: true }); }); From 1c178d9c82434b62ea35a77bd34b09b94dea2f13 Mon Sep 17 00:00:00 2001 From: vemv Date: Fri, 4 Sep 2026 12:17:43 +0200 Subject: [PATCH 19/39] feat: add settings UI for agent model selection Add `codescene.agentModel` dropdown setting with predefined models and `codescene.agentModelOverride` text field for custom model identifiers. The override takes precedence when non-blank. --- package.json | 14 ++++++++++++++ src/configuration.ts | 10 ++++++++++ src/refactoring/agent-service.ts | 3 ++- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index ab32c469..dd661a22 100644 --- a/package.json +++ b/package.json @@ -145,6 +145,20 @@ "description": "Enable CodeScene Refactoring Agent", "order": 15 }, + "codescene.agentModel": { + "type": "string", + "default": "amazon-bedrock/eu.anthropic.claude-sonnet-4-6", + "enum": ["amazon-bedrock/eu.anthropic.claude-sonnet-4-6"], + "enumDescriptions": ["Claude Sonnet 4.6 via Amazon Bedrock (EU)"], + "description": "Model to use for the Refactoring Agent.", + "order": 16 + }, + "codescene.agentModelOverride": { + "type": "string", + "default": "", + "description": "Custom model identifier. If set, overrides the dropdown selection above.", + "order": 17 + }, "codescene.authToken": { "type": "string", "default": "", diff --git a/src/configuration.ts b/src/configuration.ts index 61c0dd1a..96672dfb 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -71,3 +71,13 @@ export function toggleReviewCodeLenses() { export function getAuthToken() { return getConfiguration('authToken', ''); } + +const DEFAULT_AGENT_MODEL = 'amazon-bedrock/eu.anthropic.claude-sonnet-4-6'; + +export function getAgentModel(): string { + const override = getConfiguration('agentModelOverride', '')?.trim(); + if (override) { + return override; + } + return getConfiguration('agentModel', DEFAULT_AGENT_MODEL)!; +} diff --git a/src/refactoring/agent-service.ts b/src/refactoring/agent-service.ts index 8c7f8090..9559e6c0 100644 --- a/src/refactoring/agent-service.ts +++ b/src/refactoring/agent-service.ts @@ -10,6 +10,7 @@ import { buildAgentConfigWithToken } from './agent-config'; import { AgentInput, AgentOutput, ConfidenceLevel, AgentChange } from './agent-types'; import { getExtensionPath } from '../cs-extension-state'; import { getEffectiveToken } from '../devtools-api'; +import { getAgentModel } from '../configuration'; import { acquireGitApi, getRepoRootPath } from '../git-utils'; function stripAnsi(str: string): string { @@ -96,7 +97,7 @@ export class AgentRefactoringService { const config = buildAgentConfigWithToken(token, ioDir); const configJson = JSON.stringify(config); - const args: string[] = ['run', 'skill:render-code-fix', '--model', 'amazon-bedrock/eu.anthropic.claude-sonnet-4-6']; + const args: string[] = ['run', 'skill:render-code-fix', '--model', getAgentModel()]; const env: NodeJS.ProcessEnv = { ...process.env, CS_AGENT_CONFIG: configJson, From 19b1903c637c377effcd87f571804329e896c3a7 Mon Sep 17 00:00:00 2001 From: vemv Date: Fri, 4 Sep 2026 12:29:22 +0200 Subject: [PATCH 20/39] chore: expand CLAUDE.md --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 33a46a74..ac0308bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ Validate your work using `make lint`. Satifying all linter faults is mandatory - If you find an issue at any stage, you must fix it immediately, regardless of whether you think it's pre-existing. +This project is a VS Code extension. You might need to study the VS Code accordingly. It might be cloned at `../vscode`. This project uses CWF for rendering views, and can typically be found at `../cs-webview`. This project uses cs-agent for performing refactorings, and can typically be found at `../cs-agent`. This project uses the CodeScene CLI for performing various code analysis tasks, and can typically be found at `../codescene/cli`. From b98a9af9a8f8acc1303b1863ddf82ea362bc3f18 Mon Sep 17 00:00:00 2001 From: vemv Date: Fri, 4 Sep 2026 14:06:58 +0200 Subject: [PATCH 21/39] feat: add providerOptions setting for agent configuration Replace hardcoded opencode_config with user-configurable key-value pairs in VSCode settings. Keys come from a fixed list (amazon-bedrock:profile, amazon-bedrock:region) and render as an editable table in the settings UI. --- package.json | 17 +++++ src/configuration.ts | 4 ++ src/refactoring/agent-config.ts | 38 ++++++++--- src/refactoring/agent-types.ts | 9 +-- src/test/suite/agent-config.test.ts | 101 ++++++++++++++++++++++++++-- 5 files changed, 144 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index dd661a22..6410b47a 100644 --- a/package.json +++ b/package.json @@ -159,6 +159,23 @@ "description": "Custom model identifier. If set, overrides the dropdown selection above.", "order": 17 }, + "codescene.providerOptions": { + "type": "object", + "properties": { + "amazon-bedrock:profile": { + "type": "string", + "description": "AWS profile name for Amazon Bedrock" + }, + "amazon-bedrock:region": { + "type": "string", + "description": "AWS region for Amazon Bedrock" + } + }, + "additionalProperties": false, + "default": {}, + "description": "Provider-specific options for the Refactoring Agent. Keys must be in 'provider:option' format.", + "order": 18 + }, "codescene.authToken": { "type": "string", "default": "", diff --git a/src/configuration.ts b/src/configuration.ts index 96672dfb..c973e2f1 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -81,3 +81,7 @@ export function getAgentModel(): string { } return getConfiguration('agentModel', DEFAULT_AGENT_MODEL)!; } + +export function getProviderOptions(): Record { + return getConfiguration>('providerOptions') ?? {}; +} diff --git a/src/refactoring/agent-config.ts b/src/refactoring/agent-config.ts index faa80f80..54dbd85e 100644 --- a/src/refactoring/agent-config.ts +++ b/src/refactoring/agent-config.ts @@ -1,25 +1,41 @@ -import { getAuthToken } from '../configuration'; +import { getAuthToken, getProviderOptions } from '../configuration'; import { AgentConfig } from './agent-types'; +export function buildOpencodeConfig(opts: Record): AgentConfig['opencode_config'] | undefined { + const bedrockProfile = opts['amazon-bedrock:profile']; + const bedrockRegion = opts['amazon-bedrock:region']; + const hasBedrockOptions = bedrockProfile || bedrockRegion; + + if (!hasBedrockOptions) { + return undefined; + } + + return { + provider: { + ...(hasBedrockOptions && { + 'amazon-bedrock': { + options: { + ...(bedrockProfile && { profile: bedrockProfile }), + ...(bedrockRegion && { region: bedrockRegion }), + }, + }, + }), + }, + }; +} + export function buildAgentConfigWithToken(token: string, ioDir?: string): AgentConfig { if (!token) { throw new Error('No authentication token available for agent'); } + const opencodeConfig = buildOpencodeConfig(getProviderOptions()); + return { codescene_access_token: token, plugins: ['render-code-fix-reporter'], ...(ioDir && { io_json_dir: ioDir }), - opencode_config: { - provider: { - 'amazon-bedrock': { - options: { - profile: 'codescene-dev', - region: 'eu-west-1', - }, - }, - }, - }, + ...(opencodeConfig && { opencode_config: opencodeConfig }), }; } diff --git a/src/refactoring/agent-types.ts b/src/refactoring/agent-types.ts index 07e63b04..de3bffb3 100644 --- a/src/refactoring/agent-types.ts +++ b/src/refactoring/agent-types.ts @@ -48,13 +48,6 @@ export interface AgentConfig { plugins: string[]; io_json_dir?: string; opencode_config?: { - provider: { - 'amazon-bedrock': { - options: { - profile: string; - region: string; - }; - }; - }; + provider: Record }>; }; } diff --git a/src/test/suite/agent-config.test.ts b/src/test/suite/agent-config.test.ts index 7aff86f9..03880e05 100644 --- a/src/test/suite/agent-config.test.ts +++ b/src/test/suite/agent-config.test.ts @@ -1,11 +1,11 @@ import * as assert from 'assert'; -import { buildAgentConfigWithToken } from '../../refactoring/agent-config'; +import { buildAgentConfigWithToken, buildOpencodeConfig } from '../../refactoring/agent-config'; import { mockConfiguration, restoreDefaultConfiguration } from '../setup'; import { aceSuite } from '../ace-test-suite'; const testCases = [ { - name: 'buildAgentConfigWithToken includes required fields', + name: 'buildAgentConfigWithToken includes required fields without providerOptions', config: {}, token: 'test-token-123', ioDir: undefined, @@ -13,10 +13,7 @@ const testCases = [ assert.strictEqual(result.codescene_access_token, 'test-token-123'); assert.deepStrictEqual(result.plugins, ['render-code-fix-reporter']); assert.strictEqual(result.io_json_dir, undefined, 'io_json_dir should not be present when not provided'); - assert.ok(result.opencode_config, 'opencode_config should be present'); - assert.ok(result.opencode_config.provider['amazon-bedrock'], 'amazon-bedrock provider should be present'); - assert.strictEqual(result.opencode_config.provider['amazon-bedrock'].options.profile, 'codescene-dev'); - assert.strictEqual(result.opencode_config.provider['amazon-bedrock'].options.region, 'eu-west-1'); + assert.strictEqual(result.opencode_config, undefined, 'opencode_config should not be present when no provider options'); }, }, { @@ -28,7 +25,63 @@ const testCases = [ assert.strictEqual(result.codescene_access_token, 'test-token-456'); assert.deepStrictEqual(result.plugins, ['render-code-fix-reporter']); assert.strictEqual(result.io_json_dir, '/tmp/cs-agent-io-test', 'io_json_dir should match provided value'); + }, + }, + { + name: 'buildAgentConfigWithToken builds opencode_config from providerOptions', + config: { + providerOptions: { + 'amazon-bedrock:profile': 'my-profile', + 'amazon-bedrock:region': 'us-east-1', + }, + }, + token: 'test-token-789', + ioDir: undefined, + assertions: (result: any) => { + assert.ok(result.opencode_config, 'opencode_config should be present'); + assert.ok(result.opencode_config.provider['amazon-bedrock'], 'amazon-bedrock provider should be present'); + assert.strictEqual(result.opencode_config.provider['amazon-bedrock'].options.profile, 'my-profile'); + assert.strictEqual(result.opencode_config.provider['amazon-bedrock'].options.region, 'us-east-1'); + }, + }, + { + name: 'buildAgentConfigWithToken handles partial providerOptions', + config: { + providerOptions: { + 'amazon-bedrock:profile': 'only-profile', + }, + }, + token: 'test-token-partial', + ioDir: undefined, + assertions: (result: any) => { assert.ok(result.opencode_config, 'opencode_config should be present'); + assert.ok(result.opencode_config.provider['amazon-bedrock'], 'amazon-bedrock provider should be present'); + assert.strictEqual(result.opencode_config.provider['amazon-bedrock'].options.profile, 'only-profile'); + assert.strictEqual(result.opencode_config.provider['amazon-bedrock'].options.region, undefined); + }, + }, + { + name: 'buildAgentConfigWithToken produces exact structure as previously hardcoded', + config: { + providerOptions: { + 'amazon-bedrock:profile': 'codescene-dev', + 'amazon-bedrock:region': 'eu-west-1', + }, + }, + token: 'test-token-exact', + ioDir: undefined, + assertions: (result: any) => { + const expectedOpencodeConfig = { + provider: { + 'amazon-bedrock': { + options: { + profile: 'codescene-dev', + region: 'eu-west-1', + }, + }, + }, + }; + assert.deepStrictEqual(result.opencode_config, expectedOpencodeConfig); }, }, ]; @@ -50,4 +103,40 @@ aceSuite('AgentConfig Test Suite', () => { mockConfiguration('codescene', {}); assert.throws(() => buildAgentConfigWithToken(''), /No authentication token/); }); + + test('buildOpencodeConfig produces exact structure as previously hardcoded', () => { + const opts = { + 'amazon-bedrock:profile': 'codescene-dev', + 'amazon-bedrock:region': 'eu-west-1', + }; + const result = buildOpencodeConfig(opts); + const expected = { + provider: { + 'amazon-bedrock': { + options: { + profile: 'codescene-dev', + region: 'eu-west-1', + }, + }, + }, + }; + assert.deepStrictEqual(result, expected); + }); + + test('buildOpencodeConfig returns undefined when no options', () => { + assert.strictEqual(buildOpencodeConfig({}), undefined); + }); + + test('buildOpencodeConfig handles partial options', () => { + const result = buildOpencodeConfig({ 'amazon-bedrock:region': 'us-west-2' }); + assert.deepStrictEqual(result, { + provider: { + 'amazon-bedrock': { + options: { + region: 'us-west-2', + }, + }, + }, + }); + }); }); From 016b6bd0ca8a39c5b83f034be06ddac8342206e6 Mon Sep 17 00:00:00 2001 From: vemv Date: Tue, 8 Sep 2026 09:45:42 +0200 Subject: [PATCH 22/39] feat: expand Anthropic models --- package.json | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 6410b47a..2d8121d7 100644 --- a/package.json +++ b/package.json @@ -148,8 +148,24 @@ "codescene.agentModel": { "type": "string", "default": "amazon-bedrock/eu.anthropic.claude-sonnet-4-6", - "enum": ["amazon-bedrock/eu.anthropic.claude-sonnet-4-6"], - "enumDescriptions": ["Claude Sonnet 4.6 via Amazon Bedrock (EU)"], + "enum": [ + "amazon-bedrock/eu.anthropic.claude-sonnet-4-6", + "amazon-bedrock/eu.anthropic.claude-opus-5", + "amazon-bedrock/eu.anthropic.claude-opus-4-8", + "amazon-bedrock/eu.anthropic.claude-sonnet-4-5-20250929-v1:0", + "amazon-bedrock/eu.anthropic.claude-haiku-4-5-20251001-v1:0", + "amazon-bedrock/us.anthropic.claude-opus-5", + "amazon-bedrock/us.anthropic.claude-sonnet-4-6" + ], + "enumDescriptions": [ + "Claude Sonnet 4.6 via Amazon Bedrock (EU)", + "Claude Opus 5 via Amazon Bedrock (EU) - Most advanced", + "Claude Opus 4.8 via Amazon Bedrock (EU) - Optimized for coding", + "Claude Sonnet 4.5 via Amazon Bedrock (EU) - Optimized for agents", + "Claude Haiku 4.5 via Amazon Bedrock (EU) - Fast and lightweight", + "Claude Opus 5 via Amazon Bedrock (US) - Most advanced", + "Claude Sonnet 4.6 via Amazon Bedrock (US)" + ], "description": "Model to use for the Refactoring Agent.", "order": 16 }, From 53f7b268993baa864d7a58949c2ffe1700527361 Mon Sep 17 00:00:00 2001 From: vemv Date: Tue, 8 Sep 2026 10:01:40 +0200 Subject: [PATCH 23/39] feat: add Test connectivity button in settings Add a clickable link in the providerOptions setting description that runs cs-agent's check-connectivity skill to verify provider credentials are correctly configured. --- package.json | 6 ++- src/extension-impl.ts | 24 ++++++++++++ src/refactoring/connectivity.ts | 66 +++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 src/refactoring/connectivity.ts diff --git a/package.json b/package.json index 2d8121d7..a132d48d 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,10 @@ { "command": "codescene.copyDeviceId", "title": "CodeScene: copy Device ID" + }, + { + "command": "codescene.testConnectivity", + "title": "CodeScene: test provider connectivity" } ], "viewsContainers": { @@ -189,7 +193,7 @@ }, "additionalProperties": false, "default": {}, - "description": "Provider-specific options for the Refactoring Agent. Keys must be in 'provider:option' format.", + "markdownDescription": "Provider-specific options for the Refactoring Agent. Keys must be in 'provider:option' format.\n\n**[Test connectivity](command:codescene.testConnectivity)**", "order": 18 }, "codescene.authToken": { diff --git a/src/extension-impl.ts b/src/extension-impl.ts index 6dad03d1..f572bb26 100644 --- a/src/extension-impl.ts +++ b/src/extension-impl.ts @@ -44,6 +44,7 @@ import { SimpleExecutor } from './simple-executor'; import { getHomeViewInstance } from './code-health-monitor/home/home-view'; import { onGitDetectedAsUnavailable } from './git/git-detection'; import { ACE_ENABLED } from './build-flags'; +import { testConnectivity } from './refactoring/connectivity'; import { initExtensionId } from './extension-id'; import { guardWindowLifecycleDuringTests, reloadWindowForUpdate } from './extension-reload'; @@ -331,6 +332,29 @@ function registerCommands(context: vscode.ExtensionContext, csContext: CsContext context.subscriptions.push(toggleReviewCodeLensesCmd); registerCHRulesCommands(context); + registerTestConnectivityCommand(context); +} + +function registerTestConnectivityCommand(context: vscode.ExtensionContext) { + const cmd = vscode.commands.registerCommand('codescene.testConnectivity', async () => { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Testing provider connectivity...', + cancellable: false, + }, + async () => { + const result = await testConnectivity(); + if (result.success) { + void vscode.window.showInformationMessage('Provider connectivity test successful.'); + } else { + void vscode.window.showErrorMessage(`Provider connectivity test failed: ${result.error}`); + } + } + ); + }); + DISPOSABLES.push(cmd); + context.subscriptions.push(cmd); } function registerOpenCsSettingsCommand(context: vscode.ExtensionContext) { diff --git a/src/refactoring/connectivity.ts b/src/refactoring/connectivity.ts new file mode 100644 index 00000000..17ac5b2c --- /dev/null +++ b/src/refactoring/connectivity.ts @@ -0,0 +1,66 @@ +import { spawn } from 'child_process'; +import * as path from 'path'; +import { getExtensionPath } from '../cs-extension-state'; +import { getEffectiveToken } from '../devtools-api'; +import { buildAgentConfigWithToken } from './agent-config'; +import { getAgentModel } from '../configuration'; +import { logOutputChannel } from '../log'; + +export interface ConnectivityResult { + success: boolean; + error?: string; +} + +function getAgentBinaryPath(): string { + const extensionPath = getExtensionPath(); + if (!extensionPath) { + throw new Error('Extension path not available'); + } + const binaryName = process.platform === 'win32' ? 'cs-agent.exe' : 'cs-agent'; + return path.join(extensionPath, 'bin', binaryName); +} + +export async function testConnectivity(): Promise { + const token = getEffectiveToken(); + if (!token) { + return { success: false, error: 'No authentication token available' }; + } + + const binaryPath = getAgentBinaryPath(); + const config = buildAgentConfigWithToken(token); + const configJson = JSON.stringify(config); + const model = getAgentModel(); + + const args = ['run', 'skill:check-connectivity', '--model', model]; + const env: NodeJS.ProcessEnv = { + ...process.env, + CS_AGENT_CONFIG: configJson, + }; + + logOutputChannel.debug(`Testing connectivity: ${binaryPath} ${args.join(' ')}`); + + return new Promise((resolve) => { + const proc = spawn(binaryPath, args, { env }); + let stderr = ''; + + proc.stderr?.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + proc.on('error', (err) => { + logOutputChannel.error(`Connectivity test spawn error: ${err.message}`); + resolve({ success: false, error: err.message }); + }); + + proc.on('close', (code) => { + if (code === 0) { + logOutputChannel.info('Connectivity test successful'); + resolve({ success: true }); + } else { + const errorMsg = stderr.trim() || `Process exited with code ${code}`; + logOutputChannel.error(`Connectivity test failed: ${errorMsg}`); + resolve({ success: false, error: errorMsg }); + } + }); + }); +} From e4850f40a4746611a6aac894635007b8387bfb1f Mon Sep 17 00:00:00 2001 From: vemv Date: Tue, 8 Sep 2026 10:26:11 +0200 Subject: [PATCH 24/39] fix: pass workspace folder as cwd in connectivity test The connectivity test was failing with "not inside a git repository" because `spawn()` wasn't given a `cwd`, inheriting whatever directory VS Code was running from. --- src/refactoring/connectivity.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/refactoring/connectivity.ts b/src/refactoring/connectivity.ts index 17ac5b2c..ddf98a08 100644 --- a/src/refactoring/connectivity.ts +++ b/src/refactoring/connectivity.ts @@ -1,5 +1,6 @@ import { spawn } from 'child_process'; import * as path from 'path'; +import vscode from 'vscode'; import { getExtensionPath } from '../cs-extension-state'; import { getEffectiveToken } from '../devtools-api'; import { buildAgentConfigWithToken } from './agent-config'; @@ -39,8 +40,10 @@ export async function testConnectivity(): Promise { logOutputChannel.debug(`Testing connectivity: ${binaryPath} ${args.join(' ')}`); + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + return new Promise((resolve) => { - const proc = spawn(binaryPath, args, { env }); + const proc = spawn(binaryPath, args, { cwd: workspaceFolder, env }); let stderr = ''; proc.stderr?.on('data', (data: Buffer) => { From 9f54dd0cd247e6b482e832af75d2815688882b28 Mon Sep 17 00:00:00 2001 From: vemv Date: Tue, 8 Sep 2026 10:36:18 +0200 Subject: [PATCH 25/39] chore: expand CLAUDE.md --- CLAUDE.md | 2 ++ package-lock.json | 24 +++++------------------- 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ac0308bb..35998173 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,3 +17,5 @@ This project uses CWF for rendering views, and can typically be found at `../cs- This project uses cs-agent for performing refactorings, and can typically be found at `../cs-agent`. This project uses the CodeScene CLI for performing various code analysis tasks, and can typically be found at `../codescene/cli`. This project used in the past ACE where cs-agent is now used, and can typically be found at `../automated-refactoring`. + +When querying external projects, please use subagents. diff --git a/package-lock.json b/package-lock.json index 91bc52eb..10c8fa39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1591,8 +1591,7 @@ "version": "16.18.126", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz", "integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", @@ -1695,7 +1694,6 @@ "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -1879,7 +1877,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3111,7 +3108,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -3193,7 +3189,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5601,7 +5596,6 @@ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.4.0" } @@ -6663,7 +6657,6 @@ "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7934,8 +7927,7 @@ "@types/node": { "version": "16.18.126", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz", - "integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==", - "peer": true + "integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==" }, "@types/normalize-package-data": { "version": "2.4.4", @@ -8012,7 +8004,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, - "peer": true, "requires": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -8112,8 +8103,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "peer": true + "dev": true }, "acorn-jsx": { "version": "5.3.2", @@ -8965,7 +8955,6 @@ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", "dev": true, - "peer": true, "requires": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", @@ -9024,7 +9013,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "dev": true, - "peer": true, "requires": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -10696,8 +10684,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true, - "peer": true + "dev": true }, "proxy-from-env": { "version": "1.1.0", @@ -11437,8 +11424,7 @@ "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true, - "peer": true + "dev": true }, "uglify-js": { "version": "3.19.3", From b04faa45e3e5206b5417649406a37d38a11f01dd Mon Sep 17 00:00:00 2001 From: vemv Date: Tue, 8 Sep 2026 11:10:40 +0200 Subject: [PATCH 26/39] fix: prevent connectivity test from hanging indefinitely The spawned cs-agent process was blocking because stdin remained open. Adding `stdio: ['ignore', 'pipe', 'pipe']` closes stdin and allows the process to complete. Also adds timeout (60s default), abort signal support, and makes the progress notification cancellable. Integration test requires `CS_AGENT_AWS_PROFILE` and `CS_AGENT_AWS_REGION` environment variables. --- src/extension-impl.ts | 15 +- src/refactoring/connectivity.ts | 45 +++++- .../suite/connectivity-integration.test.ts | 132 ++++++++++++++++++ 3 files changed, 183 insertions(+), 9 deletions(-) create mode 100644 src/test/suite/connectivity-integration.test.ts diff --git a/src/extension-impl.ts b/src/extension-impl.ts index f572bb26..b0507c48 100644 --- a/src/extension-impl.ts +++ b/src/extension-impl.ts @@ -337,17 +337,24 @@ function registerCommands(context: vscode.ExtensionContext, csContext: CsContext function registerTestConnectivityCommand(context: vscode.ExtensionContext) { const cmd = vscode.commands.registerCommand('codescene.testConnectivity', async () => { + const abortController = new AbortController(); + await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, title: 'Testing provider connectivity...', - cancellable: false, + cancellable: true, }, - async () => { - const result = await testConnectivity(); + async (progress, cancelToken) => { + void progress; + cancelToken.onCancellationRequested(() => { + abortController.abort(); + }); + + const result = await testConnectivity({ signal: abortController.signal }); if (result.success) { void vscode.window.showInformationMessage('Provider connectivity test successful.'); - } else { + } else if (!result.error?.includes('cancelled')) { void vscode.window.showErrorMessage(`Provider connectivity test failed: ${result.error}`); } } diff --git a/src/refactoring/connectivity.ts b/src/refactoring/connectivity.ts index ddf98a08..4ccd1368 100644 --- a/src/refactoring/connectivity.ts +++ b/src/refactoring/connectivity.ts @@ -12,6 +12,11 @@ export interface ConnectivityResult { error?: string; } +export interface ConnectivityOptions { + signal?: AbortSignal; + timeoutMs?: number; +} + function getAgentBinaryPath(): string { const extensionPath = getExtensionPath(); if (!extensionPath) { @@ -21,7 +26,9 @@ function getAgentBinaryPath(): string { return path.join(extensionPath, 'bin', binaryName); } -export async function testConnectivity(): Promise { +const DEFAULT_TIMEOUT_MS = 60000; + +export async function testConnectivity(options?: ConnectivityOptions): Promise { const token = getEffectiveToken(); if (!token) { return { success: false, error: 'No authentication token available' }; @@ -41,10 +48,38 @@ export async function testConnectivity(): Promise { logOutputChannel.debug(`Testing connectivity: ${binaryPath} ${args.join(' ')}`); const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; return new Promise((resolve) => { - const proc = spawn(binaryPath, args, { cwd: workspaceFolder, env }); + const proc = spawn(binaryPath, args, { cwd: workspaceFolder, env, stdio: ['ignore', 'pipe', 'pipe'] }); let stderr = ''; + let resolved = false; + + const resolveOnce = (result: ConnectivityResult) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + options?.signal?.removeEventListener('abort', abortHandler); + resolve(result); + }; + + const timeoutId = setTimeout(() => { + proc.kill('SIGTERM'); + logOutputChannel.warn('Connectivity test timed out'); + resolveOnce({ success: false, error: 'Connectivity test timed out' }); + }, timeoutMs); + + const abortHandler = () => { + proc.kill('SIGTERM'); + logOutputChannel.info('Connectivity test cancelled'); + resolveOnce({ success: false, error: 'Connectivity test cancelled' }); + }; + + options?.signal?.addEventListener('abort', abortHandler); + + proc.stdout?.on('data', (data: Buffer) => { + logOutputChannel.debug(`Connectivity stdout: ${data.toString().trim()}`); + }); proc.stderr?.on('data', (data: Buffer) => { stderr += data.toString(); @@ -52,17 +87,17 @@ export async function testConnectivity(): Promise { proc.on('error', (err) => { logOutputChannel.error(`Connectivity test spawn error: ${err.message}`); - resolve({ success: false, error: err.message }); + resolveOnce({ success: false, error: err.message }); }); proc.on('close', (code) => { if (code === 0) { logOutputChannel.info('Connectivity test successful'); - resolve({ success: true }); + resolveOnce({ success: true }); } else { const errorMsg = stderr.trim() || `Process exited with code ${code}`; logOutputChannel.error(`Connectivity test failed: ${errorMsg}`); - resolve({ success: false, error: errorMsg }); + resolveOnce({ success: false, error: errorMsg }); } }); }); diff --git a/src/test/suite/connectivity-integration.test.ts b/src/test/suite/connectivity-integration.test.ts new file mode 100644 index 00000000..fc5b4e17 --- /dev/null +++ b/src/test/suite/connectivity-integration.test.ts @@ -0,0 +1,132 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import * as fs from 'fs'; +import { mockWorkspaceFolders, createMockWorkspaceFolder, restoreDefaultWorkspaceFolders } from '../setup'; +import { createTestDir } from '../integration_helper'; +import * as configModule from '../../configuration'; +import * as csExtensionState from '../../cs-extension-state'; +import { testConnectivity } from '../../refactoring/connectivity'; + +import { aceSuite } from '../ace-test-suite'; + +aceSuite('Connectivity Integration Test Suite', () => { + const testDir = createTestDir('test-connectivity-integration'); + const awsProfile = process.env.CS_AGENT_AWS_PROFILE; + const awsRegion = process.env.CS_AGENT_AWS_REGION; + let originalGetAuthToken: any; + let originalGetExtensionPath: any; + let originalGetProviderOptions: any; + let originalGetAgentModel: any; + + if (!awsProfile || !awsRegion) { + console.log( + 'Skipping Connectivity Integration tests: CS_AGENT_AWS_PROFILE and CS_AGENT_AWS_REGION environment variables not set' + ); + return; + } + + const projectRoot = path.join(__dirname, '..', '..', '..'); + const agentBinaryPath = path.join(projectRoot, 'bin', 'cs-agent'); + if (!fs.existsSync(agentBinaryPath)) { + console.log('Skipping Connectivity Integration tests: cs-agent binary not found at ' + agentBinaryPath); + return; + } + + setup(async function () { + this.timeout(60000); + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + fs.mkdirSync(testDir, { recursive: true }); + + mockWorkspaceFolders([createMockWorkspaceFolder(testDir)]); + + originalGetAuthToken = configModule.getAuthToken; + (configModule as any).getAuthToken = () => 'dummy-token'; + + originalGetProviderOptions = configModule.getProviderOptions; + (configModule as any).getProviderOptions = () => ({ + 'amazon-bedrock:profile': awsProfile, + 'amazon-bedrock:region': awsRegion, + }); + + originalGetAgentModel = configModule.getAgentModel; + (configModule as any).getAgentModel = () => 'amazon-bedrock/eu.anthropic.claude-sonnet-4-6'; + + originalGetExtensionPath = csExtensionState.getExtensionPath; + (csExtensionState as any).getExtensionPath = () => projectRoot; + + Object.defineProperty(csExtensionState.CsExtensionState, 'stateProperties', { + get: () => ({ + session: undefined, + features: { + analysis: { state: 'enabled' }, + ace: { state: 'enabled' }, + }, + }), + configurable: true, + }); + }); + + teardown(() => { + if (originalGetAuthToken) { + (configModule as any).getAuthToken = originalGetAuthToken; + } + + if (originalGetProviderOptions) { + (configModule as any).getProviderOptions = originalGetProviderOptions; + } + + if (originalGetAgentModel) { + (configModule as any).getAgentModel = originalGetAgentModel; + } + + if (originalGetExtensionPath) { + (csExtensionState as any).getExtensionPath = originalGetExtensionPath; + } + + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + + restoreDefaultWorkspaceFolders(); + }); + + test('testConnectivity returns success with valid credentials', async function () { + this.timeout(120000); + + const result = await testConnectivity({ timeoutMs: 90000 }); + + assert.ok(result, 'Result should be defined'); + assert.strictEqual(result.success, true, `Connectivity should succeed but got error: ${result.error}`); + }); + + test('testConnectivity respects timeout option', async function () { + this.timeout(10000); + + const result = await testConnectivity({ timeoutMs: 1 }); + + assert.ok(result, 'Result should be defined'); + assert.strictEqual(result.success, false, 'Should fail due to timeout'); + assert.ok( + result.error?.includes('timed out') || result.error?.includes('cancelled'), + `Error should mention timeout or cancellation, got: ${result.error}` + ); + }); + + test('testConnectivity respects abort signal', async function () { + this.timeout(10000); + + const abortController = new AbortController(); + + const resultPromise = testConnectivity({ signal: abortController.signal }); + + abortController.abort(); + + const result = await resultPromise; + + assert.ok(result, 'Result should be defined'); + assert.strictEqual(result.success, false, 'Should fail due to abort'); + assert.ok(result.error?.includes('cancelled'), `Error should mention cancellation, got: ${result.error}`); + }); +}); From 1f7683039a8eb2ffd750029a420a5ae9711be750 Mon Sep 17 00:00:00 2001 From: vemv Date: Tue, 8 Sep 2026 11:13:44 +0200 Subject: [PATCH 27/39] feat: add OpenAI, Anthropic, and Google provider auth options Extend `providerOptions` to support API key authentication for direct provider access alongside the existing Amazon Bedrock profile/region options. The `agentModel` dropdown now includes models from all four providers. Provider options map to cs-agent's expected JSON structure: - `anthropic:api-key` -> `anthropic_api_key` - `openai:api-key` -> `openai_api_key` - `google:api-key` -> `google_api_key` --- package.json | 34 +++++++- src/refactoring/agent-config.ts | 46 +++++++---- src/refactoring/agent-types.ts | 16 +++- src/test/suite/agent-config.test.ts | 115 ++++++++++++++++++++++++++++ 4 files changed, 193 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index a132d48d..fe55a8f5 100644 --- a/package.json +++ b/package.json @@ -159,7 +159,15 @@ "amazon-bedrock/eu.anthropic.claude-sonnet-4-5-20250929-v1:0", "amazon-bedrock/eu.anthropic.claude-haiku-4-5-20251001-v1:0", "amazon-bedrock/us.anthropic.claude-opus-5", - "amazon-bedrock/us.anthropic.claude-sonnet-4-6" + "amazon-bedrock/us.anthropic.claude-sonnet-4-6", + "anthropic/claude-sonnet-4-0", + "anthropic/claude-opus-4", + "anthropic/claude-haiku-3-5-sonnet", + "openai/gpt-4o", + "openai/gpt-4-turbo", + "openai/o3", + "google/gemini-2.5-pro", + "google/gemini-2.5-flash" ], "enumDescriptions": [ "Claude Sonnet 4.6 via Amazon Bedrock (EU)", @@ -168,7 +176,15 @@ "Claude Sonnet 4.5 via Amazon Bedrock (EU) - Optimized for agents", "Claude Haiku 4.5 via Amazon Bedrock (EU) - Fast and lightweight", "Claude Opus 5 via Amazon Bedrock (US) - Most advanced", - "Claude Sonnet 4.6 via Amazon Bedrock (US)" + "Claude Sonnet 4.6 via Amazon Bedrock (US)", + "Claude Sonnet 4.0 via Anthropic API", + "Claude Opus 4 via Anthropic API", + "Claude Haiku 3.5 via Anthropic API - Fast and lightweight", + "GPT-4o via OpenAI API", + "GPT-4 Turbo via OpenAI API", + "o3 via OpenAI API - Advanced reasoning", + "Gemini 2.5 Pro via Google API", + "Gemini 2.5 Flash via Google API - Fast and efficient" ], "description": "Model to use for the Refactoring Agent.", "order": 16 @@ -189,11 +205,23 @@ "amazon-bedrock:region": { "type": "string", "description": "AWS region for Amazon Bedrock" + }, + "anthropic:api-key": { + "type": "string", + "description": "API key for Anthropic" + }, + "openai:api-key": { + "type": "string", + "description": "API key for OpenAI" + }, + "google:api-key": { + "type": "string", + "description": "API key for Google (Gemini)" } }, "additionalProperties": false, "default": {}, - "markdownDescription": "Provider-specific options for the Refactoring Agent. Keys must be in 'provider:option' format.\n\n**[Test connectivity](command:codescene.testConnectivity)**", + "markdownDescription": "Provider-specific options for the Refactoring Agent. Use this to authenticate into your preferred model.\n\n**[Test connectivity](command:codescene.testConnectivity)**", "order": 18 }, "codescene.authToken": { diff --git a/src/refactoring/agent-config.ts b/src/refactoring/agent-config.ts index 54dbd85e..34f1ea47 100644 --- a/src/refactoring/agent-config.ts +++ b/src/refactoring/agent-config.ts @@ -1,27 +1,45 @@ import { getAuthToken, getProviderOptions } from '../configuration'; -import { AgentConfig } from './agent-types'; +import { AgentConfig, ProviderConfig } from './agent-types'; export function buildOpencodeConfig(opts: Record): AgentConfig['opencode_config'] | undefined { const bedrockProfile = opts['amazon-bedrock:profile']; const bedrockRegion = opts['amazon-bedrock:region']; const hasBedrockOptions = bedrockProfile || bedrockRegion; - if (!hasBedrockOptions) { + const anthropicApiKey = opts['anthropic:api-key']; + const openaiApiKey = opts['openai:api-key']; + const googleApiKey = opts['google:api-key']; + + const hasAnyOptions = hasBedrockOptions || anthropicApiKey || openaiApiKey || googleApiKey; + + if (!hasAnyOptions) { return undefined; } - return { - provider: { - ...(hasBedrockOptions && { - 'amazon-bedrock': { - options: { - ...(bedrockProfile && { profile: bedrockProfile }), - ...(bedrockRegion && { region: bedrockRegion }), - }, - }, - }), - }, - }; + const provider: ProviderConfig = {}; + + if (hasBedrockOptions) { + provider['amazon-bedrock'] = { + options: { + ...(bedrockProfile && { profile: bedrockProfile }), + ...(bedrockRegion && { region: bedrockRegion }), + }, + }; + } + + if (anthropicApiKey) { + provider.anthropic_api_key = anthropicApiKey; + } + + if (openaiApiKey) { + provider.openai_api_key = openaiApiKey; + } + + if (googleApiKey) { + provider.google_api_key = googleApiKey; + } + + return { provider }; } export function buildAgentConfigWithToken(token: string, ioDir?: string): AgentConfig { diff --git a/src/refactoring/agent-types.ts b/src/refactoring/agent-types.ts index de3bffb3..e9be70c3 100644 --- a/src/refactoring/agent-types.ts +++ b/src/refactoring/agent-types.ts @@ -43,11 +43,25 @@ export interface AgentOutput { generated_at: string; } +export interface BedrockProviderOptions { + options: { + profile?: string; + region?: string; + }; +} + +export interface ProviderConfig { + 'amazon-bedrock'?: BedrockProviderOptions; + anthropic_api_key?: string; + openai_api_key?: string; + google_api_key?: string; +} + export interface AgentConfig { codescene_access_token: string; plugins: string[]; io_json_dir?: string; opencode_config?: { - provider: Record }>; + provider: ProviderConfig; }; } diff --git a/src/test/suite/agent-config.test.ts b/src/test/suite/agent-config.test.ts index 03880e05..6bcc8726 100644 --- a/src/test/suite/agent-config.test.ts +++ b/src/test/suite/agent-config.test.ts @@ -84,6 +84,71 @@ const testCases = [ assert.deepStrictEqual(result.opencode_config, expectedOpencodeConfig); }, }, + { + name: 'buildAgentConfigWithToken builds opencode_config with Anthropic API key', + config: { + providerOptions: { + 'anthropic:api-key': 'sk-ant-test-key-123', + }, + }, + token: 'test-token-anthropic', + ioDir: undefined, + assertions: (result: any) => { + assert.ok(result.opencode_config, 'opencode_config should be present'); + assert.strictEqual(result.opencode_config.provider.anthropic_api_key, 'sk-ant-test-key-123'); + assert.strictEqual(result.opencode_config.provider['amazon-bedrock'], undefined); + }, + }, + { + name: 'buildAgentConfigWithToken builds opencode_config with OpenAI API key', + config: { + providerOptions: { + 'openai:api-key': 'sk-openai-test-key-456', + }, + }, + token: 'test-token-openai', + ioDir: undefined, + assertions: (result: any) => { + assert.ok(result.opencode_config, 'opencode_config should be present'); + assert.strictEqual(result.opencode_config.provider.openai_api_key, 'sk-openai-test-key-456'); + assert.strictEqual(result.opencode_config.provider['amazon-bedrock'], undefined); + }, + }, + { + name: 'buildAgentConfigWithToken builds opencode_config with Google API key', + config: { + providerOptions: { + 'google:api-key': 'google-test-key-789', + }, + }, + token: 'test-token-google', + ioDir: undefined, + assertions: (result: any) => { + assert.ok(result.opencode_config, 'opencode_config should be present'); + assert.strictEqual(result.opencode_config.provider.google_api_key, 'google-test-key-789'); + assert.strictEqual(result.opencode_config.provider['amazon-bedrock'], undefined); + }, + }, + { + name: 'buildAgentConfigWithToken combines multiple provider options', + config: { + providerOptions: { + 'amazon-bedrock:profile': 'my-profile', + 'anthropic:api-key': 'sk-ant-key', + 'openai:api-key': 'sk-openai-key', + 'google:api-key': 'google-key', + }, + }, + token: 'test-token-multi', + ioDir: undefined, + assertions: (result: any) => { + assert.ok(result.opencode_config, 'opencode_config should be present'); + assert.strictEqual(result.opencode_config.provider['amazon-bedrock'].options.profile, 'my-profile'); + assert.strictEqual(result.opencode_config.provider.anthropic_api_key, 'sk-ant-key'); + assert.strictEqual(result.opencode_config.provider.openai_api_key, 'sk-openai-key'); + assert.strictEqual(result.opencode_config.provider.google_api_key, 'google-key'); + }, + }, ]; aceSuite('AgentConfig Test Suite', () => { @@ -139,4 +204,54 @@ aceSuite('AgentConfig Test Suite', () => { }, }); }); + + test('buildOpencodeConfig builds Anthropic provider config', () => { + const result = buildOpencodeConfig({ 'anthropic:api-key': 'sk-ant-test-key' }); + assert.deepStrictEqual(result, { + provider: { + anthropic_api_key: 'sk-ant-test-key', + }, + }); + }); + + test('buildOpencodeConfig builds OpenAI provider config', () => { + const result = buildOpencodeConfig({ 'openai:api-key': 'sk-openai-test-key' }); + assert.deepStrictEqual(result, { + provider: { + openai_api_key: 'sk-openai-test-key', + }, + }); + }); + + test('buildOpencodeConfig builds Google provider config', () => { + const result = buildOpencodeConfig({ 'google:api-key': 'google-test-key' }); + assert.deepStrictEqual(result, { + provider: { + google_api_key: 'google-test-key', + }, + }); + }); + + test('buildOpencodeConfig combines all providers', () => { + const result = buildOpencodeConfig({ + 'amazon-bedrock:profile': 'dev-profile', + 'amazon-bedrock:region': 'eu-west-1', + 'anthropic:api-key': 'sk-ant-key', + 'openai:api-key': 'sk-openai-key', + 'google:api-key': 'google-key', + }); + assert.deepStrictEqual(result, { + provider: { + 'amazon-bedrock': { + options: { + profile: 'dev-profile', + region: 'eu-west-1', + }, + }, + anthropic_api_key: 'sk-ant-key', + openai_api_key: 'sk-openai-key', + google_api_key: 'google-key', + }, + }); + }); }); From 00e40f1ecbe7cfdca522ae08e57758394dc36cca Mon Sep 17 00:00:00 2001 From: vemv Date: Tue, 8 Sep 2026 11:21:35 +0200 Subject: [PATCH 28/39] feat: add tracking config to cs-agent environment Include `tracking.environment: "codescene-vscode"` in the agent config so analytics and MCP events identify this extension as the source. --- src/refactoring/agent-config.ts | 1 + src/refactoring/agent-types.ts | 5 +++++ src/test/suite/agent-config.test.ts | 1 + 3 files changed, 7 insertions(+) diff --git a/src/refactoring/agent-config.ts b/src/refactoring/agent-config.ts index 34f1ea47..b830e24d 100644 --- a/src/refactoring/agent-config.ts +++ b/src/refactoring/agent-config.ts @@ -52,6 +52,7 @@ export function buildAgentConfigWithToken(token: string, ioDir?: string): AgentC return { codescene_access_token: token, plugins: ['render-code-fix-reporter'], + tracking: { environment: 'codescene-vscode' }, ...(ioDir && { io_json_dir: ioDir }), ...(opencodeConfig && { opencode_config: opencodeConfig }), }; diff --git a/src/refactoring/agent-types.ts b/src/refactoring/agent-types.ts index e9be70c3..315630dd 100644 --- a/src/refactoring/agent-types.ts +++ b/src/refactoring/agent-types.ts @@ -57,6 +57,10 @@ export interface ProviderConfig { google_api_key?: string; } +export interface TrackingConfig { + environment?: string; +} + export interface AgentConfig { codescene_access_token: string; plugins: string[]; @@ -64,4 +68,5 @@ export interface AgentConfig { opencode_config?: { provider: ProviderConfig; }; + tracking?: TrackingConfig; } diff --git a/src/test/suite/agent-config.test.ts b/src/test/suite/agent-config.test.ts index 6bcc8726..1395efcc 100644 --- a/src/test/suite/agent-config.test.ts +++ b/src/test/suite/agent-config.test.ts @@ -12,6 +12,7 @@ const testCases = [ assertions: (result: any) => { assert.strictEqual(result.codescene_access_token, 'test-token-123'); assert.deepStrictEqual(result.plugins, ['render-code-fix-reporter']); + assert.deepStrictEqual(result.tracking, { environment: 'codescene-vscode' }); assert.strictEqual(result.io_json_dir, undefined, 'io_json_dir should not be present when not provided'); assert.strictEqual(result.opencode_config, undefined, 'opencode_config should not be present when no provider options'); }, From 66e410e42c2b14a01552e050b0b46539d4a95299 Mon Sep 17 00:00:00 2001 From: vemv Date: Tue, 8 Sep 2026 11:48:05 +0200 Subject: [PATCH 29/39] refactor: separate provider credentials from bedrock config Split the configuration building logic to distinguish between API key credentials (Anthropic, OpenAI, Google) and Bedrock-specific options (profile, region). The `buildProviderCredentials` function now handles API keys and populates the top-level `provider` field in `AgentConfig`, while `buildOpencodeConfig` remains focused solely on Bedrock configuration within `opencode_config.provider`. --- src/refactoring/agent-config.ts | 54 ++++++++------ src/refactoring/agent-types.ts | 8 +- src/test/suite/agent-config.test.ts | 112 ++++++++++++++-------------- 3 files changed, 95 insertions(+), 79 deletions(-) diff --git a/src/refactoring/agent-config.ts b/src/refactoring/agent-config.ts index b830e24d..af3fda4e 100644 --- a/src/refactoring/agent-config.ts +++ b/src/refactoring/agent-config.ts @@ -1,44 +1,49 @@ import { getAuthToken, getProviderOptions } from '../configuration'; -import { AgentConfig, ProviderConfig } from './agent-types'; - -export function buildOpencodeConfig(opts: Record): AgentConfig['opencode_config'] | undefined { - const bedrockProfile = opts['amazon-bedrock:profile']; - const bedrockRegion = opts['amazon-bedrock:region']; - const hasBedrockOptions = bedrockProfile || bedrockRegion; +import { AgentConfig, BedrockOpencodeProvider, ProviderCredentials } from './agent-types'; +export function buildProviderCredentials(opts: Record): ProviderCredentials | undefined { const anthropicApiKey = opts['anthropic:api-key']; const openaiApiKey = opts['openai:api-key']; const googleApiKey = opts['google:api-key']; - const hasAnyOptions = hasBedrockOptions || anthropicApiKey || openaiApiKey || googleApiKey; - - if (!hasAnyOptions) { + if (!anthropicApiKey && !openaiApiKey && !googleApiKey) { return undefined; } - const provider: ProviderConfig = {}; - - if (hasBedrockOptions) { - provider['amazon-bedrock'] = { - options: { - ...(bedrockProfile && { profile: bedrockProfile }), - ...(bedrockRegion && { region: bedrockRegion }), - }, - }; - } + const credentials: ProviderCredentials = {}; if (anthropicApiKey) { - provider.anthropic_api_key = anthropicApiKey; + credentials.anthropic_api_key = anthropicApiKey; } if (openaiApiKey) { - provider.openai_api_key = openaiApiKey; + credentials.openai_api_key = openaiApiKey; } if (googleApiKey) { - provider.google_api_key = googleApiKey; + credentials.google_api_key = googleApiKey; + } + + return credentials; +} + +export function buildOpencodeConfig(opts: Record): AgentConfig['opencode_config'] | undefined { + const bedrockProfile = opts['amazon-bedrock:profile']; + const bedrockRegion = opts['amazon-bedrock:region']; + + if (!bedrockProfile && !bedrockRegion) { + return undefined; } + const provider: BedrockOpencodeProvider = { + 'amazon-bedrock': { + options: { + ...(bedrockProfile && { profile: bedrockProfile }), + ...(bedrockRegion && { region: bedrockRegion }), + }, + }, + }; + return { provider }; } @@ -47,13 +52,16 @@ export function buildAgentConfigWithToken(token: string, ioDir?: string): AgentC throw new Error('No authentication token available for agent'); } - const opencodeConfig = buildOpencodeConfig(getProviderOptions()); + const providerOpts = getProviderOptions(); + const providerCredentials = buildProviderCredentials(providerOpts); + const opencodeConfig = buildOpencodeConfig(providerOpts); return { codescene_access_token: token, plugins: ['render-code-fix-reporter'], tracking: { environment: 'codescene-vscode' }, ...(ioDir && { io_json_dir: ioDir }), + ...(providerCredentials && { provider: providerCredentials }), ...(opencodeConfig && { opencode_config: opencodeConfig }), }; } diff --git a/src/refactoring/agent-types.ts b/src/refactoring/agent-types.ts index 315630dd..be3105b9 100644 --- a/src/refactoring/agent-types.ts +++ b/src/refactoring/agent-types.ts @@ -50,8 +50,11 @@ export interface BedrockProviderOptions { }; } -export interface ProviderConfig { +export interface BedrockOpencodeProvider { 'amazon-bedrock'?: BedrockProviderOptions; +} + +export interface ProviderCredentials { anthropic_api_key?: string; openai_api_key?: string; google_api_key?: string; @@ -65,8 +68,9 @@ export interface AgentConfig { codescene_access_token: string; plugins: string[]; io_json_dir?: string; + provider?: ProviderCredentials; opencode_config?: { - provider: ProviderConfig; + provider: BedrockOpencodeProvider; }; tracking?: TrackingConfig; } diff --git a/src/test/suite/agent-config.test.ts b/src/test/suite/agent-config.test.ts index 1395efcc..d571263b 100644 --- a/src/test/suite/agent-config.test.ts +++ b/src/test/suite/agent-config.test.ts @@ -1,5 +1,9 @@ import * as assert from 'assert'; -import { buildAgentConfigWithToken, buildOpencodeConfig } from '../../refactoring/agent-config'; +import { + buildAgentConfigWithToken, + buildOpencodeConfig, + buildProviderCredentials, +} from '../../refactoring/agent-config'; import { mockConfiguration, restoreDefaultConfiguration } from '../setup'; import { aceSuite } from '../ace-test-suite'; @@ -14,7 +18,8 @@ const testCases = [ assert.deepStrictEqual(result.plugins, ['render-code-fix-reporter']); assert.deepStrictEqual(result.tracking, { environment: 'codescene-vscode' }); assert.strictEqual(result.io_json_dir, undefined, 'io_json_dir should not be present when not provided'); - assert.strictEqual(result.opencode_config, undefined, 'opencode_config should not be present when no provider options'); + assert.strictEqual(result.provider, undefined, 'provider should not be present when no API keys'); + assert.strictEqual(result.opencode_config, undefined, 'opencode_config should not be present when no bedrock options'); }, }, { @@ -29,7 +34,7 @@ const testCases = [ }, }, { - name: 'buildAgentConfigWithToken builds opencode_config from providerOptions', + name: 'buildAgentConfigWithToken builds opencode_config from bedrock providerOptions', config: { providerOptions: { 'amazon-bedrock:profile': 'my-profile', @@ -39,6 +44,7 @@ const testCases = [ token: 'test-token-789', ioDir: undefined, assertions: (result: any) => { + assert.strictEqual(result.provider, undefined, 'provider should not be present when no API keys'); assert.ok(result.opencode_config, 'opencode_config should be present'); assert.ok(result.opencode_config.provider['amazon-bedrock'], 'amazon-bedrock provider should be present'); assert.strictEqual(result.opencode_config.provider['amazon-bedrock'].options.profile, 'my-profile'); @@ -46,7 +52,7 @@ const testCases = [ }, }, { - name: 'buildAgentConfigWithToken handles partial providerOptions', + name: 'buildAgentConfigWithToken handles partial bedrock providerOptions', config: { providerOptions: { 'amazon-bedrock:profile': 'only-profile', @@ -55,6 +61,7 @@ const testCases = [ token: 'test-token-partial', ioDir: undefined, assertions: (result: any) => { + assert.strictEqual(result.provider, undefined, 'provider should not be present when no API keys'); assert.ok(result.opencode_config, 'opencode_config should be present'); assert.ok(result.opencode_config.provider['amazon-bedrock'], 'amazon-bedrock provider should be present'); assert.strictEqual(result.opencode_config.provider['amazon-bedrock'].options.profile, 'only-profile'); @@ -62,7 +69,7 @@ const testCases = [ }, }, { - name: 'buildAgentConfigWithToken produces exact structure as previously hardcoded', + name: 'buildAgentConfigWithToken produces exact bedrock structure', config: { providerOptions: { 'amazon-bedrock:profile': 'codescene-dev', @@ -72,6 +79,7 @@ const testCases = [ token: 'test-token-exact', ioDir: undefined, assertions: (result: any) => { + assert.strictEqual(result.provider, undefined, 'provider should not be present when no API keys'); const expectedOpencodeConfig = { provider: { 'amazon-bedrock': { @@ -86,7 +94,7 @@ const testCases = [ }, }, { - name: 'buildAgentConfigWithToken builds opencode_config with Anthropic API key', + name: 'buildAgentConfigWithToken builds provider with Anthropic API key', config: { providerOptions: { 'anthropic:api-key': 'sk-ant-test-key-123', @@ -95,13 +103,13 @@ const testCases = [ token: 'test-token-anthropic', ioDir: undefined, assertions: (result: any) => { - assert.ok(result.opencode_config, 'opencode_config should be present'); - assert.strictEqual(result.opencode_config.provider.anthropic_api_key, 'sk-ant-test-key-123'); - assert.strictEqual(result.opencode_config.provider['amazon-bedrock'], undefined); + assert.ok(result.provider, 'provider should be present'); + assert.strictEqual(result.provider.anthropic_api_key, 'sk-ant-test-key-123'); + assert.strictEqual(result.opencode_config, undefined, 'opencode_config should not be present when no bedrock options'); }, }, { - name: 'buildAgentConfigWithToken builds opencode_config with OpenAI API key', + name: 'buildAgentConfigWithToken builds provider with OpenAI API key', config: { providerOptions: { 'openai:api-key': 'sk-openai-test-key-456', @@ -110,13 +118,13 @@ const testCases = [ token: 'test-token-openai', ioDir: undefined, assertions: (result: any) => { - assert.ok(result.opencode_config, 'opencode_config should be present'); - assert.strictEqual(result.opencode_config.provider.openai_api_key, 'sk-openai-test-key-456'); - assert.strictEqual(result.opencode_config.provider['amazon-bedrock'], undefined); + assert.ok(result.provider, 'provider should be present'); + assert.strictEqual(result.provider.openai_api_key, 'sk-openai-test-key-456'); + assert.strictEqual(result.opencode_config, undefined, 'opencode_config should not be present when no bedrock options'); }, }, { - name: 'buildAgentConfigWithToken builds opencode_config with Google API key', + name: 'buildAgentConfigWithToken builds provider with Google API key', config: { providerOptions: { 'google:api-key': 'google-test-key-789', @@ -125,9 +133,9 @@ const testCases = [ token: 'test-token-google', ioDir: undefined, assertions: (result: any) => { - assert.ok(result.opencode_config, 'opencode_config should be present'); - assert.strictEqual(result.opencode_config.provider.google_api_key, 'google-test-key-789'); - assert.strictEqual(result.opencode_config.provider['amazon-bedrock'], undefined); + assert.ok(result.provider, 'provider should be present'); + assert.strictEqual(result.provider.google_api_key, 'google-test-key-789'); + assert.strictEqual(result.opencode_config, undefined, 'opencode_config should not be present when no bedrock options'); }, }, { @@ -143,11 +151,12 @@ const testCases = [ token: 'test-token-multi', ioDir: undefined, assertions: (result: any) => { - assert.ok(result.opencode_config, 'opencode_config should be present'); + assert.ok(result.provider, 'provider should be present'); + assert.strictEqual(result.provider.anthropic_api_key, 'sk-ant-key'); + assert.strictEqual(result.provider.openai_api_key, 'sk-openai-key'); + assert.strictEqual(result.provider.google_api_key, 'google-key'); + assert.ok(result.opencode_config, 'opencode_config should be present for bedrock'); assert.strictEqual(result.opencode_config.provider['amazon-bedrock'].options.profile, 'my-profile'); - assert.strictEqual(result.opencode_config.provider.anthropic_api_key, 'sk-ant-key'); - assert.strictEqual(result.opencode_config.provider.openai_api_key, 'sk-openai-key'); - assert.strictEqual(result.opencode_config.provider.google_api_key, 'google-key'); }, }, ]; @@ -170,7 +179,7 @@ aceSuite('AgentConfig Test Suite', () => { assert.throws(() => buildAgentConfigWithToken(''), /No authentication token/); }); - test('buildOpencodeConfig produces exact structure as previously hardcoded', () => { + test('buildOpencodeConfig produces exact bedrock structure', () => { const opts = { 'amazon-bedrock:profile': 'codescene-dev', 'amazon-bedrock:region': 'eu-west-1', @@ -189,11 +198,17 @@ aceSuite('AgentConfig Test Suite', () => { assert.deepStrictEqual(result, expected); }); - test('buildOpencodeConfig returns undefined when no options', () => { + test('buildOpencodeConfig returns undefined when no bedrock options', () => { assert.strictEqual(buildOpencodeConfig({}), undefined); }); - test('buildOpencodeConfig handles partial options', () => { + test('buildOpencodeConfig returns undefined for API keys (not bedrock)', () => { + assert.strictEqual(buildOpencodeConfig({ 'openai:api-key': 'sk-test' }), undefined); + assert.strictEqual(buildOpencodeConfig({ 'anthropic:api-key': 'sk-ant-test' }), undefined); + assert.strictEqual(buildOpencodeConfig({ 'google:api-key': 'google-test' }), undefined); + }); + + test('buildOpencodeConfig handles partial bedrock options', () => { const result = buildOpencodeConfig({ 'amazon-bedrock:region': 'us-west-2' }); assert.deepStrictEqual(result, { provider: { @@ -206,53 +221,42 @@ aceSuite('AgentConfig Test Suite', () => { }); }); - test('buildOpencodeConfig builds Anthropic provider config', () => { - const result = buildOpencodeConfig({ 'anthropic:api-key': 'sk-ant-test-key' }); + test('buildProviderCredentials builds Anthropic credentials', () => { + const result = buildProviderCredentials({ 'anthropic:api-key': 'sk-ant-test-key' }); assert.deepStrictEqual(result, { - provider: { - anthropic_api_key: 'sk-ant-test-key', - }, + anthropic_api_key: 'sk-ant-test-key', }); }); - test('buildOpencodeConfig builds OpenAI provider config', () => { - const result = buildOpencodeConfig({ 'openai:api-key': 'sk-openai-test-key' }); + test('buildProviderCredentials builds OpenAI credentials', () => { + const result = buildProviderCredentials({ 'openai:api-key': 'sk-openai-test-key' }); assert.deepStrictEqual(result, { - provider: { - openai_api_key: 'sk-openai-test-key', - }, + openai_api_key: 'sk-openai-test-key', }); }); - test('buildOpencodeConfig builds Google provider config', () => { - const result = buildOpencodeConfig({ 'google:api-key': 'google-test-key' }); + test('buildProviderCredentials builds Google credentials', () => { + const result = buildProviderCredentials({ 'google:api-key': 'google-test-key' }); assert.deepStrictEqual(result, { - provider: { - google_api_key: 'google-test-key', - }, + google_api_key: 'google-test-key', }); }); - test('buildOpencodeConfig combines all providers', () => { - const result = buildOpencodeConfig({ - 'amazon-bedrock:profile': 'dev-profile', - 'amazon-bedrock:region': 'eu-west-1', + test('buildProviderCredentials returns undefined when no API keys', () => { + assert.strictEqual(buildProviderCredentials({}), undefined); + assert.strictEqual(buildProviderCredentials({ 'amazon-bedrock:profile': 'test' }), undefined); + }); + + test('buildProviderCredentials combines all API keys', () => { + const result = buildProviderCredentials({ 'anthropic:api-key': 'sk-ant-key', 'openai:api-key': 'sk-openai-key', 'google:api-key': 'google-key', }); assert.deepStrictEqual(result, { - provider: { - 'amazon-bedrock': { - options: { - profile: 'dev-profile', - region: 'eu-west-1', - }, - }, - anthropic_api_key: 'sk-ant-key', - openai_api_key: 'sk-openai-key', - google_api_key: 'google-key', - }, + anthropic_api_key: 'sk-ant-key', + openai_api_key: 'sk-openai-key', + google_api_key: 'google-key', }); }); }); From 9c032558c5b2a7e26d0dab0cc97127aaa2214f68 Mon Sep 17 00:00:00 2001 From: vemv Date: Tue, 8 Sep 2026 11:49:05 +0200 Subject: [PATCH 30/39] refactor: simplify conditional in buildProviderCredentials Extract the complex conditional into a `hasAnyApiKey` helper function to reduce cyclomatic complexity and improve readability. --- src/refactoring/agent-config.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/refactoring/agent-config.ts b/src/refactoring/agent-config.ts index af3fda4e..5e958995 100644 --- a/src/refactoring/agent-config.ts +++ b/src/refactoring/agent-config.ts @@ -1,12 +1,16 @@ import { getAuthToken, getProviderOptions } from '../configuration'; import { AgentConfig, BedrockOpencodeProvider, ProviderCredentials } from './agent-types'; +function hasAnyApiKey(anthropicApiKey: string | undefined, openaiApiKey: string | undefined, googleApiKey: string | undefined): boolean { + return Boolean(anthropicApiKey || openaiApiKey || googleApiKey); +} + export function buildProviderCredentials(opts: Record): ProviderCredentials | undefined { const anthropicApiKey = opts['anthropic:api-key']; const openaiApiKey = opts['openai:api-key']; const googleApiKey = opts['google:api-key']; - if (!anthropicApiKey && !openaiApiKey && !googleApiKey) { + if (!hasAnyApiKey(anthropicApiKey, openaiApiKey, googleApiKey)) { return undefined; } From 0d8e5d26f177c51f6563f528d4316464fe29089f Mon Sep 17 00:00:00 2001 From: vemv Date: Tue, 8 Sep 2026 12:00:53 +0200 Subject: [PATCH 31/39] fix: infer agent model from provider credentials When using OpenAI or Anthropic API keys without an explicit model selection, the connectivity test was failing because it used the default Bedrock model. Now getAgentModel() checks providerOptions and returns a matching model (openai/gpt-4o or anthropic/claude-sonnet-4-0) when API keys are configured. --- src/configuration.ts | 17 +++++++++- src/test/suite/agent-config.test.ts | 48 +++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/configuration.ts b/src/configuration.ts index c973e2f1..ca55aac3 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -74,12 +74,27 @@ export function getAuthToken() { const DEFAULT_AGENT_MODEL = 'amazon-bedrock/eu.anthropic.claude-sonnet-4-6'; +function inferModelFromProviderOptions(): string | undefined { + const opts = getProviderOptions(); + if (opts['openai:api-key']) { + return 'openai/gpt-4o'; + } + if (opts['anthropic:api-key']) { + return 'anthropic/claude-sonnet-4-0'; + } + return undefined; +} + export function getAgentModel(): string { const override = getConfiguration('agentModelOverride', '')?.trim(); if (override) { return override; } - return getConfiguration('agentModel', DEFAULT_AGENT_MODEL)!; + const configured = getConfiguration('agentModel', ''); + if (configured) { + return configured; + } + return inferModelFromProviderOptions() ?? DEFAULT_AGENT_MODEL; } export function getProviderOptions(): Record { diff --git a/src/test/suite/agent-config.test.ts b/src/test/suite/agent-config.test.ts index d571263b..ea61cde9 100644 --- a/src/test/suite/agent-config.test.ts +++ b/src/test/suite/agent-config.test.ts @@ -4,6 +4,7 @@ import { buildOpencodeConfig, buildProviderCredentials, } from '../../refactoring/agent-config'; +import { getAgentModel } from '../../configuration'; import { mockConfiguration, restoreDefaultConfiguration } from '../setup'; import { aceSuite } from '../ace-test-suite'; @@ -259,4 +260,51 @@ aceSuite('AgentConfig Test Suite', () => { google_api_key: 'google-key', }); }); + + test('getAgentModel returns override when set', () => { + mockConfiguration('codescene', { agentModelOverride: 'custom/model-override' }); + assert.strictEqual(getAgentModel(), 'custom/model-override'); + }); + + test('getAgentModel returns configured agentModel when set', () => { + mockConfiguration('codescene', { agentModel: 'anthropic/claude-opus-4' }); + assert.strictEqual(getAgentModel(), 'anthropic/claude-opus-4'); + }); + + test('getAgentModel infers OpenAI model from providerOptions', () => { + mockConfiguration('codescene', { + providerOptions: { 'openai:api-key': 'sk-test-key' }, + }); + assert.strictEqual(getAgentModel(), 'openai/gpt-4o'); + }); + + test('getAgentModel infers Anthropic model from providerOptions', () => { + mockConfiguration('codescene', { + providerOptions: { 'anthropic:api-key': 'sk-ant-test' }, + }); + assert.strictEqual(getAgentModel(), 'anthropic/claude-sonnet-4-0'); + }); + + test('getAgentModel prefers OpenAI over Anthropic when both set', () => { + mockConfiguration('codescene', { + providerOptions: { + 'openai:api-key': 'sk-openai', + 'anthropic:api-key': 'sk-ant', + }, + }); + assert.strictEqual(getAgentModel(), 'openai/gpt-4o'); + }); + + test('getAgentModel returns Bedrock default when no provider keys', () => { + mockConfiguration('codescene', { providerOptions: {} }); + assert.strictEqual(getAgentModel(), 'amazon-bedrock/eu.anthropic.claude-sonnet-4-6'); + }); + + test('getAgentModel override takes precedence over inferred model', () => { + mockConfiguration('codescene', { + agentModelOverride: 'custom/override', + providerOptions: { 'openai:api-key': 'sk-test' }, + }); + assert.strictEqual(getAgentModel(), 'custom/override'); + }); }); From a1729a2a623794324a068a16d191bb7899910e18 Mon Sep 17 00:00:00 2001 From: vemv Date: Tue, 8 Sep 2026 12:51:30 +0200 Subject: [PATCH 32/39] fix: infer model from provider keys even with default agentModel When agentModel had its package.json default value, the model inference from providerOptions was never reached. Now getAgentModel() checks if agentModel equals the default before returning it, allowing OpenAI or Anthropic keys to select the appropriate model automatically. --- src/configuration.ts | 12 ++++++++++-- src/test/suite/agent-config.test.ts | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/configuration.ts b/src/configuration.ts index ca55aac3..5ab64ba0 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -90,11 +90,19 @@ export function getAgentModel(): string { if (override) { return override; } + const configured = getConfiguration('agentModel', ''); - if (configured) { + const inferred = inferModelFromProviderOptions(); + + if (configured && configured !== DEFAULT_AGENT_MODEL) { return configured; } - return inferModelFromProviderOptions() ?? DEFAULT_AGENT_MODEL; + + if (inferred) { + return inferred; + } + + return configured || DEFAULT_AGENT_MODEL; } export function getProviderOptions(): Record { diff --git a/src/test/suite/agent-config.test.ts b/src/test/suite/agent-config.test.ts index ea61cde9..4b479716 100644 --- a/src/test/suite/agent-config.test.ts +++ b/src/test/suite/agent-config.test.ts @@ -307,4 +307,20 @@ aceSuite('AgentConfig Test Suite', () => { }); assert.strictEqual(getAgentModel(), 'custom/override'); }); + + test('getAgentModel infers OpenAI even when agentModel has Bedrock default', () => { + mockConfiguration('codescene', { + agentModel: 'amazon-bedrock/eu.anthropic.claude-sonnet-4-6', + providerOptions: { 'openai:api-key': 'sk-test-key' }, + }); + assert.strictEqual(getAgentModel(), 'openai/gpt-4o'); + }); + + test('getAgentModel respects explicit non-default agentModel over inferred', () => { + mockConfiguration('codescene', { + agentModel: 'openai/gpt-4-turbo', + providerOptions: { 'anthropic:api-key': 'sk-ant-key' }, + }); + assert.strictEqual(getAgentModel(), 'openai/gpt-4-turbo'); + }); }); From 83bc12286c0a0b0a1b1e4c44f04d4613f5a1bdbc Mon Sep 17 00:00:00 2001 From: vemv Date: Tue, 8 Sep 2026 14:09:00 +0200 Subject: [PATCH 33/39] fix: treat content-identical refactorings as failures When the agent returns a proposed refactoring but the resulting code is identical to the original, override the response to `unable_to_fix` with low confidence. This prevents no-op refactorings from being presented as successful suggestions. --- src/refactoring/agent-service.ts | 10 +++++-- src/test/suite/agent-service.test.ts | 44 ++++++++++++++++++++++++---- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/refactoring/agent-service.ts b/src/refactoring/agent-service.ts index 9559e6c0..a01df7b0 100644 --- a/src/refactoring/agent-service.ts +++ b/src/refactoring/agent-service.ts @@ -169,8 +169,14 @@ export class AgentRefactoringService { fnToRefactor: FnToRefactor ): RefactorResponse { const code = AgentRefactoringService.applyChanges(document, fnToRefactor, output.changes); - const confidence = AgentRefactoringService.mapConfidence(output); - const reasons = AgentRefactoringService.mapReasons(output); + + const effectiveOutput = + code === fnToRefactor.body + ? { ...output, fix_result: 'unable_to_fix' as const, confidence: 'low' as const, summary: 'No changes proposed' } + : output; + + const confidence = AgentRefactoringService.mapConfidence(effectiveOutput); + const reasons = AgentRefactoringService.mapReasons(effectiveOutput); return { code, diff --git a/src/test/suite/agent-service.test.ts b/src/test/suite/agent-service.test.ts index 13949956..c9a2378c 100644 --- a/src/test/suite/agent-service.test.ts +++ b/src/test/suite/agent-service.test.ts @@ -57,13 +57,12 @@ aceSuite('AgentRefactoringService Test Suite', () => { name: 'mapOutputToResponse maps unable_to_fix correctly', fixture: 'unable-to-fix.json', fnBody: 'function complex() { return 42; }', - assertions: (response: any, output: AgentOutput) => { - assert.strictEqual(response['trace-id'], output.task_id); + assertions: (response: any) => { assert.strictEqual(response.confidence.level, 1); assert.strictEqual(response.confidence.title, 'Unable to refactor'); assert.strictEqual(response.confidence['review-header'], 'Review required'); assert.strictEqual(response.reasons.length, 1); - assert.strictEqual(response.reasons[0].summary, output.summary); + assert.strictEqual(response.reasons[0].summary, 'No changes proposed'); }, }, { @@ -100,6 +99,7 @@ aceSuite('AgentRefactoringService Test Suite', () => { for (const tc of confidenceLevelCases) { test(`mapOutputToResponse maps confidence level ${tc.confidence} to ${tc.expectedLevel}`, () => { + const fnBody = 'function test() { oldCode; }'; const output: AgentOutput = { '@context': { '@vocab': 'https://codescene.io/schemas/code-health-fix#' }, schema_version: '1.0', @@ -108,12 +108,19 @@ aceSuite('AgentRefactoringService Test Suite', () => { confidence: tc.confidence, summary: 'Test summary', reasoning: 'Test reasoning', - changes: [], + changes: [ + { + file: 'test.ts', + change_type: 'partial', + description: 'Test change', + replacements: [{ search: 'oldCode', replace: 'newCode' }], + }, + ], generated_at: '2026-08-31T12:00:00Z', }; - const fnToRefactor = createMockFnToRefactor('function test() {}'); - const document = createMockDocument('/test/test.ts', 'function test() {}'); + const fnToRefactor = createMockFnToRefactor(fnBody); + const document = createMockDocument('/test/test.ts', fnBody); const response = AgentRefactoringService.mapOutputToResponse(output, document, fnToRefactor); @@ -199,4 +206,29 @@ aceSuite('AgentRefactoringService Test Suite', () => { assert.strictEqual(response.metadata['cached?'], false); }); + + test('mapOutputToResponse marks content-identical refactoring as unable_to_fix', () => { + const fnBody = 'function test() { return 42; }'; + const output: AgentOutput = { + '@context': { '@vocab': 'https://codescene.io/schemas/code-health-fix#' }, + schema_version: '1.0', + task_id: 'test-identical', + fix_result: 'fix_proposed', + confidence: 'high', + summary: 'Refactoring suggestion', + reasoning: 'The refactoring improves code health.', + changes: [], + generated_at: '2026-08-31T12:00:00Z', + }; + + const fnToRefactor = createMockFnToRefactor(fnBody); + const document = createMockDocument('/test/test.ts', fnBody); + + const response = AgentRefactoringService.mapOutputToResponse(output, document, fnToRefactor); + + assert.strictEqual(response.confidence.level, 1); + assert.strictEqual(response.confidence.title, 'Unable to refactor'); + assert.strictEqual(response.confidence['review-header'], 'Review required'); + assert.strictEqual(response.reasons.length, 1); + }); }); From 88f0e00216a44e0674661555444dd6ae0e45b76c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Tue, 29 Sep 2026 10:01:50 +0200 Subject: [PATCH 34/39] chore: build CWF from local ../cs-webview when available (#359) * chore: add updatecwf target for local CWF development Automatically build and copy CWF from ../cs-webview when running as vemv, otherwise download from GitHub releases. Make build depend on updatecwf to ensure CWF is always up to date. * chore: fix local builds with local CWF * fix: make updatecwf work on Windows Move the local-vs-release CWF logic from a POSIX shell recipe into scripts/update-cwf.js, since make on Windows runs recipes via cmd.exe. * fix: run fetch-github-release CLI directly The package's bin launcher passes a raw Windows path to import(), which Node rejects. Invoking dist/src/cli.js directly works on all platforms. --------- Co-authored-by: vemv --- Makefile | 11 +---------- package.json | 4 ++-- scripts/package-platform.js | 2 +- scripts/update-cwf.js | 28 ++++++++++++++++++++++++++++ 4 files changed, 32 insertions(+), 13 deletions(-) create mode 100755 scripts/update-cwf.js diff --git a/Makefile b/Makefile index 7b030b75..49c4c665 100644 --- a/Makefile +++ b/Makefile @@ -80,16 +80,7 @@ updatedocs: npm run updatedocs updatecwf: - @if [ -d "../cs-webview" ] && [ -z "$${CI}" ]; then \ - echo "Building CWF locally from ../cs-webview..."; \ - (cd ../cs-webview && npm run build); \ - rm -rf ./cs-cwf; \ - cp -r ../cs-webview/build ./cs-cwf; \ - echo "CWF built and copied to ./cs-cwf"; \ - else \ - echo "Downloading CWF from GitHub releases..."; \ - npm run updatecwf; \ - fi + node ./scripts/update-cwf.js clean: npm run clean diff --git a/package.json b/package.json index fe55a8f5..042ecfbf 100644 --- a/package.json +++ b/package.json @@ -266,8 +266,8 @@ "test": "mocha", "test:coverage": "c8 npm run test", "test:coverage:dual": "node ./scripts/test-coverage-dual.js", - "updatedocs": "fetch-github-release empear-analytics codescene-ide-protocol", - "updatecwf": "fetch-github-release empear-analytics cs-webview ./cs-cwf", + "updatedocs": "node ./node_modules/@terascope/fetch-github-release/dist/src/cli.js empear-analytics codescene-ide-protocol", + "updatecwf": "node ./node_modules/@terascope/fetch-github-release/dist/src/cli.js empear-analytics cs-webview ./cs-cwf", "release:minor": "standard-version --release-as minor", "release:patch": "standard-version --release-as patch", "release:major": "standard-version --release-as major" diff --git a/scripts/package-platform.js b/scripts/package-platform.js index 97760fd4..3827855d 100755 --- a/scripts/package-platform.js +++ b/scripts/package-platform.js @@ -89,7 +89,7 @@ function packageExtension(platform, arch, projectRoot, buildNoAce, originalVscod console.log('\nStep 4: Updating docs and webview...'); const tokenEnv = { ...process.env, GITHUB_TOKEN: process.env.GITHUB_TOKEN || '' }; execSync('npm run updatedocs', { stdio: 'inherit', env: tokenEnv }); - execSync('make updatecwf', { stdio: 'inherit', env: tokenEnv }); + execSync(`node "${path.join(__dirname, 'update-cwf.js')}"`, { stdio: 'inherit', env: tokenEnv }); console.log('\nStep 5: Building extension...'); execSync('npm run build', { diff --git a/scripts/update-cwf.js b/scripts/update-cwf.js new file mode 100755 index 00000000..1816b5b7 --- /dev/null +++ b/scripts/update-cwf.js @@ -0,0 +1,28 @@ +#!/usr/bin/env node + +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const projectRoot = path.join(__dirname, '..'); +const localCwfDir = path.join(projectRoot, '..', 'cs-webview'); +const targetDir = path.join(projectRoot, 'cs-cwf'); + +function buildLocally() { + console.log(`Building CWF locally from ${localCwfDir}...`); + execSync('npm run build', { cwd: localCwfDir, stdio: 'inherit' }); + fs.rmSync(targetDir, { recursive: true, force: true }); + fs.cpSync(path.join(localCwfDir, 'build'), targetDir, { recursive: true }); + console.log(`CWF built and copied to ${targetDir}`); +} + +function downloadRelease() { + console.log('Downloading CWF from GitHub releases...'); + execSync('npm run updatecwf', { cwd: projectRoot, stdio: 'inherit' }); +} + +if (fs.existsSync(localCwfDir) && !process.env.CI) { + buildLocally(); +} else { + downloadRelease(); +} From 978e728c252972c17ff9f77b582d8b66c7807300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Thu, 29 Oct 2026 21:17:26 +0100 Subject: [PATCH 35/39] feat(refactoring): gate ACE on sign-in, drop preflight, fetch IDE token Preflight and fns-to-refactor were ACE-specific. With cs-agent every code smell is refactorable, so the function to refactor is now resolved locally from the review (or the whole file for file-level smells). Test Connectivity no longer requires the "Auth token" setting, and the Refactor action, CodeLens, status bar item and docs panel are shown only when Enable Auto Refactor is on and the user is authenticated by any route. cs-agent hands CS_ACCESS_TOKEN to the CodeScene MCP server and silently produces no output without it. When no token setting or legacy session exists, fetch one from `cs-ide auth token --client ide` as an interim until cs-ide exposes it over JSON-RPC. Missing output now surfaces agent stderr. --- package.json | 4 + src/auth/ace-credential.ts | 4 +- src/auth/ide-token.ts | 47 ++++++ src/code-health-monitor/codelens.ts | 7 +- src/code-health-monitor/home/home-view.ts | 3 +- .../ace-acknowledgement-mapper.ts | 8 +- src/cs-statusbar.ts | 22 +-- src/devtools-api/delta-model.ts | 2 +- src/devtools-api/delta-utils.ts | 22 --- src/devtools-api/fns-to-refactor-cache.ts | 84 ---------- src/devtools-api/ide-server-client.ts | 84 +--------- src/devtools-api/index.ts | 109 +++---------- src/devtools-api/refactor-models.ts | 12 -- src/devtools-api/review-cache.ts | 41 ----- src/devtools-api/rpc-response-normalizers.ts | 39 ----- src/extension-impl.ts | 8 +- src/refactoring/agent-config.ts | 22 +-- src/refactoring/agent-service.ts | 41 +++-- src/refactoring/agent-token.ts | 6 + src/refactoring/connectivity.ts | 7 +- src/refactoring/fn-to-refactor.ts | 57 +++++++ src/refactoring/index.ts | 29 ++-- src/refactoring/utils.ts | 12 +- src/review/codeaction.ts | 22 +-- src/review/codelens.ts | 9 +- src/test/fixtures/ide-server-fixture.ts | 22 --- .../suite/ace-acknowledgement-mapper.test.ts | 11 +- src/test/suite/ace-credential.test.ts | 6 +- src/test/suite/agent-config.test.ts | 4 +- src/test/suite/codeaction.test.ts | 8 - src/test/suite/delta.test.ts | 28 ++-- src/test/suite/devtools-api-jsonrpc.test.ts | 5 - src/test/suite/devtools-api-no-ace.test.ts | 6 +- src/test/suite/fn-to-refactor.test.ts | 141 ++++++++++++++++ src/test/suite/fns-to-refactor.test.ts | 103 ------------ src/test/suite/home-view.test.ts | 40 +---- src/test/suite/ide-server-client.test.ts | 28 ---- src/test/suite/ide-token.test.ts | 32 ++++ src/test/suite/post-refactoring.test.ts | 28 ++-- src/test/suite/preflight.test.ts | 153 ------------------ 40 files changed, 456 insertions(+), 860 deletions(-) create mode 100644 src/auth/ide-token.ts delete mode 100644 src/devtools-api/delta-utils.ts delete mode 100644 src/devtools-api/fns-to-refactor-cache.ts delete mode 100644 src/devtools-api/review-cache.ts create mode 100644 src/refactoring/agent-token.ts create mode 100644 src/refactoring/fn-to-refactor.ts create mode 100644 src/test/suite/fn-to-refactor.test.ts delete mode 100644 src/test/suite/fns-to-refactor.test.ts create mode 100644 src/test/suite/ide-token.test.ts delete mode 100644 src/test/suite/preflight.test.ts diff --git a/package.json b/package.json index a3d9afe7..8b35d767 100644 --- a/package.json +++ b/package.json @@ -144,6 +144,10 @@ "command": "codescene.startAutomaticAnalysis", "when": "!config.codescene.enableAutomaticAnalysis" }, + { + "command": "codescene.testConnectivity", + "when": "codescene.aceUsable" + }, { "command": "codescene.browserSignIn", "when": "codescene.signInActive" diff --git a/src/auth/ace-credential.ts b/src/auth/ace-credential.ts index e21dab50..ab0153f5 100644 --- a/src/auth/ace-credential.ts +++ b/src/auth/ace-credential.ts @@ -3,7 +3,7 @@ import { MissingAuthTokenError } from '../missing-auth-token-error'; export interface AceAuthState { active: boolean; - status: Pick | null; + status: Pick | null; } function nonBlank(value: string | undefined): string | undefined { @@ -16,7 +16,7 @@ export function resolveAceToken(settingToken: string | undefined, legacyToken: s } export function isAceAvailable(token: string | undefined, auth: AceAuthState): boolean { - return token !== undefined || (auth.active && auth.status?.['ace-credential'] === true); + return token !== undefined || (auth.active && auth.status?.state === 'signed-in'); } export function refactorToken(token: string | undefined, auth: AceAuthState): string | undefined { diff --git a/src/auth/ide-token.ts b/src/auth/ide-token.ts new file mode 100644 index 00000000..99130449 --- /dev/null +++ b/src/auth/ide-token.ts @@ -0,0 +1,47 @@ +import { execFile } from 'child_process'; +import { ArtifactInfo } from '../artifact-info'; +import { getConfiguration } from '../configuration'; +import { getExtensionPath } from '../cs-extension-state'; +import { logOutputChannel } from '../log'; +import { redactSecrets } from '../log-redaction'; + +const TOKEN_TIMEOUT_MS = 20000; + +interface CliTokenResponse { + status?: string; + 'access-token'?: string; +} + +export function parseIdeToken(stdout: string): string | undefined { + try { + const response = JSON.parse(stdout.trim()) as CliTokenResponse; + const token = response['access-token']?.trim(); + return response.status === 'signed_in' && token ? token : undefined; + } catch { + return undefined; + } +} + +export function ideTokenEnv(serverUrl: string | undefined, base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const url = serverUrl?.trim(); + return url ? { ...base, CS_ONPREM_URL: url } : base; +} + +export function fetchIdeToken(): Promise { + const extensionPath = getExtensionPath(); + if (!extensionPath) return Promise.resolve(undefined); + const executable = new ArtifactInfo(extensionPath).absoluteExecutablePath; + const env = ideTokenEnv(getConfiguration('serverUrl', '')); + const args = ['auth', 'token', '--client', 'ide', '--output-format', 'json']; + + return new Promise((resolve) => { + execFile(executable, args, { env, timeout: TOKEN_TIMEOUT_MS, windowsHide: true }, (error, stdout, stderr) => { + const token = parseIdeToken(stdout); + if (!token) { + const detail = redactSecrets((error?.message ?? stderr ?? '').trim()); + logOutputChannel.debug(`No IDE sign-in token available${detail ? `: ${detail}` : ''}`); + } + resolve(token); + }); + }); +} diff --git a/src/code-health-monitor/codelens.ts b/src/code-health-monitor/codelens.ts index bf7c69ad..d0bef7f5 100644 --- a/src/code-health-monitor/codelens.ts +++ b/src/code-health-monitor/codelens.ts @@ -1,6 +1,7 @@ import vscode, { Uri } from 'vscode'; import { ACE_ENABLED } from '../build-flags'; import { onDidChangeConfiguration, reviewCodeLensesEnabled } from '../configuration'; +import { aceUsable } from '../devtools-api'; import { issueToDocsParams } from '../documentation/commands'; import { reviewDocumentSelector } from '../language-support'; import { DeltaFunctionInfo } from './delta-function-info'; @@ -18,6 +19,10 @@ export function register(context: vscode.ExtensionContext) { ); } +function canOfferRefactoring(functionInfo: DeltaFunctionInfo): boolean { + return ACE_ENABLED && aceUsable() && functionInfo.isRefactoringSupported; +} + export class CodeHealthMonitorCodeLens implements vscode.CodeLensProvider { private changeCodeLensesEmitter = new vscode.EventEmitter(); onDidChangeCodeLenses = this.changeCodeLensesEmitter.event; @@ -65,7 +70,7 @@ export class CodeHealthMonitorCodeLens implements vscode.CodeLensProvider { const { nIssues, scoreChange } = fileWithIssues; diff --git a/src/codescene-tab/webview/ace/acknowledgement/ace-acknowledgement-mapper.ts b/src/codescene-tab/webview/ace/acknowledgement/ace-acknowledgement-mapper.ts index 7a8b6a59..551f3d5f 100644 --- a/src/codescene-tab/webview/ace/acknowledgement/ace-acknowledgement-mapper.ts +++ b/src/codescene-tab/webview/ace/acknowledgement/ace-acknowledgement-mapper.ts @@ -23,13 +23,13 @@ export function getAutoRefactorConfig(): AutoRefactorConfig { return { disabled: true, activated: false, visible: false }; } - const hasToken = aceAvailable(); + const authenticated = aceAvailable(); const acknowledged = CsExtensionState.acknowledgedAceUsage === true; - const activated = !(!acknowledged && hasToken); + const activated = !(!acknowledged && authenticated); return { - disabled: !hasToken, + disabled: !authenticated, activated, - visible: CsExtensionState.stateProperties.features.ace.state === 'enabled', + visible: authenticated && CsExtensionState.stateProperties.features.ace.state === 'enabled', }; } diff --git a/src/cs-statusbar.ts b/src/cs-statusbar.ts index 6aadbe4f..cdbf5b38 100644 --- a/src/cs-statusbar.ts +++ b/src/cs-statusbar.ts @@ -1,7 +1,7 @@ import vscode, { Disposable } from 'vscode'; import { ACE_ENABLED } from './build-flags'; import { AnalysisFeature, CsExtensionState, CsFeature } from './cs-extension-state'; -import { aceAvailable } from './devtools-api'; +import { aceUsable } from './devtools-api'; import { CreditsInfoError } from './devtools-api/credits-info-error'; import { isDefined, toUppercase } from './utils'; import { automaticAnalysisEnabled, onDidChangeConfiguration } from './configuration'; @@ -51,6 +51,11 @@ export class CsStatusBar implements Disposable { return; } const item = this.aceStatus; + if (!aceUsable()) { + item.hide(); + return; + } + item.show(); const handler = this.aceStateHandlers[ace.state] || (() => {}); handler(ace, item); } @@ -150,14 +155,6 @@ export class CsStatusBar implements Disposable { background: 'statusBarItem.warningBackground', }); }, - disabled: (ace, item) => { - this.setStatus(item, { - text: `$(error) ACE ${toUppercase(ace.state)}`, - tooltip: 'Enable ACE in the extension settings', - command: 'codescene.ace.setEnabled', - background: 'statusBarItem.warningBackground', - }); - }, loading: (ace, item) => { this.setStatus(item, { text: '$(loading~spin) ACE', @@ -165,12 +162,9 @@ export class CsStatusBar implements Disposable { }); }, enabled: (ace, item) => { - const hasToken = aceAvailable(); this.setStatus(item, { - text: `$(${hasToken ? 'cs-logo' : 'error'}) ACE`, - command: 'codescene.openSettingsAndFocusToken', - background: hasToken ? undefined : 'statusBarItem.warningBackground', - tooltip: hasToken ? 'CodeScene Agent is active' : 'Configure Agent auth token in extension Workspace settings', + text: '$(cs-logo) ACE', + tooltip: 'CodeScene Agent is active', }); }, }; diff --git a/src/devtools-api/delta-model.ts b/src/devtools-api/delta-model.ts index 3937d019..9d2f5ea7 100644 --- a/src/devtools-api/delta-model.ts +++ b/src/devtools-api/delta-model.ts @@ -62,7 +62,7 @@ export interface FunctionFinding { 'change-details': ChangeDetail[]; function: Function; - /* Present if the function finding is deemed refactorable. See Analyser.addRefactorableFunctionsToDeltaResult() */ + /* Present if the function finding is deemed refactorable. See DevtoolsAPI.addRefactorableFunctions() */ refactorableFn?: FnToRefactor; } diff --git a/src/devtools-api/delta-utils.ts b/src/devtools-api/delta-utils.ts deleted file mode 100644 index ed8bd2ad..00000000 --- a/src/devtools-api/delta-utils.ts +++ /dev/null @@ -1,22 +0,0 @@ -import vscode from 'vscode'; -import { DevtoolsAPI } from '.'; -import { vscodeRange } from '../review/utils'; -import { Delta } from './delta-model'; - -/** - * NOTE - Mutates the delta result by adding info about refactorable functions to the 'function-level-findings' list. - */ -export async function addRefactorableFunctionsToDeltaResult(document: vscode.TextDocument, deltaForFile: Delta) { - const functionsToRefactor = await DevtoolsAPI.fnsToRefactorFromDelta(document, deltaForFile); - if (!functionsToRefactor) return; - - // Add a refactorableFn property to the findings that matches function name and range - deltaForFile['function-level-findings'].forEach((finding) => { - const findingRange = vscodeRange(finding.function.range); - if (!findingRange) return; - const refactorableFunctionForFinding = functionsToRefactor.find( - (fn) => fn.name === finding.function.name && fn.vscodeRange.intersection(findingRange) - ); - finding.refactorableFn = refactorableFunctionForFinding; - }); -} diff --git a/src/devtools-api/fns-to-refactor-cache.ts b/src/devtools-api/fns-to-refactor-cache.ts deleted file mode 100644 index a08da94c..00000000 --- a/src/devtools-api/fns-to-refactor-cache.ts +++ /dev/null @@ -1,84 +0,0 @@ -import vscode from 'vscode'; -import { FnToRefactor } from './refactor-models'; -import { CodeSmell } from './review-model'; -import { DevtoolsAPI } from '.'; - -/** - * Shared cache for fnsToRefactor results to avoid redundant binary calls. - * Key format: :v::: - */ -class FnsToRefactorCache { - private cache = new Map(); - - // Kept private in order to enforce use of the caching layer. - private async fnsToRefactorFromCodeSmell( - document: vscode.TextDocument, - codeSmell: CodeSmell - ): Promise { - const result = await DevtoolsAPI.fnsToRefactor(document, { 'code-smells': [codeSmell] }); - return result?.[0]; - } - - private buildCacheKey(document: vscode.TextDocument, codeSmell: CodeSmell): string { - const line = codeSmell['highlight-range']['start-line']; - const character = codeSmell['highlight-range']['start-column']; - return `${document.uri.toString()}:v${document.version}:${line}:${character}:${codeSmell.category}`; - } - - async fnsToRefactor(document: vscode.TextDocument, codeSmell: CodeSmell): Promise { - const cacheKey = this.buildCacheKey(document, codeSmell); - - const cached = this.cache.get(cacheKey); - if (this.cache.has(cacheKey)) { - return cached; - } - - const result = await this.fnsToRefactorFromCodeSmell(document, codeSmell); - this.cache.set(cacheKey, result); - return result; - } - - private isStaleEntry(key: string, docUri: string, currentVersion: number): boolean { - if (!key.startsWith(docUri + ':v')) { - return false; - } - - const versionMatch = key.match(/:v(\d+):/); - if (!versionMatch) { - return false; - } - - const cachedVersion = parseInt(versionMatch[1], 10); - return cachedVersion < currentVersion; - } - - invalidateForDocument(document: vscode.TextDocument): void { - const docUri = document.uri.toString(); - const currentVersion = document.version; - const keysToDelete: string[] = []; - - for (const key of this.cache.keys()) { - if (this.isStaleEntry(key, docUri, currentVersion)) { - keysToDelete.push(key); - } - } - - keysToDelete.forEach((key) => this.cache.delete(key)); - } - - cleanupForDocument(document: vscode.TextDocument): void { - const docUri = document.uri.toString(); - const keysToDelete: string[] = []; - - for (const key of this.cache.keys()) { - if (key.startsWith(docUri + ':v')) { - keysToDelete.push(key); - } - } - - keysToDelete.forEach((key) => this.cache.delete(key)); - } - -} - -export const fnsToRefactorCache = new FnsToRefactorCache(); diff --git a/src/devtools-api/ide-server-client.ts b/src/devtools-api/ide-server-client.ts index 4e2af24d..26a4b001 100644 --- a/src/devtools-api/ide-server-client.ts +++ b/src/devtools-api/ide-server-client.ts @@ -11,16 +11,14 @@ import { AbortError } from './abort-error'; import { Delta } from './delta-model'; import { DevtoolsError } from './devtools-error'; import { CheckRulesResponse, CodeHealthRulesTemplateResponse } from './model'; -import { FnToRefactor, PreFlightResponse, RefactorResponse } from './refactor-models'; +import { FnToRefactor, RefactorResponse } from './refactor-models'; import { Review } from './review-model'; import { TelemetryEvent, TelemetryResponse } from './telemetry-model'; import { checkRulesResponse, deltaResponse, deviceIdResponse, - fnToRefactorResponse, notificationRepoRoot, - preflightResponse, refactorResponse, reviewProgressResponse, reviewResponse, @@ -113,15 +111,6 @@ export interface DeltaParams { 'new-score'?: string; } -export interface FnsToRefactorParams { - 'file-name': string; - 'file-content': string; - preflight: PreFlightResponse; - 'cache-path'?: string; - 'delta-result'?: Delta; - 'code-smells'?: unknown[]; -} - export interface RefactorParams { token?: string; 'fn-to-refactor'?: FnToRefactor; @@ -262,15 +251,6 @@ export class CsIdeServerClient implements vscode.Disposable { return response ? deltaResponse(response) : null; } - async preflight(params: { force?: boolean } = {}): Promise { - return preflightResponse(await this.sendRequest('cs-ide/preflight', params)); - } - - async fnsToRefactor(params: FnsToRefactorParams): Promise { - const response = await this.sendRequest[]>('cs-ide/fns-to-refactor', params); - return response.map(fnToRefactorResponse); - } - async refactor(params: RefactorParams, signal?: AbortSignal): Promise { const cancellation = this.bindRefactorCancellation(signal); try { @@ -612,19 +592,6 @@ function isWireObject(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } -function wireField(value: Record, ...keys: string[]): T | undefined { - for (const key of keys) { - if (value[key] !== undefined) return value[key] as T; - } - return undefined; -} - -function fnsToRefactorInput(value: Record): string { - if (value['delta-result'] !== undefined) return 'delta-result'; - if (Array.isArray(value['code-smells'])) return 'code-smells'; - return 'none'; -} - function refactorPayloadFormat(value: Record): string { if (value['fn-to-refactor-nippy-b64'] !== undefined) return 'nippy-b64'; if (value['fn-to-refactor'] !== undefined) return 'json'; @@ -645,13 +612,6 @@ const requestSummaries: Record) => strin hasOldScore: value['old-score'] !== undefined, hasNewScore: value['new-score'] !== undefined, }), - 'cs-ide/preflight': (value) => formatLogFields({ force: value.force === true }), - 'cs-ide/fns-to-refactor': (value) => formatLogFields({ - fileName: value['file-name'], - input: fnsToRefactorInput(value), - smellCount: Array.isArray(value['code-smells']) ? value['code-smells'].length : undefined, - hasCachePath: value['cache-path'] !== undefined, - }), 'cs-ide/refactor': (value) => formatLogFields({ format: refactorPayloadFormat(value), skipCache: value['skip-cache'] === true, @@ -667,25 +627,9 @@ function summarizeRequest(method: string, params: unknown): string { return requestSummaries[method]?.(params) ?? ''; } -function summarizeFnsToRefactorResponse(result: unknown[]): string { - return formatLogFields({ - count: result.length, - functions: result.map(summarizeFnToRefactor), - }); -} - const responseSummaries: Record) => string> = { 'cs-ide/review': (value) => formatLogFields(reviewSummary(reviewResponse(value))), 'cs-ide/delta': (value) => formatLogFields(deltaSummary(deltaResponse(value))), - 'cs-ide/preflight': (value) => { - const response = preflightResponse(value); - return formatLogFields({ - version: response.version, - fileTypes: response['file-types'], - codeSmellCount: response['language-common']['code-smells'].length, - maxInputLoc: response['language-common']['max-input-loc'], - }); - }, 'cs-ide/refactor': (value) => { const response = refactorResponse(value); return formatLogFields({ @@ -715,9 +659,6 @@ const responseSummaries: Record) => stri }; function summarizeResponse(method: string, result: unknown): string { - if (method === 'cs-ide/fns-to-refactor' && Array.isArray(result)) { - return summarizeFnsToRefactorResponse(result); - } if (!isWireObject(result)) return ''; return responseSummaries[method]?.(result) ?? ''; } @@ -750,26 +691,3 @@ function deltaSummary( functionFindings: result['function-level-findings'].length, }; } - -function summarizeFnToRefactor(value: unknown): string { - if (!isWireObject(value)) return '(invalid)'; - const name = typeof value.name === 'string' ? value.name : '(unnamed)'; - const rangeLabel = fnRangeLabel(value.range); - const categories = refactoringTargetCategories(value); - return `${name}${rangeLabel ? `@${rangeLabel}` : ''}${categories.length ? `[${categories.join(', ')}]` : ''}`; -} - -function fnRangeLabel(range: unknown): string | undefined { - if (!isWireObject(range)) return undefined; - const startLine = wireField(range, 'startLine', 'start-line'); - const startColumn = wireField(range, 'startColumn', 'start-column'); - const endLine = wireField(range, 'endLine', 'end-line'); - const endColumn = wireField(range, 'endColumn', 'end-column'); - if ([startLine, startColumn, endLine, endColumn].includes(undefined)) return undefined; - return `${startLine}:${startColumn}-${endLine}:${endColumn}`; -} - -function refactoringTargetCategories(fn: Record): string[] { - const targets = wireField>(fn, 'refactoringTargets', 'refactoring-targets') ?? []; - return targets.map((target) => target.category).filter((category): category is string => !!category); -} diff --git a/src/devtools-api/index.ts b/src/devtools-api/index.ts index 632f939f..afb104b9 100644 --- a/src/devtools-api/index.ts +++ b/src/devtools-api/index.ts @@ -1,9 +1,7 @@ -import { CodeSmell, Review } from './review-model'; +import { Review } from './review-model'; import { assertError, getWorkspaceCwd, networkErrors, rangeStr, reportError } from '../utils'; import { AceRequestEvent, CodeHealthRulesResult } from './model'; import { FnToRefactor, RefactorResponse } from './refactor-models'; -import { basename, dirname } from 'path'; -import { existsSync } from 'fs'; import vscode, { ExtensionContext, TextDocument } from 'vscode'; import { isAceAvailable, refactorToken, resolveAceToken } from '../auth/ace-credential'; import { ACE_ENABLED } from '../build-flags'; @@ -12,11 +10,9 @@ import { CsExtensionState, CsFeature } from '../cs-extension-state'; import { logOutputChannel } from '../log'; import { RefactoringRequest } from '../refactoring/request'; import { AgentRefactoringService, ProgressCallback } from '../refactoring/agent-service'; -import { vscodeRange } from '../review/utils'; import { Delta } from './delta-model'; -import { addRefactorableFunctionsToDeltaResult } from './delta-utils'; +import { fnToRefactorForFinding } from '../refactoring/fn-to-refactor'; import { TelemetryEvent, TelemetryResponse } from './telemetry-model'; -import { ReviewCache } from './review-cache'; import { AbortError } from './abort-error'; import { acquireGitApi, fireFileDeletedFromGit, getRepoRootPath } from '../git-utils'; import Reviewer, { ReviewOpts } from '../review/reviewer'; @@ -28,7 +24,6 @@ import CsDiagnostics from '../diagnostics/cs-diagnostics'; import { relativePosix } from '../utils/fs-paths'; export class DevtoolsAPI { - private static reviewCache: ReviewCache; private static ideServer: CsIdeServerClient; private static pipeline: ReviewPipeline; private static queueSubscription?: vscode.Disposable; @@ -38,7 +33,6 @@ export class DevtoolsAPI { DevtoolsAPI.pipeline?.dispose(); DevtoolsAPI.queueSubscription?.dispose(); DevtoolsAPI.ideServer?.dispose(); - DevtoolsAPI.reviewCache = new ReviewCache(context); DevtoolsAPI.ideServer = ideServer ?? new CsIdeServerClient(binaryPath); DevtoolsAPI.pipeline = new ReviewPipeline(DevtoolsAPI.ideServer, DevtoolsAPI.pipelinePresentation(), uuid); DevtoolsAPI.queueCount = 0; @@ -202,18 +196,15 @@ export class DevtoolsAPI { normalized['file-level-findings'] ??= []; normalized['function-level-findings'] ??= []; } + if (normalized && DevtoolsAPI.aceEnabled()) DevtoolsAPI.addRefactorableFunctions(document, normalized); Reviewer.instance.reviewCache.get(document, 'any')?.setDelta(normalized); DevtoolsAPI.deltaAnalysisEmitter.fire({ document, result: normalized, updateMonitor }); - if (normalized) void DevtoolsAPI.enrichServerDelta(document, normalized, updateMonitor); } - private static async enrichServerDelta(document: TextDocument, result: Delta, updateMonitor: boolean): Promise { - try { - await addRefactorableFunctionsToDeltaResult(document, result); - DevtoolsAPI.deltaAnalysisEmitter.fire({ document, result, updateMonitor, enrichment: true }); - } catch (error) { - logOutputChannel.warn(`[cs-ide] could not enrich delta for ${document.fileName}: ${assertError(error).message}`); - } + private static addRefactorableFunctions(document: TextDocument, delta: Delta): void { + delta['function-level-findings'].forEach((finding) => { + finding.refactorableFn = fnToRefactorForFinding(document, finding); + }); } private static removeServerReview(document: TextDocument): void { @@ -224,58 +215,24 @@ export class DevtoolsAPI { private static readonly deltaAnalysisEmitter = new vscode.EventEmitter(); public static readonly onDidDeltaAnalysisComplete = DevtoolsAPI.deltaAnalysisEmitter.event; - private static readonly preflightRequestEmitter = new vscode.EventEmitter(); - public static readonly onDidChangePreflightState = DevtoolsAPI.preflightRequestEmitter.event; - private static preflightJson?: string; + private static readonly aceStateEmitter = new vscode.EventEmitter(); + public static readonly onDidChangeAceState = DevtoolsAPI.aceStateEmitter.event; + private static aceActive = false; - static async preflight() { + static enableAce() { if (!ACE_ENABLED) return; - DevtoolsAPI.preflightRequestEmitter.fire({ state: 'loading' }); - try { - const response = await DevtoolsAPI.ideServer.preflight(); - DevtoolsAPI.preflightJson = JSON.stringify(response); - DevtoolsAPI.preflightRequestEmitter.fire({ state: 'enabled' }); - return response; - } catch (e) { - if (DevtoolsAPI.shouldHandleOfflineBehavior(e)) { - DevtoolsAPI.handleOfflineBehavior(); - return; - } - DevtoolsAPI.preflightRequestEmitter.fire({ state: 'error', error: assertError(e) }); - reportError({ context: 'Unable to enable refactoring capabilities', e }); - } + DevtoolsAPI.aceActive = true; + DevtoolsAPI.aceStateEmitter.fire({ state: 'enabled' }); } static aceEnabled() { - return ACE_ENABLED && DevtoolsAPI.preflightJson !== undefined; + return ACE_ENABLED && DevtoolsAPI.aceActive; } static disableAce() { if (!ACE_ENABLED) return; - DevtoolsAPI.preflightJson = undefined; - DevtoolsAPI.preflightRequestEmitter.fire({ state: 'disabled' }); - } - - static async fnsToRefactorFromDelta(document: TextDocument, delta: Delta) { - return this.fnsToRefactor(document, { 'delta-result': delta }); - } - - static async fnsToRefactor(document: TextDocument, args: { 'delta-result': Delta } | { 'code-smells': [CodeSmell] }) { - if (!DevtoolsAPI.aceEnabled()) return; - logOutputChannel.debug(`Calling fns-to-refactor for ${basename(document.fileName)}`); - const fp = fileParts(document); - if (!validateFileParts('fnsToRefactor', fp, document)) return; - const cachePath = DevtoolsAPI.reviewCache.getCachePath(); - const ret = await DevtoolsAPI.ideServer.fnsToRefactor({ - 'file-name': fp.fileName, - 'file-content': document.getText(), - preflight: JSON.parse(DevtoolsAPI.preflightJson!), - ...(cachePath ? { 'cache-path': cachePath } : {}), - ...args, - }); - ret.forEach((fn) => (fn.vscodeRange = vscodeRange(fn.range)!)); - logOutputChannel.debug(`Completed fns-to-refactor for ${basename(document.fileName)}, found ${ret.length} function(s)`); - return ret; + DevtoolsAPI.aceActive = false; + DevtoolsAPI.aceStateEmitter.fire({ state: 'disabled' }); } private static readonly refactoringRequestEmitter = new vscode.EventEmitter(); @@ -354,13 +311,13 @@ export class DevtoolsAPI { void vscode.window.showInformationMessage('CodeScene extension is running in offline mode. Some features may be unavailable.'); } logOutputChannel.warn('CodeScene extension is running in offline mode. The requested action could not be completed. Please check your internet connection to restore full functionality.'); - DevtoolsAPI.preflightRequestEmitter.fire({ state: 'offline' }); + DevtoolsAPI.aceStateEmitter.fire({ state: 'offline' }); } private static handleBackOnline() { DevtoolsAPI.lastNetworkError = false; if (ACE_ENABLED && CsExtensionState.stateProperties.features.ace.state === 'offline') { - DevtoolsAPI.preflightRequestEmitter.fire({ state: 'enabled' }); + DevtoolsAPI.aceStateEmitter.fire({ state: 'enabled' }); void vscode.window.showInformationMessage('CodeScene extension is back online.'); } } @@ -373,35 +330,12 @@ export class DevtoolsAPI { try { DevtoolsAPI.analysisErrorEmitter.dispose(); } catch {} try { DevtoolsAPI.reviewEmitter.dispose(); } catch {} try { DevtoolsAPI.deltaAnalysisEmitter.dispose(); } catch {} - try { DevtoolsAPI.preflightRequestEmitter.dispose(); } catch {} + try { DevtoolsAPI.aceStateEmitter.dispose(); } catch {} try { DevtoolsAPI.refactoringRequestEmitter.dispose(); } catch {} try { DevtoolsAPI.refactoringErrorEmitter.dispose(); } catch {} } } -interface FileParts { - fileName: string; - documentDirectory: string; -} - -function fileParts(document: vscode.TextDocument): FileParts { - return { fileName: basename(document.fileName), documentDirectory: dirname(document.fileName) }; -} - -function validateFileParts(operation: string, fp: FileParts, document: vscode.TextDocument): boolean { - if (!fp.documentDirectory?.trim()) { - logOutputChannel.warn(`Operation ${operation} skipped for ${basename(document.fileName)}: document directory is empty`); - return false; - } - let exists = true; - try { exists = existsSync(fp.documentDirectory); } catch {} - if (!exists) { - logOutputChannel.warn(`Operation ${operation} skipped for ${basename(document.fileName)}: document directory does not exist: ${fp.documentDirectory}`); - return false; - } - return true; -} - export function aceToken(): string | undefined { return resolveAceToken(getAuthToken(), CsExtensionState.stateProperties.legacySession?.accessToken); } @@ -410,6 +344,10 @@ export function aceAvailable(): boolean { return isAceAvailable(aceToken(), CsExtensionState.stateProperties.auth); } +export function aceUsable(): boolean { + return DevtoolsAPI.aceEnabled() && aceAvailable(); +} + export function logIdString(fnToRefactor: FnToRefactor, traceId?: string) { return `[traceId ${traceId ?? 'n/a'}] "${fnToRefactor.name}" ${rangeStr(fnToRefactor.vscodeRange)}`; } @@ -426,5 +364,4 @@ export type DeltaAnalysisEvent = { document: vscode.TextDocument; result?: Delta; updateMonitor: boolean; - enrichment?: boolean; }; diff --git a/src/devtools-api/refactor-models.ts b/src/devtools-api/refactor-models.ts index 0d9425b8..0848d0a1 100644 --- a/src/devtools-api/refactor-models.ts +++ b/src/devtools-api/refactor-models.ts @@ -62,18 +62,6 @@ export interface RefactoringTarget { line: number; } -export type PreFlightResponse = { - version: number; - 'file-types': string[]; - 'language-common': RefactorSupport; - 'language-specific'?: Record>; -}; - -export type RefactorSupport = { - 'max-input-loc': number; - 'code-smells': string[]; -}; - export interface RefactorResponse { /** * Refactored code diff --git a/src/devtools-api/review-cache.ts b/src/devtools-api/review-cache.ts deleted file mode 100644 index c23e4ab2..00000000 --- a/src/devtools-api/review-cache.ts +++ /dev/null @@ -1,41 +0,0 @@ - -import fs from 'fs'; -import path from 'path'; -import { ExtensionContext } from 'vscode'; - -function deleteFilesOlderThan(days: number, folderPath: string) { - const now = new Date(); - const files = fs.readdirSync(folderPath); - const daysInMilliseconds = days * 1000 * 60 * 60 * 24; - files.forEach((file) => { - const filePath = path.join(folderPath, file); - const stats = fs.statSync(filePath); - const fileAge = now.getTime() - stats.mtime.getTime(); - if (fileAge >= daysInMilliseconds) { - fs.unlinkSync(filePath); - } - }); -} - -export class ReviewCache { - private cachePath: string | undefined; - - constructor(context: ExtensionContext) { - const storagePath = context.storageUri?.fsPath; - if (storagePath) { - const cachePath = path.join(storagePath, ".review-caches"); - this.cachePath = cachePath; - if (!fs.existsSync(cachePath)) { - fs.mkdirSync(cachePath, {recursive: true}); - } - deleteFilesOlderThan(30, cachePath); - } - else { - this.cachePath = undefined; - } - } - - getCachePath(): string | undefined { - return this.cachePath; - } -} diff --git a/src/devtools-api/rpc-response-normalizers.ts b/src/devtools-api/rpc-response-normalizers.ts index 4ab2f7f0..678b05fa 100644 --- a/src/devtools-api/rpc-response-normalizers.ts +++ b/src/devtools-api/rpc-response-normalizers.ts @@ -2,10 +2,7 @@ import { ChangeDetail, ChangeType, Delta, FunctionFinding } from './delta-model' import { CheckRulesResponse } from './model'; import { Confidence, - FnToRefactor, - PreFlightResponse, RefactorResponse, - RefactorSupport, RefactoringProperties, } from './refactor-models'; import { CodeSmell, Function as ReviewFunction, Range, Review } from './review-model'; @@ -92,42 +89,6 @@ export function deltaResponse(value: WireObject): Delta { }; } -function refactorSupport(value: WireObject): RefactorSupport { - return { - 'max-input-loc': field(value, 'maxInputLoc', 'max-input-loc')!, - 'code-smells': field(value, 'codeSmells', 'code-smells') ?? [], - }; -} - -export function preflightResponse(value: WireObject): PreFlightResponse { - const languageSpecific = field>(value, 'languageSpecific', 'language-specific'); - return { - version: value.version, - 'file-types': field(value, 'fileTypes', 'file-types') ?? [], - 'language-common': refactorSupport(field(value, 'languageCommon', 'language-common')!), - ...(languageSpecific - ? { 'language-specific': Object.fromEntries(Object.entries(languageSpecific).map(([language, support]) => [language, refactorSupport(support)])) } - : {}), - }; -} - -export function fnToRefactorResponse(value: WireObject): FnToRefactor { - return { - body: value.body, - name: value.name, - 'file-type': field(value, 'fileType', 'file-type')!, - ...(field(value, 'functionType', 'function-type') === undefined - ? {} - : { 'function-type': field(value, 'functionType', 'function-type') }), - ...(field(value, 'nippyB64', 'nippy-b64') === undefined && value['nippy-b-64'] === undefined - ? {} - : { 'nippy-b64': field(value, 'nippyB64', 'nippy-b64') ?? value['nippy-b-64'] }), - range: range(value.range)!, - 'refactoring-targets': field(value, 'refactoringTargets', 'refactoring-targets') ?? [], - vscodeRange: undefined!, - }; -} - function confidence(value: WireObject): Confidence { return { level: value.level, diff --git a/src/extension-impl.ts b/src/extension-impl.ts index 6a4c8b0c..9d61c612 100644 --- a/src/extension-impl.ts +++ b/src/extension-impl.ts @@ -5,7 +5,7 @@ import { activate as activateCHMonitor, deactivate as deactivateAddon } from './ import { register as registerCHRulesCommands } from './code-health-rules'; import { automaticAnalysisEnabled, onDidChangeConfiguration, setAutomaticAnalysisEnabled, toggleReviewCodeLenses } from './configuration'; import { CsExtensionState } from './cs-extension-state'; -import { DevtoolsAPI } from './devtools-api'; +import { aceUsable, DevtoolsAPI } from './devtools-api'; import type { CsIdeServerClient } from './devtools-api/ide-server-client'; import CsDiagnostics from './diagnostics/cs-diagnostics'; import { register as registerDocumentationCommands } from './documentation/commands'; @@ -238,6 +238,12 @@ function registerCommands(context: vscode.ExtensionContext) { function registerTestConnectivityCommand(context: vscode.ExtensionContext) { const cmd = vscode.commands.registerCommand('codescene.testConnectivity', async () => { + if (!aceUsable()) { + void vscode.window.showInformationMessage( + 'Enable "Enable Auto Refactor" and sign in to CodeScene to test provider connectivity.' + ); + return; + } const abortController = new AbortController(); await vscode.window.withProgress( diff --git a/src/refactoring/agent-config.ts b/src/refactoring/agent-config.ts index 5e958995..2fe69728 100644 --- a/src/refactoring/agent-config.ts +++ b/src/refactoring/agent-config.ts @@ -1,4 +1,4 @@ -import { getAuthToken, getProviderOptions } from '../configuration'; +import { getProviderOptions } from '../configuration'; import { AgentConfig, BedrockOpencodeProvider, ProviderCredentials } from './agent-types'; function hasAnyApiKey(anthropicApiKey: string | undefined, openaiApiKey: string | undefined, googleApiKey: string | undefined): boolean { @@ -51,17 +51,13 @@ export function buildOpencodeConfig(opts: Record): AgentConfig[' return { provider }; } -export function buildAgentConfigWithToken(token: string, ioDir?: string): AgentConfig { - if (!token) { - throw new Error('No authentication token available for agent'); - } - +export function buildAgentConfigWithToken(token: string | undefined, ioDir?: string): AgentConfig { const providerOpts = getProviderOptions(); const providerCredentials = buildProviderCredentials(providerOpts); const opencodeConfig = buildOpencodeConfig(providerOpts); return { - codescene_access_token: token, + codescene_access_token: token ?? '', plugins: ['render-code-fix-reporter'], tracking: { environment: 'codescene-vscode' }, ...(ioDir && { io_json_dir: ioDir }), @@ -69,15 +65,3 @@ export function buildAgentConfigWithToken(token: string, ioDir?: string): AgentC ...(opencodeConfig && { opencode_config: opencodeConfig }), }; } - -export function buildAgentConfig(): AgentConfig { - const token = getAuthToken(); - if (!token) { - throw new Error('No authentication token available for agent'); - } - return buildAgentConfigWithToken(token); -} - -export function buildAgentConfigJson(): string { - return JSON.stringify(buildAgentConfig()); -} diff --git a/src/refactoring/agent-service.ts b/src/refactoring/agent-service.ts index acb15ba9..33757d78 100644 --- a/src/refactoring/agent-service.ts +++ b/src/refactoring/agent-service.ts @@ -9,7 +9,7 @@ import { logOutputChannel } from '../log'; import { buildAgentConfigWithToken } from './agent-config'; import { AgentInput, AgentOutput, ConfidenceLevel, AgentChange } from './agent-types'; import { getExtensionPath } from '../cs-extension-state'; -import { aceToken } from '../devtools-api'; +import { resolveAgentToken } from './agent-token'; import { getAgentModel } from '../configuration'; import { acquireGitApi, getRepoRootPath } from '../git-utils'; @@ -54,10 +54,13 @@ export class AgentRefactoringService { logOutputChannel.info(`Agent refactoring started for task ${taskId}`); - await AgentRefactoringService.invokeAgent(workDir, ioDir, signal, onProgress); + const token = await AgentRefactoringService.requireToken(); + const stderr = await AgentRefactoringService.invokeAgent(workDir, ioDir, token, signal, onProgress); if (!fs.existsSync(outputPath)) { - throw new Error('Agent did not produce output file'); + const detail = stripAnsi(stderr).trim().split('\n').slice(-5).join('\n'); + logOutputChannel.error(`Agent exited successfully without producing output. stderr: ${detail}`); + throw new Error(detail ? `Agent did not produce output file: ${detail}` : 'Agent did not produce output file'); } const outputContent = fs.readFileSync(outputPath, 'utf-8'); @@ -88,12 +91,22 @@ export class AgentRefactoringService { }; } - private static spawnAgentProcess(workDir: string, ioDir: string): { proc: ChildProcess; binaryPath: string } { - const binaryPath = AgentRefactoringService.getAgentBinaryPath(); - const token = aceToken(); + private static async requireToken(): Promise { + const token = await resolveAgentToken(); if (!token) { - throw new Error('No authentication token available for agent'); + throw new Error( + 'Refactoring requires a CodeScene sign-in or access token for the CodeScene MCP server. Sign in to CodeScene or set the "Auth token" setting.' + ); } + return token; + } + + private static spawnAgentProcess( + workDir: string, + ioDir: string, + token: string + ): { proc: ChildProcess; binaryPath: string } { + const binaryPath = AgentRefactoringService.getAgentBinaryPath(); const config = buildAgentConfigWithToken(token, ioDir); const configJson = JSON.stringify(config); @@ -132,9 +145,15 @@ export class AgentRefactoringService { }); } - private static invokeAgent(workDir: string, ioDir: string, signal?: AbortSignal, onProgress?: ProgressCallback): Promise { + private static invokeAgent( + workDir: string, + ioDir: string, + token: string, + signal?: AbortSignal, + onProgress?: ProgressCallback + ): Promise { return new Promise((resolve, reject) => { - const { proc } = AgentRefactoringService.spawnAgentProcess(workDir, ioDir); + const { proc } = AgentRefactoringService.spawnAgentProcess(workDir, ioDir, token); const { getStderr } = AgentRefactoringService.setupStderrHandler(proc); AgentRefactoringService.setupStdoutHandler(proc, onProgress); @@ -147,10 +166,10 @@ export class AgentRefactoringService { proc.on('close', (code: number | null) => { signal?.removeEventListener('abort', abortHandler); + const stderr = getStderr(); if (code === 0) { - resolve(); + resolve(stderr); } else { - const stderr = getStderr(); logOutputChannel.error(`Agent exited with code ${code}: ${stderr}`); reject(new Error(`Agent exited with code ${code}: ${stderr}`)); } diff --git a/src/refactoring/agent-token.ts b/src/refactoring/agent-token.ts new file mode 100644 index 00000000..0621dd5c --- /dev/null +++ b/src/refactoring/agent-token.ts @@ -0,0 +1,6 @@ +import { fetchIdeToken } from '../auth/ide-token'; +import { aceToken } from '../devtools-api'; + +export async function resolveAgentToken(): Promise { + return aceToken() ?? (await fetchIdeToken()); +} diff --git a/src/refactoring/connectivity.ts b/src/refactoring/connectivity.ts index b9a6bfc8..26678d08 100644 --- a/src/refactoring/connectivity.ts +++ b/src/refactoring/connectivity.ts @@ -29,13 +29,8 @@ function getAgentBinaryPath(): string { const DEFAULT_TIMEOUT_MS = 60000; export async function testConnectivity(options?: ConnectivityOptions): Promise { - const token = aceToken(); - if (!token) { - return { success: false, error: 'No authentication token available' }; - } - const binaryPath = getAgentBinaryPath(); - const config = buildAgentConfigWithToken(token); + const config = buildAgentConfigWithToken(aceToken()); const configJson = JSON.stringify(config); const model = getAgentModel(); diff --git a/src/refactoring/fn-to-refactor.ts b/src/refactoring/fn-to-refactor.ts new file mode 100644 index 00000000..e280db90 --- /dev/null +++ b/src/refactoring/fn-to-refactor.ts @@ -0,0 +1,57 @@ +import { basename, extname } from 'path'; +import vscode from 'vscode'; +import { ChangeType, FunctionFinding } from '../devtools-api/delta-model'; +import { FnToRefactor, RefactoringTarget } from '../devtools-api/refactor-models'; +import { CodeSmell, Range, Review } from '../devtools-api/review-model'; +import { findFunctionForCodeSmell, vscodeRange } from '../review/utils'; +import { isDefined } from '../utils'; + +function wholeLinesRange(document: vscode.TextDocument, startLine: number, endLine: number): Range { + const lastLine = Math.max(startLine, Math.min(endLine, document.lineCount)); + return { + 'start-line': startLine, + 'start-column': 1, + 'end-line': lastLine, + 'end-column': document.lineAt(lastLine - 1).text.length + 1, + }; +} + +function buildFnToRefactor( + document: vscode.TextDocument, + name: string, + range: Range, + targets: RefactoringTarget[] +): FnToRefactor { + const fnRange = vscodeRange(range)!; + return { + name, + body: document.getText(fnRange), + 'file-type': extname(document.fileName).slice(1), + range, + 'refactoring-targets': targets, + vscodeRange: fnRange, + }; +} + +export function fnToRefactorForCodeSmell( + document: vscode.TextDocument, + codeSmell: CodeSmell, + review?: Review +): FnToRefactor { + const target = { category: codeSmell.category, line: codeSmell['highlight-range']['start-line'] }; + const fn = review && findFunctionForCodeSmell(review, codeSmell); + if (fn) { + return buildFnToRefactor(document, fn.function, wholeLinesRange(document, fn.range['start-line'], fn.range['end-line']), [target]); + } + return buildFnToRefactor(document, basename(document.fileName), wholeLinesRange(document, 1, document.lineCount), [target]); +} + +export function fnToRefactorForFinding(document: vscode.TextDocument, finding: FunctionFinding): FnToRefactor | undefined { + const { name, range } = finding.function; + if (!range) return; + const targets = finding['change-details'] + .filter((detail) => detail['change-type'] !== ChangeType.Fixed && isDefined(detail.line)) + .map((detail) => ({ category: detail.category, line: detail.line! })); + if (targets.length === 0) return; + return buildFnToRefactor(document, name, wholeLinesRange(document, range['start-line'], range['end-line']), targets); +} diff --git a/src/refactoring/index.ts b/src/refactoring/index.ts index 950d5734..d5508061 100644 --- a/src/refactoring/index.ts +++ b/src/refactoring/index.ts @@ -1,42 +1,51 @@ import vscode from 'vscode'; -import { getConfiguration } from '../configuration'; +import { getConfiguration, onDidChangeConfiguration } from '../configuration'; import { CsExtensionState } from '../cs-extension-state'; -import { DevtoolsAPI } from '../devtools-api'; +import { aceUsable, DevtoolsAPI } from '../devtools-api'; import { logOutputChannel } from '../log'; import { CsRefactoringCommands } from './commands'; import { createTmpDiffUriScheme } from './utils'; /** - * Initialize commands and diff scheme and try to enable ACE by requesting a preflight from the - * Devtools API. + * Initialize commands and diff scheme and enable ACE. * Provides the codescene.ace.setEnabled command to enable/disable ACE, which respects * the enableAutoRefactor configuration setting. * * @param context */ +export const ACE_USABLE_CONTEXT_KEY = 'codescene.aceUsable'; + +function updateAceUsableContext() { + void vscode.commands.executeCommand('setContext', ACE_USABLE_CONTEXT_KEY, aceUsable()); +} + export function initAce(context: vscode.ExtensionContext) { context.subscriptions.push( + CsExtensionState.onAceStateChanged(updateAceUsableContext), + CsExtensionState.onAuthChanged(updateAceUsableContext), + onDidChangeConfiguration('authToken', updateAceUsableContext), vscode.commands.registerCommand('codescene.ace.setEnabled', (enable = true) => { - enable ? void enableAce() : disableAce(); + enable ? enableAce() : disableAce(); }), new CsRefactoringCommands(), createTmpDiffUriScheme(), - DevtoolsAPI.onDidChangePreflightState((event) => { + DevtoolsAPI.onDidChangeAceState((event) => { CsExtensionState.setACEState(event); }) ); - void enableAce(); + enableAce(); + updateAceUsableContext(); } -async function enableAce() { +function enableAce() { const enableACE = getConfiguration('enableAutoRefactor'); if (!enableACE) { DevtoolsAPI.disableAce(); return; } - const preflight = await DevtoolsAPI.preflight(); - if (preflight) logOutputChannel.info('ACE enabled!'); + DevtoolsAPI.enableAce(); + logOutputChannel.info('ACE enabled!'); } function disableAce() { diff --git a/src/refactoring/utils.ts b/src/refactoring/utils.ts index 1db017be..e5a26358 100644 --- a/src/refactoring/utils.ts +++ b/src/refactoring/utils.ts @@ -11,8 +11,10 @@ import vscode, { import { FnToRefactor, RefactorResponse } from '../devtools-api/refactor-models'; import { isDefined } from '../utils'; import { RefactoringRequest } from './request'; -import { fnsToRefactorCache } from '../devtools-api/fns-to-refactor-cache'; import { CodeSmell } from '../devtools-api/review-model'; +import { DevtoolsAPI } from '../devtools-api'; +import Reviewer from '../review/reviewer'; +import { fnToRefactorForCodeSmell } from './fn-to-refactor'; function singleLineCommentSeparator(languageId: string) { switch (languageId) { @@ -159,8 +161,8 @@ export function isFunctionUnchangedInDocument( } export async function findFnToRefactor(document: vscode.TextDocument | undefined, codeSmell: CodeSmell | undefined) { - if (document && codeSmell) { - const fn = await fnsToRefactorCache.fnsToRefactor(document, codeSmell); - return fn; - } + if (!DevtoolsAPI.aceEnabled()) return; + if (!document || !codeSmell) return; + const reviewResult = await Reviewer.instance?.reviewCache.get(document, 'any')?.review.reviewResult; + return fnToRefactorForCodeSmell(document, codeSmell, reviewResult || undefined); } diff --git a/src/review/codeaction.ts b/src/review/codeaction.ts index c0735557..f4aabcc6 100644 --- a/src/review/codeaction.ts +++ b/src/review/codeaction.ts @@ -6,8 +6,8 @@ import { toDocsParamsRanged } from '../documentation/commands'; import { reviewDocumentSelector } from '../language-support'; import { isDefined } from '../utils'; import Reviewer from './reviewer'; -import { aceAvailable } from '../devtools-api'; -import { fnsToRefactorCache } from '../devtools-api/fns-to-refactor-cache'; +import { aceUsable } from '../devtools-api'; +import { findFnToRefactor } from '../refactoring/utils'; import { buildInsertText, getLineIndentation } from '../utils/codeaction-utils'; export function register(context: vscode.ExtensionContext) { @@ -75,15 +75,11 @@ export class ReviewCodeActionProvider implements vscode.CodeActionProvider, vsco codeSmells: any[], actions: vscode.CodeAction[] ) { - if (!ACE_ENABLED) { + if (!ACE_ENABLED || !aceUsable()) { return; } - const aceUsable = aceAvailable(); - const fnsToRefactor = await Promise.all( - codeSmells.map((codeSmell) => fnsToRefactorCache.fnsToRefactor(document, codeSmell)) - ); - const fnToRefactor = fnsToRefactor.find(isDefined); + const fnToRefactor = await findFnToRefactor(document, codeSmells[0]); if (!fnToRefactor) return; @@ -95,11 +91,6 @@ export class ReviewCodeActionProvider implements vscode.CodeActionProvider, vsco title: 'Refactor using CodeScene Agent', arguments: [document, 'codeaction', fnToRefactor], }; - refactorAction.disabled = !aceUsable - ? { - reason: 'Activate the refactoring agent by providing your authentication token in Workspace settings.', - } - : undefined; actions.push(refactorAction); } @@ -113,10 +104,7 @@ export class ReviewCodeActionProvider implements vscode.CodeActionProvider, vsco const reviewResultRaw = await reviewCacheItem.review.reviewResult; const reviewResult: Review | undefined = reviewResultRaw && typeof reviewResultRaw === 'object' ? reviewResultRaw : undefined; - const fnsToRefactor = await Promise.all( - codeSmells.map((codeSmell) => fnsToRefactorCache.fnsToRefactor(document, codeSmell)) - ); - const fnToRefactor = fnsToRefactor.find(isDefined); + const fnToRefactor = await findFnToRefactor(document, codeSmells[0]); codeSmells.forEach((codeSmell) => { const { category } = codeSmell; diff --git a/src/review/codelens.ts b/src/review/codelens.ts index 3fe477bd..5418cd44 100644 --- a/src/review/codelens.ts +++ b/src/review/codelens.ts @@ -4,7 +4,7 @@ import { onDidChangeConfiguration, reviewCodeLensesEnabled } from '../configurat import { DevtoolsAPI } from '../devtools-api'; import { Delta } from '../devtools-api/delta-model'; import { Review } from '../devtools-api/review-model'; -import { fnsToRefactorCache } from '../devtools-api/fns-to-refactor-cache'; +import { findFnToRefactor } from '../refactoring/utils'; import { FnToRefactor } from '../devtools-api/refactor-models'; import { CsDiagnostic } from '../diagnostics/cs-diagnostic'; import { toDocsParamsRanged } from '../documentation/commands'; @@ -52,9 +52,6 @@ implements vscode.CodeLensProvider, vscode.Disposa onDidChangeConfiguration('enableReviewCodeLenses', () => this.changeCodeLensesEmitter.fire()), DevtoolsAPI.onDidReviewComplete(() => this.changeCodeLensesEmitter.fire()), DevtoolsAPI.onDidDeltaAnalysisComplete(() => this.changeCodeLensesEmitter.fire()), - vscode.workspace.onDidChangeTextDocument((event) => { - fnsToRefactorCache.invalidateForDocument(event.document); - }), vscode.workspace.onDidCloseTextDocument((document) => { this.cleanupCachesForDocument(document); }) @@ -76,8 +73,6 @@ implements vscode.CodeLensProvider, vscode.Disposa this.commandDisposables.delete(key); this.commandCache.delete(key); }); - - fnsToRefactorCache.cleanupForDocument(document); } dispose() { @@ -148,7 +143,7 @@ implements vscode.CodeLensProvider, vscode.Disposa private async openInteractiveDocsCommand(codeLens: CsCodeLens, document: vscode.TextDocument) { const { codeSmell, range, cacheKey } = codeLens; - const fnToRefactor = await fnsToRefactorCache.fnsToRefactor(document, codeSmell); + const fnToRefactor = await findFnToRefactor(document, codeSmell); let cached = this.commandCache.get(cacheKey); diff --git a/src/test/fixtures/ide-server-fixture.ts b/src/test/fixtures/ide-server-fixture.ts index 5708e126..01b5c1df 100644 --- a/src/test/fixtures/ide-server-fixture.ts +++ b/src/test/fixtures/ide-server-fixture.ts @@ -19,28 +19,6 @@ connection.onRequest('cs-ide/delta', () => ({ oldGitBlobSha: 'old-sha', newGitBlobSha: 'new-sha', })); -connection.onRequest('cs-ide/preflight', () => ({ - version: 2, - fileTypes: ['ts'], - languageCommon: { maxInputLoc: 100, codeSmells: ['Complex Method'] }, -})); -connection.onRequest('cs-ide/fns-to-refactor', (params: { 'file-name'?: string; fileName?: string }) => { - const fileName = params && typeof params === 'object' && !Array.isArray(params) - ? params['file-name'] ?? params.fileName - : undefined; - if (!fileName) { - throw new Error('Missing file-name'); - } - return [{ - body: 'function f() {}', - name: 'f', - fileType: 'TypeScript', - functionType: 'Function', - nippyB64: 'encoded', - range: { startLine: 1, startColumn: 1, endLine: 1, endColumn: 16 }, - refactoringTargets: [{ category: 'Complex Method', line: 1 }], - }]; -}); connection.onRequest('cs-ide/refactor', (params, token) => { if (params?.token === 'unauthorized') { throw new ResponseError(-32603, 'Request failed [401]'); diff --git a/src/test/suite/ace-acknowledgement-mapper.test.ts b/src/test/suite/ace-acknowledgement-mapper.test.ts index 1ebd0785..66fb8adb 100644 --- a/src/test/suite/ace-acknowledgement-mapper.test.ts +++ b/src/test/suite/ace-acknowledgement-mapper.test.ts @@ -118,7 +118,16 @@ aceSuite('AceAcknowledgementMapper Test Suite', () => { cloud: disabledWith(authStatusFixture('signed-in-oauth')), onpremPat: disabledWith(authStatusFixture('signed-in-setting-token-onprem')), }, - { cloud: false, onpremPat: true } + { cloud: false, onpremPat: false } ); }); + + test('visible requires an authenticated user', () => { + mockAcknowledged(true); + const visibleWith = (token: string) => { + mockToken(token); + return getAutoRefactorConfig().visible; + }; + assert.deepStrictEqual({ authenticated: visibleWith('secret'), unauthenticated: visibleWith('') }, { authenticated: true, unauthenticated: false }); + }); }); diff --git a/src/test/suite/ace-credential.test.ts b/src/test/suite/ace-credential.test.ts index f1024116..52aec7b3 100644 --- a/src/test/suite/ace-credential.test.ts +++ b/src/test/suite/ace-credential.test.ts @@ -18,7 +18,9 @@ suite('isAceAvailable', () => { expected: false, }, { name: 'no token, signed in with an ACE credential', token: undefined, auth: SIGNED_IN_CLOUD, expected: true }, - { name: 'no token, signed in without an ACE credential', token: undefined, auth: SIGNED_IN_ONPREM_PAT, expected: false }, + { name: 'no token, signed in without an ACE credential', token: undefined, auth: SIGNED_IN_ONPREM_PAT, expected: true }, + { name: 'no token, signing in', token: undefined, auth: { active: true, status: authStatusFixture('signing-in') }, expected: false }, + { name: 'no token, signed out', token: undefined, auth: { active: true, status: authStatusFixture('signed-out') }, expected: false }, { name: 'a token while signed out', token: 'cloud-ace-1', auth: { active: true, status: authStatusFixture('signed-out') }, expected: true }, ]; @@ -39,7 +41,7 @@ suite('refactorToken', () => { }); test('throws when ACE is unavailable', () => { - assert.throws(() => refactorToken(undefined, SIGNED_IN_ONPREM_PAT), MissingAuthTokenError); + assert.throws(() => refactorToken(undefined, INACTIVE), MissingAuthTokenError); }); }); diff --git a/src/test/suite/agent-config.test.ts b/src/test/suite/agent-config.test.ts index 4b479716..e20cddb0 100644 --- a/src/test/suite/agent-config.test.ts +++ b/src/test/suite/agent-config.test.ts @@ -175,9 +175,9 @@ aceSuite('AgentConfig Test Suite', () => { }); } - test('buildAgentConfigWithToken throws when token is empty', () => { + test('buildAgentConfigWithToken falls back to an empty token when none is available', () => { mockConfiguration('codescene', {}); - assert.throws(() => buildAgentConfigWithToken(''), /No authentication token/); + assert.strictEqual(buildAgentConfigWithToken(undefined).codescene_access_token, ''); }); test('buildOpencodeConfig produces exact bedrock structure', () => { diff --git a/src/test/suite/codeaction.test.ts b/src/test/suite/codeaction.test.ts index 21a11a02..2896bf76 100644 --- a/src/test/suite/codeaction.test.ts +++ b/src/test/suite/codeaction.test.ts @@ -6,12 +6,10 @@ import { ReviewCodeActionProvider } from '../../review/codeaction'; import { CodeSmell } from '../../devtools-api/review-model'; import Reviewer from '../../review/reviewer'; import { CsReview } from '../../review/cs-review'; -import { DevtoolsAPI } from '../../devtools-api'; import { CsExtensionState } from '../../cs-extension-state'; let provider: ReviewCodeActionProvider; let mockReviewCache: any; -let originalDevtoolsAPIInstance: any; const originalCsExtensionStateInstance = (CsExtensionState as any)._instance; function setupTestEnvironment() { @@ -32,11 +30,6 @@ function setupTestEnvironment() { reviewCache: mockReviewCache }; - originalDevtoolsAPIInstance = (DevtoolsAPI as any).instance; - (DevtoolsAPI as any).instance = { - preflightJson: undefined - }; - (CsExtensionState as any)._instance = { stateProperties: { auth: { active: false, status: null } @@ -46,7 +39,6 @@ function setupTestEnvironment() { function teardownTestEnvironment() { (CsExtensionState as any)._instance = originalCsExtensionStateInstance; - (DevtoolsAPI as any).instance = originalDevtoolsAPIInstance; vscode.window.setActiveEditor(undefined); mockReviewCache.reviewMap.clear(); } diff --git a/src/test/suite/delta.test.ts b/src/test/suite/delta.test.ts index 559c6e82..3160ef83 100644 --- a/src/test/suite/delta.test.ts +++ b/src/test/suite/delta.test.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import * as fs from 'fs'; import * as vscode from 'vscode'; import { DevtoolsAPI } from '../../devtools-api'; +import { fnToRefactorForCodeSmell } from '../../refactoring/fn-to-refactor'; import { mockWorkspaceFolders, createMockWorkspaceFolder, restoreDefaultWorkspaceFolders } from '../setup'; import { TestTextDocument } from '../mocks/test-text-document'; import { createMockExtensionContext } from '../mocks/mock-extension-context'; @@ -59,7 +60,7 @@ suite('Delta Integration Test Suite', () => { }); - aceTest('fnsToRefactor with code-smells path returns refactorable functions', async function() { + aceTest('fnToRefactorForCodeSmell resolves the enclosing function from the review', async function() { this.timeout(60000); const complexContent = 'int f(int a) {\n if (a > 0) {\n if (a > 1) {\n if (a > 2) {\n if (a > 3) {\n return a;\n }\n }\n }\n }\n return 0;\n}\n'; @@ -69,8 +70,6 @@ suite('Delta Integration Test Suite', () => { assertNoAnalysisError(); assert.ok(review, 'Review should be defined'); - await DevtoolsAPI.preflight(); - const functionLevelSmells = review['function-level-code-smells']; assert.ok(functionLevelSmells.length > 0, 'Should have function-level code smells'); @@ -79,27 +78,26 @@ suite('Delta Integration Test Suite', () => { const codeSmell = firstFunction['code-smells'][0]; - const result = await DevtoolsAPI.fnsToRefactor(doc, { 'code-smells': [codeSmell] }); + const result = fnToRefactorForCodeSmell(doc, codeSmell, review); - const expected = [{ - 'file-type': 'cpp', - 'nippy-b64': 'TlBZAHAFagRuYW1laQFmagRib2R5EACUaW50IGYoaW50IGEpIHsKICBpZiAoYSA+IDApIHsKICAgIGlmIChhID4gMSkgewogICAgICBpZiAoYSA+IDIpIHsKICAgICAgICBpZiAoYSA+IDMpIHsKICAgICAgICAgIHJldHVybiBhOwogICAgICAgIH0KICAgICAgfQogICAgfQogIH0KICByZXR1cm4gMDsKfWoJZmlsZS10eXBlaQNjcHBqBXJhbmdlcARqCnN0YXJ0LWxpbmUqAAAAAWoIZW5kLWxpbmUqAAAADGoMc3RhcnQtY29sdW1uZAFqCmVuZC1jb2x1bW5kAmoTcmVmYWN0b3JpbmctdGFyZ2V0c24BcAJqCGNhdGVnb3J5aRdEZWVwLCBOZXN0ZWQgQ29tcGxleGl0eWoEbGluZSoAAAAB', - 'refactoring-targets': [ - { - category: 'Deep, Nested Complexity', - line: 1 - } - ], - body: 'int f(int a) {\n if (a > 0) {\n if (a > 1) {\n if (a > 2) {\n if (a > 3) {\n return a;\n }\n }\n }\n }\n return 0;\n}', + const expected = { name: 'f', + body: 'int f(int a) {\n if (a > 0) {\n if (a > 1) {\n if (a > 2) {\n if (a > 3) {\n return a;\n }\n }\n }\n }\n return 0;\n}', + 'file-type': 'cpp', range: { 'start-line': 1, 'start-column': 1, 'end-line': 12, 'end-column': 2 }, + 'refactoring-targets': [ + { + category: 'Deep, Nested Complexity', + line: 1 + } + ], vscodeRange: new vscode.Range(0, 0, 11, 1) - }]; + }; assert.deepStrictEqual(result, expected, 'Result should match expected structure'); }); diff --git a/src/test/suite/devtools-api-jsonrpc.test.ts b/src/test/suite/devtools-api-jsonrpc.test.ts index 63b66bc1..f4a86fb7 100644 --- a/src/test/suite/devtools-api-jsonrpc.test.ts +++ b/src/test/suite/devtools-api-jsonrpc.test.ts @@ -2,7 +2,6 @@ import * as assert from 'assert'; import * as path from 'path'; import { AnalysisEvent, DevtoolsAPI } from '../../devtools-api'; import { CsIdeServerClient } from '../../devtools-api/ide-server-client'; -import { aceTest } from '../ace-test-suite'; import { createMockExtensionContext } from '../mocks/mock-extension-context'; suite('DevtoolsAPI JSON-RPC Test Suite', () => { @@ -61,8 +60,4 @@ suite('DevtoolsAPI JSON-RPC Test Suite', () => { queueDone: 0, }); }); - - aceTest('uses the shared server for preflight', async () => { - assert.strictEqual((await DevtoolsAPI.preflight())?.version, 2); - }); }); diff --git a/src/test/suite/devtools-api-no-ace.test.ts b/src/test/suite/devtools-api-no-ace.test.ts index 4901b870..804bd8fb 100644 --- a/src/test/suite/devtools-api-no-ace.test.ts +++ b/src/test/suite/devtools-api-no-ace.test.ts @@ -21,9 +21,9 @@ noAceSuite('DevtoolsAPI no-ACE guard Test Suite', () => { restoreDefaultWorkspaceFolders(); }); - test('preflight returns immediately when ACE is disabled', async function () { - const response = await DevtoolsAPI.preflight(); - assert.strictEqual(response, undefined); + test('enableAce is a no-op when ACE is disabled', () => { + DevtoolsAPI.enableAce(); + assert.strictEqual(DevtoolsAPI.aceEnabled(), false); }); test('aceEnabled returns false when ACE is disabled', () => { diff --git a/src/test/suite/fn-to-refactor.test.ts b/src/test/suite/fn-to-refactor.test.ts new file mode 100644 index 00000000..0ddc8026 --- /dev/null +++ b/src/test/suite/fn-to-refactor.test.ts @@ -0,0 +1,141 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { ChangeType, FunctionFinding } from '../../devtools-api/delta-model'; +import { CodeSmell, Review } from '../../devtools-api/review-model'; +import { fnToRefactorForCodeSmell, fnToRefactorForFinding } from '../../refactoring/fn-to-refactor'; +import { TestTextDocument } from '../mocks/test-text-document'; + +const content = 'const a = 1;\nfunction f(x) {\n return x;\n}\n'; +const document = new TestTextDocument('/repo/src/file.ts', content, 'typescript'); + +function smell(category: string, startLine: number, endLine: number): CodeSmell { + return { + category, + details: '', + 'highlight-range': { 'start-line': startLine, 'start-column': 1, 'end-line': endLine, 'end-column': 2 }, + }; +} + +const complexMethod = smell('Complex Method', 2, 2); +const lowCohesion = smell('Low Cohesion', 1, 1); + +const review: Review = { + 'file-level-code-smells': [lowCohesion], + 'function-level-code-smells': [ + { + function: 'f', + range: { 'start-line': 2, 'start-column': 1, 'end-line': 4, 'end-column': 2 }, + 'code-smells': [complexMethod], + }, + ], + 'raw-score': 'raw', +}; + +suite('fnToRefactorForCodeSmell', () => { + const cases = [ + { + name: 'uses the enclosing function for function-level smells', + codeSmell: complexMethod, + review, + expected: { + name: 'f', + body: 'function f(x) {\n return x;\n}', + range: { 'start-line': 2, 'start-column': 1, 'end-line': 4, 'end-column': 2 }, + vscodeRange: new vscode.Range(1, 0, 3, 1), + targets: [{ category: 'Complex Method', line: 2 }], + }, + }, + { + name: 'uses the whole file for file-level smells', + codeSmell: lowCohesion, + review, + expected: { + name: 'file.ts', + body: content, + range: { 'start-line': 1, 'start-column': 1, 'end-line': 5, 'end-column': 1 }, + vscodeRange: new vscode.Range(0, 0, 4, 0), + targets: [{ category: 'Low Cohesion', line: 1 }], + }, + }, + { + name: 'uses the whole file when no review is available', + codeSmell: complexMethod, + review: undefined, + expected: { + name: 'file.ts', + body: content, + range: { 'start-line': 1, 'start-column': 1, 'end-line': 5, 'end-column': 1 }, + vscodeRange: new vscode.Range(0, 0, 4, 0), + targets: [{ category: 'Complex Method', line: 2 }], + }, + }, + ]; + + cases.forEach(({ name, codeSmell, review, expected }) => { + test(name, () => { + const fn = fnToRefactorForCodeSmell(document, codeSmell, review); + assert.strictEqual(fn.name, expected.name); + assert.strictEqual(fn.body, expected.body); + assert.strictEqual(fn['file-type'], 'ts'); + assert.deepStrictEqual(fn.range, expected.range); + assert.deepStrictEqual(fn.vscodeRange, expected.vscodeRange); + assert.deepStrictEqual(fn['refactoring-targets'], expected.targets); + }); + }); +}); + +suite('fnToRefactorForFinding', () => { + const functionRange = { 'start-line': 2, 'start-column': 1, 'end-line': 4, 'end-column': 2 }; + + function finding(changeDetails: FunctionFinding['change-details'], range = functionRange): FunctionFinding { + return { function: { name: 'f', range }, 'change-details': changeDetails }; + } + + const detail = (category: string, changeType: ChangeType, line?: number) => ({ + category, + 'change-type': changeType, + description: '', + line, + }); + + const cases = [ + { + name: 'targets every remaining smell in the function', + finding: finding([ + detail('Complex Method', ChangeType.Introduced, 2), + detail('Bumpy Road Ahead', ChangeType.Improved, 3), + detail('Large Method', ChangeType.Fixed, 2), + ]), + expectedTargets: [ + { category: 'Complex Method', line: 2 }, + { category: 'Bumpy Road Ahead', line: 3 }, + ], + }, + { + name: 'is not refactorable when every smell was fixed', + finding: finding([detail('Complex Method', ChangeType.Fixed, 2)]), + expectedTargets: undefined, + }, + { + name: 'skips changes without a line', + finding: finding([detail('Complex Method', ChangeType.Degraded)]), + expectedTargets: undefined, + }, + { + name: 'is not refactorable without a function range', + finding: { function: { name: 'f' }, 'change-details': [detail('Complex Method', ChangeType.Degraded, 2)] }, + expectedTargets: undefined, + }, + ]; + + cases.forEach(({ name, finding, expectedTargets }) => { + test(name, () => { + const fn = fnToRefactorForFinding(document, finding); + assert.deepStrictEqual(fn?.['refactoring-targets'], expectedTargets); + if (fn) { + assert.strictEqual(fn.name, 'f'); + assert.strictEqual(fn.body, 'function f(x) {\n return x;\n}'); + } + }); + }); +}); diff --git a/src/test/suite/fns-to-refactor.test.ts b/src/test/suite/fns-to-refactor.test.ts deleted file mode 100644 index 7c2e3827..00000000 --- a/src/test/suite/fns-to-refactor.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import * as assert from 'assert'; -import * as path from 'path'; -import * as fs from 'fs'; -import * as vscode from 'vscode'; -import { DevtoolsAPI } from '../../devtools-api'; -import { mockWorkspaceFolders, createMockWorkspaceFolder, restoreDefaultWorkspaceFolders } from '../setup'; -import { TestTextDocument } from '../mocks/test-text-document'; -import { createMockExtensionContext } from '../mocks/mock-extension-context'; -import { createTestDir, ensureBinary } from '../integration_helper'; - -import { aceSuite } from '../ace-test-suite'; - -aceSuite('FnsToRefactor Integration Test Suite', () => { - const testDir = createTestDir('test-fns-to-refactor'); - let analysisError: Error | undefined; - let errorListener: vscode.Disposable; - - function createTestFile(filename: string, content: string): TestTextDocument { - const testFile = path.resolve(testDir, filename); - fs.writeFileSync(testFile, content); - return new TestTextDocument(testFile, content, 'cpp'); - } - - function assertNoAnalysisError() { - if (analysisError) { - assert.fail(`Analysis failed with error: ${analysisError.message}\n${analysisError.stack}`); - } - } - - setup(async function() { - this.timeout(60000); - if (fs.existsSync(testDir)) { - fs.rmSync(testDir, { recursive: true, force: true }); - } - fs.mkdirSync(testDir, { recursive: true }); - - mockWorkspaceFolders([createMockWorkspaceFolder(testDir)]); - - const binaryPath = await ensureBinary(); - const mockContext = createMockExtensionContext(testDir); - - DevtoolsAPI.init(binaryPath, mockContext); - - analysisError = undefined; - - errorListener = DevtoolsAPI.onDidAnalysisFail((error) => { - analysisError = error; - }); - - await DevtoolsAPI.preflight(); - }); - - teardown(() => { - errorListener?.dispose(); - - if (fs.existsSync(testDir)) { - fs.rmSync(testDir, { recursive: true, force: true }); - } - - restoreDefaultWorkspaceFolders(); - }); - - - test('fnsToRefactorFromDelta returns undefined when ACE is disabled', async function() { - this.timeout(10000); - - DevtoolsAPI.disableAce(); - - const content = 'int foo() { return 42; }\n'; - const doc = createTestFile('test-disabled.cpp', content); - - const deltaResult = { - 'score-change': -1.0, - 'file-level-findings': [], - 'function-level-findings': [] - }; - - const result = await DevtoolsAPI.fnsToRefactorFromDelta(doc, deltaResult); - - assert.strictEqual(result, undefined, 'Should return undefined when ACE is disabled'); - }); - - test('fnsToRefactorFromDelta handles delta with no function findings', async function() { - this.timeout(60000); - - const cleanContent = 'int add(int a, int b) { return a + b; }\n'; - const doc = createTestFile('clean.cpp', cleanContent); - - const deltaResult = { - 'score-change': 0.0, - 'file-level-findings': [], - 'function-level-findings': [] - }; - - const result = await DevtoolsAPI.fnsToRefactorFromDelta(doc, deltaResult); - - assertNoAnalysisError(); - - assert.ok(Array.isArray(result), 'Result should be an array'); - assert.strictEqual(result.length, 0, 'Should return empty array for delta with no findings'); - }); - -}); diff --git a/src/test/suite/home-view.test.ts b/src/test/suite/home-view.test.ts index 6f5b9192..d8ccb685 100644 --- a/src/test/suite/home-view.test.ts +++ b/src/test/suite/home-view.test.ts @@ -213,40 +213,14 @@ suite('HomeView', () => { assert.strictEqual(badgeCount, 0); }); - const enrichmentCases = [ - { - name: 'adds a file on its first delta', - initialFiles: [] as string[], - enrichment: false, - expectedFiles: ['/workspace/fileA.ts'], - }, - { - name: 'does not resurrect a file removed while its delta was being enriched', - initialFiles: [] as string[], - enrichment: true, - expectedFiles: [] as string[], - }, - { - name: 'updates a file that is still monitored when its enriched delta arrives', - initialFiles: ['/workspace/fileA.ts'], - enrichment: true, - expectedFiles: ['/workspace/fileA.ts'], - }, - ]; - - enrichmentCases.forEach(({ name, initialFiles, enrichment, expectedFiles }) => { - test(name, () => { - initialFiles.forEach(addFileToHomeView); - - (homeView as any).updateFileDeltaData({ - document: mockDocument('/workspace/fileA.ts'), - result: mockDeltaResult, - updateMonitor: true, - enrichment, - }); - - assert.deepStrictEqual([...homeView.getFileIssueMap().keys()], expectedFiles); + test('adds a file on its first delta', () => { + (homeView as any).updateFileDeltaData({ + document: mockDocument('/workspace/fileA.ts'), + result: mockDeltaResult, + updateMonitor: true, }); + + assert.deepStrictEqual([...homeView.getFileIssueMap().keys()], ['/workspace/fileA.ts']); }); }); diff --git a/src/test/suite/ide-server-client.test.ts b/src/test/suite/ide-server-client.test.ts index 10c8ae72..472020ac 100644 --- a/src/test/suite/ide-server-client.test.ts +++ b/src/test/suite/ide-server-client.test.ts @@ -76,12 +76,6 @@ suite('CsIdeServerClient Test Suite', () => { }); test('sends typed requests over one connection', async () => { - const codeSmells = { - 'file-name': 'file.ts', - 'file-content': 'function f() {}', - preflight: { version: 2, 'file-types': ['ts'], 'language-common': { 'max-input-loc': 100, 'code-smells': [] } }, - 'code-smells': [{ category: 'Complex Method' }], - }; const telemetry = { 'editor-type': 'VSCode', 'event-name': 'test', @@ -104,17 +98,6 @@ suite('CsIdeServerClient Test Suite', () => { 'old-git-blob-sha': 'old-sha', 'new-git-blob-sha': 'new-sha', }); - assert.deepStrictEqual(await client.preflight({ force: true }), { - version: 2, - 'file-types': ['ts'], - 'language-common': { 'max-input-loc': 100, 'code-smells': ['Complex Method'] }, - }); - const [fn] = await client.fnsToRefactor(codeSmells); - assert.strictEqual(fn['file-type'], 'TypeScript'); - assert.strictEqual(fn['function-type'], 'Function'); - assert.strictEqual(fn['nippy-b64'], 'encoded'); - assert.deepStrictEqual(fn.range, { 'start-line': 1, 'start-column': 1, 'end-line': 1, 'end-column': 16 }); - assert.deepStrictEqual(fn['refactoring-targets'], [{ category: 'Complex Method', line: 1 }]); const refactor = await client.refactor({ token: 'token' }); assert.strictEqual(refactor['trace-id'], 'trace-1'); assert.deepStrictEqual(refactor['credits-info'], { limit: 10, used: 1 }); @@ -321,13 +304,6 @@ suite('CsIdeServerClient Test Suite', () => { await client.review({ path: 'file.ts' }); await client.delta({ 'old-score': 'old' }); - await client.preflight({ force: true }); - await client.fnsToRefactor({ - 'file-name': 'file.ts', - 'file-content': 'function f() {}', - preflight: { version: 2, 'file-types': ['ts'], 'language-common': { 'max-input-loc': 100, 'code-smells': [] } }, - 'code-smells': [{ category: 'Complex Method' } as any], - }); await client.refactor({ token: 'secret-token', 'fn-to-refactor-nippy-b64': 'encoded', 'skip-cache': true }); await client.telemetry({ 'editor-type': 'VSCode', 'event-name': 'test-event', 'extension-version': '1.0.0', 'user-id': 'user-99' }); await client.deviceId(); @@ -341,10 +317,6 @@ suite('CsIdeServerClient Test Suite', () => { assertLogContains('info', 'score=9.68 fileSmells=0 functionSmells=0 hasRulesError=false sha=review-sha'); assertLogContains('info', 'method=cs-ide/delta hasOldScore=true hasNewScore=false'); assertLogContains('info', 'oldScore=10 newScore=9.68 scoreChange=-0.32 fileFindings=0 functionFindings=0'); - assertLogContains('info', 'method=cs-ide/preflight force=true'); - assertLogContains('info', 'version=2 fileTypes=ts codeSmellCount=1 maxInputLoc=100'); - assertLogContains('info', 'method=cs-ide/fns-to-refactor fileName=file.ts input=code-smells smellCount=1'); - assertLogContains('info', 'functions=f@1:1-1:16[Complex Method]'); assertLogContains('info', 'method=cs-ide/refactor format=nippy-b64 skipCache=true'); assertLogContains('info', 'traceId=trace-1 confidence=1 cached=false reasonCount=0'); assertLogContains('info', 'method=cs-ide/telemetry eventName=test-event'); diff --git a/src/test/suite/ide-token.test.ts b/src/test/suite/ide-token.test.ts new file mode 100644 index 00000000..279f1686 --- /dev/null +++ b/src/test/suite/ide-token.test.ts @@ -0,0 +1,32 @@ +import * as assert from 'assert'; +import { ideTokenEnv, parseIdeToken } from '../../auth/ide-token'; + +suite('ide-token', () => { + const parseCases = [ + { name: 'returns the token when signed in', stdout: '{"status":"signed_in","access-token":"abc","api-url":"https://api.codescene.io"}\n', expected: 'abc' }, + { name: 'trims the token', stdout: '{"status":"signed_in","access-token":" abc "}', expected: 'abc' }, + { name: 'ignores a signed out session', stdout: '{"status":"signed_out"}', expected: undefined }, + { name: 'ignores a signed in session without token', stdout: '{"status":"signed_in"}', expected: undefined }, + { name: 'ignores a blank token', stdout: '{"status":"signed_in","access-token":" "}', expected: undefined }, + { name: 'ignores invalid json', stdout: 'not json', expected: undefined }, + { name: 'ignores empty output', stdout: '', expected: undefined }, + ]; + + parseCases.forEach(({ name, stdout, expected }) => { + test(`parseIdeToken ${name}`, () => { + assert.strictEqual(parseIdeToken(stdout), expected); + }); + }); + + const envCases = [ + { name: 'sets CS_ONPREM_URL from the server URL', serverUrl: ' https://cs.example ', expected: { KEEP: '1', CS_ONPREM_URL: 'https://cs.example' } }, + { name: 'leaves the environment alone for a blank URL', serverUrl: ' ', expected: { KEEP: '1' } }, + { name: 'leaves the environment alone for no URL', serverUrl: undefined, expected: { KEEP: '1' } }, + ]; + + envCases.forEach(({ name, serverUrl, expected }) => { + test(`ideTokenEnv ${name}`, () => { + assert.deepStrictEqual(ideTokenEnv(serverUrl, { KEEP: '1' }), expected); + }); + }); +}); diff --git a/src/test/suite/post-refactoring.test.ts b/src/test/suite/post-refactoring.test.ts index a193d645..6bee95ac 100644 --- a/src/test/suite/post-refactoring.test.ts +++ b/src/test/suite/post-refactoring.test.ts @@ -8,6 +8,7 @@ import { TestTextDocument } from '../mocks/test-text-document'; import { createMockExtensionContext } from '../mocks/mock-extension-context'; import { RefactoringRequest } from '../../refactoring/request'; import { ChangeType } from '../../devtools-api/delta-model'; +import { fnToRefactorForFinding } from '../../refactoring/fn-to-refactor'; import * as configModule from '../../configuration'; import * as csExtensionState from '../../cs-extension-state'; import { createTestDir, ensureBinary } from '../integration_helper'; @@ -101,7 +102,7 @@ aceSuite('PostRefactoring Integration Test Suite', () => { refactoringError = error; }); - await DevtoolsAPI.preflight(); + DevtoolsAPI.enableAce(); }); teardown(() => { @@ -156,14 +157,11 @@ aceSuite('PostRefactoring Integration Test Suite', () => { }] }; - const fnsToRefactor = await DevtoolsAPI.fnsToRefactorFromDelta(doc, deltaResult); + const fnToRefactor = fnToRefactorForFinding(doc, deltaResult['function-level-findings'][0]); assertNoAnalysisError(); + assert.ok(fnToRefactor, 'Should find a refactorable function'); - assert.ok(Array.isArray(fnsToRefactor), 'Result should be an array'); - assert.ok(fnsToRefactor.length > 0, 'Should find at least one refactorable function'); - - const fnToRefactor = fnsToRefactor[0]; const request = new RefactoringRequest(fnToRefactor, doc, false); const response = await request.promise; @@ -212,12 +210,10 @@ aceSuite('PostRefactoring Integration Test Suite', () => { }] }; - const fnsToRefactor = await DevtoolsAPI.fnsToRefactorFromDelta(doc, deltaResult); + const fnToRefactor = fnToRefactorForFinding(doc, deltaResult['function-level-findings'][0]); assertNoAnalysisError(); - assert.ok(fnsToRefactor && fnsToRefactor.length > 0, 'Should find refactorable functions'); - - const fnToRefactor = fnsToRefactor[0]; + assert.ok(fnToRefactor, 'Should find a refactorable function'); const request1 = new RefactoringRequest(fnToRefactor, doc, false); const response1 = await request1.promise; @@ -265,12 +261,10 @@ aceSuite('PostRefactoring Integration Test Suite', () => { }] }; - const fnsToRefactor = await DevtoolsAPI.fnsToRefactorFromDelta(doc, deltaResult); + const fnToRefactor = fnToRefactorForFinding(doc, deltaResult['function-level-findings'][0]); assertNoAnalysisError(); - assert.ok(fnsToRefactor && fnsToRefactor.length > 0, 'Should find refactorable functions'); - - const fnToRefactor = fnsToRefactor[0]; + assert.ok(fnToRefactor, 'Should find a refactorable function'); assert.ok(fnToRefactor['refactoring-targets'].length > 0, 'Should have refactoring targets'); const request = new RefactoringRequest(fnToRefactor, doc, false); @@ -316,12 +310,10 @@ aceSuite('PostRefactoring Integration Test Suite', () => { }] }; - const fnsToRefactor = await DevtoolsAPI.fnsToRefactorFromDelta(doc, deltaResult); + const fnToRefactor = fnToRefactorForFinding(doc, deltaResult['function-level-findings'][0]); assertNoAnalysisError(); - assert.ok(fnsToRefactor && fnsToRefactor.length > 0, 'Should find refactorable functions'); - - const fnToRefactor = fnsToRefactor[0]; + assert.ok(fnToRefactor, 'Should find a refactorable function'); const request = new RefactoringRequest(fnToRefactor, doc, false); const response = await request.promise; diff --git a/src/test/suite/preflight.test.ts b/src/test/suite/preflight.test.ts deleted file mode 100644 index 916b8cff..00000000 --- a/src/test/suite/preflight.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import * as assert from 'assert'; -import * as fs from 'fs'; -import { DevtoolsAPI } from '../../devtools-api'; -import { mockWorkspaceFolders, createMockWorkspaceFolder, restoreDefaultWorkspaceFolders } from '../setup'; -import { createMockExtensionContext } from '../mocks/mock-extension-context'; -import { createTestDir, ensureBinary } from '../integration_helper'; - -import { aceSuite } from '../ace-test-suite'; - -aceSuite('Preflight Integration Test Suite', () => { - const testDir = createTestDir('test-preflight'); - let preflightStateChangeFired = false; - let lastPreflightState: any; - let stateChangeListener: any; - - function waitForEvent(ms: number = 1000): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - function assertPreflightResponseStructure(response: any) { - assert.ok(response, 'Response should exist'); - assert.ok('version' in response); - assert.ok('language-common' in response); - assert.ok('language-specific' in response); - assert.ok('file-types' in response); - - assert.ok(response['language-common'], 'language-common should exist'); - assert.ok('max-input-loc' in response['language-common']); - assert.ok('code-smells' in response['language-common']); - assert.ok(Array.isArray(response['language-common']['code-smells'])); - assert.ok(Array.isArray(response['file-types'])); - } - - setup(async function() { - this.timeout(60000); - if (fs.existsSync(testDir)) { - fs.rmSync(testDir, { recursive: true, force: true }); - } - fs.mkdirSync(testDir, { recursive: true }); - - mockWorkspaceFolders([createMockWorkspaceFolder(testDir)]); - - const binaryPath = await ensureBinary(); - const mockContext = createMockExtensionContext(testDir); - - DevtoolsAPI.init(binaryPath, mockContext); - DevtoolsAPI.disableAce(); - - preflightStateChangeFired = false; - lastPreflightState = undefined; - - stateChangeListener = DevtoolsAPI.onDidChangePreflightState((state) => { - preflightStateChangeFired = true; - lastPreflightState = state; - }); - }); - - teardown(() => { - stateChangeListener?.dispose(); - - if (fs.existsSync(testDir)) { - fs.rmSync(testDir, { recursive: true, force: true }); - } - - restoreDefaultWorkspaceFolders(); - }); - - test('preflight returns expected response structure', async function() { - this.timeout(60000); - - const response = await DevtoolsAPI.preflight(); - - assertPreflightResponseStructure(response); - assert.ok(response, 'Response should exist'); - assert.strictEqual(response.version, 2.0); - assert.ok(response['language-common']['max-input-loc'] > 0); - assert.ok(response['language-common']['code-smells'].length > 0); - assert.ok(response['file-types'].length > 0); - }); - - test('preflight fires state change event with loading state', async function() { - this.timeout(60000); - - preflightStateChangeFired = false; - lastPreflightState = undefined; - - const preflightPromise = DevtoolsAPI.preflight(); - - await waitForEvent(100); - - assert.ok(preflightStateChangeFired, 'Preflight state change event should fire'); - assert.ok(lastPreflightState, 'Last preflight state should be captured'); - - await preflightPromise; - }); - - test('preflight fires state change event with enabled state on success', async function() { - this.timeout(60000); - - const initialStateChangeFired = preflightStateChangeFired; - const initialState = lastPreflightState; - assert.strictEqual(initialStateChangeFired, false, 'State change event should not have fired initially'); - assert.strictEqual(initialState, undefined, 'Last preflight state should be undefined initially'); - - await DevtoolsAPI.preflight(); - - await waitForEvent(100); - - assert.strictEqual(preflightStateChangeFired, true, 'Preflight state change event should fire'); - assert.ok(lastPreflightState, 'Last preflight state should be captured'); - assert.strictEqual(lastPreflightState.state, 'enabled'); - }); - - test('preflight enables ACE when successful', async function() { - this.timeout(60000); - - const initialAceState = DevtoolsAPI.aceEnabled(); - assert.strictEqual(initialAceState, false, 'ACE should be disabled initially'); - - await DevtoolsAPI.preflight(); - - await waitForEvent(100); - - const finalAceState = DevtoolsAPI.aceEnabled(); - - assert.strictEqual(finalAceState, true, 'ACE should be enabled after successful preflight'); - }); - - test('preflight contains expected code smells', async function() { - this.timeout(60000); - - const response = await DevtoolsAPI.preflight(); - - assert.ok(response, 'Response should exist'); - const codeSmells = response['language-common']['code-smells']; - assert.ok(codeSmells.includes('Complex Conditional')); - assert.ok(codeSmells.includes('Complex Method')); - assert.ok(codeSmells.includes('Large Method')); - }); - - test('preflight contains expected file types', async function() { - this.timeout(60000); - - const response = await DevtoolsAPI.preflight(); - - assert.ok(response, 'Response should exist'); - const fileTypes = response['file-types']; - assert.ok(fileTypes.includes('cpp')); - assert.ok(fileTypes.includes('java')); - assert.ok(fileTypes.includes('js')); - assert.ok(fileTypes.includes('ts')); - }); -}); From 8f7852ae50411d4e4c20a19ae58d269688d589c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Thu, 29 Oct 2026 21:46:32 +0100 Subject: [PATCH 36/39] feat(auth): fetch the agent token over cs-ide/auth-token JSON-RPC Replace the interim `cs-ide auth token` spawn with the cs-ide/auth-token request, so the server URL, active account and token refresh are handled by the running cs-ide instead of a separate process. Pin the CLI build that ships the request. The delta presentation tests now expect a single event, since the ACE enrichment pass that produced the second one is gone. --- scripts/cli-config.js | 2 +- src/auth/auth-token.ts | 9 +++++ src/auth/ide-token.ts | 47 ----------------------- src/devtools-api/auth-log-summaries.ts | 1 + src/devtools-api/ide-server-client.ts | 5 +++ src/devtools-api/index.ts | 4 ++ src/refactoring/agent-token.ts | 16 +++++++- src/test/fixtures/ide-server-fixture.ts | 1 + src/test/suite/auth-token.test.ts | 18 +++++++++ src/test/suite/delta-presentation.test.ts | 8 ++-- src/test/suite/ide-server-client.test.ts | 5 +++ src/test/suite/ide-token.test.ts | 32 --------------- 12 files changed, 62 insertions(+), 86 deletions(-) create mode 100644 src/auth/auth-token.ts delete mode 100644 src/auth/ide-token.ts create mode 100644 src/test/suite/auth-token.test.ts delete mode 100644 src/test/suite/ide-token.test.ts diff --git a/scripts/cli-config.js b/scripts/cli-config.js index a61eaac2..c7eb102e 100644 --- a/scripts/cli-config.js +++ b/scripts/cli-config.js @@ -6,7 +6,7 @@ * - TypeScript source files (via artifact-info.ts) that import from this file */ -const requiredDevtoolsVersion = 'eff2f1fd6e7d928b0abd2f59f2243c63cefdf885'; +const requiredDevtoolsVersion = '4a1fe05787f040c67f8a9da547108c87082e6ec5'; const nativeOsNames = { darwin: 'macos', linux: 'linux', win32: 'windows' }; const nativeArchNames = { x64: 'amd64', arm64: 'aarch64' }; diff --git a/src/auth/auth-token.ts b/src/auth/auth-token.ts new file mode 100644 index 00000000..0ce40d23 --- /dev/null +++ b/src/auth/auth-token.ts @@ -0,0 +1,9 @@ +export interface AuthTokenResponse { + 'credential-source': 'env-token' | 'setting-token' | 'oauth'; + 'access-token': string; +} + +export function tokenFromResponse(response: Partial | null | undefined): string | undefined { + const token = response?.['access-token']?.trim(); + return token ? token : undefined; +} diff --git a/src/auth/ide-token.ts b/src/auth/ide-token.ts deleted file mode 100644 index 99130449..00000000 --- a/src/auth/ide-token.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { execFile } from 'child_process'; -import { ArtifactInfo } from '../artifact-info'; -import { getConfiguration } from '../configuration'; -import { getExtensionPath } from '../cs-extension-state'; -import { logOutputChannel } from '../log'; -import { redactSecrets } from '../log-redaction'; - -const TOKEN_TIMEOUT_MS = 20000; - -interface CliTokenResponse { - status?: string; - 'access-token'?: string; -} - -export function parseIdeToken(stdout: string): string | undefined { - try { - const response = JSON.parse(stdout.trim()) as CliTokenResponse; - const token = response['access-token']?.trim(); - return response.status === 'signed_in' && token ? token : undefined; - } catch { - return undefined; - } -} - -export function ideTokenEnv(serverUrl: string | undefined, base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { - const url = serverUrl?.trim(); - return url ? { ...base, CS_ONPREM_URL: url } : base; -} - -export function fetchIdeToken(): Promise { - const extensionPath = getExtensionPath(); - if (!extensionPath) return Promise.resolve(undefined); - const executable = new ArtifactInfo(extensionPath).absoluteExecutablePath; - const env = ideTokenEnv(getConfiguration('serverUrl', '')); - const args = ['auth', 'token', '--client', 'ide', '--output-format', 'json']; - - return new Promise((resolve) => { - execFile(executable, args, { env, timeout: TOKEN_TIMEOUT_MS, windowsHide: true }, (error, stdout, stderr) => { - const token = parseIdeToken(stdout); - if (!token) { - const detail = redactSecrets((error?.message ?? stderr ?? '').trim()); - logOutputChannel.debug(`No IDE sign-in token available${detail ? `: ${detail}` : ''}`); - } - resolve(token); - }); - }); -} diff --git a/src/devtools-api/auth-log-summaries.ts b/src/devtools-api/auth-log-summaries.ts index 84859cf2..ed65221a 100644 --- a/src/devtools-api/auth-log-summaries.ts +++ b/src/devtools-api/auth-log-summaries.ts @@ -29,4 +29,5 @@ export const authResponseSummaries: Record = { 'cs-ide/auth-cancel-login': statusResponse, 'cs-ide/auth-logout': statusResponse, 'cs-ide/auth-select-account': statusResponse, + 'cs-ide/auth-token': (value) => formatLogFields({ source: value['credential-source'] }), }; diff --git a/src/devtools-api/ide-server-client.ts b/src/devtools-api/ide-server-client.ts index 26a4b001..07e7ab8b 100644 --- a/src/devtools-api/ide-server-client.ts +++ b/src/devtools-api/ide-server-client.ts @@ -2,6 +2,7 @@ import { ChildProcess, spawn } from 'child_process'; import * as path from 'path'; import vscode from 'vscode'; import { CancellationToken, CancellationTokenSource, createMessageConnection, MessageConnection } from 'vscode-jsonrpc/node'; +import type { AuthTokenResponse } from '../auth/auth-token'; import type { AuthStatus } from '../centralized-webview-framework/types/auth'; import { formatLogFields, logOutputChannel } from '../log'; import { redactSecrets } from '../log-redaction'; @@ -266,6 +267,10 @@ export class CsIdeServerClient implements vscode.Disposable { return this.sendRequest('cs-ide/telemetry', { event }); } + authToken(): Promise { + return this.sendRequest('cs-ide/auth-token', {}); + } + async deviceId(): Promise<{ 'device-id': string }> { return deviceIdResponse(await this.sendRequest('cs-ide/device-id', {})); } diff --git a/src/devtools-api/index.ts b/src/devtools-api/index.ts index afb104b9..5155b7f1 100644 --- a/src/devtools-api/index.ts +++ b/src/devtools-api/index.ts @@ -240,6 +240,10 @@ export class DevtoolsAPI { private static readonly refactoringErrorEmitter = new vscode.EventEmitter(); public static readonly onDidRefactoringFail = DevtoolsAPI.refactoringErrorEmitter.event; + static authToken() { + return DevtoolsAPI.ideServer.authToken(); + } + static async postRefactoring(request: RefactoringRequest, onProgress?: ProgressCallback): Promise { this.checkAceEnabled(); const { document, fnToRefactor, skipCache, signal } = request; diff --git a/src/refactoring/agent-token.ts b/src/refactoring/agent-token.ts index 0621dd5c..1e2a77be 100644 --- a/src/refactoring/agent-token.ts +++ b/src/refactoring/agent-token.ts @@ -1,5 +1,17 @@ -import { fetchIdeToken } from '../auth/ide-token'; -import { aceToken } from '../devtools-api'; +import { tokenFromResponse } from '../auth/auth-token'; +import { aceToken, DevtoolsAPI } from '../devtools-api'; +import { logOutputChannel } from '../log'; +import { redactSecrets } from '../log-redaction'; + +async function fetchIdeToken(): Promise { + try { + return tokenFromResponse(await DevtoolsAPI.authToken()); + } catch (error) { + const detail = redactSecrets(error instanceof Error ? error.message : String(error)); + logOutputChannel.debug(`No IDE sign-in token available: ${detail}`); + return undefined; + } +} export async function resolveAgentToken(): Promise { return aceToken() ?? (await fetchIdeToken()); diff --git a/src/test/fixtures/ide-server-fixture.ts b/src/test/fixtures/ide-server-fixture.ts index 01b5c1df..df7afcfb 100644 --- a/src/test/fixtures/ide-server-fixture.ts +++ b/src/test/fixtures/ide-server-fixture.ts @@ -183,6 +183,7 @@ const signedOutStatus = { error: null, }; connection.onRequest('cs-ide/auth-status', () => signedOutStatus); +connection.onRequest('cs-ide/auth-token', () => ({ 'credential-source': 'oauth', 'access-token': 'fixture-access-token' })); connection.onRequest('cs-ide/auth-configure', () => signedOutStatus); connection.onRequest('cs-ide/auth-login', () => { throw new ResponseError(-32803, 'This CodeScene server does not support browser sign-in.', { code: 'auth/oauth-unsupported' }); diff --git a/src/test/suite/auth-token.test.ts b/src/test/suite/auth-token.test.ts new file mode 100644 index 00000000..fe0483d1 --- /dev/null +++ b/src/test/suite/auth-token.test.ts @@ -0,0 +1,18 @@ +import * as assert from 'assert'; +import { tokenFromResponse } from '../../auth/auth-token'; + +suite('tokenFromResponse', () => { + const cases = [ + { name: 'returns the token', response: { 'credential-source': 'oauth' as const, 'access-token': 'abc' }, expected: 'abc' }, + { name: 'trims the token', response: { 'access-token': ' abc ' }, expected: 'abc' }, + { name: 'ignores a blank token', response: { 'access-token': ' ' }, expected: undefined }, + { name: 'ignores a missing token', response: {}, expected: undefined }, + { name: 'ignores a missing response', response: undefined, expected: undefined }, + ]; + + cases.forEach(({ name, response, expected }) => { + test(name, () => { + assert.strictEqual(tokenFromResponse(response), expected); + }); + }); +}); diff --git a/src/test/suite/delta-presentation.test.ts b/src/test/suite/delta-presentation.test.ts index 3b5ffb78..69dffc78 100644 --- a/src/test/suite/delta-presentation.test.ts +++ b/src/test/suite/delta-presentation.test.ts @@ -79,11 +79,11 @@ suite('Delta presentation Test Suite', () => { const testCases = [ { - name: 'presents an enriched delta to the monitor when the review owns the monitor entry', + name: 'presents the delta to the monitor when the review owns the monitor entry', updateMonitor: true, }, { - name: 'keeps an enriched delta out of the monitor when the review does not own the monitor entry', + name: 'keeps the delta out of the monitor when the review does not own the monitor entry', updateMonitor: false, }, ]; @@ -104,10 +104,10 @@ suite('Delta presentation Test Suite', () => { server.deltaEmitter.fire({ id, repoRoot, path: 'src/file.ts', result: degradation() }); await review; - await waitUntil(() => events.length === 2); + await waitUntil(() => events.length === 1); assert.deepStrictEqual( events.map((event) => event.updateMonitor), - [updateMonitor, updateMonitor] + [updateMonitor] ); }); }); diff --git a/src/test/suite/ide-server-client.test.ts b/src/test/suite/ide-server-client.test.ts index 472020ac..f3e35ab3 100644 --- a/src/test/suite/ide-server-client.test.ts +++ b/src/test/suite/ide-server-client.test.ts @@ -105,6 +105,7 @@ suite('CsIdeServerClient Test Suite', () => { assert.deepStrictEqual(refactor.confidence['recommended-action'], { description: 'Apply', details: 'Safe change' }); assert.deepStrictEqual(await client.telemetry(telemetry), { status: 202, params: { event: telemetry } }); assert.deepStrictEqual(await client.deviceId(), { 'device-id': 'device-42' }); + assert.deepStrictEqual(await client.authToken(), { 'credential-source': 'oauth', 'access-token': 'fixture-access-token' }); assert.deepStrictEqual(await client.codeHealthRulesTemplate(), { template: '{"rule_sets":[]}' }); assert.deepStrictEqual(await client.checkRules('/repo', 'src/file.ts'), { result: 'matched', @@ -307,6 +308,7 @@ suite('CsIdeServerClient Test Suite', () => { await client.refactor({ token: 'secret-token', 'fn-to-refactor-nippy-b64': 'encoded', 'skip-cache': true }); await client.telemetry({ 'editor-type': 'VSCode', 'event-name': 'test-event', 'extension-version': '1.0.0', 'user-id': 'user-99' }); await client.deviceId(); + await client.authToken(); await client.codeHealthRulesTemplate(); await client.checkRules('/repo', 'src/file.ts'); await client.getWatchInventory('/repo'); @@ -325,6 +327,8 @@ suite('CsIdeServerClient Test Suite', () => { assertLogContains('info', 'method=cs-ide/device-id'); assertLogContains('info', 'method=cs-ide/device-id durationMs='); assertLogContains('info', 'ok=true'); + assertLogContains('info', 'method=cs-ide/auth-token durationMs='); + assertLogContains('info', 'source=oauth'); assertLogContains('info', 'method=cs-ide/check-rules repoRoot=/repo path=src/file.ts'); assertLogContains('info', 'method=cs-ide/getWatchInventory repoRoot=/repo'); assertLogContains('info', 'count=1 files=requested.ts'); @@ -333,6 +337,7 @@ suite('CsIdeServerClient Test Suite', () => { assertLogOmits('function f() {}'); assertLogOmits('encoded'); assertLogOmits('device-42'); + assertLogOmits('fixture-access-token'); assertLogOmits('user-99'); assertLogOmits('{"rule_sets":[]}'); assert.ok(!capturedLogText().includes('file-content'), capturedLogText()); diff --git a/src/test/suite/ide-token.test.ts b/src/test/suite/ide-token.test.ts deleted file mode 100644 index 279f1686..00000000 --- a/src/test/suite/ide-token.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import * as assert from 'assert'; -import { ideTokenEnv, parseIdeToken } from '../../auth/ide-token'; - -suite('ide-token', () => { - const parseCases = [ - { name: 'returns the token when signed in', stdout: '{"status":"signed_in","access-token":"abc","api-url":"https://api.codescene.io"}\n', expected: 'abc' }, - { name: 'trims the token', stdout: '{"status":"signed_in","access-token":" abc "}', expected: 'abc' }, - { name: 'ignores a signed out session', stdout: '{"status":"signed_out"}', expected: undefined }, - { name: 'ignores a signed in session without token', stdout: '{"status":"signed_in"}', expected: undefined }, - { name: 'ignores a blank token', stdout: '{"status":"signed_in","access-token":" "}', expected: undefined }, - { name: 'ignores invalid json', stdout: 'not json', expected: undefined }, - { name: 'ignores empty output', stdout: '', expected: undefined }, - ]; - - parseCases.forEach(({ name, stdout, expected }) => { - test(`parseIdeToken ${name}`, () => { - assert.strictEqual(parseIdeToken(stdout), expected); - }); - }); - - const envCases = [ - { name: 'sets CS_ONPREM_URL from the server URL', serverUrl: ' https://cs.example ', expected: { KEEP: '1', CS_ONPREM_URL: 'https://cs.example' } }, - { name: 'leaves the environment alone for a blank URL', serverUrl: ' ', expected: { KEEP: '1' } }, - { name: 'leaves the environment alone for no URL', serverUrl: undefined, expected: { KEEP: '1' } }, - ]; - - envCases.forEach(({ name, serverUrl, expected }) => { - test(`ideTokenEnv ${name}`, () => { - assert.deepStrictEqual(ideTokenEnv(serverUrl, { KEEP: '1' }), expected); - }); - }); -}); From c7b8882c195e4b7724d221879eadd116aea073f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Thu, 29 Oct 2026 22:55:13 +0100 Subject: [PATCH 37/39] fix(refactoring): apply agent replacements despite line-ending and trailing-newline drift The agent returns search/replace edits written with LF line endings and, for whole-method edits, a trailing newline. The function body taken from the editor keeps CRLF on Windows files and ends right after the closing brace. Neither form matched, so String.replace silently did nothing, the result equalled the original and a proposed fix was downgraded to "unable to fix" with an empty diff. Apply replacements on LF-normalised text, restore the original line endings afterwards, and retry without trailing newlines when the exact search is not found. Replacement text is now inserted literally so "$&" sequences are not interpreted. A search that still does not match logs a warning with the search and the body. --- src/refactoring/agent-service.ts | 25 ++++++-- src/test/suite/agent-service.test.ts | 88 ++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 4 deletions(-) diff --git a/src/refactoring/agent-service.ts b/src/refactoring/agent-service.ts index 33757d78..19979cfe 100644 --- a/src/refactoring/agent-service.ts +++ b/src/refactoring/agent-service.ts @@ -18,6 +18,10 @@ function stripAnsi(str: string): string { return str.replace(/\x1b\[[0-9;]*m/g, ''); } +function toLf(str: string): string { + return str.replace(/\r\n/g, '\n'); +} + const INPUT_FILE = 'render-code-fix-input.json'; const OUTPUT_FILE = 'render-code-fix-output.json'; @@ -214,10 +218,21 @@ export class AgentRefactoringService { private static applyChange(code: string, change: AgentChange): string { if (change.change_type === 'whole_file' && change.whole_file_content) { - return change.whole_file_content; + return toLf(change.whole_file_content); } if (!change.replacements) return code; - return change.replacements.reduce((c, r) => c.replace(r.search, r.replace), code); + return change.replacements.reduce((c, r) => { + const exact = { search: toLf(r.search), replace: toLf(r.replace) }; + const trimmed = { search: exact.search.replace(/\n+$/, ''), replace: exact.replace.replace(/\n+$/, '') }; + const match = [exact, trimmed].find((candidate) => candidate.search && c.includes(candidate.search)); + if (!match) { + logOutputChannel.warn( + `Agent replacement did not match the function body.\nsearch: ${JSON.stringify(r.search)}\nbody: ${JSON.stringify(code)}` + ); + return c; + } + return c.replace(match.search, () => match.replace); + }, code); } private static applyChanges( @@ -228,10 +243,12 @@ export class AgentRefactoringService { const relevantChanges = changes.filter( (change) => path.basename(change.file) === path.basename(document.fileName) ); - return relevantChanges.reduce( + const usesCrlf = fnToRefactor.body.includes('\r\n'); + const patched = relevantChanges.reduce( (code, change) => AgentRefactoringService.applyChange(code, change), - fnToRefactor.body + toLf(fnToRefactor.body) ); + return usesCrlf ? patched.replace(/\n/g, '\r\n') : patched; } private static mapConfidence(output: AgentOutput): Confidence { diff --git a/src/test/suite/agent-service.test.ts b/src/test/suite/agent-service.test.ts index c9a2378c..c3f05c54 100644 --- a/src/test/suite/agent-service.test.ts +++ b/src/test/suite/agent-service.test.ts @@ -186,6 +186,94 @@ aceSuite('AgentRefactoringService Test Suite', () => { assert.strictEqual(response.code, fnBody); }); + test('mapOutputToResponse applies LF-based replacements to a CRLF function body', () => { + const fnBody = 'void F(int a)\r\n{\r\n if (a > 0)\r\n {\r\n Run(a);\r\n }\r\n}'; + const output: AgentOutput = { + '@context': { '@vocab': 'https://codescene.io/schemas/code-health-fix#' }, + schema_version: '1.0', + task_id: 'test-crlf', + fix_result: 'fix_proposed', + confidence: 'high', + summary: 'Applied change', + reasoning: 'Test', + changes: [ + { + file: 'test.cs', + change_type: 'partial', + description: 'Guard clause', + replacements: [ + { + search: ' if (a > 0)\n {\n Run(a);\n }\n', + replace: ' if (a <= 0) return;\n Run(a);\n', + }, + ], + }, + ], + generated_at: '2026-08-31T12:00:00Z', + }; + + const fnToRefactor = createMockFnToRefactor(fnBody); + const document = createMockDocument('/test/test.cs', fnBody); + + const response = AgentRefactoringService.mapOutputToResponse(output, document, fnToRefactor); + + assert.strictEqual(response.code, 'void F(int a)\r\n{\r\n if (a <= 0) return;\r\n Run(a);\r\n}'); + assert.strictEqual(response.confidence.title, 'Applied change'); + }); + + const trailingNewlineCases = [ + { name: 'LF', eol: '\n' }, + { name: 'CRLF', eol: '\r\n' }, + ]; + + for (const { name, eol } of trailingNewlineCases) { + test(`mapOutputToResponse applies a whole-method replacement whose search ends with a trailing newline (${name})`, () => { + const fnBody = [' void F(int a)', ' {', ' Run(a);', ' }'].join(eol); + const output: AgentOutput = { + '@context': { '@vocab': 'https://codescene.io/schemas/code-health-fix#' }, + schema_version: '1.0', + task_id: 'test-trailing-newline', + fix_result: 'fix_proposed', + confidence: 'high', + summary: 'Applied change', + reasoning: 'Test', + changes: [ + { + file: 'test.cs', + change_type: 'partial', + description: 'Extract helper', + replacements: [ + { + search: ' void F(int a)\n {\n Run(a);\n }\n', + replace: ' void F(int a)\n {\n Helper(a);\n }\n\n void Helper(int a)\n {\n Run(a);\n }\n', + }, + ], + }, + ], + generated_at: '2026-08-31T12:00:00Z', + }; + + const fnToRefactor = createMockFnToRefactor(fnBody); + const document = createMockDocument('/test/test.cs', fnBody); + + const response = AgentRefactoringService.mapOutputToResponse(output, document, fnToRefactor); + + const expected = [ + ' void F(int a)', + ' {', + ' Helper(a);', + ' }', + '', + ' void Helper(int a)', + ' {', + ' Run(a);', + ' }', + ].join(eol); + assert.strictEqual(response.code, expected); + assert.strictEqual(response.confidence.title, 'Applied change'); + }); + } + test('mapOutputToResponse sets metadata cached to false', () => { const output: AgentOutput = { '@context': { '@vocab': 'https://codescene.io/schemas/code-health-fix#' }, From 58a1e0e668ea4b295ed009c4179ac36c1312a958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Thu, 29 Oct 2026 22:55:35 +0100 Subject: [PATCH 38/39] feat(refactoring): ask to save unsaved changes before running the agent cs-agent reads the target file from disk, but the refactoring is applied to the function body taken from the editor buffer. With unsaved edits the two disagree, the agent's search strings no longer match and the user ends up on a "Refactoring Failed" panel that only points at the log. Prompt with a modal before the panel opens: "Save and refactor" saves the document and continues, dismissing it cancels without opening the panel. The Retry button goes through the same command. runRefactoring still rejects a dirty document as a backstop for other callers. --- src/refactoring/agent-service.ts | 3 ++ src/refactoring/commands.ts | 3 ++ src/refactoring/unsaved-changes.ts | 17 ++++++++ src/test/suite/agent-service.test.ts | 11 +++++ src/test/suite/unsaved-changes.test.ts | 56 ++++++++++++++++++++++++++ 5 files changed, 90 insertions(+) create mode 100644 src/refactoring/unsaved-changes.ts create mode 100644 src/test/suite/unsaved-changes.test.ts diff --git a/src/refactoring/agent-service.ts b/src/refactoring/agent-service.ts index 19979cfe..dea554f1 100644 --- a/src/refactoring/agent-service.ts +++ b/src/refactoring/agent-service.ts @@ -44,6 +44,9 @@ export class AgentRefactoringService { skipCleanup?: boolean, onProgress?: ProgressCallback ): Promise { + if (document.isDirty) { + throw new Error(`Save ${path.basename(document.fileName)} before refactoring: the agent reads the file from disk.`); + } const taskId = `refactor-${uuidv4()}`; const gitApi = acquireGitApi(); const repo = gitApi?.getRepository(document.uri); diff --git a/src/refactoring/commands.ts b/src/refactoring/commands.ts index ca8cb0d8..19f76231 100644 --- a/src/refactoring/commands.ts +++ b/src/refactoring/commands.ts @@ -3,6 +3,7 @@ import { CsExtensionState } from '../cs-extension-state'; import { FnToRefactor } from '../devtools-api/refactor-models'; import Telemetry from '../telemetry'; import { RefactoringRequest } from './request'; +import { ensureSavedForRefactoring } from './unsaved-changes'; import { createTempDocument, decorateCode, findFnToRefactor, selectCode, targetEditor } from './utils'; import { reportError } from '../utils'; import { CodeSceneCWFAceTabPanel } from '../codescene-tab/webview/ace/cwf-webview-ace-panel'; @@ -54,6 +55,8 @@ export class CsRefactoringCommands implements vscode.Disposable { return; } + if (!(await ensureSavedForRefactoring(document))) return; + if (!CsExtensionState.acknowledgedAceUsage) { Telemetry.logUsage('ace-info/presented', { source }); CodeSceneCWFAceAcknowledgementTabPanel.show(new RefactoringRequest(toRefactor, document, skipCache)); diff --git a/src/refactoring/unsaved-changes.ts b/src/refactoring/unsaved-changes.ts new file mode 100644 index 00000000..13677158 --- /dev/null +++ b/src/refactoring/unsaved-changes.ts @@ -0,0 +1,17 @@ +import path from 'path'; +import vscode from 'vscode'; + +export const SAVE_AND_REFACTOR = 'Save and refactor'; + +export async function ensureSavedForRefactoring(document: vscode.TextDocument): Promise { + if (!document.isDirty) return true; + + const choice = await vscode.window.showWarningMessage( + `Save ${path.basename(document.fileName)} before refactoring? The refactoring agent reads the file from disk.`, + { modal: true }, + SAVE_AND_REFACTOR + ); + if (choice !== SAVE_AND_REFACTOR) return false; + + return document.save(); +} diff --git a/src/test/suite/agent-service.test.ts b/src/test/suite/agent-service.test.ts index c3f05c54..5965649e 100644 --- a/src/test/suite/agent-service.test.ts +++ b/src/test/suite/agent-service.test.ts @@ -274,6 +274,17 @@ aceSuite('AgentRefactoringService Test Suite', () => { }); } + test('runRefactoring rejects a document with unsaved changes', async () => { + const fnBody = 'function test() { oldCode; }'; + const fnToRefactor = createMockFnToRefactor(fnBody); + const document = { ...createMockDocument('/test/test.ts', fnBody), isDirty: true }; + + await assert.rejects( + () => AgentRefactoringService.runRefactoring(document, fnToRefactor), + /Save .*test\.ts.* before refactoring/ + ); + }); + test('mapOutputToResponse sets metadata cached to false', () => { const output: AgentOutput = { '@context': { '@vocab': 'https://codescene.io/schemas/code-health-fix#' }, diff --git a/src/test/suite/unsaved-changes.test.ts b/src/test/suite/unsaved-changes.test.ts new file mode 100644 index 00000000..31db4ec9 --- /dev/null +++ b/src/test/suite/unsaved-changes.test.ts @@ -0,0 +1,56 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { ensureSavedForRefactoring, SAVE_AND_REFACTOR } from '../../refactoring/unsaved-changes'; + +suite('ensureSavedForRefactoring', () => { + const originalWarning = vscode.window.showWarningMessage; + let prompts: Array<{ message: string; options: unknown; items: unknown[] }>; + + function stubPrompt(answer: string | undefined) { + (vscode.window as any).showWarningMessage = async (message: string, options: unknown, ...items: unknown[]) => { + prompts.push({ message, options, items }); + return answer; + }; + } + + setup(() => { + prompts = []; + }); + + teardown(() => { + (vscode.window as any).showWarningMessage = originalWarning; + }); + + const cases = [ + { name: 'a saved document proceeds without prompting', isDirty: false, answer: undefined, saveResult: true, expected: true, prompted: false, saved: false }, + { name: 'a dirty document is saved when the user accepts', isDirty: true, answer: SAVE_AND_REFACTOR, saveResult: true, expected: true, prompted: true, saved: true }, + { name: 'a dirty document is left alone when the user dismisses', isDirty: true, answer: undefined, saveResult: true, expected: false, prompted: true, saved: false }, + { name: 'a failed save cancels the refactoring', isDirty: true, answer: SAVE_AND_REFACTOR, saveResult: false, expected: false, prompted: true, saved: true }, + ]; + + for (const tc of cases) { + test(tc.name, async () => { + stubPrompt(tc.answer); + let saveCalls = 0; + const document = { + isDirty: tc.isDirty, + fileName: '/repo/CSharp/PrimitiveObsessionExample.cs', + save: async () => { + saveCalls++; + return tc.saveResult; + }, + } as unknown as vscode.TextDocument; + + const proceed = await ensureSavedForRefactoring(document); + + assert.strictEqual(proceed, tc.expected); + assert.strictEqual(prompts.length > 0, tc.prompted); + assert.strictEqual(saveCalls > 0, tc.saved); + if (tc.prompted) { + assert.match(prompts[0].message, /PrimitiveObsessionExample\.cs/); + assert.deepStrictEqual(prompts[0].options, { modal: true }); + assert.deepStrictEqual(prompts[0].items, [SAVE_AND_REFACTOR]); + } + }); + } +}); From 77641291c2163f5ec618de38b2bc27adb754c0d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Thu, 29 Oct 2026 23:09:31 +0100 Subject: [PATCH 39/39] refactor(devtools-api): drop the unused cs-ide/refactor request cs-agent performs refactorings now, so nothing calls the ACE refactor request. Remove the client method, its cancellation and HTTP-failure mapping, the response normalizer, log summaries and test fixtures. Reword the "Auth token" setting, README entry and the missing-sign-in error so signing in is presented as the normal route for refactoring. --- README.md | 2 +- package.json | 2 +- src/devtools-api/ide-server-client.ts | 83 +------------------- src/devtools-api/rpc-response-normalizers.ts | 38 --------- src/refactoring/agent-service.ts | 14 ++-- src/test/fixtures/ide-server-fixture.ts | 29 ------- src/test/suite/ide-server-client.test.ts | 26 ------ 7 files changed, 13 insertions(+), 181 deletions(-) diff --git a/README.md b/README.md index c4bcdb11..56fa44bd 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Location: `Code Health Monitor` > `Extension Settings` | **Enable Review Code Lenses** | Boolean | true | Enables/Disables showing Code Health Score and Code Smells in the active documents | | **Enable Auto Refactor** | Boolean | false | Enables/Disables ACE, CodeScenes automatic refactoring tool | | **Server URL** | String | - | CodeScene server for browser sign-in. Empty uses `CS_ONPREM_URL`, then CodeScene Cloud. Used when Experimental: Sign In is on. | -| **Auth Token** | String | - | Personal access token — fallback for WSL, remote and headless environments. Browser sign-in is recommended. Also used for Automatic Refactoring in ACE. | +| **Auth Token** | String | - | Personal access token — an alternative to signing in, for WSL, remote and headless environments. Also used for refactoring. Browser sign-in is recommended. | | **Experimental: Sign In** | Boolean | false | Sign in to CodeScene in the browser (experimental). Requires a CodeScene IDE server with sign-in support. | | **Enable Telemetry** | Boolean | true | Enable/Disable collecting anonymized telemetry for tracking usage and feature engagement. | diff --git a/package.json b/package.json index 8b35d767..34bfdbc6 100644 --- a/package.json +++ b/package.json @@ -267,7 +267,7 @@ "codescene.authToken": { "type": "string", "default": "", - "description": "Personal access token — fallback for WSL, remote and headless environments. Browser sign-in is recommended.", + "description": "Personal access token — an alternative to signing in, for WSL, remote and headless environments. Also used for refactoring. Browser sign-in is recommended.", "order": 19.5 }, "codescene.experimental.signIn": { diff --git a/src/devtools-api/ide-server-client.ts b/src/devtools-api/ide-server-client.ts index 07e7ab8b..5f438ff5 100644 --- a/src/devtools-api/ide-server-client.ts +++ b/src/devtools-api/ide-server-client.ts @@ -1,18 +1,15 @@ import { ChildProcess, spawn } from 'child_process'; import * as path from 'path'; import vscode from 'vscode'; -import { CancellationToken, CancellationTokenSource, createMessageConnection, MessageConnection } from 'vscode-jsonrpc/node'; +import { createMessageConnection, MessageConnection } from 'vscode-jsonrpc/node'; import type { AuthTokenResponse } from '../auth/auth-token'; import type { AuthStatus } from '../centralized-webview-framework/types/auth'; import { formatLogFields, logOutputChannel } from '../log'; import { redactSecrets } from '../log-redaction'; import { authRequestSummaries, authResponseSummaries, authStatusSummary } from './auth-log-summaries'; import { relativePosix, toPosixRelPath } from '../utils/fs-paths'; -import { AbortError } from './abort-error'; import { Delta } from './delta-model'; -import { DevtoolsError } from './devtools-error'; import { CheckRulesResponse, CodeHealthRulesTemplateResponse } from './model'; -import { FnToRefactor, RefactorResponse } from './refactor-models'; import { Review } from './review-model'; import { TelemetryEvent, TelemetryResponse } from './telemetry-model'; import { @@ -20,26 +17,12 @@ import { deltaResponse, deviceIdResponse, notificationRepoRoot, - refactorResponse, reviewProgressResponse, reviewResponse, watchInventoryResponse, } from './rpc-response-normalizers'; const STARTUP_TIMEOUT_MS = 30000; -const HTTP_FAILURE = /^Request failed \[(\d{3})\]/; - -/** - * cs-ide reports failed backend calls as a JSON-RPC error with the HTTP status only in the message. - * Callers tell auth failures apart through `DevtoolsError.status`, as they did before the cs-ide server. - */ -function httpFailure(error: unknown): DevtoolsError | undefined { - if (!(error instanceof Error)) return; - const match = HTTP_FAILURE.exec(error.message); - if (!match) return; - return new DevtoolsError({ message: error.message, status: Number(match[1]) }); -} - export function distributionServerCommand( distributionPath: string, serverArgs: string[] = ['server'] @@ -112,13 +95,6 @@ export interface DeltaParams { 'new-score'?: string; } -export interface RefactorParams { - token?: string; - 'fn-to-refactor'?: FnToRefactor; - 'fn-to-refactor-nippy-b64'?: string; - 'skip-cache'?: boolean; -} - interface ResultNotification { id?: string; path: string; @@ -252,17 +228,6 @@ export class CsIdeServerClient implements vscode.Disposable { return response ? deltaResponse(response) : null; } - async refactor(params: RefactorParams, signal?: AbortSignal): Promise { - const cancellation = this.bindRefactorCancellation(signal); - try { - return refactorResponse(await this.sendRequest('cs-ide/refactor', params, cancellation.token)); - } catch (error) { - throw this.refactorRejection(error, signal); - } finally { - cancellation.dispose(); - } - } - async telemetry(event: TelemetryEvent): Promise { return this.sendRequest('cs-ide/telemetry', { event }); } @@ -346,26 +311,7 @@ export class CsIdeServerClient implements vscode.Disposable { this.authStatusEmitter.dispose(); } - private bindRefactorCancellation(signal?: AbortSignal): { token: CancellationToken; dispose: () => void } { - const cancellation = new CancellationTokenSource(); - const cancel = () => cancellation.cancel(); - signal?.addEventListener('abort', cancel); - if (signal?.aborted) cancel(); - return { - token: cancellation.token, - dispose: () => { - signal?.removeEventListener('abort', cancel); - cancellation.dispose(); - }, - }; - } - - private refactorRejection(error: unknown, signal?: AbortSignal): unknown { - if (signal?.aborted) return new AbortError(); - return httpFailure(error) ?? error; - } - - private async sendRequest(method: string, params: unknown, token?: CancellationToken): Promise { + private async sendRequest(method: string, params: unknown): Promise { await this.start(); if (!this.connection) throw new Error('CodeScene IDE server is not running'); const id = ++this.requestSeq; @@ -375,9 +321,7 @@ export class CsIdeServerClient implements vscode.Disposable { `[cs-ide] sending id=${id} method=${method}${requestSummary ? ` ${requestSummary}` : ''}` ); try { - const result = token === undefined - ? await this.connection.sendRequest(method, params) - : await this.connection.sendRequest(method, params, token); + const result = await this.connection.sendRequest(method, params); const responseSummary = summarizeResponse(method, result); logOutputChannel.info( `[cs-ide] received id=${id} method=${method} durationMs=${Date.now() - started}${responseSummary ? ` ${responseSummary}` : ''}` @@ -597,12 +541,6 @@ function isWireObject(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } -function refactorPayloadFormat(value: Record): string { - if (value['fn-to-refactor-nippy-b64'] !== undefined) return 'nippy-b64'; - if (value['fn-to-refactor'] !== undefined) return 'json'; - return 'none'; -} - function telemetryEventName(value: Record): unknown { return isWireObject(value.event) ? value.event['event-name'] : undefined; } @@ -617,10 +555,6 @@ const requestSummaries: Record) => strin hasOldScore: value['old-score'] !== undefined, hasNewScore: value['new-score'] !== undefined, }), - 'cs-ide/refactor': (value) => formatLogFields({ - format: refactorPayloadFormat(value), - skipCache: value['skip-cache'] === true, - }), 'cs-ide/telemetry': (value) => formatLogFields({ eventName: telemetryEventName(value) }), 'cs-ide/check-rules': (value) => formatLogFields({ repoRoot: value['repo-root'], path: value.path }), 'cs-ide/getWatchInventory': (value) => formatLogFields({ repoRoot: value['repo-root'] }), @@ -635,17 +569,6 @@ function summarizeRequest(method: string, params: unknown): string { const responseSummaries: Record) => string> = { 'cs-ide/review': (value) => formatLogFields(reviewSummary(reviewResponse(value))), 'cs-ide/delta': (value) => formatLogFields(deltaSummary(deltaResponse(value))), - 'cs-ide/refactor': (value) => { - const response = refactorResponse(value); - return formatLogFields({ - traceId: response['trace-id'], - confidence: response.confidence.level, - cached: response.metadata['cached?'] === true, - reasonCount: response.reasons.length, - added: response['refactoring-properties']['added-code-smells'], - removed: response['refactoring-properties']['removed-code-smells'], - }); - }, 'cs-ide/telemetry': (value) => formatLogFields({ status: value.status }), 'cs-ide/device-id': () => formatLogFields({ ok: true }), 'cs-ide/code-health-rules-template': () => formatLogFields({ ok: true }), diff --git a/src/devtools-api/rpc-response-normalizers.ts b/src/devtools-api/rpc-response-normalizers.ts index 678b05fa..662de029 100644 --- a/src/devtools-api/rpc-response-normalizers.ts +++ b/src/devtools-api/rpc-response-normalizers.ts @@ -1,10 +1,5 @@ import { ChangeDetail, ChangeType, Delta, FunctionFinding } from './delta-model'; import { CheckRulesResponse } from './model'; -import { - Confidence, - RefactorResponse, - RefactoringProperties, -} from './refactor-models'; import { CodeSmell, Function as ReviewFunction, Range, Review } from './review-model'; type WireObject = Record; @@ -89,39 +84,6 @@ export function deltaResponse(value: WireObject): Delta { }; } -function confidence(value: WireObject): Confidence { - return { - level: value.level, - title: value.title, - 'recommended-action': field(value, 'recommendedAction', 'recommended-action')!, - ...(field(value, 'reviewHeader', 'review-header') === undefined - ? {} - : { 'review-header': field(value, 'reviewHeader', 'review-header') }), - }; -} - -function refactoringProperties(value: WireObject): RefactoringProperties { - return { - 'added-code-smells': field(value, 'addedCodeSmells', 'added-code-smells') ?? [], - 'removed-code-smells': field(value, 'removedCodeSmells', 'removed-code-smells') ?? [], - }; -} - -export function refactorResponse(value: WireObject): RefactorResponse { - return { - code: value.code, - confidence: confidence(value.confidence), - ...(value.declarations === undefined ? {} : { declarations: value.declarations }), - metadata: value.metadata, - reasons: value.reasons ?? [], - 'refactoring-properties': refactoringProperties(field(value, 'refactoringProperties', 'refactoring-properties')!), - 'trace-id': field(value, 'traceId', 'trace-id')!, - ...(field(value, 'creditsInfo', 'credits-info') === undefined - ? {} - : { 'credits-info': field(value, 'creditsInfo', 'credits-info') }), - }; -} - export function checkRulesResponse(value: WireObject): CheckRulesResponse { return { result: value.result, diff --git a/src/refactoring/agent-service.ts b/src/refactoring/agent-service.ts index dea554f1..e061b68c 100644 --- a/src/refactoring/agent-service.ts +++ b/src/refactoring/agent-service.ts @@ -64,11 +64,7 @@ export class AgentRefactoringService { const token = await AgentRefactoringService.requireToken(); const stderr = await AgentRefactoringService.invokeAgent(workDir, ioDir, token, signal, onProgress); - if (!fs.existsSync(outputPath)) { - const detail = stripAnsi(stderr).trim().split('\n').slice(-5).join('\n'); - logOutputChannel.error(`Agent exited successfully without producing output. stderr: ${detail}`); - throw new Error(detail ? `Agent did not produce output file: ${detail}` : 'Agent did not produce output file'); - } + if (!fs.existsSync(outputPath)) throw AgentRefactoringService.missingOutputError(stderr); const outputContent = fs.readFileSync(outputPath, 'utf-8'); const output: AgentOutput = JSON.parse(outputContent); @@ -83,6 +79,12 @@ export class AgentRefactoringService { } } + private static missingOutputError(stderr: string): Error { + const detail = stripAnsi(stderr).trim().split('\n').slice(-5).join('\n'); + logOutputChannel.error(`Agent exited successfully without producing output. stderr: ${detail}`); + return new Error(detail ? `Agent did not produce output file: ${detail}` : 'Agent did not produce output file'); + } + private static buildInput(document: TextDocument, fnToRefactor: FnToRefactor, taskId: string): AgentInput { const target = fnToRefactor['refactoring-targets'][0]; if (!target) { @@ -102,7 +104,7 @@ export class AgentRefactoringService { const token = await resolveAgentToken(); if (!token) { throw new Error( - 'Refactoring requires a CodeScene sign-in or access token for the CodeScene MCP server. Sign in to CodeScene or set the "Auth token" setting.' + 'Refactoring requires you to be signed in to CodeScene. Sign in, or set a personal access token in the "Auth token" setting.' ); } return token; diff --git a/src/test/fixtures/ide-server-fixture.ts b/src/test/fixtures/ide-server-fixture.ts index df7afcfb..f74580b9 100644 --- a/src/test/fixtures/ide-server-fixture.ts +++ b/src/test/fixtures/ide-server-fixture.ts @@ -19,35 +19,6 @@ connection.onRequest('cs-ide/delta', () => ({ oldGitBlobSha: 'old-sha', newGitBlobSha: 'new-sha', })); -connection.onRequest('cs-ide/refactor', (params, token) => { - if (params?.token === 'unauthorized') { - throw new ResponseError(-32603, 'Request failed [401]'); - } - if (params?.token === 'hang') { - return new Promise((resolve, reject) => { - const cancel = () => reject(new ResponseError(-32800, 'Request cancelled')); - if (token.isCancellationRequested) { - cancel(); - return; - } - token.onCancellationRequested(cancel); - }); - } - return { - code: 'function f() {}', - confidence: { - level: 1, - title: 'High confidence', - recommendedAction: { description: 'Apply', details: 'Safe change' }, - reviewHeader: 'Review', - }, - metadata: { 'cached?': false }, - reasons: [], - refactoringProperties: { addedCodeSmells: [], removedCodeSmells: ['Complex Method'] }, - traceId: 'trace-1', - creditsInfo: { limit: 10, used: 1 }, - }; -}); connection.onRequest('cs-ide/telemetry', (params) => ({ status: 202, params })); connection.onRequest('cs-ide/device-id', () => ({ deviceId: 'device-42' })); connection.onRequest('cs-ide/code-health-rules-template', () => ({ template: '{"rule_sets":[]}' })); diff --git a/src/test/suite/ide-server-client.test.ts b/src/test/suite/ide-server-client.test.ts index f3e35ab3..7a22d132 100644 --- a/src/test/suite/ide-server-client.test.ts +++ b/src/test/suite/ide-server-client.test.ts @@ -1,7 +1,5 @@ import * as assert from 'assert'; import * as path from 'path'; -import { AbortError } from '../../devtools-api/abort-error'; -import { DevtoolsError } from '../../devtools-api/devtools-error'; import { CsIdeServerClient } from '../../devtools-api/ide-server-client'; import { gitBlobSha } from '../../review/review-pipeline'; import { authStatusFixture } from '../fixtures/auth/auth-fixtures'; @@ -98,11 +96,6 @@ suite('CsIdeServerClient Test Suite', () => { 'old-git-blob-sha': 'old-sha', 'new-git-blob-sha': 'new-sha', }); - const refactor = await client.refactor({ token: 'token' }); - assert.strictEqual(refactor['trace-id'], 'trace-1'); - assert.deepStrictEqual(refactor['credits-info'], { limit: 10, used: 1 }); - assert.deepStrictEqual(refactor['refactoring-properties'], { 'added-code-smells': [], 'removed-code-smells': ['Complex Method'] }); - assert.deepStrictEqual(refactor.confidence['recommended-action'], { description: 'Apply', details: 'Safe change' }); assert.deepStrictEqual(await client.telemetry(telemetry), { status: 202, params: { event: telemetry } }); assert.deepStrictEqual(await client.deviceId(), { 'device-id': 'device-42' }); assert.deepStrictEqual(await client.authToken(), { 'credential-source': 'oauth', 'access-token': 'fixture-access-token' }); @@ -114,20 +107,6 @@ suite('CsIdeServerClient Test Suite', () => { }); }); - test('maps a cancelled refactor to AbortError', async () => { - const controller = new AbortController(); - const pending = client.refactor({ token: 'hang' }, controller.signal); - controller.abort(); - await assert.rejects(pending, (error: unknown) => error instanceof AbortError); - }); - - test('maps an HTTP failure from the server to a DevtoolsError with its status', async () => { - await assert.rejects( - client.refactor({ token: 'unauthorized' }), - (error: unknown) => error instanceof DevtoolsError && error.status === 401 - ); - }); - test('sends check-rules path relative to the repo root', async () => { const rpc = client as unknown as { sendRequest(method: string, params: unknown): Promise }; await client.checkRules('/repo', '/repo/src/file.ts'); @@ -305,7 +284,6 @@ suite('CsIdeServerClient Test Suite', () => { await client.review({ path: 'file.ts' }); await client.delta({ 'old-score': 'old' }); - await client.refactor({ token: 'secret-token', 'fn-to-refactor-nippy-b64': 'encoded', 'skip-cache': true }); await client.telemetry({ 'editor-type': 'VSCode', 'event-name': 'test-event', 'extension-version': '1.0.0', 'user-id': 'user-99' }); await client.deviceId(); await client.authToken(); @@ -319,8 +297,6 @@ suite('CsIdeServerClient Test Suite', () => { assertLogContains('info', 'score=9.68 fileSmells=0 functionSmells=0 hasRulesError=false sha=review-sha'); assertLogContains('info', 'method=cs-ide/delta hasOldScore=true hasNewScore=false'); assertLogContains('info', 'oldScore=10 newScore=9.68 scoreChange=-0.32 fileFindings=0 functionFindings=0'); - assertLogContains('info', 'method=cs-ide/refactor format=nippy-b64 skipCache=true'); - assertLogContains('info', 'traceId=trace-1 confidence=1 cached=false reasonCount=0'); assertLogContains('info', 'method=cs-ide/telemetry eventName=test-event'); assertLogContains('info', 'method=cs-ide/telemetry durationMs='); assertLogContains('info', 'status=202'); @@ -333,9 +309,7 @@ suite('CsIdeServerClient Test Suite', () => { assertLogContains('info', 'method=cs-ide/getWatchInventory repoRoot=/repo'); assertLogContains('info', 'count=1 files=requested.ts'); - assertLogOmits('secret-token'); assertLogOmits('function f() {}'); - assertLogOmits('encoded'); assertLogOmits('device-42'); assertLogOmits('fixture-access-token'); assertLogOmits('user-99');