feat: 工具层及 mcp 大重构 (#252)

* feat: 第一版大重构

* fix: 修复类型问题

* chore: 更新版本到 1.3.2

* Add brave as alternative WebSearchTool

* fix: 修正顺序

* fix: 修复对穷鬼模式的 auto dream 和 session memory 越过

* feat: 穷鬼模式去除 session-summary

* feat: 创建 builtin-tools 包,搬运所有工具实现

将 src/tools/ 下的全部 60 个工具目录迁移至 packages/builtin-tools/src/tools/,
内部导入路径已更新为 src/ alias 模式。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 更新 src/ 中所有工具引用至 builtin-tools 包,删除 src/tools/

- src/tools.ts 及 178 个 src/ 文件的 import 路径从 ./tools/ 改为 builtin-tools/tools/
- 删除 src/tools/ 整个目录(已迁移至 packages/builtin-tools/)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: 添加 builtin-tools 路径别名至 tsconfig,更新 bun.lock

- tsconfig.json 新增 builtin-tools/* 和 builtin-tools 路径映射
- 新增 packages/builtin-tools/src 至 include

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 为 builtin-tools、mcp-client、agent-tools 添加 @claude-code-best 作用域前缀

所有包名及 import 路径统一添加 @claude-code-best/ 前缀:
- builtin-tools → @claude-code-best/builtin-tools
- mcp-client → @claude-code-best/mcp-client
- agent-tools → @claude-code-best/agent-tools

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 修复 node 环境没有 bun 的问题

---------

Co-authored-by: Eric-Guo <eric.guocz@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-13 09:52:05 +08:00
committed by GitHub
parent bbb8b613a9
commit 2fb1c9dcd8
559 changed files with 9346 additions and 1837 deletions

View File

@@ -0,0 +1,625 @@
import { dirname, isAbsolute, sep } from 'path'
import { logEvent } from 'src/services/analytics/index.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
import { diagnosticTracker } from 'src/services/diagnosticTracking.js'
import { clearDeliveredDiagnosticsForFile } from 'src/services/lsp/LSPDiagnosticRegistry.js'
import { getLspServerManager } from 'src/services/lsp/manager.js'
import { notifyVscodeFileUpdated } from 'src/services/mcp/vscodeSdkMcp.js'
import { checkTeamMemSecrets } from 'src/services/teamMemorySync/teamMemSecretGuard.js'
import {
activateConditionalSkillsForPaths,
addSkillDirectories,
discoverSkillDirsForPaths,
} from 'src/skills/loadSkillsDir.js'
import type { ToolUseContext } from 'src/Tool.js'
import { buildTool, type ToolDef } from 'src/Tool.js'
import { getCwd } from 'src/utils/cwd.js'
import { logForDebugging } from 'src/utils/debug.js'
import { countLinesChanged } from 'src/utils/diff.js'
import { isEnvTruthy } from 'src/utils/envUtils.js'
import { isENOENT } from 'src/utils/errors.js'
import {
FILE_NOT_FOUND_CWD_NOTE,
findSimilarFile,
getFileModificationTime,
suggestPathUnderCwd,
writeTextContent,
} from 'src/utils/file.js'
import {
fileHistoryEnabled,
fileHistoryTrackEdit,
} from 'src/utils/fileHistory.js'
import { logFileOperation } from 'src/utils/fileOperationAnalytics.js'
import {
type LineEndingType,
readFileSyncWithMetadata,
} from 'src/utils/fileRead.js'
import { formatFileSize } from 'src/utils/format.js'
import { getFsImplementation } from 'src/utils/fsOperations.js'
import {
fetchSingleFileGitDiff,
type ToolUseDiff,
} from 'src/utils/gitDiff.js'
import { logError } from 'src/utils/log.js'
import { expandPath } from 'src/utils/path.js'
import {
checkWritePermissionForTool,
matchingRuleForInput,
} from 'src/utils/permissions/filesystem.js'
import type { PermissionDecision } from 'src/utils/permissions/PermissionResult.js'
import { matchWildcardPattern } from 'src/utils/permissions/shellRuleMatching.js'
import { validateInputForSettingsFileEdit } from 'src/utils/settings/validateEditTool.js'
import { NOTEBOOK_EDIT_TOOL_NAME } from '../NotebookEditTool/constants.js'
import {
FILE_EDIT_TOOL_NAME,
FILE_UNEXPECTEDLY_MODIFIED_ERROR,
} from './constants.js'
import { getEditToolDescription } from './prompt.js'
import {
type FileEditInput,
type FileEditOutput,
inputSchema,
outputSchema,
} from './types.js'
import {
getToolUseSummary,
renderToolResultMessage,
renderToolUseErrorMessage,
renderToolUseMessage,
renderToolUseRejectedMessage,
userFacingName,
} from './UI.js'
import {
areFileEditsInputsEquivalent,
findActualString,
getPatchForEdit,
preserveQuoteStyle,
} from './utils.js'
// V8/Bun string length limit is ~2^30 characters (~1 billion). For typical
// ASCII/Latin-1 files, 1 byte on disk = 1 character, so 1 GiB in stat bytes
// ≈ 1 billion characters ≈ the runtime string limit. Multi-byte UTF-8 files
// can be larger on disk per character, but 1 GiB is a safe byte-level guard
// that prevents OOM without being unnecessarily restrictive.
const MAX_EDIT_FILE_SIZE = 1024 * 1024 * 1024 // 1 GiB (stat bytes)
export const FileEditTool = buildTool({
name: FILE_EDIT_TOOL_NAME,
searchHint: 'modify file contents in place',
maxResultSizeChars: 100_000,
strict: true,
async description() {
return 'A tool for editing files'
},
async prompt() {
return getEditToolDescription()
},
userFacingName,
getToolUseSummary,
getActivityDescription(input) {
const summary = getToolUseSummary(input)
return summary ? `Editing ${summary}` : 'Editing file'
},
get inputSchema() {
return inputSchema()
},
get outputSchema() {
return outputSchema()
},
toAutoClassifierInput(input) {
return `${input.file_path}: ${input.new_string}`
},
getPath(input): string {
return input.file_path
},
backfillObservableInput(input) {
// hooks.mdx documents file_path as absolute; expand so hook allowlists
// can't be bypassed via ~ or relative paths.
if (typeof input.file_path === 'string') {
input.file_path = expandPath(input.file_path)
}
},
async preparePermissionMatcher({ file_path }) {
return pattern => matchWildcardPattern(pattern, file_path)
},
async checkPermissions(input, context): Promise<PermissionDecision> {
const appState = context.getAppState()
return checkWritePermissionForTool(
FileEditTool,
input,
appState.toolPermissionContext,
)
},
renderToolUseMessage,
renderToolResultMessage,
renderToolUseRejectedMessage,
renderToolUseErrorMessage,
async validateInput(input: FileEditInput, toolUseContext: ToolUseContext) {
const { file_path, old_string, new_string, replace_all = false } = input
// Use expandPath for consistent path normalization (especially on Windows
// where "/" vs "\" can cause readFileState lookup mismatches)
const fullFilePath = expandPath(file_path)
// Reject edits to team memory files that introduce secrets
const secretError = checkTeamMemSecrets(fullFilePath, new_string)
if (secretError) {
return { result: false, message: secretError, errorCode: 0 }
}
if (old_string === new_string) {
return {
result: false,
behavior: 'ask',
message:
'No changes to make: old_string and new_string are exactly the same.',
errorCode: 1,
}
}
// Check if path should be ignored based on permission settings
const appState = toolUseContext.getAppState()
const denyRule = matchingRuleForInput(
fullFilePath,
appState.toolPermissionContext,
'edit',
'deny',
)
if (denyRule !== null) {
return {
result: false,
behavior: 'ask',
message:
'File is in a directory that is denied by your permission settings.',
errorCode: 2,
}
}
// SECURITY: Skip filesystem operations for UNC paths to prevent NTLM credential leaks.
// On Windows, fs.existsSync() on UNC paths triggers SMB authentication which could
// leak credentials to malicious servers. Let the permission check handle UNC paths.
if (fullFilePath.startsWith('\\\\') || fullFilePath.startsWith('//')) {
return { result: true }
}
const fs = getFsImplementation()
// Prevent OOM on multi-GB files.
try {
const { size } = await fs.stat(fullFilePath)
if (size > MAX_EDIT_FILE_SIZE) {
return {
result: false,
behavior: 'ask',
message: `File is too large to edit (${formatFileSize(size)}). Maximum editable file size is ${formatFileSize(MAX_EDIT_FILE_SIZE)}.`,
errorCode: 10,
}
}
} catch (e) {
if (!isENOENT(e)) {
throw e
}
}
// Read the file as bytes first so we can detect encoding from the buffer
// instead of calling detectFileEncoding (which does its own sync readSync
// and would fail with a wasted ENOENT when the file doesn't exist).
let fileContent: string | null
try {
const fileBuffer = await fs.readFileBytes(fullFilePath)
const encoding: BufferEncoding =
fileBuffer.length >= 2 &&
fileBuffer[0] === 0xff &&
fileBuffer[1] === 0xfe
? 'utf16le'
: 'utf8'
fileContent = fileBuffer.toString(encoding).replaceAll('\r\n', '\n')
} catch (e) {
if (isENOENT(e)) {
fileContent = null
} else {
throw e
}
}
// File doesn't exist
if (fileContent === null) {
// Empty old_string on nonexistent file means new file creation — valid
if (old_string === '') {
return { result: true }
}
// Try to find a similar file with a different extension
const similarFilename = findSimilarFile(fullFilePath)
const cwdSuggestion = await suggestPathUnderCwd(fullFilePath)
let message = `File does not exist. ${FILE_NOT_FOUND_CWD_NOTE} ${getCwd()}.`
if (cwdSuggestion) {
message += ` Did you mean ${cwdSuggestion}?`
} else if (similarFilename) {
message += ` Did you mean ${similarFilename}?`
}
return {
result: false,
behavior: 'ask',
message,
errorCode: 4,
}
}
// File exists with empty old_string — only valid if file is empty
if (old_string === '') {
// Only reject if the file has content (for file creation attempt)
if (fileContent.trim() !== '') {
return {
result: false,
behavior: 'ask',
message: 'Cannot create new file - file already exists.',
errorCode: 3,
}
}
// Empty file with empty old_string is valid - we're replacing empty with content
return {
result: true,
}
}
if (fullFilePath.endsWith('.ipynb')) {
return {
result: false,
behavior: 'ask',
message: `File is a Jupyter Notebook. Use the ${NOTEBOOK_EDIT_TOOL_NAME} to edit this file.`,
errorCode: 5,
}
}
const readTimestamp = toolUseContext.readFileState.get(fullFilePath)
if (!readTimestamp || readTimestamp.isPartialView) {
return {
result: false,
behavior: 'ask',
message:
'File has not been read yet. Read it first before writing to it.',
meta: {
isFilePathAbsolute: String(isAbsolute(file_path)),
},
errorCode: 6,
}
}
// Check if file exists and get its last modified time
if (readTimestamp) {
const lastWriteTime = getFileModificationTime(fullFilePath)
if (lastWriteTime > readTimestamp.timestamp) {
// Timestamp indicates modification, but on Windows timestamps can change
// without content changes (cloud sync, antivirus, etc.). For full reads,
// compare content as a fallback to avoid false positives.
const isFullRead =
readTimestamp.offset === undefined &&
readTimestamp.limit === undefined
if (isFullRead && fileContent === readTimestamp.content) {
// Content unchanged, safe to proceed
} else {
return {
result: false,
behavior: 'ask',
message:
'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.',
errorCode: 7,
}
}
}
}
const file = fileContent
// Use findActualString to handle quote normalization
const actualOldString = findActualString(file, old_string)
if (!actualOldString) {
return {
result: false,
behavior: 'ask',
message: `String to replace not found in file.\nString: ${old_string}`,
meta: {
isFilePathAbsolute: String(isAbsolute(file_path)),
},
errorCode: 8,
}
}
const matches = file.split(actualOldString).length - 1
// Check if we have multiple matches but replace_all is false
if (matches > 1 && !replace_all) {
return {
result: false,
behavior: 'ask',
message: `Found ${matches} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: ${old_string}`,
meta: {
isFilePathAbsolute: String(isAbsolute(file_path)),
actualOldString,
},
errorCode: 9,
}
}
// Additional validation for Claude settings files
const settingsValidationResult = validateInputForSettingsFileEdit(
fullFilePath,
file,
() => {
// Simulate the edit to get the final content using the exact same logic as the tool
return replace_all
? file.replaceAll(actualOldString, new_string)
: file.replace(actualOldString, new_string)
},
)
if (settingsValidationResult !== null) {
return settingsValidationResult
}
return { result: true, meta: { actualOldString } }
},
inputsEquivalent(input1, input2) {
return areFileEditsInputsEquivalent(
{
file_path: input1.file_path,
edits: [
{
old_string: input1.old_string,
new_string: input1.new_string,
replace_all: input1.replace_all ?? false,
},
],
},
{
file_path: input2.file_path,
edits: [
{
old_string: input2.old_string,
new_string: input2.new_string,
replace_all: input2.replace_all ?? false,
},
],
},
)
},
async call(
input: FileEditInput,
{
readFileState,
userModified,
updateFileHistoryState,
dynamicSkillDirTriggers,
},
_,
parentMessage,
) {
const { file_path, old_string, new_string, replace_all = false } = input
// 1. Get current state
const fs = getFsImplementation()
const absoluteFilePath = expandPath(file_path)
// Discover skills from this file's path (fire-and-forget, non-blocking)
// Skip in simple mode - no skills available
const cwd = getCwd()
if (!isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {
const newSkillDirs = await discoverSkillDirsForPaths(
[absoluteFilePath],
cwd,
)
if (newSkillDirs.length > 0) {
// Store discovered dirs for attachment display
for (const dir of newSkillDirs) {
dynamicSkillDirTriggers?.add(dir)
}
// Don't await - let skill loading happen in the background
addSkillDirectories(newSkillDirs).catch(() => {})
}
// Activate conditional skills whose path patterns match this file
activateConditionalSkillsForPaths([absoluteFilePath], cwd)
}
await diagnosticTracker.beforeFileEdited(absoluteFilePath)
// Ensure parent directory exists before the atomic read-modify-write section.
// These awaits must stay OUTSIDE the critical section below — a yield between
// the staleness check and writeTextContent lets concurrent edits interleave.
await fs.mkdir(dirname(absoluteFilePath))
if (fileHistoryEnabled()) {
// Backup captures pre-edit content — safe to call before the staleness
// check (idempotent v1 backup keyed on content hash; if staleness fails
// later we just have an unused backup, not corrupt state).
await fileHistoryTrackEdit(
updateFileHistoryState,
absoluteFilePath,
parentMessage.uuid,
)
}
// 2. Load current state and confirm no changes since last read
// Please avoid async operations between here and writing to disk to preserve atomicity
const {
content: originalFileContents,
fileExists,
encoding,
lineEndings: endings,
} = readFileForEdit(absoluteFilePath)
if (fileExists) {
const lastWriteTime = getFileModificationTime(absoluteFilePath)
const lastRead = readFileState.get(absoluteFilePath)
if (!lastRead || lastWriteTime > lastRead.timestamp) {
// Timestamp indicates modification, but on Windows timestamps can change
// without content changes (cloud sync, antivirus, etc.). For full reads,
// compare content as a fallback to avoid false positives.
const isFullRead =
lastRead &&
lastRead.offset === undefined &&
lastRead.limit === undefined
const contentUnchanged =
isFullRead && originalFileContents === lastRead.content
if (!contentUnchanged) {
throw new Error(FILE_UNEXPECTEDLY_MODIFIED_ERROR)
}
}
}
// 3. Use findActualString to handle quote normalization
const actualOldString =
findActualString(originalFileContents, old_string) || old_string
// Preserve curly quotes in new_string when the file uses them
const actualNewString = preserveQuoteStyle(
old_string,
actualOldString,
new_string,
)
// 4. Generate patch
const { patch, updatedFile } = getPatchForEdit({
filePath: absoluteFilePath,
fileContents: originalFileContents,
oldString: actualOldString,
newString: actualNewString,
replaceAll: replace_all,
})
// 5. Write to disk
writeTextContent(absoluteFilePath, updatedFile, encoding, endings)
// Notify LSP servers about file modification (didChange) and save (didSave)
const lspManager = getLspServerManager()
if (lspManager) {
// Clear previously delivered diagnostics so new ones will be shown
clearDeliveredDiagnosticsForFile(`file://${absoluteFilePath}`)
// didChange: Content has been modified
lspManager
.changeFile(absoluteFilePath, updatedFile)
.catch((err: Error) => {
logForDebugging(
`LSP: Failed to notify server of file change for ${absoluteFilePath}: ${err.message}`,
)
logError(err)
})
// didSave: File has been saved to disk (triggers diagnostics in TypeScript server)
lspManager.saveFile(absoluteFilePath).catch((err: Error) => {
logForDebugging(
`LSP: Failed to notify server of file save for ${absoluteFilePath}: ${err.message}`,
)
logError(err)
})
}
// Notify VSCode about the file change for diff view
notifyVscodeFileUpdated(absoluteFilePath, originalFileContents, updatedFile)
// 6. Update read timestamp, to invalidate stale writes
readFileState.set(absoluteFilePath, {
content: updatedFile,
timestamp: getFileModificationTime(absoluteFilePath),
offset: undefined,
limit: undefined,
})
// 7. Log events
if (absoluteFilePath.endsWith(`${sep}CLAUDE.md`)) {
logEvent('tengu_write_claudemd', {})
}
countLinesChanged(patch)
logFileOperation({
operation: 'edit',
tool: 'FileEditTool',
filePath: absoluteFilePath,
})
logEvent('tengu_edit_string_lengths', {
oldStringBytes: Buffer.byteLength(old_string, 'utf8'),
newStringBytes: Buffer.byteLength(new_string, 'utf8'),
replaceAll: replace_all,
})
let gitDiff: ToolUseDiff | undefined
if (
isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) &&
getFeatureValue_CACHED_MAY_BE_STALE('tengu_quartz_lantern', false)
) {
const startTime = Date.now()
const diff = await fetchSingleFileGitDiff(absoluteFilePath)
if (diff) gitDiff = diff
logEvent('tengu_tool_use_diff_computed', {
isEditTool: true,
durationMs: Date.now() - startTime,
hasDiff: !!diff,
})
}
// 8. Yield result
const data = {
filePath: file_path,
oldString: actualOldString,
newString: new_string,
originalFile: originalFileContents,
structuredPatch: patch,
userModified: userModified ?? false,
replaceAll: replace_all,
...(gitDiff && { gitDiff }),
}
return {
data,
}
},
mapToolResultToToolResultBlockParam(data: FileEditOutput, toolUseID) {
const { filePath, userModified, replaceAll } = data
const modifiedNote = userModified
? '. The user modified your proposed changes before accepting them. '
: ''
if (replaceAll) {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: `The file ${filePath} has been updated${modifiedNote}. All occurrences were successfully replaced.`,
}
}
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: `The file ${filePath} has been updated successfully${modifiedNote}.`,
}
},
} satisfies ToolDef<ReturnType<typeof inputSchema>, FileEditOutput>)
// --
function readFileForEdit(absoluteFilePath: string): {
content: string
fileExists: boolean
encoding: BufferEncoding
lineEndings: LineEndingType
} {
try {
// eslint-disable-next-line custom-rules/no-sync-fs
const meta = readFileSyncWithMetadata(absoluteFilePath)
return {
content: meta.content,
fileExists: true,
encoding: meta.encoding,
lineEndings: meta.lineEndings,
}
} catch (e) {
if (isENOENT(e)) {
return {
content: '',
fileExists: false,
encoding: 'utf8',
lineEndings: 'LF',
}
}
throw e
}
}

View File

@@ -0,0 +1,323 @@
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
import type { StructuredPatchHunk } from 'diff'
import * as React from 'react'
import { Suspense, use, useState } from 'react'
import { FileEditToolUseRejectedMessage } from 'src/components/FileEditToolUseRejectedMessage.js'
import { MessageResponse } from 'src/components/MessageResponse.js'
import { extractTag } from 'src/utils/messages.js'
import { FallbackToolUseErrorMessage } from 'src/components/FallbackToolUseErrorMessage.js'
import { FileEditToolUpdatedMessage } from 'src/components/FileEditToolUpdatedMessage.js'
import { Text } from '@anthropic/ink'
import { FilePathLink } from 'src/components/FilePathLink.js'
import type { Tools } from 'src/Tool.js'
import type { Message, ProgressMessage } from 'src/types/message.js'
import { adjustHunkLineNumbers, CONTEXT_LINES } from 'src/utils/diff.js'
import { FILE_NOT_FOUND_CWD_NOTE, getDisplayPath } from 'src/utils/file.js'
import { logError } from 'src/utils/log.js'
import { getPlansDirectory } from 'src/utils/plans.js'
import { readEditContext } from 'src/utils/readEditContext.js'
import { firstLineOf } from 'src/utils/stringUtils.js'
import type { ThemeName } from 'src/utils/theme.js'
import type { FileEditOutput } from './types.js'
import {
findActualString,
getPatchForEdit,
preserveQuoteStyle,
} from './utils.js'
export function userFacingName(
input:
| Partial<{
file_path: string
old_string: string
new_string: string
replace_all: boolean
edits: unknown[]
}>
| undefined,
): string {
if (!input) {
return 'Update'
}
if (input.file_path?.startsWith(getPlansDirectory())) {
return 'Updated plan'
}
// Hashline edits always modify an existing file (line-ref based)
if (input.edits != null) {
return 'Update'
}
if (input.old_string === '') {
return 'Create'
}
return 'Update'
}
export function getToolUseSummary(
input:
| Partial<{
file_path: string
old_string: string
new_string: string
replace_all: boolean
}>
| undefined,
): string | null {
if (!input?.file_path) {
return null
}
return getDisplayPath(input.file_path)
}
export function renderToolUseMessage(
{ file_path }: { file_path?: string },
{ verbose }: { verbose: boolean },
): React.ReactNode {
if (!file_path) {
return null
}
// For plan files, path is already in userFacingName
if (file_path.startsWith(getPlansDirectory())) {
return ''
}
return (
<FilePathLink filePath={file_path}>
{verbose ? file_path : getDisplayPath(file_path)}
</FilePathLink>
)
}
export function renderToolResultMessage(
{ filePath, structuredPatch, originalFile }: FileEditOutput,
_progressMessagesForMessage: ProgressMessage[],
{ style, verbose }: { style?: 'condensed'; verbose: boolean },
): React.ReactNode {
// For plan files, show /plan hint above the diff
const isPlanFile = filePath.startsWith(getPlansDirectory())
return (
<FileEditToolUpdatedMessage
filePath={filePath}
structuredPatch={structuredPatch}
firstLine={originalFile.split('\n')[0] ?? null}
fileContent={originalFile}
style={style}
verbose={verbose}
previewHint={isPlanFile ? '/plan to preview' : undefined}
/>
)
}
export function renderToolUseRejectedMessage(
input: {
file_path: string
old_string?: string
new_string?: string
replace_all?: boolean
edits?: unknown[]
},
options: {
columns: number
messages: Message[]
progressMessagesForMessage: ProgressMessage[]
style?: 'condensed'
theme: ThemeName
tools: Tools
verbose: boolean
},
): React.ReactElement {
const { style, verbose } = options
const filePath = input.file_path
const oldString = input.old_string ?? ''
const newString = input.new_string ?? ''
const replaceAll = input.replace_all ?? false
// Defensive: if input has an unexpected shape, show a simple rejection message
if ('edits' in input && input.edits != null) {
return (
<FileEditToolUseRejectedMessage
file_path={filePath}
operation="update"
firstLine={null}
verbose={verbose}
/>
)
}
const isNewFile = oldString === ''
// For new file creation, show content preview instead of diff
if (isNewFile) {
return (
<FileEditToolUseRejectedMessage
file_path={filePath}
operation="write"
content={newString}
firstLine={firstLineOf(newString)}
verbose={verbose}
/>
)
}
return (
<EditRejectionDiff
filePath={filePath}
oldString={oldString}
newString={newString}
replaceAll={replaceAll}
style={style}
verbose={verbose}
/>
)
}
export function renderToolUseErrorMessage(
result: ToolResultBlockParam['content'],
options: {
progressMessagesForMessage: ProgressMessage[]
tools: Tools
verbose: boolean
},
): React.ReactElement {
const { verbose } = options
if (
!verbose &&
typeof result === 'string' &&
extractTag(result, 'tool_use_error')
) {
const errorMessage = extractTag(result, 'tool_use_error')
// Show a less scary message for intended behavior
if (errorMessage?.includes('File has not been read yet')) {
return (
<MessageResponse>
<Text dimColor>File must be read first</Text>
</MessageResponse>
)
}
if (errorMessage?.includes(FILE_NOT_FOUND_CWD_NOTE)) {
return (
<MessageResponse>
<Text color="error">File not found</Text>
</MessageResponse>
)
}
return (
<MessageResponse>
<Text color="error">Error editing file</Text>
</MessageResponse>
)
}
return <FallbackToolUseErrorMessage result={result} verbose={verbose} />
}
type RejectionDiffData = {
patch: StructuredPatchHunk[]
firstLine: string | null
fileContent: string | undefined
}
function EditRejectionDiff({
filePath,
oldString,
newString,
replaceAll,
style,
verbose,
}: {
filePath: string
oldString: string
newString: string
replaceAll: boolean
style?: 'condensed'
verbose: boolean
}): React.ReactNode {
const [dataPromise] = useState(() =>
loadRejectionDiff(filePath, oldString, newString, replaceAll),
)
return (
<Suspense
fallback={
<FileEditToolUseRejectedMessage
file_path={filePath}
operation="update"
firstLine={null}
verbose={verbose}
/>
}
>
<EditRejectionBody
promise={dataPromise}
filePath={filePath}
style={style}
verbose={verbose}
/>
</Suspense>
)
}
function EditRejectionBody({
promise,
filePath,
style,
verbose,
}: {
promise: Promise<RejectionDiffData>
filePath: string
style?: 'condensed'
verbose: boolean
}): React.ReactNode {
const { patch, firstLine, fileContent } = use(promise)
return (
<FileEditToolUseRejectedMessage
file_path={filePath}
operation="update"
patch={patch}
firstLine={firstLine}
fileContent={fileContent}
style={style}
verbose={verbose}
/>
)
}
async function loadRejectionDiff(
filePath: string,
oldString: string,
newString: string,
replaceAll: boolean,
): Promise<RejectionDiffData> {
try {
// Chunked read — context window around the first occurrence. replaceAll
// still shows matches *within* the window via getPatchForEdit; we accept
// losing the all-occurrences view to keep the read bounded.
const ctx = await readEditContext(filePath, oldString, CONTEXT_LINES)
if (ctx === null || ctx.truncated || ctx.content === '') {
// ENOENT / not found / truncated — diff just the tool inputs.
const { patch } = getPatchForEdit({
filePath,
fileContents: oldString,
oldString,
newString,
})
return { patch, firstLine: null, fileContent: undefined }
}
const actualOld = findActualString(ctx.content, oldString) || oldString
const actualNew = preserveQuoteStyle(oldString, actualOld, newString)
const { patch } = getPatchForEdit({
filePath,
fileContents: ctx.content,
oldString: actualOld,
newString: actualNew,
replaceAll,
})
return {
patch: adjustHunkLineNumbers(patch, ctx.lineOffset - 1),
firstLine: ctx.lineOffset === 1 ? firstLineOf(ctx.content) : null,
fileContent: ctx.content,
}
} catch (e) {
// User may have manually applied the change while the diff was shown.
logError(e as Error)
return { patch: [], firstLine: null, fileContent: undefined }
}
}

View File

@@ -0,0 +1,208 @@
import { mock, describe, expect, test } from "bun:test";
// Mock log.ts to cut the heavy dependency chain
mock.module("src/utils/log.ts", () => ({
logError: () => {},
logToFile: () => {},
getLogDisplayTitle: () => "",
logEvent: () => {},
logMCPError: () => {},
logMCPDebug: () => {},
dateToFilename: (d: Date) => d.toISOString().replace(/[:.]/g, "-"),
getLogFilePath: () => "/tmp/mock-log",
attachErrorLogSink: () => {},
getInMemoryErrors: () => [],
loadErrorLogs: async () => [],
getErrorLogByIndex: async () => null,
captureAPIRequest: () => {},
_resetErrorLogForTesting: () => {},
}));
const {
normalizeQuotes,
stripTrailingWhitespace,
findActualString,
preserveQuoteStyle,
applyEditToFile,
LEFT_SINGLE_CURLY_QUOTE,
RIGHT_SINGLE_CURLY_QUOTE,
LEFT_DOUBLE_CURLY_QUOTE,
RIGHT_DOUBLE_CURLY_QUOTE,
} = await import("../utils");
// ─── normalizeQuotes ────────────────────────────────────────────────────
describe("normalizeQuotes", () => {
test("converts left single curly to straight", () => {
expect(normalizeQuotes(`${LEFT_SINGLE_CURLY_QUOTE}hello`)).toBe("'hello");
});
test("converts right single curly to straight", () => {
expect(normalizeQuotes(`hello${RIGHT_SINGLE_CURLY_QUOTE}`)).toBe("hello'");
});
test("converts left double curly to straight", () => {
expect(normalizeQuotes(`${LEFT_DOUBLE_CURLY_QUOTE}hello`)).toBe('"hello');
});
test("converts right double curly to straight", () => {
expect(normalizeQuotes(`hello${RIGHT_DOUBLE_CURLY_QUOTE}`)).toBe('hello"');
});
test("leaves straight quotes unchanged", () => {
expect(normalizeQuotes("'hello' \"world\"")).toBe("'hello' \"world\"");
});
test("handles empty string", () => {
expect(normalizeQuotes("")).toBe("");
});
});
// ─── stripTrailingWhitespace ────────────────────────────────────────────
describe("stripTrailingWhitespace", () => {
test("strips trailing spaces from lines", () => {
expect(stripTrailingWhitespace("hello \nworld ")).toBe("hello\nworld");
});
test("strips trailing tabs", () => {
expect(stripTrailingWhitespace("hello\t\nworld\t")).toBe("hello\nworld");
});
test("preserves leading whitespace", () => {
expect(stripTrailingWhitespace(" hello \n world ")).toBe(
" hello\n world"
);
});
test("handles empty string", () => {
expect(stripTrailingWhitespace("")).toBe("");
});
test("handles CRLF line endings", () => {
expect(stripTrailingWhitespace("hello \r\nworld ")).toBe(
"hello\r\nworld"
);
});
test("handles no trailing whitespace", () => {
expect(stripTrailingWhitespace("hello\nworld")).toBe("hello\nworld");
});
test("handles CR-only line endings", () => {
expect(stripTrailingWhitespace("hello \rworld ")).toBe("hello\rworld");
});
test("handles content with no trailing newline", () => {
expect(stripTrailingWhitespace("hello ")).toBe("hello");
});
});
// ─── findActualString ───────────────────────────────────────────────────
describe("findActualString", () => {
test("finds exact match", () => {
expect(findActualString("hello world", "hello")).toBe("hello");
});
test("finds match with curly quotes normalized", () => {
const fileContent = `${LEFT_DOUBLE_CURLY_QUOTE}hello${RIGHT_DOUBLE_CURLY_QUOTE}`;
const result = findActualString(fileContent, '"hello"');
expect(result).not.toBeNull();
});
test("returns null when not found", () => {
expect(findActualString("hello world", "xyz")).toBeNull();
});
test("returns null for empty search in non-empty content", () => {
// Empty string is always found at index 0 via includes()
const result = findActualString("hello", "");
expect(result).toBe("");
});
});
// ─── preserveQuoteStyle ─────────────────────────────────────────────────
describe("preserveQuoteStyle", () => {
test("returns newString unchanged when no normalization happened", () => {
expect(preserveQuoteStyle("hello", "hello", "world")).toBe("world");
});
test("converts straight double quotes to curly in replacement", () => {
const oldString = '"hello"';
const actualOldString = `${LEFT_DOUBLE_CURLY_QUOTE}hello${RIGHT_DOUBLE_CURLY_QUOTE}`;
const newString = '"world"';
const result = preserveQuoteStyle(oldString, actualOldString, newString);
expect(result).toContain(LEFT_DOUBLE_CURLY_QUOTE);
expect(result).toContain(RIGHT_DOUBLE_CURLY_QUOTE);
});
test("converts straight single quotes to curly in replacement", () => {
const oldString = "'hello'";
const actualOldString = `${LEFT_SINGLE_CURLY_QUOTE}hello${RIGHT_SINGLE_CURLY_QUOTE}`;
const newString = "'world'";
const result = preserveQuoteStyle(oldString, actualOldString, newString);
expect(result).toContain(LEFT_SINGLE_CURLY_QUOTE);
expect(result).toContain(RIGHT_SINGLE_CURLY_QUOTE);
});
test("treats apostrophe in contraction as right curly quote", () => {
const oldString = "'it's a test'";
const actualOldString = `${LEFT_SINGLE_CURLY_QUOTE}it${RIGHT_SINGLE_CURLY_QUOTE}s a test${RIGHT_SINGLE_CURLY_QUOTE}`;
const newString = "'don't worry'";
const result = preserveQuoteStyle(oldString, actualOldString, newString);
// The leading ' at position 0 should be LEFT_SINGLE_CURLY_QUOTE
expect(result[0]).toBe(LEFT_SINGLE_CURLY_QUOTE);
// The apostrophe in "don't" (between n and t) should be RIGHT_SINGLE_CURLY_QUOTE
expect(result).toContain(RIGHT_SINGLE_CURLY_QUOTE);
});
});
// ─── applyEditToFile ────────────────────────────────────────────────────
describe("applyEditToFile", () => {
test("replaces first occurrence by default", () => {
expect(applyEditToFile("foo bar foo", "foo", "baz")).toBe("baz bar foo");
});
test("replaces all occurrences with replaceAll=true", () => {
expect(applyEditToFile("foo bar foo", "foo", "baz", true)).toBe(
"baz bar baz"
);
});
test("handles deletion (empty newString) with trailing newline", () => {
const result = applyEditToFile("line1\nline2\nline3\n", "line2", "");
expect(result).toBe("line1\nline3\n");
});
test("handles deletion without trailing newline", () => {
const result = applyEditToFile("foobar", "foo", "");
expect(result).toBe("bar");
});
test("handles no match (returns original)", () => {
expect(applyEditToFile("hello world", "xyz", "abc")).toBe("hello world");
});
test("handles empty original content with insertion", () => {
expect(applyEditToFile("", "", "new content")).toBe("new content");
});
test("handles multiline oldString and newString", () => {
const content = "line1\nline2\nline3\n";
const result = applyEditToFile(content, "line2\nline3", "replaced");
expect(result).toBe("line1\nreplaced\n");
});
test("handles multiline replacement across multiple lines", () => {
const content = "header\nold line A\nold line B\nfooter\n";
const result = applyEditToFile(
content,
"old line A\nold line B",
"new line X\nnew line Y"
);
expect(result).toBe("header\nnew line X\nnew line Y\nfooter\n");
});
});

View File

@@ -0,0 +1,11 @@
// In its own file to avoid circular dependencies
export const FILE_EDIT_TOOL_NAME = 'Edit'
// Permission pattern for granting session-level access to the project's .claude/ folder
export const CLAUDE_FOLDER_PERMISSION_PATTERN = '/.claude/**'
// Permission pattern for granting session-level access to the global ~/.claude/ folder
export const GLOBAL_CLAUDE_FOLDER_PERMISSION_PATTERN = '~/.claude/**'
export const FILE_UNEXPECTEDLY_MODIFIED_ERROR =
'File has been unexpectedly modified. Read it again before attempting to write it.'

View File

@@ -0,0 +1,28 @@
import { isCompactLinePrefixEnabled } from 'src/utils/file.js'
import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js'
function getPreReadInstruction(): string {
return `\n- You must use your \`${FILE_READ_TOOL_NAME}\` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. `
}
export function getEditToolDescription(): string {
return getDefaultEditDescription()
}
function getDefaultEditDescription(): string {
const prefixFormat = isCompactLinePrefixEnabled()
? 'line number + tab'
: 'spaces + line number + arrow'
const minimalUniquenessHint =
process.env.USER_TYPE === 'ant'
? `\n- Use the smallest old_string that's clearly unique — usually 2-4 adjacent lines is sufficient. Avoid including 10+ lines of context when less uniquely identifies the target.`
: ''
return `Performs exact string replacements in files.
Usage:${getPreReadInstruction()}
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: ${prefixFormat}. Everything after that is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string.
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
- The edit will FAIL if \`old_string\` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use \`replace_all\` to change every instance of \`old_string\`.${minimalUniquenessHint}
- Use \`replace_all\` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.`
}

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type FileEditToolUseRejectedMessage = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type MessageResponse = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type logEvent = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type logError = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type extractTag = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type expandPath = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type countCharInString = any;

View File

@@ -0,0 +1,85 @@
import { z } from 'zod/v4'
import { lazySchema } from 'src/utils/lazySchema.js'
import { semanticBoolean } from 'src/utils/semanticBoolean.js'
// The input schema with optional replace_all
const inputSchema = lazySchema(() =>
z.strictObject({
file_path: z.string().describe('The absolute path to the file to modify'),
old_string: z.string().describe('The text to replace'),
new_string: z
.string()
.describe(
'The text to replace it with (must be different from old_string)',
),
replace_all: semanticBoolean(
z.boolean().default(false).optional(),
).describe('Replace all occurrences of old_string (default false)'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
// Parsed output — what call() receives. z.output not z.input: with
// semanticBoolean the input side is unknown (preprocess accepts anything).
export type FileEditInput = z.output<InputSchema>
// Individual edit without file_path
export type EditInput = Omit<FileEditInput, 'file_path'>
// Runtime version where replace_all is always defined
export type FileEdit = {
old_string: string
new_string: string
replace_all: boolean
}
export const hunkSchema = lazySchema(() =>
z.object({
oldStart: z.number(),
oldLines: z.number(),
newStart: z.number(),
newLines: z.number(),
lines: z.array(z.string()),
}),
)
export const gitDiffSchema = lazySchema(() =>
z.object({
filename: z.string(),
status: z.enum(['modified', 'added']),
additions: z.number(),
deletions: z.number(),
changes: z.number(),
patch: z.string(),
repository: z
.string()
.nullable()
.optional()
.describe('GitHub owner/repo when available'),
}),
)
// Output schema for FileEditTool
const outputSchema = lazySchema(() =>
z.object({
filePath: z.string().describe('The file path that was edited'),
oldString: z.string().describe('The original string that was replaced'),
newString: z.string().describe('The new string that replaced it'),
originalFile: z
.string()
.describe('The original file contents before editing'),
structuredPatch: z
.array(hunkSchema())
.describe('Diff patch showing the changes'),
userModified: z
.boolean()
.describe('Whether the user modified the proposed changes'),
replaceAll: z.boolean().describe('Whether all occurrences were replaced'),
gitDiff: gitDiffSchema().optional(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type FileEditOutput = z.infer<OutputSchema>
export { inputSchema, outputSchema }

View File

@@ -0,0 +1,775 @@
import { type StructuredPatchHunk, structuredPatch } from 'diff'
import { logError } from 'src/utils/log.js'
import { expandPath } from 'src/utils/path.js'
import { countCharInString } from 'src/utils/stringUtils.js'
import {
DIFF_TIMEOUT_MS,
getPatchForDisplay,
getPatchFromContents,
} from 'src/utils/diff.js'
import { errorMessage, isENOENT } from 'src/utils/errors.js'
import {
addLineNumbers,
convertLeadingTabsToSpaces,
readFileSyncCached,
} from 'src/utils/file.js'
import type { EditInput, FileEdit } from './types.js'
// Claude can't output curly quotes, so we define them as constants here for Claude to use
// in the code. We do this because we normalize curly quotes to straight quotes
// when applying edits.
export const LEFT_SINGLE_CURLY_QUOTE = ''
export const RIGHT_SINGLE_CURLY_QUOTE = ''
export const LEFT_DOUBLE_CURLY_QUOTE = '“'
export const RIGHT_DOUBLE_CURLY_QUOTE = '”'
/**
* Normalizes quotes in a string by converting curly quotes to straight quotes
* @param str The string to normalize
* @returns The string with all curly quotes replaced by straight quotes
*/
export function normalizeQuotes(str: string): string {
return str
.replaceAll(LEFT_SINGLE_CURLY_QUOTE, "'")
.replaceAll(RIGHT_SINGLE_CURLY_QUOTE, "'")
.replaceAll(LEFT_DOUBLE_CURLY_QUOTE, '"')
.replaceAll(RIGHT_DOUBLE_CURLY_QUOTE, '"')
}
/**
* Strips trailing whitespace from each line in a string while preserving line endings
* @param str The string to process
* @returns The string with trailing whitespace removed from each line
*/
export function stripTrailingWhitespace(str: string): string {
// Handle different line endings: CRLF, LF, CR
// Use a regex that matches line endings and captures them
const lines = str.split(/(\r\n|\n|\r)/)
let result = ''
for (let i = 0; i < lines.length; i++) {
const part = lines[i]
if (part !== undefined) {
if (i % 2 === 0) {
// Even indices are line content
result += part.replace(/\s+$/, '')
} else {
// Odd indices are line endings
result += part
}
}
}
return result
}
/**
* Finds the actual string in the file content that matches the search string,
* accounting for quote normalization
* @param fileContent The file content to search in
* @param searchString The string to search for
* @returns The actual string found in the file, or null if not found
*/
export function findActualString(
fileContent: string,
searchString: string,
): string | null {
// First try exact match
if (fileContent.includes(searchString)) {
return searchString
}
// Try with normalized quotes
const normalizedSearch = normalizeQuotes(searchString)
const normalizedFile = normalizeQuotes(fileContent)
const searchIndex = normalizedFile.indexOf(normalizedSearch)
if (searchIndex !== -1) {
// Find the actual string in the file that matches
return fileContent.substring(searchIndex, searchIndex + searchString.length)
}
return null
}
/**
* When old_string matched via quote normalization (curly quotes in file,
* straight quotes from model), apply the same curly quote style to new_string
* so the edit preserves the file's typography.
*
* Uses a simple open/close heuristic: a quote character preceded by whitespace,
* start of string, or opening punctuation is treated as an opening quote;
* otherwise it's a closing quote.
*/
export function preserveQuoteStyle(
oldString: string,
actualOldString: string,
newString: string,
): string {
// If they're the same, no normalization happened
if (oldString === actualOldString) {
return newString
}
// Detect which curly quote types were in the file
const hasDoubleQuotes =
actualOldString.includes(LEFT_DOUBLE_CURLY_QUOTE) ||
actualOldString.includes(RIGHT_DOUBLE_CURLY_QUOTE)
const hasSingleQuotes =
actualOldString.includes(LEFT_SINGLE_CURLY_QUOTE) ||
actualOldString.includes(RIGHT_SINGLE_CURLY_QUOTE)
if (!hasDoubleQuotes && !hasSingleQuotes) {
return newString
}
let result = newString
if (hasDoubleQuotes) {
result = applyCurlyDoubleQuotes(result)
}
if (hasSingleQuotes) {
result = applyCurlySingleQuotes(result)
}
return result
}
function isOpeningContext(chars: string[], index: number): boolean {
if (index === 0) {
return true
}
const prev = chars[index - 1]
return (
prev === ' ' ||
prev === '\t' ||
prev === '\n' ||
prev === '\r' ||
prev === '(' ||
prev === '[' ||
prev === '{' ||
prev === '\u2014' || // em dash
prev === '\u2013' // en dash
)
}
function applyCurlyDoubleQuotes(str: string): string {
const chars = [...str]
const result: string[] = []
for (let i = 0; i < chars.length; i++) {
if (chars[i] === '"') {
result.push(
isOpeningContext(chars, i)
? LEFT_DOUBLE_CURLY_QUOTE
: RIGHT_DOUBLE_CURLY_QUOTE,
)
} else {
result.push(chars[i]!)
}
}
return result.join('')
}
function applyCurlySingleQuotes(str: string): string {
const chars = [...str]
const result: string[] = []
for (let i = 0; i < chars.length; i++) {
if (chars[i] === "'") {
// Don't convert apostrophes in contractions (e.g., "don't", "it's")
// An apostrophe between two letters is a contraction, not a quote
const prev = i > 0 ? chars[i - 1] : undefined
const next = i < chars.length - 1 ? chars[i + 1] : undefined
const prevIsLetter = prev !== undefined && /\p{L}/u.test(prev)
const nextIsLetter = next !== undefined && /\p{L}/u.test(next)
if (prevIsLetter && nextIsLetter) {
// Apostrophe in a contraction — use right single curly quote
result.push(RIGHT_SINGLE_CURLY_QUOTE)
} else {
result.push(
isOpeningContext(chars, i)
? LEFT_SINGLE_CURLY_QUOTE
: RIGHT_SINGLE_CURLY_QUOTE,
)
}
} else {
result.push(chars[i]!)
}
}
return result.join('')
}
/**
* Transform edits to ensure replace_all always has a boolean value
* @param edits Array of edits with optional replace_all
* @returns Array of edits with replace_all guaranteed to be boolean
*/
export function applyEditToFile(
originalContent: string,
oldString: string,
newString: string,
replaceAll: boolean = false,
): string {
const f = replaceAll
? (content: string, search: string, replace: string) =>
content.replaceAll(search, () => replace)
: (content: string, search: string, replace: string) =>
content.replace(search, () => replace)
if (newString !== '') {
return f(originalContent, oldString, newString)
}
const stripTrailingNewline =
!oldString.endsWith('\n') && originalContent.includes(oldString + '\n')
return stripTrailingNewline
? f(originalContent, oldString + '\n', newString)
: f(originalContent, oldString, newString)
}
/**
* Applies an edit to a file and returns the patch and updated file.
* Does not write the file to disk.
*/
export function getPatchForEdit({
filePath,
fileContents,
oldString,
newString,
replaceAll = false,
}: {
filePath: string
fileContents: string
oldString: string
newString: string
replaceAll?: boolean
}): { patch: StructuredPatchHunk[]; updatedFile: string } {
return getPatchForEdits({
filePath,
fileContents,
edits: [
{ old_string: oldString, new_string: newString, replace_all: replaceAll },
],
})
}
/**
* Applies a list of edits to a file and returns the patch and updated file.
* Does not write the file to disk.
*
* NOTE: The returned patch is to be used for display purposes only - it has spaces instead of tabs
*/
export function getPatchForEdits({
filePath,
fileContents,
edits,
}: {
filePath: string
fileContents: string
edits: FileEdit[]
}): { patch: StructuredPatchHunk[]; updatedFile: string } {
let updatedFile = fileContents
const appliedNewStrings: string[] = []
// Special case for empty files.
if (
!fileContents &&
edits.length === 1 &&
edits[0] &&
edits[0].old_string === '' &&
edits[0].new_string === ''
) {
const patch = getPatchForDisplay({
filePath,
fileContents,
edits: [
{
old_string: fileContents,
new_string: updatedFile,
replace_all: false,
},
],
})
return { patch, updatedFile: '' }
}
// Apply each edit and check if it actually changes the file
for (const edit of edits) {
// Strip trailing newlines from old_string before checking
const oldStringToCheck = edit.old_string.replace(/\n+$/, '')
// Check if old_string is a substring of any previously applied new_string
for (const previousNewString of appliedNewStrings) {
if (
oldStringToCheck !== '' &&
previousNewString.includes(oldStringToCheck)
) {
throw new Error(
'Cannot edit file: old_string is a substring of a new_string from a previous edit.',
)
}
}
const previousContent = updatedFile
updatedFile =
edit.old_string === ''
? edit.new_string
: applyEditToFile(
updatedFile,
edit.old_string,
edit.new_string,
edit.replace_all,
)
// If this edit didn't change anything, throw an error
if (updatedFile === previousContent) {
throw new Error('String not found in file. Failed to apply edit.')
}
// Track the new string that was applied
appliedNewStrings.push(edit.new_string)
}
if (updatedFile === fileContents) {
throw new Error(
'Original and edited file match exactly. Failed to apply edit.',
)
}
// We already have before/after content, so call getPatchFromContents directly.
// Previously this went through getPatchForDisplay with edits=[{old:fileContents,new:updatedFile}],
// which transforms fileContents twice (once as preparedFileContents, again as escapedOldString
// inside the reduce) and runs a no-op full-content .replace(). This saves ~20% on large files.
const patch = getPatchFromContents({
filePath,
oldContent: convertLeadingTabsToSpaces(fileContents),
newContent: convertLeadingTabsToSpaces(updatedFile),
})
return { patch, updatedFile }
}
// Cap on edited_text_file attachment snippets. Format-on-save of a large file
// previously injected the entire file per turn (observed max 16.1KB, ~14K
// tokens/session). 8KB preserves meaningful context while bounding worst case.
const DIFF_SNIPPET_MAX_BYTES = 8192
/**
* Used for attachments, to show snippets when files change.
*
* TODO: Unify this with the other snippet logic.
*/
export function getSnippetForTwoFileDiff(
fileAContents: string,
fileBContents: string,
): string {
const patch = structuredPatch(
'file.txt',
'file.txt',
fileAContents,
fileBContents,
undefined,
undefined,
{
context: 8,
timeout: DIFF_TIMEOUT_MS,
},
)
if (!patch) {
return ''
}
const full = patch.hunks
.map(_ => ({
startLine: _.oldStart,
content: _.lines
// Filter out deleted lines AND diff metadata lines
.filter(_ => !_.startsWith('-') && !_.startsWith('\\'))
.map(_ => _.slice(1))
.join('\n'),
}))
.map(addLineNumbers)
.join('\n...\n')
if (full.length <= DIFF_SNIPPET_MAX_BYTES) {
return full
}
// Truncate at the last line boundary that fits within the cap.
// Marker format matches BashTool/utils.ts.
const cutoff = full.lastIndexOf('\n', DIFF_SNIPPET_MAX_BYTES)
const kept =
cutoff > 0 ? full.slice(0, cutoff) : full.slice(0, DIFF_SNIPPET_MAX_BYTES)
const remaining = countCharInString(full, '\n', kept.length) + 1
return `${kept}\n\n... [${remaining} lines truncated] ...`
}
const CONTEXT_LINES = 4
/**
* Gets a snippet from a file showing the context around a patch with line numbers.
* @param originalFile The original file content before applying the patch
* @param patch The diff hunks to use for determining snippet location
* @param newFile The file content after applying the patch
* @returns The snippet text with line numbers and the starting line number
*/
export function getSnippetForPatch(
patch: StructuredPatchHunk[],
newFile: string,
): { formattedSnippet: string; startLine: number } {
if (patch.length === 0) {
// No changes, return empty snippet
return { formattedSnippet: '', startLine: 1 }
}
// Find the first and last changed lines across all hunks
let minLine = Infinity
let maxLine = -Infinity
for (const hunk of patch) {
if (hunk.oldStart < minLine) {
minLine = hunk.oldStart
}
// For the end line, we need to consider the new lines count since we're showing the new file
const hunkEnd = hunk.oldStart + (hunk.newLines || 0) - 1
if (hunkEnd > maxLine) {
maxLine = hunkEnd
}
}
// Calculate the range with context
const startLine = Math.max(1, minLine - CONTEXT_LINES)
const endLine = maxLine + CONTEXT_LINES
// Split the new file into lines and get the snippet
const fileLines = newFile.split(/\r?\n/)
const snippetLines = fileLines.slice(startLine - 1, endLine)
const snippet = snippetLines.join('\n')
// Add line numbers
const formattedSnippet = addLineNumbers({
content: snippet,
startLine,
})
return { formattedSnippet, startLine }
}
/**
* Gets a snippet from a file showing the context around a single edit.
* This is a convenience function that uses the original algorithm.
* @param originalFile The original file content
* @param oldString The text to replace
* @param newString The text to replace it with
* @param contextLines The number of lines to show before and after the change
* @returns The snippet and the starting line number
*/
export function getSnippet(
originalFile: string,
oldString: string,
newString: string,
contextLines: number = 4,
): { snippet: string; startLine: number } {
// Use the original algorithm from FileEditTool.tsx
const before = originalFile.split(oldString)[0] ?? ''
const replacementLine = before.split(/\r?\n/).length - 1
const newFileLines = applyEditToFile(
originalFile,
oldString,
newString,
).split(/\r?\n/)
// Calculate the start and end line numbers for the snippet
const startLine = Math.max(0, replacementLine - contextLines)
const endLine =
replacementLine + contextLines + newString.split(/\r?\n/).length
// Get snippet
const snippetLines = newFileLines.slice(startLine, endLine)
const snippet = snippetLines.join('\n')
return { snippet, startLine: startLine + 1 }
}
export function getEditsForPatch(patch: StructuredPatchHunk[]): FileEdit[] {
return patch.map(hunk => {
// Extract the changes from this hunk
const contextLines: string[] = []
const oldLines: string[] = []
const newLines: string[] = []
// Parse each line and categorize it
for (const line of hunk.lines) {
if (line.startsWith(' ')) {
// Context line - appears in both versions
contextLines.push(line.slice(1))
oldLines.push(line.slice(1))
newLines.push(line.slice(1))
} else if (line.startsWith('-')) {
// Deleted line - only in old version
oldLines.push(line.slice(1))
} else if (line.startsWith('+')) {
// Added line - only in new version
newLines.push(line.slice(1))
}
}
return {
old_string: oldLines.join('\n'),
new_string: newLines.join('\n'),
replace_all: false,
}
})
}
/**
* Contains replacements to de-sanitize strings from Claude
* Since Claude can't see any of these strings (sanitized in the API)
* It'll output the sanitized versions in the edit response
*/
const DESANITIZATIONS: Record<string, string> = {
'<fnr>': '<function_results>',
'<n>': '<name>',
'</n>': '</name>',
'<o>': '<output>',
'</o>': '</output>',
'<e>': '<error>',
'</e>': '</error>',
'<s>': '<system>',
'</s>': '</system>',
'<r>': '<result>',
'</r>': '</result>',
'< META_START >': '<META_START>',
'< META_END >': '<META_END>',
'< EOT >': '<EOT>',
'< META >': '<META>',
'< SOS >': '<SOS>',
'\n\nH:': '\n\nHuman:',
'\n\nA:': '\n\nAssistant:',
}
/**
* Normalizes a match string by applying specific replacements
* This helps handle when exact matches fail due to formatting differences
* @returns The normalized string and which replacements were applied
*/
function desanitizeMatchString(matchString: string): {
result: string
appliedReplacements: Array<{ from: string; to: string }>
} {
let result = matchString
const appliedReplacements: Array<{ from: string; to: string }> = []
for (const [from, to] of Object.entries(DESANITIZATIONS)) {
const beforeReplace = result
result = result.replaceAll(from, to)
if (beforeReplace !== result) {
appliedReplacements.push({ from, to })
}
}
return { result, appliedReplacements }
}
/**
* Normalize the input for the FileEditTool
* If the string to replace is not found in the file, try with a normalized version
* Returns the normalized input if successful, or the original input if not
*/
export function normalizeFileEditInput({
file_path,
edits,
}: {
file_path: string
edits: EditInput[]
}): {
file_path: string
edits: EditInput[]
} {
if (edits.length === 0) {
return { file_path, edits }
}
// Markdown uses two trailing spaces as a hard line break — stripping would
// silently change semantics. Skip stripTrailingWhitespace for .md/.mdx.
const isMarkdown = /\.(md|mdx)$/i.test(file_path)
try {
const fullPath = expandPath(file_path)
// Use cached file read to avoid redundant I/O operations.
// If the file doesn't exist, readFileSyncCached throws ENOENT which the
// catch below handles by returning the original input (no TOCTOU pre-check).
const fileContent = readFileSyncCached(fullPath)
return {
file_path,
edits: edits.map(({ old_string, new_string, replace_all }) => {
const normalizedNewString = isMarkdown
? new_string
: stripTrailingWhitespace(new_string)
// If exact string match works, keep it as is
if (fileContent.includes(old_string)) {
return {
old_string,
new_string: normalizedNewString,
replace_all,
}
}
// Try de-sanitize string if exact match fails
const { result: desanitizedOldString, appliedReplacements } =
desanitizeMatchString(old_string)
if (fileContent.includes(desanitizedOldString)) {
// Apply the same exact replacements to new_string
let desanitizedNewString = normalizedNewString
for (const { from, to } of appliedReplacements) {
desanitizedNewString = desanitizedNewString.replaceAll(from, to)
}
return {
old_string: desanitizedOldString,
new_string: desanitizedNewString,
replace_all,
}
}
return {
old_string,
new_string: normalizedNewString,
replace_all,
}
}),
}
} catch (error) {
// If there's any error reading the file, just return original input.
// ENOENT is expected when the file doesn't exist yet (e.g., new file).
if (!isENOENT(error)) {
logError(error)
}
}
return { file_path, edits }
}
/**
* Compare two sets of edits to determine if they are equivalent
* by applying both sets to the original content and comparing results.
* This handles cases where edits might be different but produce the same outcome.
*/
export function areFileEditsEquivalent(
edits1: FileEdit[],
edits2: FileEdit[],
originalContent: string,
): boolean {
// Fast path: check if edits are literally identical
if (
edits1.length === edits2.length &&
edits1.every((edit1, index) => {
const edit2 = edits2[index]
return (
edit2 !== undefined &&
edit1.old_string === edit2.old_string &&
edit1.new_string === edit2.new_string &&
edit1.replace_all === edit2.replace_all
)
})
) {
return true
}
// Try applying both sets of edits
let result1: { patch: StructuredPatchHunk[]; updatedFile: string } | null =
null
let error1: string | null = null
let result2: { patch: StructuredPatchHunk[]; updatedFile: string } | null =
null
let error2: string | null = null
try {
result1 = getPatchForEdits({
filePath: 'temp',
fileContents: originalContent,
edits: edits1,
})
} catch (e) {
error1 = errorMessage(e)
}
try {
result2 = getPatchForEdits({
filePath: 'temp',
fileContents: originalContent,
edits: edits2,
})
} catch (e) {
error2 = errorMessage(e)
}
// If both threw errors, they're equal only if the errors are the same
if (error1 !== null && error2 !== null) {
// Normalize error messages for comparison
return error1 === error2
}
// If one threw an error and the other didn't, they're not equal
if (error1 !== null || error2 !== null) {
return false
}
// Both succeeded - compare the results
return result1!.updatedFile === result2!.updatedFile
}
/**
* Unified function to check if two file edit inputs are equivalent.
* Handles file edits (FileEditTool).
*/
export function areFileEditsInputsEquivalent(
input1: {
file_path: string
edits: FileEdit[]
},
input2: {
file_path: string
edits: FileEdit[]
},
): boolean {
// Fast path: different files
if (input1.file_path !== input2.file_path) {
return false
}
// Fast path: literal equality
if (
input1.edits.length === input2.edits.length &&
input1.edits.every((edit1, index) => {
const edit2 = input2.edits[index]
return (
edit2 !== undefined &&
edit1.old_string === edit2.old_string &&
edit1.new_string === edit2.new_string &&
edit1.replace_all === edit2.replace_all
)
})
) {
return true
}
// Semantic comparison (requires file read). If the file doesn't exist,
// compare against empty content (no TOCTOU pre-check).
let fileContent = ''
try {
fileContent = readFileSyncCached(input1.file_path)
} catch (error) {
if (!isENOENT(error)) {
throw error
}
}
return areFileEditsEquivalent(input1.edits, input2.edits, fileContent)
}