patch.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. export * as PatchTool from "./patch"
  2. import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
  3. import { ToolFailure } from "@opencode-ai/ai"
  4. import { FileDiff } from "@opencode-ai/schema/file-diff"
  5. import { Effect, Result, Schema } from "effect"
  6. import path from "path"
  7. import { Bom } from "@opencode-ai/util/bom"
  8. import { FSUtil } from "@opencode-ai/util/fs-util"
  9. import { Environment } from "../../environment"
  10. import { Formatter } from "../../formatter"
  11. import { FileMutation } from "../../file-mutation"
  12. import { Location } from "../../location"
  13. import { Patch } from "@opencode-ai/util/patch"
  14. import { Permission } from "../../permission"
  15. import DESCRIPTION from "../patch.txt"
  16. import { fileDiff } from "./file-diff"
  17. export const name = "patch"
  18. export const Input = Schema.Struct({
  19. patchText: Schema.String.annotate({
  20. description: "The full patch text describing add, update, and delete operations",
  21. }),
  22. })
  23. export const Applied = Schema.Struct({
  24. type: Schema.Literals(["add", "update", "delete"]),
  25. resource: Schema.String,
  26. target: Schema.String,
  27. })
  28. export const Output = Schema.Struct({
  29. applied: Schema.Array(Applied),
  30. files: Schema.Array(FileDiff.Info),
  31. })
  32. export type Output = typeof Output.Type
  33. export const toModelOutput = (output: Output) =>
  34. [
  35. "Success. Updated the following files:",
  36. ...output.applied.map(
  37. (item) => `${item.type === "add" ? "A" : item.type === "delete" ? "D" : "M"} ${item.resource}`,
  38. ),
  39. ].join("\n")
  40. type Prepared =
  41. | (Extract<Patch.Hunk, { readonly type: "add" }> & {
  42. readonly target: Target
  43. readonly content: string
  44. readonly before: string
  45. readonly after: string
  46. })
  47. | (Extract<Patch.Hunk, { readonly type: "delete" }> & {
  48. readonly target: Target
  49. readonly before: string
  50. readonly after: string
  51. })
  52. | (Extract<Patch.Hunk, { readonly type: "update" }> & {
  53. readonly target: Target
  54. readonly content: string
  55. readonly before: string
  56. readonly after: string
  57. readonly moveTarget?: Target
  58. })
  59. interface Target {
  60. readonly absolute: string
  61. readonly resource: string
  62. readonly externalDirectory?: {
  63. readonly directory: string
  64. readonly resource: string
  65. }
  66. }
  67. export const Plugin = {
  68. id: "opencode.tool.patch",
  69. effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
  70. const environment = yield* Environment.Service
  71. const mutation = yield* FileMutation.Service
  72. const formatter = yield* Formatter.Service
  73. const location = yield* Location.Service
  74. const permission = yield* Permission.Service
  75. yield* ctx.tool
  76. .transform((draft) =>
  77. draft.add({
  78. name,
  79. options: { codemode: false, permission: "edit" },
  80. description: DESCRIPTION,
  81. input: Input,
  82. output: Output,
  83. execute: (input, context) => {
  84. const applied: Array<typeof Applied.Type> = []
  85. const parsed = Patch.parse(input.patchText)
  86. const lockTargets = Result.isSuccess(parsed)
  87. ? parsed.success.flatMap((hunk) => [
  88. path.resolve(location.directory, hunk.path),
  89. ...(hunk.type === "update" && hunk.movePath ? [path.resolve(location.directory, hunk.movePath)] : []),
  90. ])
  91. : []
  92. const fail = (operation: string, error: unknown) => {
  93. const completed = applied.map((item) => item.resource).join(", ")
  94. return new ToolFailure({
  95. message: `${operation}: ${errorMessage(error)}${completed ? `. Completed before failure: ${completed}` : ""}`,
  96. })
  97. }
  98. return Effect.gen(function* () {
  99. const source = {
  100. type: "tool" as const,
  101. messageID: context.messageID,
  102. id: context.id,
  103. }
  104. if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
  105. const hunks = yield* Effect.fromResult(parsed).pipe(
  106. Effect.mapError((error) => new ToolFailure({ message: `patch verification failed: ${error.message}` })),
  107. )
  108. if (hunks.length === 0) {
  109. return yield* new ToolFailure({ message: "patch rejected: empty patch" })
  110. }
  111. const prepared: Prepared[] = []
  112. const targets: Target[] = []
  113. const updates = new Map<string, string>()
  114. for (const hunk of hunks) {
  115. yield* Effect.gen(function* () {
  116. const target = resolveTarget(location, hunk.path)
  117. targets.push(target)
  118. if (target.externalDirectory) {
  119. yield* permission.assert({
  120. action: "external_directory",
  121. resources: [target.externalDirectory.resource],
  122. save: [target.externalDirectory.resource],
  123. metadata: {
  124. filepath: target.absolute,
  125. parentDir: target.externalDirectory.directory,
  126. },
  127. sessionID: context.sessionID,
  128. agent: context.agent,
  129. source,
  130. })
  131. }
  132. if (hunk.type === "add") {
  133. const content =
  134. hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
  135. prepared.push({
  136. ...hunk,
  137. target,
  138. content,
  139. before: "",
  140. after: Bom.split(content).text,
  141. })
  142. return
  143. }
  144. if (hunk.type === "delete") {
  145. const content = yield* FileMutation.readText(environment.files, target.absolute).pipe(
  146. Effect.mapError(
  147. (error) =>
  148. new ToolFailure({
  149. message: `patch verification failed: Failed to delete ${target.resource}: ${errorMessage(error)}`,
  150. }),
  151. ),
  152. )
  153. prepared.push({ ...hunk, target, before: content.text, after: "" })
  154. return
  155. }
  156. const previous = updates.get(target.absolute)
  157. const original =
  158. previous ??
  159. (yield* Effect.gen(function* () {
  160. const content = yield* FileMutation.readText(environment.files, target.absolute).pipe(
  161. Effect.mapError(
  162. (error) =>
  163. new ToolFailure({
  164. message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`,
  165. }),
  166. ),
  167. )
  168. return Bom.join(content.text, content.bom)
  169. }))
  170. const before = Bom.split(original).text
  171. const update = yield* Effect.try({
  172. try: () => Patch.derive(hunk.path, hunk.chunks, original),
  173. catch: (error) => new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
  174. })
  175. const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
  176. if (moveTarget) targets.push(moveTarget)
  177. if (moveTarget?.externalDirectory) {
  178. yield* permission.assert({
  179. action: "external_directory",
  180. resources: [moveTarget.externalDirectory.resource],
  181. save: [moveTarget.externalDirectory.resource],
  182. metadata: {
  183. filepath: moveTarget.absolute,
  184. parentDir: moveTarget.externalDirectory.directory,
  185. },
  186. sessionID: context.sessionID,
  187. agent: context.agent,
  188. source,
  189. })
  190. }
  191. prepared.push({
  192. ...hunk,
  193. target,
  194. content: Patch.joinBom(update.content, update.bom),
  195. before,
  196. after: update.content,
  197. moveTarget,
  198. })
  199. if (!moveTarget) updates.set(target.absolute, Patch.joinBom(update.content, update.bom))
  200. }).pipe(
  201. Effect.mapError((error) =>
  202. error instanceof ToolFailure
  203. ? error
  204. : new ToolFailure({ message: `Unable to prepare patch at ${hunk.path}`, error }),
  205. ),
  206. )
  207. }
  208. const patchFiles = prepared.map((change) => patchFile(change))
  209. yield* permission.assert({
  210. action: "edit",
  211. resources: [...new Set(targets.map((target) => target.resource))],
  212. save: ["*"],
  213. metadata: {
  214. filepath: targets.map((target) => target.resource).join(", "),
  215. diff: patchFiles.map((file) => `${file.patch}\n`).join(""),
  216. files: patchFiles,
  217. },
  218. sessionID: context.sessionID,
  219. agent: context.agent,
  220. source,
  221. })
  222. yield* Effect.forEach(
  223. prepared,
  224. (change) =>
  225. Effect.gen(function* () {
  226. if (change.type === "add") {
  227. yield* environment.files
  228. .write(change.target.absolute, new TextEncoder().encode(change.content))
  229. .pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
  230. applied.push({
  231. type: change.type,
  232. resource: change.target.resource,
  233. target: change.target.absolute,
  234. })
  235. return
  236. }
  237. if (change.type === "delete") {
  238. yield* environment.files
  239. .remove(change.target.absolute)
  240. .pipe(Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)))
  241. applied.push({
  242. type: change.type,
  243. resource: change.target.resource,
  244. target: change.target.absolute,
  245. })
  246. return
  247. }
  248. if (change.moveTarget) {
  249. const moveTarget = change.moveTarget
  250. yield* environment.files
  251. .write(moveTarget.absolute, new TextEncoder().encode(change.content))
  252. .pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
  253. yield* environment.files
  254. .remove(change.target.absolute)
  255. .pipe(
  256. Effect.mapError((error) =>
  257. fail(`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`, error),
  258. ),
  259. )
  260. applied.push({
  261. type: change.type,
  262. resource: change.moveTarget.resource,
  263. target: change.moveTarget.absolute,
  264. })
  265. return
  266. }
  267. yield* environment.files
  268. .write(change.target.absolute, new TextEncoder().encode(change.content))
  269. .pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
  270. applied.push({
  271. type: change.type,
  272. resource: change.target.resource,
  273. target: change.target.absolute,
  274. })
  275. }),
  276. { discard: true },
  277. )
  278. const formatted = new Map<string, string>()
  279. yield* Effect.forEach(
  280. [...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
  281. (target) =>
  282. Effect.gen(function* () {
  283. const current = yield* FileMutation.readText(environment.files, target).pipe(
  284. Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
  285. )
  286. formatted.set(
  287. target,
  288. (yield* formatter.file(target))
  289. ? yield* FileMutation.syncTextBom(environment.files, target, current.bom).pipe(
  290. Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
  291. )
  292. : current.text,
  293. )
  294. }),
  295. { discard: true },
  296. )
  297. const files = yield* Effect.forEach(prepared, (change) => {
  298. if (change.type === "delete") return Effect.succeed(patchFile(change))
  299. const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target
  300. return Effect.succeed(patchFile(change, formatted.get(target.absolute)))
  301. })
  302. return { applied, files }
  303. }).pipe(
  304. mutation.withLock(lockTargets),
  305. Effect.map((output) => ({
  306. output,
  307. content: toModelOutput(output),
  308. metadata: { files: output.files },
  309. })),
  310. Effect.mapError((error) =>
  311. error instanceof ToolFailure ? error : new ToolFailure({ message: "Unable to apply patch", error }),
  312. ),
  313. )
  314. },
  315. }),
  316. )
  317. .pipe(Effect.orDie)
  318. yield* ctx.session.hook("context", (event) =>
  319. Effect.sync(() => {
  320. const usePatch =
  321. event.model.id.includes("gpt-") && !event.model.id.includes("oss") && !event.model.id.includes("gpt-4")
  322. if (usePatch) {
  323. delete event.tools.edit
  324. delete event.tools.write
  325. return
  326. }
  327. delete event.tools.patch
  328. }),
  329. )
  330. }),
  331. }
  332. function errorMessage(error: unknown) {
  333. if (error instanceof Environment.NotFound) return "file does not exist"
  334. if (error instanceof Environment.WrongKind)
  335. return error.actual === "directory" ? "path is a directory" : `path is ${error.actual}`
  336. if (error instanceof Environment.Failed) return errorMessage(error.cause)
  337. return error instanceof Error ? error.message : String(error)
  338. }
  339. function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type {
  340. const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
  341. const diff = fileDiff(
  342. change.target.absolute,
  343. change.before,
  344. after,
  345. change.type === "add" ? "added" : change.type === "delete" ? "deleted" : "modified",
  346. )
  347. return {
  348. ...diff,
  349. file: target,
  350. patch: trimDiff(diff.patch),
  351. }
  352. }
  353. function trimDiff(diff: string) {
  354. const lines = diff.split("\n")
  355. const content = lines.filter(
  356. (line) =>
  357. (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
  358. !line.startsWith("---") &&
  359. !line.startsWith("+++"),
  360. )
  361. if (content.length === 0) return diff
  362. const indent = content.reduce((result, line) => {
  363. const value = line.slice(1)
  364. if (value.trim().length === 0) return result
  365. return Math.min(result, value.match(/^(\s*)/)?.[1].length ?? result)
  366. }, Infinity)
  367. if (indent === Infinity || indent === 0) return diff
  368. return lines
  369. .map((line) => {
  370. if (
  371. (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
  372. !line.startsWith("---") &&
  373. !line.startsWith("+++")
  374. ) {
  375. return line[0] + line.slice(1 + indent)
  376. }
  377. return line
  378. })
  379. .join("\n")
  380. }
  381. function resolveTarget(location: Location.Interface, value: string): Target {
  382. const absolute =
  383. process.platform === "win32"
  384. ? FSUtil.normalizePath(path.resolve(location.directory, value))
  385. : path.resolve(location.directory, value)
  386. const projectRoot = path.parse(location.project.directory).root
  387. const external =
  388. !FSUtil.contains(location.directory, absolute) &&
  389. (location.project.directory === projectRoot || !FSUtil.contains(location.project.directory, absolute))
  390. const directory = path.dirname(absolute)
  391. const resource =
  392. process.platform === "win32"
  393. ? FSUtil.normalizePathPattern(path.join(directory, "*"))
  394. : path.join(directory, "*").replaceAll("\\", "/")
  395. return {
  396. absolute,
  397. resource: path.relative(location.project.directory, absolute).replaceAll("\\", "/") || ".",
  398. externalDirectory: external ? { directory, resource } : undefined,
  399. }
  400. }