diff --git a/extensions/cli/src/services/GitAiIntegrationService.test.ts b/extensions/cli/src/services/GitAiIntegrationService.test.ts new file mode 100644 index 00000000000..edac165530e --- /dev/null +++ b/extensions/cli/src/services/GitAiIntegrationService.test.ts @@ -0,0 +1,514 @@ +import type { ChildProcess } from "child_process"; +import { EventEmitter } from "events"; + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import type { PreprocessedToolCall } from "../tools/types.js"; + +import { GitAiIntegrationService } from "./GitAiIntegrationService.js"; + +// Mock child_process +vi.mock("child_process", () => ({ + exec: vi.fn(), + spawn: vi.fn(), +})); + +// Mock session functions +vi.mock("../session.js", () => ({ + getCurrentSession: vi.fn(), + getSessionFilePath: vi.fn(), +})); + +// Mock serviceContainer +vi.mock("./ServiceContainer.js", () => ({ + serviceContainer: { + getSync: vi.fn(), + }, +})); + +// Mock logger +vi.mock("../util/logger.js", () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +describe("GitAiIntegrationService", () => { + let service: GitAiIntegrationService; + let mockExec: any; + let mockSpawn: any; + let mockGetCurrentSession: any; + let mockGetSessionFilePath: any; + let mockServiceContainer: any; + + beforeEach(async () => { + // Import mocked modules + const childProcess = await import("child_process"); + const session = await import("../session.js"); + const { serviceContainer } = await import("./ServiceContainer.js"); + + mockExec = childProcess.exec as any; + mockSpawn = childProcess.spawn as any; + mockGetCurrentSession = session.getCurrentSession as any; + mockGetSessionFilePath = session.getSessionFilePath as any; + mockServiceContainer = serviceContainer; + + // Setup default mocks + mockGetCurrentSession.mockReturnValue({ + sessionId: "test-session-id", + workspaceDirectory: "/test/workspace", + chatModelTitle: "claude-sonnet-4-5", + }); + + mockGetSessionFilePath.mockReturnValue( + "/test/.continue/sessions/test-session-id.json", + ); + + mockServiceContainer.getSync.mockReturnValue({ + value: { + model: { + model: "claude-sonnet-4-5", + }, + }, + }); + + service = new GitAiIntegrationService(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("initialization", () => { + it("should check if git-ai is available on initialization", async () => { + mockExec.mockImplementation((_cmd: string, callback: Function) => { + callback(null); // No error = git-ai is available + }); + + const state = await service.initialize(); + + expect(state.isEnabled).toBe(true); + expect(state.isGitAiAvailable).toBe(true); + expect(mockExec).toHaveBeenCalledWith( + "git-ai --version", + expect.any(Function), + ); + }); + + it("should mark git-ai as unavailable if version check fails", async () => { + mockExec.mockImplementation((_cmd: string, callback: Function) => { + callback(new Error("command not found")); // Error = git-ai not available + }); + + const state = await service.initialize(); + + expect(state.isEnabled).toBe(true); + expect(state.isGitAiAvailable).toBe(false); + }); + }); + + describe("trackToolUse", () => { + beforeEach(async () => { + mockExec.mockImplementation((_cmd: string, callback: Function) => { + callback(null); // git-ai is available + }); + await service.initialize(); + }); + + it("should not track non-file-editing tools", async () => { + const toolCall: PreprocessedToolCall = { + id: "test-id", + name: "Bash", + arguments: { command: "ls" }, + argumentsStr: JSON.stringify({ command: "ls" }), + startNotified: false, + tool: {} as any, + }; + + await service.trackToolUse(toolCall, "PreToolUse"); + + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it("should track Edit tool usage", async () => { + const mockProcess = createMockChildProcess(); + mockSpawn.mockReturnValue(mockProcess); + + const toolCall: PreprocessedToolCall = { + id: "test-id", + name: "Edit", + arguments: {}, + argumentsStr: JSON.stringify({}), + startNotified: false, + tool: {} as any, + preprocessResult: { + args: { + resolvedPath: "/test/file.ts", + }, + }, + }; + + await service.trackToolUse(toolCall, "PreToolUse"); + + expect(mockSpawn).toHaveBeenCalledWith( + "git-ai", + ["checkpoint", "continue-cli", "--hook-input", "stdin"], + { cwd: "/test/workspace" }, + ); + + // Check that the correct JSON was written to stdin + const writtenData = (mockProcess.stdin!.write as any).mock.calls[0][0]; + const hookInput = JSON.parse(writtenData); + + expect(hookInput).toMatchObject({ + session_id: "test-session-id", + transcript_path: "/test/.continue/sessions/test-session-id.json", + cwd: "/test/workspace", + model: "claude-sonnet-4-5", + hook_event_name: "PreToolUse", + tool_input: { + file_path: "/test/file.ts", + }, + }); + }); + + it("should track MultiEdit tool usage", async () => { + const mockProcess = createMockChildProcess(); + mockSpawn.mockReturnValue(mockProcess); + + const toolCall: PreprocessedToolCall = { + id: "test-id", + name: "MultiEdit", + arguments: {}, + argumentsStr: JSON.stringify({}), + startNotified: false, + tool: {} as any, + preprocessResult: { + args: { + file_path: "/test/file.ts", + }, + }, + }; + + await service.trackToolUse(toolCall, "PostToolUse"); + + const writtenData = (mockProcess.stdin!.write as any).mock.calls[0][0]; + const hookInput = JSON.parse(writtenData); + + expect(hookInput.hook_event_name).toBe("PostToolUse"); + expect(hookInput.tool_input.file_path).toBe("/test/file.ts"); + }); + + it("should track Write tool usage", async () => { + const mockProcess = createMockChildProcess(); + mockSpawn.mockReturnValue(mockProcess); + + const toolCall: PreprocessedToolCall = { + id: "test-id", + name: "Write", + arguments: {}, + argumentsStr: JSON.stringify({}), + startNotified: false, + tool: {} as any, + preprocessResult: { + args: { + filepath: "/test/newfile.ts", + }, + }, + }; + + await service.trackToolUse(toolCall, "PreToolUse"); + + const writtenData = (mockProcess.stdin!.write as any).mock.calls[0][0]; + const hookInput = JSON.parse(writtenData); + + expect(hookInput.tool_input.file_path).toBe("/test/newfile.ts"); + }); + + it("should not track if no file path is found", async () => { + const toolCall: PreprocessedToolCall = { + id: "test-id", + name: "Edit", + arguments: {}, + argumentsStr: JSON.stringify({}), + startNotified: false, + tool: {} as any, + preprocessResult: { + args: {}, // No file path + }, + }; + + await service.trackToolUse(toolCall, "PreToolUse"); + + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it("should not track if service is disabled", async () => { + service.setEnabled(false); + + const toolCall: PreprocessedToolCall = { + id: "test-id", + name: "Edit", + arguments: {}, + argumentsStr: JSON.stringify({}), + startNotified: false, + tool: {} as any, + preprocessResult: { + args: { resolvedPath: "/test/file.ts" }, + }, + }; + + await service.trackToolUse(toolCall, "PreToolUse"); + + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it("should not track if git-ai is unavailable", async () => { + // Reinitialize with git-ai unavailable + mockExec.mockImplementation((_cmd: string, callback: Function) => { + callback(new Error("not found")); + }); + await service.initialize(); + + const toolCall: PreprocessedToolCall = { + id: "test-id", + name: "Edit", + arguments: {}, + argumentsStr: JSON.stringify({}), + startNotified: false, + tool: {} as any, + preprocessResult: { + args: { resolvedPath: "/test/file.ts" }, + }, + }; + + await service.trackToolUse(toolCall, "PreToolUse"); + + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it("should omit model field if model is not available", async () => { + mockServiceContainer.getSync.mockReturnValue({ + value: { + model: null, + }, + }); + + const mockProcess = createMockChildProcess(); + mockSpawn.mockReturnValue(mockProcess); + + const toolCall: PreprocessedToolCall = { + id: "test-id", + name: "Edit", + arguments: {}, + argumentsStr: JSON.stringify({}), + startNotified: false, + tool: {} as any, + preprocessResult: { + args: { resolvedPath: "/test/file.ts" }, + }, + }; + + await service.trackToolUse(toolCall, "PreToolUse"); + + const writtenData = (mockProcess.stdin!.write as any).mock.calls[0][0]; + const hookInput = JSON.parse(writtenData); + + expect(hookInput.model).toBeUndefined(); + }); + + it("should handle git-ai errors gracefully", async () => { + const mockProcess = createMockChildProcess(1); // Exit with error code + mockSpawn.mockReturnValue(mockProcess); + + const toolCall: PreprocessedToolCall = { + id: "test-id", + name: "Edit", + arguments: {}, + argumentsStr: JSON.stringify({}), + startNotified: false, + tool: {} as any, + preprocessResult: { + args: { resolvedPath: "/test/file.ts" }, + }, + }; + + // Should not throw + await expect( + service.trackToolUse(toolCall, "PreToolUse"), + ).resolves.toBeUndefined(); + + // Should mark git-ai as unavailable + const state = service.getState(); + expect(state.isGitAiAvailable).toBe(false); + }); + + it("should handle spawn errors gracefully", async () => { + const mockProcess = createMockChildProcess(); + mockSpawn.mockReturnValue(mockProcess); + + const toolCall: PreprocessedToolCall = { + id: "test-id", + name: "Edit", + arguments: {}, + argumentsStr: JSON.stringify({}), + startNotified: false, + tool: {} as any, + preprocessResult: { + args: { resolvedPath: "/test/file.ts" }, + }, + }; + + // Trigger an error event + setTimeout(() => { + mockProcess.emit("error", new Error("spawn failed")); + }, 0); + + // Should not throw + await expect( + service.trackToolUse(toolCall, "PreToolUse"), + ).resolves.toBeUndefined(); + + // Should mark git-ai as unavailable + const state = service.getState(); + expect(state.isGitAiAvailable).toBe(false); + }); + }); + + describe("extractFilePathFromToolCall", () => { + it("should extract path from Edit tool", () => { + const toolCall: PreprocessedToolCall = { + id: "test-id", + name: "Edit", + arguments: {}, + argumentsStr: JSON.stringify({}), + startNotified: false, + tool: {} as any, + preprocessResult: { + args: { resolvedPath: "/test/edit.ts" }, + }, + }; + + const result = service.extractFilePathFromToolCall(toolCall); + expect(result).toBe("/test/edit.ts"); + }); + + it("should extract path from MultiEdit tool", () => { + const toolCall: PreprocessedToolCall = { + id: "test-id", + name: "MultiEdit", + arguments: {}, + argumentsStr: JSON.stringify({}), + startNotified: false, + tool: {} as any, + preprocessResult: { + args: { file_path: "/test/multiedit.ts" }, + }, + }; + + const result = service.extractFilePathFromToolCall(toolCall); + expect(result).toBe("/test/multiedit.ts"); + }); + + it("should extract path from Write tool", () => { + const toolCall: PreprocessedToolCall = { + id: "test-id", + name: "Write", + arguments: {}, + argumentsStr: JSON.stringify({}), + startNotified: false, + tool: {} as any, + preprocessResult: { + args: { filepath: "/test/write.ts" }, + }, + }; + + const result = service.extractFilePathFromToolCall(toolCall); + expect(result).toBe("/test/write.ts"); + }); + + it("should return null if no preprocessResult", () => { + const toolCall: PreprocessedToolCall = { + id: "test-id", + name: "Edit", + arguments: {}, + argumentsStr: JSON.stringify({}), + startNotified: false, + tool: {} as any, + }; + + const result = service.extractFilePathFromToolCall(toolCall); + expect(result).toBeNull(); + }); + + it("should return null if no args", () => { + const toolCall: PreprocessedToolCall = { + id: "test-id", + name: "Edit", + arguments: {}, + argumentsStr: JSON.stringify({}), + startNotified: false, + tool: {} as any, + preprocessResult: {} as any, + }; + + const result = service.extractFilePathFromToolCall(toolCall); + expect(result).toBeNull(); + }); + + it("should return null for unknown tool", () => { + const toolCall: PreprocessedToolCall = { + id: "test-id", + name: "UnknownTool", + arguments: {}, + argumentsStr: JSON.stringify({}), + startNotified: false, + tool: {} as any, + preprocessResult: { + args: { somePath: "/test/file.ts" }, + }, + }; + + const result = service.extractFilePathFromToolCall(toolCall); + expect(result).toBeNull(); + }); + }); + + describe("setEnabled", () => { + it("should enable the service", () => { + service.setEnabled(true); + const state = service.getState(); + expect(state.isEnabled).toBe(true); + }); + + it("should disable the service", () => { + service.setEnabled(false); + const state = service.getState(); + expect(state.isEnabled).toBe(false); + }); + }); +}); + +/** + * Helper function to create a mock ChildProcess + */ +function createMockChildProcess(exitCode: number = 0): ChildProcess { + const mockProcess = new EventEmitter() as any; + + mockProcess.stdin = { + write: vi.fn(), + end: vi.fn(), + }; + + mockProcess.stdout = new EventEmitter(); + mockProcess.stderr = new EventEmitter(); + + // Simulate process completion after a short delay + setTimeout(() => { + mockProcess.emit("close", exitCode); + }, 10); + + return mockProcess; +} diff --git a/extensions/cli/src/services/GitAiIntegrationService.ts b/extensions/cli/src/services/GitAiIntegrationService.ts new file mode 100644 index 00000000000..fd8b64a1e34 --- /dev/null +++ b/extensions/cli/src/services/GitAiIntegrationService.ts @@ -0,0 +1,251 @@ +import { exec, spawn } from "child_process"; + +import { PreprocessedToolCall } from "src/tools/types.js"; + +import { getCurrentSession, getSessionFilePath } from "../session.js"; +import { logger } from "../util/logger.js"; + +import { BaseService } from "./BaseService.js"; +import { serviceContainer } from "./ServiceContainer.js"; +import type { ModelServiceState } from "./types.js"; + +interface GitAiHookInput { + session_id: string; + transcript_path: string; + cwd: string; + model?: string; + hook_event_name: "PreToolUse" | "PostToolUse"; + tool_input: { + file_path: string; + }; +} + +export interface GitAiIntegrationServiceState { + isEnabled: boolean; + isGitAiAvailable: boolean | null; // null = not checked yet +} + +export class GitAiIntegrationService extends BaseService { + constructor() { + super("GitAiIntegrationService", { + isEnabled: true, + isGitAiAvailable: null, + }); + } + + async doInitialize(): Promise { + // Check if git-ai is available on first initialization + const isAvailable = await this.checkGitAiAvailable(); + return { + isEnabled: true, + isGitAiAvailable: isAvailable, + }; + } + + private async checkGitAiAvailable(): Promise { + return new Promise((resolve) => { + try { + exec("git-ai --version", (error) => { + if (error) { + resolve(false); + return; + } + resolve(true); + }); + } catch { + // Handle edge case where exec throws synchronously + resolve(false); + } + }); + } + + /** + * Helper function to call git-ai checkpoint with the given hook input + */ + private async callGitAiCheckpoint( + hookInput: GitAiHookInput, + workspaceDirectory: string, + ): Promise { + const hookInputJson = JSON.stringify(hookInput); + + logger.debug("Calling git-ai checkpoint", { + hookInput, + workspaceDirectory, + }); + + await new Promise((resolve, reject) => { + const gitAiProcess = spawn( + "git-ai", + ["checkpoint", "continue-cli", "--hook-input", "stdin"], + { cwd: workspaceDirectory }, + ); + + let stdout = ""; + let stderr = ""; + + gitAiProcess.stdout?.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + + gitAiProcess.stderr?.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + gitAiProcess.on("error", (error: Error) => { + reject(error); + }); + + gitAiProcess.on("close", (code: number | null) => { + if (code === 0) { + logger.debug("git-ai checkpoint completed", { stdout, stderr }); + resolve(); + } else { + reject( + new Error(`git-ai checkpoint exited with code ${code}: ${stderr}`), + ); + } + }); + + // Write JSON to stdin and close + gitAiProcess.stdin?.write(hookInputJson); + gitAiProcess.stdin?.end(); + }); + } + + async trackToolUse( + toolCall: PreprocessedToolCall, + hookEventName: "PreToolUse" | "PostToolUse", + ): Promise { + try { + if (!this.currentState.isEnabled) { + return; + } + const isFileEdit = ["Edit", "MultiEdit", "Write"].includes(toolCall.name); + if (!isFileEdit) { + return; + } + + const filePath = this.extractFilePathFromToolCall(toolCall); + if (filePath) { + if (hookEventName === "PreToolUse") { + await this.beforeFileEdit(filePath); + } else if (hookEventName === "PostToolUse") { + await this.afterFileEdit(filePath); + } + } + } catch (error) { + logger.warn("git-ai tool use tracking failed", { + error, + toolCall, + hookEventName, + }); + // Don't throw - allow tool use to proceed without Git AI checkpoint + } + } + + async beforeFileEdit(filePath: string): Promise { + if (!this.currentState.isEnabled) { + return; + } + + // Skip if git-ai is not available + if (this.currentState.isGitAiAvailable === false) { + return; + } + + try { + const session = getCurrentSession(); + const sessionFilePath = getSessionFilePath(); + + // Get current model from ModelService via serviceContainer + const modelState = serviceContainer.getSync("model"); + const modelName = modelState?.value?.model?.model; + + const hookInput: GitAiHookInput = { + session_id: session.sessionId, + transcript_path: sessionFilePath, + cwd: session.workspaceDirectory, + hook_event_name: "PreToolUse", + tool_input: { + file_path: filePath, + }, + }; + + // Only include model if it's available + if (modelName) { + hookInput.model = modelName; + } + + await this.callGitAiCheckpoint(hookInput, session.workspaceDirectory); + } catch (error) { + logger.warn("git-ai checkpoint (pre-edit) failed", { error, filePath }); + // Mark as unavailable if command fails + this.setState({ isGitAiAvailable: false }); + // Don't throw - allow file edit to proceed + } + } + + async afterFileEdit(filePath: string): Promise { + if (!this.currentState.isEnabled) { + return; + } + + // Skip if git-ai is not available + if (this.currentState.isGitAiAvailable === false) { + return; + } + + try { + const session = getCurrentSession(); + const sessionFilePath = getSessionFilePath(); + + // Get current model from ModelService via serviceContainer + const modelState = serviceContainer.getSync("model"); + const modelName = modelState?.value?.model?.model; + + const hookInput: GitAiHookInput = { + session_id: session.sessionId, + transcript_path: sessionFilePath, + cwd: session.workspaceDirectory, + hook_event_name: "PostToolUse", + tool_input: { + file_path: filePath, + }, + }; + + // Only include model if it's available + if (modelName) { + hookInput.model = modelName; + } + + await this.callGitAiCheckpoint(hookInput, session.workspaceDirectory); + } catch (error) { + logger.warn("git-ai checkpoint (post-edit) failed", { error, filePath }); + // Mark as unavailable if command fails + this.setState({ isGitAiAvailable: false }); + // Don't throw - file edit already completed + } + } + + setEnabled(enabled: boolean): void { + this.setState({ isEnabled: enabled }); + } + + extractFilePathFromToolCall(toolCall: PreprocessedToolCall): string | null { + const preprocessed = toolCall.preprocessResult; + if (!preprocessed?.args) return null; + + const args = preprocessed.args; + + // Extract file path based on tool type + if (toolCall.name === "Edit" && args.resolvedPath) { + return args.resolvedPath; + } else if (toolCall.name === "MultiEdit" && args.file_path) { + return args.file_path; + } else if (toolCall.name === "Write" && args.filepath) { + return args.filepath; + } + + return null; + } +} diff --git a/extensions/cli/src/services/index.ts b/extensions/cli/src/services/index.ts index ee79874e0a6..f53a53c8471 100644 --- a/extensions/cli/src/services/index.ts +++ b/extensions/cli/src/services/index.ts @@ -10,6 +10,7 @@ import { AuthService } from "./AuthService.js"; import { ChatHistoryService } from "./ChatHistoryService.js"; import { ConfigService } from "./ConfigService.js"; import { FileIndexService } from "./FileIndexService.js"; +import { GitAiIntegrationService } from "./GitAiIntegrationService.js"; import { MCPService } from "./MCPService.js"; import { ModelService } from "./ModelService.js"; import { ResourceMonitoringService } from "./ResourceMonitoringService.js"; @@ -46,6 +47,7 @@ const agentFileService = new AgentFileService(); const toolPermissionService = new ToolPermissionService(); const systemMessageService = new SystemMessageService(); const artifactUploadService = new ArtifactUploadService(); +const gitAiIntegrationService = new GitAiIntegrationService(); /** * Initialize all services and register them with the service container @@ -310,6 +312,12 @@ export async function initializeServices(initOptions: ServiceInitOptions = {}) { [], // No dependencies for now, but could depend on SESSION in future ); + serviceContainer.register( + SERVICE_NAMES.GIT_AI_INTEGRATION, + () => gitAiIntegrationService.initialize(), + [], // No dependencies + ); + // Eagerly initialize all services to ensure they're ready when needed // This avoids race conditions and "service not ready" errors await serviceContainer.initializeAll(); @@ -372,6 +380,7 @@ export const services = { agentFile: agentFileService, toolPermissions: toolPermissionService, artifactUpload: artifactUploadService, + gitAiIntegration: gitAiIntegrationService, } as const; // Export the service container for advanced usage diff --git a/extensions/cli/src/services/types.ts b/extensions/cli/src/services/types.ts index f9f15f52aa7..b9804bcd26b 100644 --- a/extensions/cli/src/services/types.ts +++ b/extensions/cli/src/services/types.ts @@ -132,6 +132,7 @@ export interface ArtifactUploadServiceState { export type { ChatHistoryState } from "./ChatHistoryService.js"; export type { FileIndexServiceState } from "./FileIndexService.js"; +export type { GitAiIntegrationServiceState } from "./GitAiIntegrationService.js"; /** * Service names as constants to prevent typos @@ -151,6 +152,7 @@ export const SERVICE_NAMES = { STORAGE_SYNC: "storageSync", AGENT_FILE: "agentFile", ARTIFACT_UPLOAD: "artifactUpload", + GIT_AI_INTEGRATION: "gitAiIntegration", } as const; /** diff --git a/extensions/cli/src/tools/gitAiIntegration.test.ts b/extensions/cli/src/tools/gitAiIntegration.test.ts new file mode 100644 index 00000000000..cc5c5f52a9e --- /dev/null +++ b/extensions/cli/src/tools/gitAiIntegration.test.ts @@ -0,0 +1,337 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import type { PreprocessedToolCall } from "./types.js"; + +import { executeToolCall } from "./index.js"; + +// Mock the services +vi.mock("../services/index.js", () => ({ + services: { + gitAiIntegration: { + trackToolUse: vi.fn().mockResolvedValue(undefined), + }, + }, + SERVICE_NAMES: {}, + serviceContainer: {}, +})); + +// Mock telemetry services +vi.mock("../telemetry/telemetryService.js", () => ({ + telemetryService: { + logToolResult: vi.fn(), + }, +})); + +vi.mock("../telemetry/posthogService.js", () => ({ + posthogService: { + capture: vi.fn(), + }, +})); + +// Mock logger +vi.mock("../util/logger.js", () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +describe("Git AI Integration - executeToolCall", () => { + let mockGitAiService: any; + + beforeEach(async () => { + const { services } = await import("../services/index.js"); + mockGitAiService = services.gitAiIntegration; + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("File editing tools", () => { + it("should call git-ai before and after Edit tool execution", async () => { + const mockTool = { + run: vi.fn().mockResolvedValue("Edit completed"), + }; + + const toolCall: PreprocessedToolCall = { + id: "test-edit-id", + name: "Edit", + arguments: { file_path: "/test/file.ts" }, + argumentsStr: JSON.stringify({ file_path: "/test/file.ts" }), + startNotified: false, + tool: mockTool as any, + preprocessResult: { + args: { + resolvedPath: "/test/file.ts", + oldContent: "old", + newContent: "new", + }, + }, + }; + + const result = await executeToolCall(toolCall); + + expect(result).toBe("Edit completed"); + + // Should call trackToolUse twice: PreToolUse and PostToolUse + expect(mockGitAiService.trackToolUse).toHaveBeenCalledTimes(2); + + // Check PreToolUse call + expect(mockGitAiService.trackToolUse).toHaveBeenNthCalledWith( + 1, + toolCall, + "PreToolUse", + ); + + // Check PostToolUse call + expect(mockGitAiService.trackToolUse).toHaveBeenNthCalledWith( + 2, + toolCall, + "PostToolUse", + ); + + // Verify tool.run was called + expect(mockTool.run).toHaveBeenCalledWith({ + resolvedPath: "/test/file.ts", + oldContent: "old", + newContent: "new", + }); + }); + + it("should call git-ai before and after MultiEdit tool execution", async () => { + const mockTool = { + run: vi.fn().mockResolvedValue("MultiEdit completed"), + }; + + const toolCall: PreprocessedToolCall = { + id: "test-multiedit-id", + name: "MultiEdit", + arguments: { file_path: "/test/file.ts" }, + argumentsStr: JSON.stringify({ file_path: "/test/file.ts" }), + startNotified: false, + tool: mockTool as any, + preprocessResult: { + args: { + file_path: "/test/file.ts", + edits: [], + }, + }, + }; + + await executeToolCall(toolCall); + + expect(mockGitAiService.trackToolUse).toHaveBeenCalledTimes(2); + expect(mockGitAiService.trackToolUse).toHaveBeenCalledWith( + toolCall, + "PreToolUse", + ); + expect(mockGitAiService.trackToolUse).toHaveBeenCalledWith( + toolCall, + "PostToolUse", + ); + }); + + it("should call git-ai before and after Write tool execution", async () => { + const mockTool = { + run: vi.fn().mockResolvedValue("Write completed"), + }; + + const toolCall: PreprocessedToolCall = { + id: "test-write-id", + name: "Write", + arguments: { filepath: "/test/newfile.ts" }, + argumentsStr: JSON.stringify({ filepath: "/test/newfile.ts" }), + startNotified: false, + tool: mockTool as any, + preprocessResult: { + args: { + filepath: "/test/newfile.ts", + content: "new content", + }, + }, + }; + + await executeToolCall(toolCall); + + expect(mockGitAiService.trackToolUse).toHaveBeenCalledTimes(2); + expect(mockGitAiService.trackToolUse).toHaveBeenCalledWith( + toolCall, + "PreToolUse", + ); + expect(mockGitAiService.trackToolUse).toHaveBeenCalledWith( + toolCall, + "PostToolUse", + ); + }); + + it("should complete file edit even if git-ai tracking encounters errors internally", async () => { + // Note: trackToolUse has internal error handling, so it won't throw + // This test verifies that the tool execution completes normally + const mockTool = { + run: vi.fn().mockResolvedValue("Edit completed despite internal error"), + }; + + const toolCall: PreprocessedToolCall = { + id: "test-id", + name: "Edit", + arguments: {}, + argumentsStr: JSON.stringify({}), + startNotified: false, + tool: mockTool as any, + preprocessResult: { + args: { resolvedPath: "/test/file.ts" }, + }, + }; + + const result = await executeToolCall(toolCall); + + expect(result).toBe("Edit completed despite internal error"); + expect(mockTool.run).toHaveBeenCalled(); + expect(mockGitAiService.trackToolUse).toHaveBeenCalledTimes(2); + }); + }); + + describe("Non-file editing tools", () => { + it("should call trackToolUse for Bash tool (service will no-op internally)", async () => { + const mockTool = { + run: vi.fn().mockResolvedValue("Command output"), + }; + + const toolCall: PreprocessedToolCall = { + id: "test-bash-id", + name: "Bash", + arguments: { command: "ls" }, + argumentsStr: JSON.stringify({ command: "ls" }), + startNotified: false, + tool: mockTool as any, + }; + + await executeToolCall(toolCall); + + // trackToolUse is called but service checks isFileEdit internally + expect(mockGitAiService.trackToolUse).toHaveBeenCalledTimes(2); + expect(mockGitAiService.trackToolUse).toHaveBeenCalledWith( + toolCall, + "PreToolUse", + ); + expect(mockGitAiService.trackToolUse).toHaveBeenCalledWith( + toolCall, + "PostToolUse", + ); + expect(mockTool.run).toHaveBeenCalled(); + }); + + it("should call trackToolUse for Read tool (service will no-op internally)", async () => { + const mockTool = { + run: vi.fn().mockResolvedValue("File contents"), + }; + + const toolCall: PreprocessedToolCall = { + id: "test-read-id", + name: "Read", + arguments: { file_path: "/test/file.ts" }, + argumentsStr: JSON.stringify({ file_path: "/test/file.ts" }), + startNotified: false, + tool: mockTool as any, + }; + + await executeToolCall(toolCall); + + expect(mockGitAiService.trackToolUse).toHaveBeenCalledTimes(2); + expect(mockTool.run).toHaveBeenCalled(); + }); + + it("should call trackToolUse for Grep tool (service will no-op internally)", async () => { + const mockTool = { + run: vi.fn().mockResolvedValue("Search results"), + }; + + const toolCall: PreprocessedToolCall = { + id: "test-grep-id", + name: "Grep", + arguments: { pattern: "test" }, + argumentsStr: JSON.stringify({ pattern: "test" }), + startNotified: false, + tool: mockTool as any, + }; + + await executeToolCall(toolCall); + + expect(mockGitAiService.trackToolUse).toHaveBeenCalledTimes(2); + expect(mockTool.run).toHaveBeenCalled(); + }); + }); + + describe("Error handling", () => { + it("should propagate tool execution errors", async () => { + const mockTool = { + run: vi.fn().mockRejectedValue(new Error("Tool execution failed")), + }; + + const toolCall: PreprocessedToolCall = { + id: "test-id", + name: "Edit", + arguments: {}, + argumentsStr: JSON.stringify({}), + startNotified: false, + tool: mockTool as any, + preprocessResult: { + args: { resolvedPath: "/test/file.ts" }, + }, + }; + + await expect(executeToolCall(toolCall)).rejects.toThrow( + "Tool execution failed", + ); + + // PreToolUse should have been called + expect(mockGitAiService.trackToolUse).toHaveBeenCalledWith( + toolCall, + "PreToolUse", + ); + }); + }); + + describe("Execution order", () => { + it("should execute in correct order: PreToolUse -> tool.run -> PostToolUse", async () => { + const executionOrder: string[] = []; + + mockGitAiService.trackToolUse.mockImplementation( + (_toolCall: any, phase: string) => { + executionOrder.push(`git-ai:${phase}`); + return Promise.resolve(); + }, + ); + + const mockTool = { + run: vi.fn().mockImplementation(() => { + executionOrder.push("tool:run"); + return Promise.resolve("result"); + }), + }; + + const toolCall: PreprocessedToolCall = { + id: "test-id", + name: "Edit", + arguments: {}, + argumentsStr: JSON.stringify({}), + startNotified: false, + tool: mockTool as any, + preprocessResult: { + args: { resolvedPath: "/test/file.ts" }, + }, + }; + + await executeToolCall(toolCall); + + expect(executionOrder).toEqual([ + "git-ai:PreToolUse", + "tool:run", + "git-ai:PostToolUse", + ]); + }); + }); +}); diff --git a/extensions/cli/src/tools/index.tsx b/extensions/cli/src/tools/index.tsx index cf3e1b1dd1d..efc7a1e5f79 100644 --- a/extensions/cli/src/tools/index.tsx +++ b/extensions/cli/src/tools/index.tsx @@ -207,6 +207,9 @@ export async function executeToolCall( arguments: toolCall.arguments, }); + // Track edits if Git AI is enabled (no-op if not enabled) + await services.gitAiIntegration.trackToolUse(toolCall, "PreToolUse"); + // IMPORTANT: if preprocessed args are present, uses preprocessed args instead of original args // Preprocessed arg names may be different const result = await toolCall.tool.run( @@ -214,6 +217,9 @@ export async function executeToolCall( ); const duration = Date.now() - startTime; + // Track edits if Git AI is enabled (no-op if not enabled) + await services.gitAiIntegration.trackToolUse(toolCall, "PostToolUse"); + telemetryService.logToolResult({ toolName: toolCall.name, success: true,