diff --git a/core/config/types.ts b/core/config/types.ts index 0b76419df6d..d50478f5488 100644 --- a/core/config/types.ts +++ b/core/config/types.ts @@ -280,11 +280,6 @@ declare global { replacement: string; } - export interface ContinueError { - title: string; - message: string; - } - export interface CompletionOptions extends BaseCompletionOptions { model: string; } diff --git a/core/index.d.ts b/core/index.d.ts index 2d9b9b7ba7b..70d5d8d565e 100644 --- a/core/index.d.ts +++ b/core/index.d.ts @@ -310,11 +310,6 @@ export interface FileEdit { replacement: string; } -export interface ContinueError { - title: string; - message: string; -} - export interface CompletionOptions extends BaseCompletionOptions { model: string; } diff --git a/core/indexing/ignore.ts b/core/indexing/ignore.ts index 2b231a5b18d..96b08f6e1bc 100644 --- a/core/indexing/ignore.ts +++ b/core/indexing/ignore.ts @@ -2,6 +2,7 @@ import ignore from "ignore"; import path from "path"; import { fileURLToPath } from "url"; +import { ContinueError, ContinueErrorReason } from "../util/errors"; // Security-focused ignore patterns - these should always be excluded for security reasons export const DEFAULT_SECURITY_IGNORE_FILETYPES = [ @@ -259,7 +260,8 @@ export function isSecurityConcern(filePathOrUri: string) { export function throwIfFileIsSecurityConcern(filepath: string) { if (isSecurityConcern(filepath)) { - throw new Error( + throw new ContinueError( + ContinueErrorReason.FileIsSecurityConcern, `Reading or Editing ${filepath} is not allowed because it is a security concern. Do not attempt to read or edit this file in any way.`, ); } diff --git a/core/util/errors.ts b/core/util/errors.ts index f80fa6b70df..90539f8f31e 100644 --- a/core/util/errors.ts +++ b/core/util/errors.ts @@ -10,3 +10,42 @@ export function getRootCause(err: any): any { } return err; } + +export class ContinueError extends Error { + reason: ContinueErrorReason; + + constructor(reason: ContinueErrorReason, message?: string) { + super(message); + this.reason = reason; + } +} + +export enum ContinueErrorReason { + // Find and Replace validation errors + FindAndReplaceIdenticalStrings = "find_and_replace_old_and_new_strings_identical", + FindAndReplaceMissingOldString = "find_and_replace_missing_old_string", + FindAndReplaceMissingNewString = "find_and_replace_missing_new_string", + FindAndReplaceOldStringNotFound = "find_and_replace_old_string_not_found", + FindAndReplaceMultipleOccurrences = "find_and_replace_multiple_occurrences", + FindAndReplaceMissingFilepath = "find_and_replace_missing_filepath", + + // Multi-edit + MultiEditEditsArrayRequired = "multi_edit_edits_array_required", + MultiEditEditsArrayEmpty = "multi_edit_edits_array_empty", + MultiEditSubsequentEditsOnCreation = "multi_edit_subsequent_edits_on_creation", + MultiEditEmptyOldStringNotFirst = "multi_edit_empty_old_string_not_first", + + // General Edit + EditToolFileNotRead = "edit_tool_file_not_yet_read", + + // General File + FileAlreadyExists = "file_already_exists", + FileNotFound = "file_not_found", + FileWriteError = "file_write_error", + FileIsSecurityConcern = "file_is_security_concern", + ParentDirectoryNotFound = "parent_directory_not_found", + + // Other + Unspecified = "unspecified", + Unknown = "unknown", +} diff --git a/extensions/cli/spec/otlp-metrics.md b/extensions/cli/spec/otlp-metrics.md index f1398558f09..ab394af7f2e 100644 --- a/extensions/cli/spec/otlp-metrics.md +++ b/extensions/cli/spec/otlp-metrics.md @@ -188,7 +188,7 @@ All metrics and events share these standard attributes: ### ✅ Tool Result Event -**Event Name:** `continue_cli_tool_result` +**Event Name:** `tool_result` **Attributes:** diff --git a/extensions/cli/src/stream/streamChatResponse.helpers.ts b/extensions/cli/src/stream/streamChatResponse.helpers.ts index b82f63212cf..608cb02b6e2 100644 --- a/extensions/cli/src/stream/streamChatResponse.helpers.ts +++ b/extensions/cli/src/stream/streamChatResponse.helpers.ts @@ -1,5 +1,6 @@ // Helper functions extracted from streamChatResponse.ts to reduce file size +import { ContinueError, ContinueErrorReason } from "core/util/errors.js"; import { ChatCompletionToolMessageParam } from "openai/resources/chat/completions.mjs"; import { checkToolPermission } from "../permissions/permissionChecker.js"; @@ -354,6 +355,11 @@ export async function preprocessStreamedToolCalls( // Notify the UI about the tool start, even though it failed callbacks?.onToolStart?.(toolCall.name, toolCall.arguments); + const errorReason = + error instanceof ContinueError + ? error.reason + : ContinueErrorReason.Unknown; + const errorMessage = error instanceof Error ? error.message : String(error); @@ -368,6 +374,15 @@ export async function preprocessStreamedToolCalls( success: false, durationMs: duration, error: errorMessage, + errorReason, + // modelName, TODO + }); + posthogService.capture("tool_call_outcome", { + succeeded: false, + toolName: toolCall.name, + errorReason, + duration_ms: duration, + // model: options.modelName, TODO }); // Add error to chat history diff --git a/extensions/cli/src/telemetry/telemetryService.ts b/extensions/cli/src/telemetry/telemetryService.ts index 263f21ac7a7..e9fbca2c6a8 100644 --- a/extensions/cli/src/telemetry/telemetryService.ts +++ b/extensions/cli/src/telemetry/telemetryService.ts @@ -498,9 +498,11 @@ class TelemetryService { success: boolean; durationMs: number; error?: string; + errorReason?: string; decision?: "accept" | "reject"; source?: string; toolParameters?: string; + // modelName: string; }) { if (!this.isEnabled()) return; @@ -515,8 +517,6 @@ class TelemetryService { if (options.error) attributes.error = options.error; if (options.decision) attributes.decision = options.decision; if (options.source) attributes.source = options.source; - if (options.toolParameters) - attributes.tool_parameters = options.toolParameters; // TODO: Implement OTLP logs export logger.debug("Tool result event", attributes); diff --git a/extensions/cli/src/tools/edit.ts b/extensions/cli/src/tools/edit.ts index 545d1426987..ce183efcde4 100644 --- a/extensions/cli/src/tools/edit.ts +++ b/extensions/cli/src/tools/edit.ts @@ -1,6 +1,7 @@ import * as fs from "fs"; import { throwIfFileIsSecurityConcern } from "core/indexing/ignore.js"; +import { ContinueError, ContinueErrorReason } from "core/util/errors.js"; import { telemetryService } from "../telemetry/telemetryService.js"; import { @@ -70,24 +71,37 @@ WARNINGS: // Validate arguments if (!file_path) { - throw new Error("file_path is required"); + throw new ContinueError( + ContinueErrorReason.FindAndReplaceMissingFilepath, + "file_path is required", + ); } if (!old_string) { - throw new Error("old_string is required"); + throw new ContinueError( + ContinueErrorReason.FindAndReplaceMissingOldString, + "old_string is required", + ); } if (new_string === undefined) { - throw new Error("new_string is required"); + throw new ContinueError( + ContinueErrorReason.FindAndReplaceMissingNewString, + "new_string is required", + ); } if (old_string === new_string) { - throw new Error("old_string and new_string must be different"); + throw new ContinueError( + ContinueErrorReason.FindAndReplaceIdenticalStrings, + "old_string and new_string must be different", + ); } const resolvedPath = fs.realpathSync(file_path); // Check if file has been read if (!readFilesSet.has(resolvedPath)) { - throw new Error( + throw new ContinueError( + ContinueErrorReason.EditToolFileNotRead, `You must use the ${readFileTool.name} tool to read ${file_path} before editing it.`, ); } @@ -96,7 +110,10 @@ WARNINGS: // Check if file exists if (!fs.existsSync(resolvedPath)) { - throw new Error(`File ${file_path} does not exist`); + throw new ContinueError( + ContinueErrorReason.FileNotFound, + `File ${file_path} does not exist`, + ); } // Read current file content @@ -104,7 +121,10 @@ WARNINGS: // Check if old_string exists in the file if (!oldContent.includes(old_string)) { - throw new Error(`String not found in file: ${old_string}`); + throw new ContinueError( + ContinueErrorReason.FindAndReplaceOldStringNotFound, + `String not found in file: ${old_string}`, + ); } let newContent: string; @@ -116,7 +136,8 @@ WARNINGS: // Replace only the first occurrence const occurrences = oldContent.split(old_string).length - 1; if (occurrences > 1) { - throw new Error( + throw new ContinueError( + ContinueErrorReason.FindAndReplaceMultipleOccurrences, `String "${old_string}" appears ${occurrences} times in the file. Either provide a more specific string with surrounding context to make it unique, or use replace_all=true to replace all occurrences.`, ); } @@ -179,7 +200,11 @@ WARNINGS: return `Successfully edited ${args.resolvedPath}\nDiff:\n${diff}`; } catch (error) { - throw new Error( + if (error instanceof ContinueError) { + throw error; + } + throw new ContinueError( + ContinueErrorReason.FileWriteError, `Error: failed to edit ${args.resolvedPath}: ${ error instanceof Error ? error.message : String(error) }`, diff --git a/extensions/cli/src/tools/index.tsx b/extensions/cli/src/tools/index.tsx index cc51f0e015d..8f47c9b4a84 100644 --- a/extensions/cli/src/tools/index.tsx +++ b/extensions/cli/src/tools/index.tsx @@ -1,4 +1,8 @@ // @ts-ignore +import { ContinueError, ContinueErrorReason } from "core/util/errors.js"; + +import { posthogService } from "src/telemetry/posthogService.js"; + import { getServiceSync, MCPServiceState, @@ -212,6 +216,11 @@ export async function executeToolCall( durationMs: duration, toolParameters: JSON.stringify(toolCall.arguments), }); + posthogService.capture("tool_call_outcome", { + succeeded: true, + toolName: toolCall.name, + duration_ms: duration, + }); logger.debug("Tool execution completed", { toolName: toolCall.name, @@ -222,14 +231,25 @@ export async function executeToolCall( } catch (error) { const duration = Date.now() - startTime; const errorMessage = error instanceof Error ? error.message : String(error); + const errorReason = + error instanceof ContinueError + ? error.reason + : ContinueErrorReason.Unknown; telemetryService.logToolResult({ toolName: toolCall.name, success: false, durationMs: duration, error: errorMessage, + errorReason, toolParameters: JSON.stringify(toolCall.arguments), }); + posthogService.capture("tool_call_outcome", { + succeeded: false, + toolName: toolCall.name, + duration_ms: duration, + errorReason, + }); return `Error executing tool "${toolCall.name}": ${errorMessage}`; } diff --git a/extensions/cli/src/tools/multiEdit.test.ts b/extensions/cli/src/tools/multiEdit.test.ts index 597cab76722..af269a80cf9 100644 --- a/extensions/cli/src/tools/multiEdit.test.ts +++ b/extensions/cli/src/tools/multiEdit.test.ts @@ -90,7 +90,7 @@ describe("multiEditTool", () => { }; await expect(multiEditTool.preprocess!(args)).rejects.toThrow( - "edits array is required and must contain at least one edit", + "edits array must contain at least one edit", ); }); diff --git a/extensions/cli/src/tools/multiEdit.ts b/extensions/cli/src/tools/multiEdit.ts index 351a37afea7..488f21ee25c 100644 --- a/extensions/cli/src/tools/multiEdit.ts +++ b/extensions/cli/src/tools/multiEdit.ts @@ -2,6 +2,7 @@ import * as fs from "fs"; import * as path from "path"; import { throwIfFileIsSecurityConcern } from "core/indexing/ignore.js"; +import { ContinueError, ContinueErrorReason } from "core/util/errors.js"; import { telemetryService } from "../telemetry/telemetryService.js"; import { @@ -33,11 +34,21 @@ function validateMultiEditArgs(args: any): { const { file_path, edits } = args as MultiEditArgs; if (!file_path) { - throw new Error("file_path is required"); + throw new ContinueError( + ContinueErrorReason.FindAndReplaceMissingFilepath, + "file_path is required", + ); + } + if (!edits || !Array.isArray(edits)) { + throw new ContinueError( + ContinueErrorReason.MultiEditEditsArrayRequired, + "edits array is required", + ); } - if (!edits || !Array.isArray(edits) || edits.length === 0) { - throw new Error( - "edits array is required and must contain at least one edit", + if (edits.length === 0) { + throw new ContinueError( + ContinueErrorReason.MultiEditEditsArrayEmpty, + "edits array must contain at least one edit", ); } @@ -55,13 +66,20 @@ function validateEdits(edits: EditOperation[]): void { for (let i = 0; i < edits.length; i++) { const edit = edits[i]; if (!edit.old_string && edit.old_string !== "") { - throw new Error(`Edit ${i + 1}: old_string is required`); + throw new ContinueError( + ContinueErrorReason.FindAndReplaceMissingOldString, + `Edit ${i + 1}: old_string is required`, + ); } if (edit.new_string === undefined) { - throw new Error(`Edit ${i + 1}: new_string is required`); + throw new ContinueError( + ContinueErrorReason.FindAndReplaceMissingNewString, + `Edit ${i + 1}: new_string is required`, + ); } if (edit.old_string === edit.new_string) { - throw new Error( + throw new ContinueError( + ContinueErrorReason.FindAndReplaceIdenticalStrings, `Edit ${i + 1}: old_string and new_string must be different`, ); } @@ -76,18 +94,25 @@ function validateFileAccess( // For new file creation, check if parent directory exists const parentDir = path.dirname(resolvedPath); if (parentDir && !fs.existsSync(parentDir)) { - throw new Error(`Parent directory does not exist: ${parentDir}`); + throw new ContinueError( + ContinueErrorReason.ParentDirectoryNotFound, + `Parent directory does not exist: ${parentDir}`, + ); } } else { // For existing files, check if file has been read // Check with the original path first, then with absolute path if (!readFilesSet.has(resolvedPath)) { - throw new Error( + throw new ContinueError( + ContinueErrorReason.EditToolFileNotRead, `You must use the ${readFileTool.name} tool to read ${resolvedPath} before editing it.`, ); } if (!fs.existsSync(resolvedPath)) { - throw new Error(`File ${resolvedPath} does not exist`); + throw new ContinueError( + ContinueErrorReason.FileNotFound, + `File ${resolvedPath} does not exist`, + ); } } } @@ -107,7 +132,8 @@ function applyEdit( // Check if old_string exists in current content if (!content.includes(old_string)) { - throw new Error( + throw new ContinueError( + ContinueErrorReason.FindAndReplaceOldStringNotFound, `Edit ${editIndex + 1}: String not found in file: "${old_string}"`, ); } @@ -119,7 +145,8 @@ function applyEdit( // Replace only the first occurrence, but check for uniqueness const occurrences = content.split(old_string).length - 1; if (occurrences > 1) { - throw new Error( + throw new ContinueError( + ContinueErrorReason.FindAndReplaceMultipleOccurrences, `Edit ${editIndex + 1}: String "${old_string}" appears ${occurrences} times in the file. Either provide a more specific string with surrounding context to make it unique, or use replace_all=true to replace all occurrences.`, ); } @@ -275,7 +302,11 @@ WARNINGS: const action = args.isCreatingNewFile ? "created" : "edited"; return `Successfully ${action} ${args.file_path} with ${args.editCount} edit${args.editCount === 1 ? "" : "s"}\nDiff:\n${diff}`; } catch (error) { - throw new Error( + if (error instanceof ContinueError) { + throw error; + } + throw new ContinueError( + ContinueErrorReason.FileWriteError, `Error: failed to edit ${args.file_path}: ${ error instanceof Error ? error.message : String(error) }`, diff --git a/gui/src/components/gui/ErrorStepContainer.tsx b/gui/src/components/gui/ErrorStepContainer.tsx deleted file mode 100644 index 80526132ced..00000000000 --- a/gui/src/components/gui/ErrorStepContainer.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { MinusCircleIcon, XMarkIcon } from "@heroicons/react/24/outline"; -import { ContinueError } from "core"; -import styled from "styled-components"; -import { defaultBorderRadius, vscBackground } from ".."; -import HeaderButtonWithToolTip from "./HeaderButtonWithToolTip"; - -const Div = styled.div` - padding: 8px; - background-color: #ff000011; - border-radius: ${defaultBorderRadius}; - border: 1px solid #cc0000; - margin: 8px; -`; - -interface ErrorStepContainerProps { - error: ContinueError; - onClose: () => void; - onDelete: () => void; -} - -function ErrorStepContainer(props: ErrorStepContainerProps) { - return ( -
-
- props.onClose()} - > - - - props.onDelete()}> - - -
-
-
-          {props.error.message as string}
-        
-
-
- ); -} - -export default ErrorStepContainer; diff --git a/gui/src/redux/thunks/callToolById.ts b/gui/src/redux/thunks/callToolById.ts index 2fee37ca58f..cbf6801fce1 100644 --- a/gui/src/redux/thunks/callToolById.ts +++ b/gui/src/redux/thunks/callToolById.ts @@ -1,6 +1,7 @@ import { createAsyncThunk, unwrapResult } from "@reduxjs/toolkit"; import { ContextItem } from "core"; import { CLIENT_TOOLS_IMPLS } from "core/tools/builtIn"; +import { ContinueError, ContinueErrorReason } from "core/util/errors"; import posthog from "posthog-js"; import { callClientTool } from "../../util/clientTools/callClientTool"; import { selectSelectedChatModel } from "../slices/configSlice"; @@ -45,7 +46,7 @@ export const callToolById = createAsyncThunk< const selectedChatModel = selectSelectedChatModel(state); - posthog.capture("gui_tool_call_decision", { + posthog.capture("tool_call_decision", { model: selectedChatModel, decision: isAutoApproved ? "auto_accept" : "accept", toolName: toolCallState.toolCall.function.name, @@ -63,7 +64,7 @@ export const callToolById = createAsyncThunk< ); let output: ContextItem[] | undefined = undefined; - let errorMessage: string | undefined = undefined; + let error: ContinueError | undefined = undefined; let streamResponse: boolean; // IMPORTANT: @@ -80,14 +81,14 @@ export const callToolById = createAsyncThunk< const { output: clientToolOutput, respondImmediately, - errorMessage: clientToolError, + error: clientToolError, } = await callClientTool(toolCallState, { dispatch, ideMessenger: extra.ideMessenger, getState, }); output = clientToolOutput; - errorMessage = clientToolError; + error = clientToolError; streamResponse = respondImmediately; } else { // Tool is called on core side @@ -98,12 +99,17 @@ export const callToolById = createAsyncThunk< throw new Error(result.error); } else { output = result.content.contextItems; - errorMessage = result.content.errorMessage; + error = result.content.errorMessage + ? new ContinueError( + ContinueErrorReason.Unspecified, + result.content.errorMessage, + ) + : undefined; } streamResponse = true; } - if (errorMessage) { + if (error) { dispatch( updateToolCallOutput({ toolCallId, @@ -112,7 +118,7 @@ export const callToolById = createAsyncThunk< icon: "problems", name: "Tool Call Error", description: "Tool Call Failed", - content: `${toolCallState.toolCall.function.name} failed with the message: ${errorMessage}\n\nPlease try something else or request further instructions.`, + content: `${toolCallState.toolCall.function.name} failed with the message: ${error.message}\n\nPlease try something else or request further instructions.`, hidden: false, }, ], @@ -129,16 +135,16 @@ export const callToolById = createAsyncThunk< // Capture telemetry for tool call execution outcome with duration const duration_ms = Date.now() - startTime; - posthog.capture("gui_tool_call_outcome", { + posthog.capture("tool_call_outcome", { model: selectedChatModel, - succeeded: errorMessage === undefined, + succeeded: !error, toolName: toolCallState.toolCall.function.name, - errorMessage: errorMessage, + errorReason: error?.reason, duration_ms: duration_ms, }); if (streamResponse) { - if (errorMessage) { + if (error) { logToolUsage(toolCallState, false, false, extra.ideMessenger, output); dispatch( errorToolCall({ diff --git a/gui/src/redux/thunks/cancelToolCall.ts b/gui/src/redux/thunks/cancelToolCall.ts index 141ee3229f5..b532fe0bc30 100644 --- a/gui/src/redux/thunks/cancelToolCall.ts +++ b/gui/src/redux/thunks/cancelToolCall.ts @@ -27,7 +27,7 @@ export const cancelToolCallThunk = createAsyncThunk< if (toolCallState) { // Track tool call rejection - posthog.capture("gui_tool_call_decision", { + posthog.capture("tool_call_decision", { model: selectedChatModel, decision: "reject", toolName: toolCallState.toolCall.function.name, diff --git a/gui/src/redux/thunks/streamResponse_toolCalls.test.ts b/gui/src/redux/thunks/streamResponse_toolCalls.test.ts index decf0b5f835..d3c1fda4747 100644 --- a/gui/src/redux/thunks/streamResponse_toolCalls.test.ts +++ b/gui/src/redux/thunks/streamResponse_toolCalls.test.ts @@ -567,7 +567,7 @@ describe("streamResponseThunk - tool calls", () => { // Verify telemetry events for auto-approved tool execution // Use partial matching to allow additional fields (e.g. model) in payload expect(mockPosthog.capture).toHaveBeenCalledWith( - "gui_tool_call_decision", + "tool_call_decision", expect.objectContaining({ decision: "auto_accept", toolName: "search_codebase", @@ -576,11 +576,11 @@ describe("streamResponseThunk - tool calls", () => { ); expect(mockPosthog.capture).toHaveBeenCalledWith( - "gui_tool_call_outcome", + "tool_call_outcome", expect.objectContaining({ succeeded: true, toolName: "search_codebase", - errorMessage: undefined, + errorReason: undefined, duration_ms: expect.any(Number), }), ); @@ -2819,7 +2819,7 @@ describe("streamResponseThunk - tool calls", () => { // Verify telemetry events for manual approval flow // Use partial matching to allow additional fields (e.g. model) in payload expect(mockPosthog.capture).toHaveBeenCalledWith( - "gui_tool_call_decision", + "tool_call_decision", expect.objectContaining({ decision: "accept", toolName: "search_codebase", @@ -2828,11 +2828,11 @@ describe("streamResponseThunk - tool calls", () => { ); expect(mockPosthog.capture).toHaveBeenCalledWith( - "gui_tool_call_outcome", + "tool_call_outcome", expect.objectContaining({ succeeded: true, toolName: "search_codebase", - errorMessage: undefined, + errorReason: undefined, duration_ms: expect.any(Number), }), ); diff --git a/gui/src/util/clientTools/callClientTool.ts b/gui/src/util/clientTools/callClientTool.ts index 509812b94a3..7b76fc6f517 100644 --- a/gui/src/util/clientTools/callClientTool.ts +++ b/gui/src/util/clientTools/callClientTool.ts @@ -1,5 +1,6 @@ import { ContextItem, ToolCallState } from "core"; import { BuiltInToolNames } from "core/tools/builtIn"; +import { ContinueError, ContinueErrorReason } from "core/util/errors"; import { IIdeMessenger } from "../../context/IdeMessenger"; import { AppThunkDispatch, RootState } from "../../redux/store"; import { editToolImpl } from "./editImpl"; @@ -18,7 +19,7 @@ export interface ClientToolOutput { } export interface ClientToolResult extends ClientToolOutput { - errorMessage: string | undefined; + error?: ContinueError; } export type ClientToolImpl = ( @@ -51,18 +52,16 @@ export async function callClientTool( default: throw new Error(`Invalid client tool name ${toolCall.function.name}`); } - return { - ...output, - errorMessage: undefined, - }; + return output; } catch (e) { - let errorMessage = `${e}`; - if (e instanceof Error) { - errorMessage = e.message; - } return { respondImmediately: true, - errorMessage, + error: + e instanceof ContinueError + ? e + : e instanceof Error + ? new ContinueError(ContinueErrorReason.Unspecified, e.message) + : new ContinueError(ContinueErrorReason.Unknown), output: undefined, }; } diff --git a/gui/src/util/clientTools/findAndReplaceUtils.ts b/gui/src/util/clientTools/findAndReplaceUtils.ts index d610fb88d27..0404259a1b2 100644 --- a/gui/src/util/clientTools/findAndReplaceUtils.ts +++ b/gui/src/util/clientTools/findAndReplaceUtils.ts @@ -1,4 +1,5 @@ import { EditOperation } from "core/tools/definitions/multiEdit"; +import { ContinueError, ContinueErrorReason } from "core/util/errors"; export const FOUND_MULTIPLE_FIND_STRINGS_ERROR = "Either provide a more specific string with surrounding context to make it unique, or use replace_all=true to replace all occurrences."; @@ -16,7 +17,10 @@ export function performFindAndReplace( const errorContext = index !== undefined ? `edit at index ${index}: ` : ""; // Check if old_string exists in current content if (!content.includes(oldString)) { - throw new Error(`${errorContext}string not found in file: "${oldString}"`); + throw new ContinueError( + ContinueErrorReason.FindAndReplaceOldStringNotFound, + `${errorContext}string not found in file: "${oldString}"`, + ); } if (replaceAll) { @@ -37,7 +41,8 @@ export function performFindAndReplace( } if (count > 1) { - throw new Error( + throw new ContinueError( + ContinueErrorReason.FindAndReplaceMultipleOccurrences, `${errorContext}String "${oldString}" appears ${count} times in the file. ${FOUND_MULTIPLE_FIND_STRINGS_ERROR}`, ); } @@ -63,13 +68,22 @@ export function validateSingleEdit( const context = index !== undefined ? `edit at index ${index}: ` : ""; if (!oldString && oldString !== "") { - throw new Error(`${context}old_string is required`); + throw new ContinueError( + ContinueErrorReason.FindAndReplaceMissingOldString, + `${context}old_string is required`, + ); } if (newString === undefined) { - throw new Error(`${context}new_string is required`); + throw new ContinueError( + ContinueErrorReason.FindAndReplaceMissingNewString, + `${context}new_string is required`, + ); } if (oldString === newString) { - throw new Error(`${context}old_string and new_string must be different`); + throw new ContinueError( + ContinueErrorReason.FindAndReplaceIdenticalStrings, + `${context}old_string and new_string must be different`, + ); } } @@ -79,13 +93,15 @@ export function validateCreatingForMultiEdit(edits: EditOperation[]) { const isCreating = edits[0].old_string === ""; if (edits.length > 1) { if (isCreating) { - throw new Error( + throw new ContinueError( + ContinueErrorReason.MultiEditSubsequentEditsOnCreation, "cannot make subsequent edits on a file you are creating", ); } else { for (let i = 1; i < edits.length; i++) { if (edits[i].old_string === "") { - throw new Error( + throw new ContinueError( + ContinueErrorReason.MultiEditEmptyOldStringNotFirst, `edit at index ${i}: ${EMPTY_NON_FIRST_EDIT_MESSAGE}`, ); } diff --git a/gui/src/util/clientTools/multiEditImpl.test.ts b/gui/src/util/clientTools/multiEditImpl.test.ts index 7c262cb8246..dffd9ba0184 100644 --- a/gui/src/util/clientTools/multiEditImpl.test.ts +++ b/gui/src/util/clientTools/multiEditImpl.test.ts @@ -58,9 +58,7 @@ describe("multiEditImpl", () => { it("should throw if edits array is empty", async () => { await expect( multiEditImpl({ filepath: "test.txt", edits: [] }, "id", mockExtras), - ).rejects.toThrow( - "edits array is required and must contain at least one edit", - ); + ).rejects.toThrow("edits array must contain at least one edit"); }); it("should throw if edit has invalid old_string", async () => { diff --git a/gui/src/util/clientTools/multiEditImpl.ts b/gui/src/util/clientTools/multiEditImpl.ts index 8b12109e5ad..1a9f7dbd5ef 100644 --- a/gui/src/util/clientTools/multiEditImpl.ts +++ b/gui/src/util/clientTools/multiEditImpl.ts @@ -1,3 +1,4 @@ +import { ContinueError, ContinueErrorReason } from "core/util/errors"; import { inferResolvedUriFromRelativePath, resolveRelativePathInDir, @@ -22,11 +23,21 @@ export const multiEditImpl: ClientToolImpl = async ( // Validate arguments if (!filepath) { - throw new Error("filepath is required"); + throw new ContinueError( + ContinueErrorReason.FindAndReplaceMissingFilepath, + "filepath is required", + ); + } + if (!edits || !Array.isArray(edits)) { + throw new ContinueError( + ContinueErrorReason.MultiEditEditsArrayRequired, + "edits array is required", + ); } - if (!edits || !Array.isArray(edits) || edits.length === 0) { - throw new Error( - "edits array is required and must contain at least one edit", + if (edits.length === 0) { + throw new ContinueError( + ContinueErrorReason.MultiEditEditsArrayEmpty, + "edits array must contain at least one edit", ); } @@ -47,7 +58,8 @@ export const multiEditImpl: ClientToolImpl = async ( let fileUri: string; if (isCreatingNewFile) { if (resolvedUri) { - throw new Error( + throw new ContinueError( + ContinueErrorReason.FileAlreadyExists, `file ${filepath} already exists, cannot create new file`, ); } @@ -60,7 +72,8 @@ export const multiEditImpl: ClientToolImpl = async ( ); } else { if (!resolvedUri) { - throw new Error( + throw new ContinueError( + ContinueErrorReason.FileNotFound, `file ${filepath} does not exist. If you are trying to edit it, correct the filepath. If you are trying to create it, you must pass old_string=""`, ); } diff --git a/gui/src/util/clientTools/singleFindAndReplaceImpl.ts b/gui/src/util/clientTools/singleFindAndReplaceImpl.ts index 92c3da17838..b8137c9f5ff 100644 --- a/gui/src/util/clientTools/singleFindAndReplaceImpl.ts +++ b/gui/src/util/clientTools/singleFindAndReplaceImpl.ts @@ -1,3 +1,4 @@ +import { ContinueError, ContinueErrorReason } from "core/util/errors"; import { resolveRelativePathInDir } from "core/util/ideUtils"; import { v4 as uuid } from "uuid"; import { applyForEditTool } from "../../redux/thunks/handleApplyStateUpdate"; @@ -24,7 +25,10 @@ export const singleFindAndReplaceImpl: ClientToolImpl = async ( // Validate arguments if (!filepath) { - throw new Error("filepath is required"); + throw new ContinueError( + ContinueErrorReason.FindAndReplaceMissingFilepath, + "filepath is required", + ); } validateSingleEdit(old_string, new_string); @@ -34,7 +38,10 @@ export const singleFindAndReplaceImpl: ClientToolImpl = async ( extras.ideMessenger.ide, ); if (!resolvedFilepath) { - throw new Error(`File ${filepath} does not exist`); + throw new ContinueError( + ContinueErrorReason.FileNotFound, + `File ${filepath} does not exist`, + ); } // Read the current file content