lonny-agent 0.2.4 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (509) hide show
  1. package/.github/workflows/ci.yml +21 -0
  2. package/AGENTS.md +106 -0
  3. package/CHANGELOG.md +24 -0
  4. package/README.md +326 -326
  5. package/dist/agent/__tests__/session-restore.test.d.ts +2 -0
  6. package/dist/agent/__tests__/session-restore.test.d.ts.map +1 -0
  7. package/dist/agent/__tests__/session-restore.test.js +260 -0
  8. package/dist/agent/__tests__/session-restore.test.js.map +1 -0
  9. package/dist/agent/commands.d.ts +26 -0
  10. package/dist/agent/commands.d.ts.map +1 -0
  11. package/dist/agent/commands.js +253 -0
  12. package/dist/agent/commands.js.map +1 -0
  13. package/dist/agent/compaction.js +8 -1
  14. package/dist/agent/compaction.js.map +1 -1
  15. package/dist/agent/index.d.ts +2 -1
  16. package/dist/agent/index.d.ts.map +1 -1
  17. package/dist/agent/index.js +2 -2
  18. package/dist/agent/index.js.map +1 -1
  19. package/dist/agent/llm.d.ts +4 -0
  20. package/dist/agent/llm.d.ts.map +1 -1
  21. package/dist/agent/memory.d.ts +12 -0
  22. package/dist/agent/memory.d.ts.map +1 -0
  23. package/dist/agent/memory.js +51 -0
  24. package/dist/agent/memory.js.map +1 -0
  25. package/dist/agent/prompt-builder.d.ts +6 -1
  26. package/dist/agent/prompt-builder.d.ts.map +1 -1
  27. package/dist/agent/prompt-builder.js +100 -33
  28. package/dist/agent/prompt-builder.js.map +1 -1
  29. package/dist/agent/providers/google.d.ts.map +1 -1
  30. package/dist/agent/providers/google.js.map +1 -1
  31. package/dist/agent/providers/openai.d.ts +1 -0
  32. package/dist/agent/providers/openai.d.ts.map +1 -1
  33. package/dist/agent/providers/openai.js +59 -6
  34. package/dist/agent/providers/openai.js.map +1 -1
  35. package/dist/agent/session-chat.d.ts +3 -0
  36. package/dist/agent/session-chat.d.ts.map +1 -0
  37. package/dist/agent/session-chat.js +384 -0
  38. package/dist/agent/session-chat.js.map +1 -0
  39. package/dist/agent/session-display.d.ts +29 -0
  40. package/dist/agent/session-display.d.ts.map +1 -0
  41. package/dist/agent/session-display.js +179 -0
  42. package/dist/agent/session-display.js.map +1 -0
  43. package/dist/agent/session-persistence.d.ts +55 -0
  44. package/dist/agent/session-persistence.d.ts.map +1 -0
  45. package/dist/agent/session-persistence.js +101 -0
  46. package/dist/agent/session-persistence.js.map +1 -0
  47. package/dist/agent/session-utils.d.ts +5 -0
  48. package/dist/agent/session-utils.d.ts.map +1 -0
  49. package/dist/agent/session-utils.js +51 -0
  50. package/dist/agent/session-utils.js.map +1 -0
  51. package/dist/agent/session.d.ts +19 -17
  52. package/dist/agent/session.d.ts.map +1 -1
  53. package/dist/agent/session.js +234 -578
  54. package/dist/agent/session.js.map +1 -1
  55. package/dist/cli/index.d.ts +4 -0
  56. package/dist/cli/index.d.ts.map +1 -1
  57. package/dist/cli/index.js +67 -2
  58. package/dist/cli/index.js.map +1 -1
  59. package/dist/config/index.d.ts +2 -2
  60. package/dist/config/index.d.ts.map +1 -1
  61. package/dist/config/index.js +7 -1
  62. package/dist/config/index.js.map +1 -1
  63. package/dist/index.js +24 -3
  64. package/dist/index.js.map +1 -1
  65. package/dist/tools/__tests__/bash.test.js +169 -2
  66. package/dist/tools/__tests__/bash.test.js.map +1 -1
  67. package/dist/tools/__tests__/edit.test.js +1091 -340
  68. package/dist/tools/__tests__/edit.test.js.map +1 -1
  69. package/dist/tools/__tests__/fetch.test.js +1 -1
  70. package/dist/tools/__tests__/fetch.test.js.map +1 -1
  71. package/dist/tools/__tests__/glob.test.js +1 -1
  72. package/dist/tools/__tests__/glob.test.js.map +1 -1
  73. package/dist/tools/__tests__/grep.test.js +33 -1
  74. package/dist/tools/__tests__/grep.test.js.map +1 -1
  75. package/dist/tools/__tests__/ls.test.js +1 -1
  76. package/dist/tools/__tests__/ls.test.js.map +1 -1
  77. package/dist/tools/__tests__/read.test.js +1 -1
  78. package/dist/tools/__tests__/read.test.js.map +1 -1
  79. package/dist/tools/__tests__/registry.test.js +313 -0
  80. package/dist/tools/__tests__/registry.test.js.map +1 -1
  81. package/dist/tools/__tests__/sed.test.d.ts +2 -0
  82. package/dist/tools/__tests__/sed.test.d.ts.map +1 -0
  83. package/dist/tools/__tests__/sed.test.js +228 -0
  84. package/dist/tools/__tests__/sed.test.js.map +1 -0
  85. package/dist/tools/__tests__/write_plan.test.js +1 -1
  86. package/dist/tools/__tests__/write_plan.test.js.map +1 -1
  87. package/dist/tools/bash.d.ts.map +1 -1
  88. package/dist/tools/bash.js +711 -18
  89. package/dist/tools/bash.js.map +1 -1
  90. package/dist/tools/codebase/ast/__tests__/ast-edit-demo.d.ts +2 -0
  91. package/dist/tools/codebase/ast/__tests__/ast-edit-demo.d.ts.map +1 -0
  92. package/dist/tools/codebase/ast/__tests__/ast-edit-demo.js +7 -0
  93. package/dist/tools/codebase/ast/__tests__/ast-edit-demo.js.map +1 -0
  94. package/dist/tools/codebase/ast/__tests__/ast-edit-final.d.ts +6 -0
  95. package/dist/tools/codebase/ast/__tests__/ast-edit-final.d.ts.map +1 -0
  96. package/dist/tools/codebase/ast/__tests__/ast-edit-final.js +11 -0
  97. package/dist/tools/codebase/ast/__tests__/ast-edit-final.js.map +1 -0
  98. package/dist/tools/codebase/ast/__tests__/ast-edit-test.d.ts +2 -0
  99. package/dist/tools/codebase/ast/__tests__/ast-edit-test.d.ts.map +1 -0
  100. package/dist/tools/codebase/ast/__tests__/ast-edit-test.js +9 -0
  101. package/dist/tools/codebase/ast/__tests__/ast-edit-test.js.map +1 -0
  102. package/dist/tools/codebase/ast/__tests__/integration.test.d.ts +2 -0
  103. package/dist/tools/codebase/ast/__tests__/integration.test.d.ts.map +1 -0
  104. package/dist/tools/codebase/ast/__tests__/integration.test.js +415 -0
  105. package/dist/tools/codebase/ast/__tests__/integration.test.js.map +1 -0
  106. package/dist/tools/codebase/ast/__tests__/tree-sitter.test.d.ts +2 -0
  107. package/dist/tools/codebase/ast/__tests__/tree-sitter.test.d.ts.map +1 -0
  108. package/dist/tools/codebase/ast/__tests__/tree-sitter.test.js +414 -0
  109. package/dist/tools/codebase/ast/__tests__/tree-sitter.test.js.map +1 -0
  110. package/dist/tools/codebase/ast/adapter.d.ts +23 -0
  111. package/dist/tools/codebase/ast/adapter.d.ts.map +1 -0
  112. package/dist/tools/codebase/ast/adapter.js +11 -0
  113. package/dist/tools/codebase/ast/adapter.js.map +1 -0
  114. package/dist/tools/codebase/ast/tools.d.ts +3 -0
  115. package/dist/tools/codebase/ast/tools.d.ts.map +1 -0
  116. package/dist/tools/codebase/ast/tools.js +286 -0
  117. package/dist/tools/codebase/ast/tools.js.map +1 -0
  118. package/dist/tools/codebase/ast/tree-sitter-adapter.d.ts +3 -0
  119. package/dist/tools/codebase/ast/tree-sitter-adapter.d.ts.map +1 -0
  120. package/dist/tools/codebase/ast/tree-sitter-adapter.js +412 -0
  121. package/dist/tools/codebase/ast/tree-sitter-adapter.js.map +1 -0
  122. package/dist/tools/codebase/ast/tree-sitter-converters.d.ts +4 -0
  123. package/dist/tools/codebase/ast/tree-sitter-converters.d.ts.map +1 -0
  124. package/dist/tools/codebase/ast/tree-sitter-converters.js +447 -0
  125. package/dist/tools/codebase/ast/tree-sitter-converters.js.map +1 -0
  126. package/dist/tools/codebase/ast/types.d.ts +103 -0
  127. package/dist/tools/codebase/ast/types.d.ts.map +1 -0
  128. package/dist/tools/codebase/ast/types.js +2 -0
  129. package/dist/tools/codebase/ast/types.js.map +1 -0
  130. package/dist/tools/codebase/ast/wasm/tree-sitter-python.wasm +0 -0
  131. package/dist/tools/codebase/ast/wasm/tree-sitter-tsx.wasm +0 -0
  132. package/dist/tools/codebase/ast/wasm/tree-sitter-typescript.wasm +0 -0
  133. package/dist/tools/codebase/ast/wasm/tree-sitter.wasm +0 -0
  134. package/dist/tools/codebase/ast/wasm/web-tree-sitter.wasm +0 -0
  135. package/dist/tools/codebase/find.d.ts +3 -0
  136. package/dist/tools/codebase/find.d.ts.map +1 -0
  137. package/dist/tools/codebase/find.js +61 -0
  138. package/dist/tools/codebase/find.js.map +1 -0
  139. package/dist/tools/codebase/glob.d.ts +3 -0
  140. package/dist/tools/codebase/glob.d.ts.map +1 -0
  141. package/dist/tools/codebase/glob.js +37 -0
  142. package/dist/tools/codebase/glob.js.map +1 -0
  143. package/dist/tools/codebase/grep.d.ts +3 -0
  144. package/dist/tools/codebase/grep.d.ts.map +1 -0
  145. package/dist/tools/codebase/grep.js +169 -0
  146. package/dist/tools/codebase/grep.js.map +1 -0
  147. package/dist/tools/codebase/ls.d.ts +3 -0
  148. package/dist/tools/codebase/ls.d.ts.map +1 -0
  149. package/dist/tools/codebase/ls.js +39 -0
  150. package/dist/tools/codebase/ls.js.map +1 -0
  151. package/dist/tools/codebase/read.d.ts +10 -0
  152. package/dist/tools/codebase/read.d.ts.map +1 -0
  153. package/dist/tools/codebase/read.js +81 -0
  154. package/dist/tools/codebase/read.js.map +1 -0
  155. package/dist/tools/delete_memory.d.ts +3 -0
  156. package/dist/tools/delete_memory.d.ts.map +1 -0
  157. package/dist/tools/delete_memory.js +46 -0
  158. package/dist/tools/delete_memory.js.map +1 -0
  159. package/dist/tools/edit/diff-compute.d.ts +4 -0
  160. package/dist/tools/edit/diff-compute.d.ts.map +1 -0
  161. package/dist/tools/edit/diff-compute.js +18 -0
  162. package/dist/tools/edit/diff-compute.js.map +1 -0
  163. package/dist/tools/edit/diff-render.d.ts +18 -0
  164. package/dist/tools/edit/diff-render.d.ts.map +1 -0
  165. package/dist/tools/edit/diff-render.js +128 -0
  166. package/dist/tools/edit/diff-render.js.map +1 -0
  167. package/dist/tools/edit/edit.d.ts +4 -0
  168. package/dist/tools/edit/edit.d.ts.map +1 -0
  169. package/dist/tools/edit/edit.js +440 -0
  170. package/dist/tools/edit/edit.js.map +1 -0
  171. package/dist/tools/edit/matcher.d.ts +24 -0
  172. package/dist/tools/edit/matcher.d.ts.map +1 -0
  173. package/dist/tools/edit/matcher.js +66 -0
  174. package/dist/tools/edit/matcher.js.map +1 -0
  175. package/dist/tools/edit/parser.d.ts +10 -0
  176. package/dist/tools/edit/parser.d.ts.map +1 -0
  177. package/dist/tools/edit/parser.js +120 -0
  178. package/dist/tools/edit/parser.js.map +1 -0
  179. package/dist/tools/edit/types.d.ts +16 -0
  180. package/dist/tools/edit/types.d.ts.map +1 -0
  181. package/dist/tools/edit/types.js +2 -0
  182. package/dist/tools/edit/types.js.map +1 -0
  183. package/dist/tools/edit/write_plan.d.ts +5 -0
  184. package/dist/tools/edit/write_plan.d.ts.map +1 -0
  185. package/dist/tools/edit/write_plan.js +98 -0
  186. package/dist/tools/edit/write_plan.js.map +1 -0
  187. package/dist/tools/edit.d.ts +50 -9
  188. package/dist/tools/edit.d.ts.map +1 -1
  189. package/dist/tools/edit.js +394 -159
  190. package/dist/tools/edit.js.map +1 -1
  191. package/dist/tools/execute/bash/constants.d.ts +24 -0
  192. package/dist/tools/execute/bash/constants.d.ts.map +1 -0
  193. package/dist/tools/execute/bash/constants.js +88 -0
  194. package/dist/tools/execute/bash/constants.js.map +1 -0
  195. package/dist/tools/execute/bash/errors.d.ts +39 -0
  196. package/dist/tools/execute/bash/errors.d.ts.map +1 -0
  197. package/dist/tools/execute/bash/errors.js +249 -0
  198. package/dist/tools/execute/bash/errors.js.map +1 -0
  199. package/dist/tools/execute/bash/execution.d.ts +18 -0
  200. package/dist/tools/execute/bash/execution.d.ts.map +1 -0
  201. package/dist/tools/execute/bash/execution.js +207 -0
  202. package/dist/tools/execute/bash/execution.js.map +1 -0
  203. package/dist/tools/execute/bash/index.d.ts +3 -0
  204. package/dist/tools/execute/bash/index.d.ts.map +1 -0
  205. package/dist/tools/execute/bash/index.js +117 -0
  206. package/dist/tools/execute/bash/index.js.map +1 -0
  207. package/dist/tools/execute/bash/platform.d.ts +6 -0
  208. package/dist/tools/execute/bash/platform.d.ts.map +1 -0
  209. package/dist/tools/execute/bash/platform.js +64 -0
  210. package/dist/tools/execute/bash/platform.js.map +1 -0
  211. package/dist/tools/execute/bash/security.d.ts +12 -0
  212. package/dist/tools/execute/bash/security.d.ts.map +1 -0
  213. package/dist/tools/execute/bash/security.js +26 -0
  214. package/dist/tools/execute/bash/security.js.map +1 -0
  215. package/dist/tools/execute/bash/validation.d.ts +16 -0
  216. package/dist/tools/execute/bash/validation.d.ts.map +1 -0
  217. package/dist/tools/execute/bash/validation.js +57 -0
  218. package/dist/tools/execute/bash/validation.js.map +1 -0
  219. package/dist/tools/execute/bash.d.ts +3 -0
  220. package/dist/tools/execute/bash.d.ts.map +1 -0
  221. package/dist/tools/execute/bash.js +728 -0
  222. package/dist/tools/execute/bash.js.map +1 -0
  223. package/dist/tools/execute/git.d.ts +3 -0
  224. package/dist/tools/execute/git.d.ts.map +1 -0
  225. package/dist/tools/execute/git.js +183 -0
  226. package/dist/tools/execute/git.js.map +1 -0
  227. package/dist/tools/execute/task_complete.d.ts +7 -0
  228. package/dist/tools/execute/task_complete.d.ts.map +1 -0
  229. package/dist/tools/execute/task_complete.js +38 -0
  230. package/dist/tools/execute/task_complete.js.map +1 -0
  231. package/dist/tools/fetch.d.ts.map +1 -1
  232. package/dist/tools/fetch.js +2 -0
  233. package/dist/tools/fetch.js.map +1 -1
  234. package/dist/tools/find.d.ts.map +1 -1
  235. package/dist/tools/find.js +2 -0
  236. package/dist/tools/find.js.map +1 -1
  237. package/dist/tools/git.d.ts.map +1 -1
  238. package/dist/tools/git.js +2 -0
  239. package/dist/tools/git.js.map +1 -1
  240. package/dist/tools/glob.d.ts.map +1 -1
  241. package/dist/tools/glob.js +2 -0
  242. package/dist/tools/glob.js.map +1 -1
  243. package/dist/tools/grep.d.ts.map +1 -1
  244. package/dist/tools/grep.js +56 -30
  245. package/dist/tools/grep.js.map +1 -1
  246. package/dist/tools/install/clawhub.d.ts +26 -0
  247. package/dist/tools/install/clawhub.d.ts.map +1 -0
  248. package/dist/tools/install/clawhub.js +182 -0
  249. package/dist/tools/install/clawhub.js.map +1 -0
  250. package/dist/tools/install/constants.d.ts +5 -0
  251. package/dist/tools/install/constants.d.ts.map +1 -0
  252. package/dist/tools/install/constants.js +5 -0
  253. package/dist/tools/install/constants.js.map +1 -0
  254. package/dist/tools/install/install_skill.d.ts +3 -0
  255. package/dist/tools/install/install_skill.d.ts.map +1 -0
  256. package/dist/tools/install/install_skill.js +132 -0
  257. package/dist/tools/install/install_skill.js.map +1 -0
  258. package/dist/tools/install/npm.d.ts +8 -0
  259. package/dist/tools/install/npm.d.ts.map +1 -0
  260. package/dist/tools/install/npm.js +52 -0
  261. package/dist/tools/install/npm.js.map +1 -0
  262. package/dist/tools/install/skill-content.d.ts +9 -0
  263. package/dist/tools/install/skill-content.d.ts.map +1 -0
  264. package/dist/tools/install/skill-content.js +89 -0
  265. package/dist/tools/install/skill-content.js.map +1 -0
  266. package/dist/tools/install_skill.d.ts.map +1 -1
  267. package/dist/tools/install_skill.js +2 -0
  268. package/dist/tools/install_skill.js.map +1 -1
  269. package/dist/tools/list_memory.d.ts +3 -0
  270. package/dist/tools/list_memory.d.ts.map +1 -0
  271. package/dist/tools/list_memory.js +55 -0
  272. package/dist/tools/list_memory.js.map +1 -0
  273. package/dist/tools/ls.d.ts.map +1 -1
  274. package/dist/tools/ls.js +2 -0
  275. package/dist/tools/ls.js.map +1 -1
  276. package/dist/tools/memory/delete_memory.d.ts +3 -0
  277. package/dist/tools/memory/delete_memory.d.ts.map +1 -0
  278. package/dist/tools/memory/delete_memory.js +46 -0
  279. package/dist/tools/memory/delete_memory.js.map +1 -0
  280. package/dist/tools/memory/list_memory.d.ts +3 -0
  281. package/dist/tools/memory/list_memory.d.ts.map +1 -0
  282. package/dist/tools/memory/list_memory.js +55 -0
  283. package/dist/tools/memory/list_memory.js.map +1 -0
  284. package/dist/tools/memory/save_memory.d.ts +3 -0
  285. package/dist/tools/memory/save_memory.d.ts.map +1 -0
  286. package/dist/tools/memory/save_memory.js +45 -0
  287. package/dist/tools/memory/save_memory.js.map +1 -0
  288. package/dist/tools/read.d.ts.map +1 -1
  289. package/dist/tools/read.js +2 -0
  290. package/dist/tools/read.js.map +1 -1
  291. package/dist/tools/registry.d.ts +7 -8
  292. package/dist/tools/registry.d.ts.map +1 -1
  293. package/dist/tools/registry.js +114 -46
  294. package/dist/tools/registry.js.map +1 -1
  295. package/dist/tools/save_memory.d.ts +3 -0
  296. package/dist/tools/save_memory.d.ts.map +1 -0
  297. package/dist/tools/save_memory.js +45 -0
  298. package/dist/tools/save_memory.js.map +1 -0
  299. package/dist/tools/search.d.ts.map +1 -1
  300. package/dist/tools/search.js +2 -0
  301. package/dist/tools/search.js.map +1 -1
  302. package/dist/tools/sed.d.ts +4 -0
  303. package/dist/tools/sed.d.ts.map +1 -0
  304. package/dist/tools/sed.js +121 -0
  305. package/dist/tools/sed.js.map +1 -0
  306. package/dist/tools/tree.d.ts +12 -0
  307. package/dist/tools/tree.d.ts.map +1 -0
  308. package/dist/tools/tree.js +87 -0
  309. package/dist/tools/tree.js.map +1 -0
  310. package/dist/tools/types.d.ts +12 -0
  311. package/dist/tools/types.d.ts.map +1 -1
  312. package/dist/tools/web/fetch.d.ts +3 -0
  313. package/dist/tools/web/fetch.d.ts.map +1 -0
  314. package/dist/tools/web/fetch.js +49 -0
  315. package/dist/tools/web/fetch.js.map +1 -0
  316. package/dist/tools/web/search.d.ts +3 -0
  317. package/dist/tools/web/search.d.ts.map +1 -0
  318. package/dist/tools/web/search.js +134 -0
  319. package/dist/tools/web/search.js.map +1 -0
  320. package/dist/tools/write_plan.d.ts.map +1 -1
  321. package/dist/tools/write_plan.js +2 -0
  322. package/dist/tools/write_plan.js.map +1 -1
  323. package/dist/tui/commands.d.ts +4 -0
  324. package/dist/tui/commands.d.ts.map +1 -0
  325. package/dist/tui/commands.js +118 -0
  326. package/dist/tui/commands.js.map +1 -0
  327. package/dist/tui/components/colors.d.ts +36 -0
  328. package/dist/tui/components/colors.d.ts.map +1 -0
  329. package/dist/tui/components/colors.js +48 -0
  330. package/dist/tui/components/colors.js.map +1 -0
  331. package/dist/tui/components/header-bar.d.ts +26 -0
  332. package/dist/tui/components/header-bar.d.ts.map +1 -0
  333. package/dist/tui/components/header-bar.js +78 -0
  334. package/dist/tui/components/header-bar.js.map +1 -0
  335. package/dist/tui/components/index.d.ts +9 -0
  336. package/dist/tui/components/index.d.ts.map +1 -0
  337. package/dist/tui/components/index.js +9 -0
  338. package/dist/tui/components/index.js.map +1 -0
  339. package/dist/tui/components/landing-screen.d.ts +12 -0
  340. package/dist/tui/components/landing-screen.d.ts.map +1 -0
  341. package/dist/tui/components/landing-screen.js +53 -0
  342. package/dist/tui/components/landing-screen.js.map +1 -0
  343. package/dist/tui/components/pixel-logo.d.ts +5 -0
  344. package/dist/tui/components/pixel-logo.d.ts.map +1 -0
  345. package/dist/tui/components/pixel-logo.js +27 -0
  346. package/dist/tui/components/pixel-logo.js.map +1 -0
  347. package/dist/tui/components/plan-utils.d.ts +11 -0
  348. package/dist/tui/components/plan-utils.d.ts.map +1 -0
  349. package/dist/tui/components/plan-utils.js +62 -0
  350. package/dist/tui/components/plan-utils.js.map +1 -0
  351. package/dist/tui/components/plans-list.d.ts +18 -0
  352. package/dist/tui/components/plans-list.d.ts.map +1 -0
  353. package/dist/tui/components/plans-list.js +46 -0
  354. package/dist/tui/components/plans-list.js.map +1 -0
  355. package/dist/tui/components/rich-footer.d.ts +29 -0
  356. package/dist/tui/components/rich-footer.d.ts.map +1 -0
  357. package/dist/tui/components/rich-footer.js +124 -0
  358. package/dist/tui/components/rich-footer.js.map +1 -0
  359. package/dist/tui/components/todo-panel.d.ts +14 -0
  360. package/dist/tui/components/todo-panel.d.ts.map +1 -0
  361. package/dist/tui/components/todo-panel.js +83 -0
  362. package/dist/tui/components/todo-panel.js.map +1 -0
  363. package/dist/tui/components.d.ts.map +1 -1
  364. package/dist/tui/components.js +14 -7
  365. package/dist/tui/components.js.map +1 -1
  366. package/dist/tui/index.d.ts +2 -1
  367. package/dist/tui/index.d.ts.map +1 -1
  368. package/dist/tui/index.js +99 -505
  369. package/dist/tui/index.js.map +1 -1
  370. package/dist/tui/overlays.d.ts +28 -0
  371. package/dist/tui/overlays.d.ts.map +1 -0
  372. package/dist/tui/overlays.js +154 -0
  373. package/dist/tui/overlays.js.map +1 -0
  374. package/dist/tui/themes.d.ts +5 -0
  375. package/dist/tui/themes.d.ts.map +1 -0
  376. package/dist/tui/themes.js +38 -0
  377. package/dist/tui/themes.js.map +1 -0
  378. package/dist/web/index.d.ts.map +1 -1
  379. package/dist/web/index.js +62 -101
  380. package/dist/web/index.js.map +1 -1
  381. package/dist/web/public/app.js +5 -855
  382. package/dist/web/public/confirm.js +70 -0
  383. package/dist/web/public/index.html +121 -120
  384. package/dist/web/public/input.js +107 -0
  385. package/dist/web/public/messages.js +411 -0
  386. package/dist/web/public/sidebar.js +82 -0
  387. package/dist/web/public/state.js +72 -0
  388. package/dist/web/public/style.css +247 -70
  389. package/dist/web/public/utils.js +37 -0
  390. package/dist/web/public/websocket.js +267 -0
  391. package/dist/web/public/ws.js +19 -0
  392. package/dist/web/session-bridge.d.ts.map +1 -1
  393. package/dist/web/session-bridge.js +85 -10
  394. package/dist/web/session-bridge.js.map +1 -1
  395. package/docs/ast-tools.md +117 -0
  396. package/package.json +11 -5
  397. package/scripts/check-line-count.mjs +53 -0
  398. package/scripts/copy-ast-wasm.mjs +32 -0
  399. package/scripts/copy-native.mjs +24 -0
  400. package/scripts/copy-web.mjs +15 -0
  401. package/src/agent/__tests__/session-restore.test.ts +284 -0
  402. package/src/agent/commands.ts +274 -0
  403. package/src/agent/compaction.ts +9 -2
  404. package/src/agent/index.ts +7 -2
  405. package/src/agent/llm.ts +4 -0
  406. package/src/agent/memory.ts +60 -0
  407. package/src/agent/prompt-builder.ts +111 -33
  408. package/src/agent/providers/google.ts +0 -1
  409. package/src/agent/providers/openai.ts +79 -8
  410. package/src/agent/session-chat.ts +480 -0
  411. package/src/agent/session-display.ts +195 -0
  412. package/src/agent/session-persistence.ts +146 -0
  413. package/src/agent/session-utils.ts +54 -0
  414. package/src/agent/session.ts +269 -670
  415. package/src/cli/index.ts +73 -4
  416. package/src/config/index.ts +14 -7
  417. package/src/index.ts +30 -3
  418. package/src/tools/__tests__/bash.test.ts +188 -2
  419. package/src/tools/__tests__/edit.test.ts +1216 -356
  420. package/src/tools/__tests__/fetch.test.ts +1 -1
  421. package/src/tools/__tests__/glob.test.ts +1 -1
  422. package/src/tools/__tests__/grep.test.ts +36 -1
  423. package/src/tools/__tests__/ls.test.ts +1 -1
  424. package/src/tools/__tests__/read.test.ts +1 -1
  425. package/src/tools/__tests__/registry.test.ts +332 -0
  426. package/src/tools/__tests__/write_plan.test.ts +1 -1
  427. package/src/tools/codebase/ast/__tests__/integration.test.ts +452 -0
  428. package/src/tools/codebase/ast/__tests__/tree-sitter.test.ts +466 -0
  429. package/src/tools/codebase/ast/adapter.ts +44 -0
  430. package/src/tools/codebase/ast/tools.ts +322 -0
  431. package/src/tools/codebase/ast/tree-sitter-adapter.ts +468 -0
  432. package/src/tools/codebase/ast/tree-sitter-converters.ts +480 -0
  433. package/src/tools/codebase/ast/types.ts +102 -0
  434. package/src/tools/codebase/ast/wasm/tree-sitter-python.wasm +0 -0
  435. package/src/tools/codebase/ast/wasm/tree-sitter-tsx.wasm +0 -0
  436. package/src/tools/codebase/ast/wasm/tree-sitter-typescript.wasm +0 -0
  437. package/src/tools/codebase/ast/wasm/tree-sitter.wasm +0 -0
  438. package/src/tools/codebase/ast/wasm/web-tree-sitter.wasm +0 -0
  439. package/src/tools/{find.ts → codebase/find.ts} +4 -2
  440. package/src/tools/{glob.ts → codebase/glob.ts} +4 -2
  441. package/src/tools/{grep.ts → codebase/grep.ts} +61 -32
  442. package/src/tools/{ls.ts → codebase/ls.ts} +4 -2
  443. package/src/tools/{read.ts → codebase/read.ts} +5 -3
  444. package/src/tools/edit/diff-compute.ts +21 -0
  445. package/src/tools/edit/diff-render.ts +145 -0
  446. package/src/tools/edit/edit.ts +468 -0
  447. package/src/tools/edit/matcher.ts +70 -0
  448. package/src/tools/edit/parser.ts +131 -0
  449. package/src/tools/edit/types.ts +19 -0
  450. package/src/tools/{write_plan.ts → edit/write_plan.ts} +4 -2
  451. package/src/tools/execute/bash/constants.ts +93 -0
  452. package/src/tools/execute/bash/errors.ts +304 -0
  453. package/src/tools/execute/bash/execution.ts +211 -0
  454. package/src/tools/execute/bash/index.ts +131 -0
  455. package/src/tools/execute/bash/platform.ts +65 -0
  456. package/src/tools/execute/bash/security.ts +28 -0
  457. package/src/tools/execute/bash/validation.ts +78 -0
  458. package/src/tools/execute/git.ts +209 -0
  459. package/src/tools/execute/task_complete.ts +40 -0
  460. package/src/tools/install/clawhub.ts +222 -0
  461. package/src/tools/install/constants.ts +4 -0
  462. package/src/tools/install/install_skill.ts +150 -0
  463. package/src/tools/install/npm.ts +60 -0
  464. package/src/tools/install/skill-content.ts +108 -0
  465. package/src/tools/memory/delete_memory.ts +46 -0
  466. package/src/tools/memory/list_memory.ts +53 -0
  467. package/src/tools/memory/save_memory.ts +45 -0
  468. package/src/tools/registry.ts +121 -50
  469. package/src/tools/tree.ts +103 -0
  470. package/src/tools/types.ts +13 -0
  471. package/src/tools/{fetch.ts → web/fetch.ts} +4 -2
  472. package/src/tools/{search.ts → web/search.ts} +4 -2
  473. package/src/tui/commands.ts +135 -0
  474. package/src/tui/components/colors.ts +52 -0
  475. package/src/tui/components/header-bar.ts +88 -0
  476. package/src/tui/components/index.ts +8 -0
  477. package/src/tui/components/landing-screen.ts +67 -0
  478. package/src/tui/components/pixel-logo.ts +33 -0
  479. package/src/tui/components/plan-utils.ts +69 -0
  480. package/src/tui/components/plans-list.ts +55 -0
  481. package/src/tui/components/rich-footer.ts +146 -0
  482. package/src/tui/components/todo-panel.ts +95 -0
  483. package/src/tui/index.ts +110 -561
  484. package/src/tui/overlays.ts +203 -0
  485. package/src/tui/themes.ts +41 -0
  486. package/src/web/index.ts +66 -107
  487. package/src/web/public/app.js +5 -855
  488. package/src/web/public/confirm.js +70 -0
  489. package/src/web/public/index.html +121 -120
  490. package/src/web/public/input.js +107 -0
  491. package/src/web/public/messages.js +411 -0
  492. package/src/web/public/sidebar.js +82 -0
  493. package/src/web/public/state.js +72 -0
  494. package/src/web/public/style.css +247 -70
  495. package/src/web/public/utils.js +37 -0
  496. package/src/web/public/websocket.js +267 -0
  497. package/src/web/public/ws.js +19 -0
  498. package/src/web/session-bridge.ts +90 -12
  499. package/.claude/settings.local.json +0 -14
  500. package/.kilo/plans/1780064105789-mighty-pixel.md +0 -171
  501. package/.kilo/plans/1780293725888-quick-wizard.md +0 -62
  502. package/.lonny/plan-web-cwd-status.md +0 -38
  503. package/AGENT.md +0 -99
  504. package/src/tools/bash.ts +0 -39
  505. package/src/tools/edit.ts +0 -554
  506. package/src/tools/exec.ts +0 -348
  507. package/src/tools/git.ts +0 -76
  508. package/src/tools/install_skill.ts +0 -539
  509. package/src/tui/components.ts +0 -635
@@ -1,19 +1,532 @@
1
1
  import * as fs from 'node:fs'
2
+ import * as os from 'node:os'
2
3
  import * as path from 'node:path'
3
4
  import { afterAll, beforeAll, describe, expect, it } from 'vitest'
4
5
  import { FileReadTracker } from '../../diff/apply.js'
5
- import { createEditTool } from '../edit.js'
6
+ import { computeDiff } from '../edit/diff-compute.js'
7
+ import {
8
+ escapeHtml,
9
+ generateDiff,
10
+ generateDiffWithContext,
11
+ renderDiffTerminal,
12
+ } from '../edit/diff-render.js'
13
+ import { createEditTool } from '../edit/edit.js'
14
+ import { findAllLinesTolerant, normalizeLine } from '../edit/matcher.js'
15
+ import { parseMarkdownEdit } from '../edit/parser.js'
6
16
  import { makeTempDir } from './helpers.js'
7
17
 
8
- describe('edit tool', () => {
18
+ // ── Minimal mock for FileReadTracker ─────────────────────────────────────
19
+ function makeApplier(): FileReadTracker {
20
+ const readSet = new Set<string>()
21
+ return {
22
+ markRead(f: string) {
23
+ readSet.add(f)
24
+ },
25
+ checkModified(f: string): string | null {
26
+ return readSet.has(f) ? null : `Warning: file was not read first.`
27
+ },
28
+ } as FileReadTracker
29
+ }
30
+
31
+ // ── Tests for pure functions ─────────────────────────────────────────────
32
+ describe('normalizeLine', () => {
33
+ it('trims leading whitespace', () => {
34
+ expect(normalizeLine(' hello')).toBe('hello')
35
+ })
36
+
37
+ it('trims trailing whitespace', () => {
38
+ expect(normalizeLine('hello ')).toBe('hello')
39
+ })
40
+
41
+ it('collapses internal space runs', () => {
42
+ expect(normalizeLine('a b')).toBe('a b')
43
+ })
44
+
45
+ it('collapses internal tab runs', () => {
46
+ expect(normalizeLine('a\t\tb')).toBe('a b')
47
+ })
48
+
49
+ it('handles mixed spaces and tabs', () => {
50
+ expect(normalizeLine(' a \t b ')).toBe('a b')
51
+ })
52
+
53
+ it('reduces blank line with spaces to empty string', () => {
54
+ expect(normalizeLine(' ')).toBe('')
55
+ })
56
+
57
+ it('preserves single spaces', () => {
58
+ expect(normalizeLine('a b c')).toBe('a b c')
59
+ })
60
+
61
+ it('handles empty string', () => {
62
+ expect(normalizeLine('')).toBe('')
63
+ })
64
+ })
65
+
66
+ describe('findAllLinesTolerant', () => {
67
+ it('finds exact match', () => {
68
+ const result = findAllLinesTolerant('hello\nworld\nfoo\n', 'hello\nworld')
69
+ expect(result).toHaveLength(1)
70
+ expect(result[0]!.index).toBe(0)
71
+ expect(result[0]!.length).toBe('hello\nworld'.length)
72
+ })
73
+
74
+ it('finds match with trailing whitespace in content', () => {
75
+ const result = findAllLinesTolerant('hello \nworld\n', 'hello\nworld')
76
+ expect(result).toHaveLength(1)
77
+ // "hello \n" = 7 chars, "world" = 5 chars, total match = 12
78
+ expect(result[0]!.length).toBe(12)
79
+ })
80
+
81
+ it('finds match with leading whitespace in content', () => {
82
+ const result = findAllLinesTolerant(' hello\nworld\n', 'hello\nworld')
83
+ expect(result).toHaveLength(1)
84
+ // " hello\n" = 8 chars, "world" = 5, total = 13
85
+ expect(result[0]!.length).toBe(13)
86
+ })
87
+
88
+ it('finds match with extra internal spaces', () => {
89
+ const result = findAllLinesTolerant('foo bar\nbaz\n', 'foo bar\nbaz')
90
+ expect(result).toHaveLength(1)
91
+ })
92
+
93
+ it('returns empty array for empty oldString', () => {
94
+ expect(findAllLinesTolerant('abc', '')).toEqual([])
95
+ })
96
+
97
+ it('returns empty when oldString longer than content', () => {
98
+ expect(findAllLinesTolerant('a', 'a\nb\nc')).toEqual([])
99
+ })
100
+
101
+ it('returns empty when not found', () => {
102
+ expect(findAllLinesTolerant('abc\ndef\n', 'xyz')).toEqual([])
103
+ })
104
+
105
+ it('finds multiple matches', () => {
106
+ const result = findAllLinesTolerant('a\nb\na\nb\n', 'a\nb')
107
+ expect(result).toHaveLength(2)
108
+ })
109
+
110
+ it('computes correct index for non-first match', () => {
111
+ const result = findAllLinesTolerant('x\na\nb\n', 'a\nb')
112
+ expect(result).toHaveLength(1)
113
+ expect(result[0]!.index).toBe(2) // "x\n" = 2 chars
114
+ })
115
+
116
+ it('computes correct length matching original whitespace', () => {
117
+ const result = findAllLinesTolerant('hello \nworld\n', 'hello\nworld')
118
+ expect(result).toHaveLength(1)
119
+ const match = contentSlice('hello \nworld\n', result[0]!)
120
+ expect(match).toBe('hello \nworld')
121
+ })
122
+
123
+ it('handles CRLF content - correct position computation', () => {
124
+ const result = findAllLinesTolerant('hello\r\nworld\r\nfoo\r\n', 'hello\nworld')
125
+ expect(result).toHaveLength(1)
126
+ expect(result[0]!.index).toBe(0)
127
+ // 12 = 'hello\r' (6) + '\n' (1) + 'world' (5, trailing \r excluded)
128
+ expect(result[0]!.length).toBe(12)
129
+ const match = contentSlice('hello\r\nworld\r\nfoo\r\n', result[0]!)
130
+ expect(match).toBe('hello\r\nworld')
131
+ })
132
+
133
+ it('handles mixed CRLF/LF content', () => {
134
+ const result = findAllLinesTolerant('hello\r\nworld\nfoo\r\n', 'hello\nworld')
135
+ expect(result).toHaveLength(1)
136
+ expect(result[0]!.index).toBe(0)
137
+ expect(result[0]!.length).toBe(12)
138
+ const match = contentSlice('hello\r\nworld\nfoo\r\n', result[0]!)
139
+ expect(match).toBe('hello\r\nworld')
140
+ })
141
+
142
+ it('handles CRLF with whitespace normalization', () => {
143
+ const result = findAllLinesTolerant('hello \r\nworld\r\n', 'hello\nworld')
144
+ expect(result).toHaveLength(1)
145
+ // 13 = 'hello \r' (7) + '\n' (1) + 'world' (5, trailing \r excluded)
146
+ expect(result[0]!.length).toBe(13)
147
+ const match = contentSlice('hello \r\nworld\r\n', result[0]!)
148
+ expect(match).toBe('hello \r\nworld')
149
+ })
150
+ })
151
+
152
+ function contentSlice(s: string, m: { index: number; length: number }): string {
153
+ return s.slice(m.index, m.index + m.length)
154
+ }
155
+
156
+ describe('computeDiff', () => {
157
+ it('returns empty array for empty strings', () => {
158
+ expect(computeDiff('', '')).toEqual([])
159
+ })
160
+
161
+ it('returns delete lines when new is empty', () => {
162
+ const result = computeDiff('a\nb', '')
163
+ expect(result).toHaveLength(2)
164
+ expect(result.every(l => l.type === 'delete')).toBe(true)
165
+ })
166
+
167
+ it('returns insert lines when old is empty', () => {
168
+ const result = computeDiff('', 'a\nb')
169
+ expect(result).toHaveLength(2)
170
+ expect(result.every(l => l.type === 'insert')).toBe(true)
171
+ })
172
+
173
+ it('returns equal lines for unchanged content', () => {
174
+ const result = computeDiff('hello\nworld', 'hello\nworld')
175
+ expect(result).toHaveLength(2)
176
+ expect(result.every(l => l.type === 'equal')).toBe(true)
177
+ })
178
+
179
+ it('produces mixed diff for changed content', () => {
180
+ const result = computeDiff('hello\nworld', 'hi\nworld')
181
+ // hello→hi should be delete+insert, world should be equal
182
+ expect(result).toHaveLength(3)
183
+ expect(result[0]!.type).toBe('delete')
184
+ expect(result[0]!.content).toBe('hello')
185
+ expect(result[1]!.type).toBe('insert')
186
+ expect(result[1]!.content).toBe('hi')
187
+ expect(result[2]!.type).toBe('equal')
188
+ expect(result[2]!.content).toBe('world')
189
+ })
190
+ })
191
+
192
+ describe('renderDiffTerminal', () => {
193
+ it('returns empty string for empty input', () => {
194
+ expect(renderDiffTerminal([])).toBe('')
195
+ })
196
+
197
+ it('renders delete lines in red with - prefix', () => {
198
+ const lines = computeDiff('old', '')
199
+ const output = renderDiffTerminal(lines)
200
+ expect(output).toContain('\x1b[38;2;255;80;80m')
201
+ expect(output).toContain('- 1 old')
202
+ expect(output).toContain('\x1b[0m')
203
+ })
204
+
205
+ it('renders insert lines in green with + prefix', () => {
206
+ const lines = computeDiff('', 'new')
207
+ const output = renderDiffTerminal(lines)
208
+ expect(output).toContain('\x1b[38;2;0;200;100m')
209
+ expect(output).toContain('+ 1 new')
210
+ })
211
+
212
+ it('renders equal lines in dim with space prefix', () => {
213
+ const lines = computeDiff('same', 'same')
214
+ const output = renderDiffTerminal(lines)
215
+ expect(output).toContain('\x1b[38;2;100;100;100m')
216
+ expect(output).toContain(' same')
217
+ expect(output).toContain('\x1b[0m')
218
+ })
219
+ })
220
+
221
+ describe('generateDiff', () => {
222
+ it('returns terminal-colored unified diff with line numbers', () => {
223
+ const output = generateDiff('old', 'new')
224
+ expect(output).toContain('\x1b[38;2;255;80;80m')
225
+ expect(output).toContain('- 1 old')
226
+ expect(output).toContain('\x1b[38;2;0;200;100m')
227
+ expect(output).toContain('+ 1 new')
228
+ expect(output).toContain('1')
229
+ })
230
+
231
+ it('returns terminal-colored unified diff with custom start line number', () => {
232
+ const output = generateDiff('old', 'new', 10)
233
+ expect(output).toContain('- 10 old')
234
+ expect(output).toContain('+ 10 new')
235
+ })
236
+ })
237
+
238
+ describe('generateDiffWithContext', () => {
239
+ it('returns context line before match', () => {
240
+ const output = generateDiffWithContext('keep\nreplace\nkeep', 'replace', 'REPLACE', 5, 7)
241
+ expect(output).toContain('keep')
242
+ expect(output).toContain('REPLACE')
243
+ })
244
+
245
+ it('returns no context before when match starts at first line', () => {
246
+ const output = generateDiffWithContext('line1\nline2', 'line1', 'LINE1', 0, 5)
247
+ expect(output).toContain('LINE1')
248
+ const dimCount = (output.match(/\x1b\[38;2;100;100;100m/g) || []).length
249
+ expect(dimCount).toBe(1)
250
+ })
251
+
252
+ it('returns no context after when match ends at last line', () => {
253
+ const output = generateDiffWithContext('line1\nline2', 'line2', 'LINE2', 6, 5)
254
+ expect(output).toContain('LINE2')
255
+ const dimCount = (output.match(/\x1b\[38;2;100;100;100m/g) || []).length
256
+ expect(dimCount).toBe(1)
257
+ })
258
+
259
+ it('handles matchIndex at exact end of content (append behavior)', () => {
260
+ const output = generateDiffWithContext('line1\nline2', '', 'appended', 11, 0)
261
+ expect(output).toContain('appended')
262
+ // Should show line 2 context (the last line)
263
+ expect(output).toContain('line2')
264
+ })
265
+
266
+ it('handles match spanning only the last line with no trailing newline', () => {
267
+ const output = generateDiffWithContext('first\nlast', 'last', 'LAST', 6, 4)
268
+ expect(output).toContain('LAST')
269
+ expect(output).toContain('first')
270
+ // No context after since match is on the last line
271
+ const dimCount = (output.match(/\x1b\[38;2;100;100;100m/g) || []).length
272
+ expect(dimCount).toBe(1)
273
+ })
274
+
275
+ it('handles fullContent with no trailing newline', () => {
276
+ const output = generateDiffWithContext('line1\nline2\nline3', 'line2', 'LINE2', 6, 5)
277
+ expect(output).toContain('LINE2')
278
+ expect(output).toContain('line1')
279
+ expect(output).toContain('line3')
280
+ })
281
+
282
+ it('handles match on single-line content with no trailing newline', () => {
283
+ const output = generateDiffWithContext('onlyline', 'onlyline', 'REPLACED', 0, 8)
284
+ expect(output).toContain('REPLACED')
285
+ // No context dim lines, only the diff
286
+ const dimCount = (output.match(/\x1b\[38;2;100;100;100m/g) || []).length
287
+ expect(dimCount).toBe(0)
288
+ })
289
+ })
290
+
291
+ describe('escapeHtml', () => {
292
+ it('escapes ampersands', () => {
293
+ expect(escapeHtml('a&b')).toBe('a&amp;b')
294
+ })
295
+
296
+ it('escapes less-than', () => {
297
+ expect(escapeHtml('<tag>')).toBe('&lt;tag&gt;')
298
+ })
299
+
300
+ it('escapes greater-than', () => {
301
+ expect(escapeHtml('a > b')).toBe('a &gt; b')
302
+ })
303
+
304
+ it('escapes double quotes', () => {
305
+ expect(escapeHtml('say "hello"')).toBe('say &quot;hello&quot;')
306
+ })
307
+
308
+ it('handles empty string', () => {
309
+ expect(escapeHtml('')).toBe('')
310
+ })
311
+
312
+ it('handles string with no special chars', () => {
313
+ expect(escapeHtml('hello world')).toBe('hello world')
314
+ })
315
+ })
316
+
317
+ describe('parseMarkdownEdit', () => {
318
+ it('parses single edit block', () => {
319
+ const input = '```edit\nfile: src/test.ts\nold: |\n hello\nnew: |\n world\n```'
320
+ const edits = parseMarkdownEdit(input)
321
+ expect(edits).toHaveLength(1)
322
+ expect(edits[0]!.file_path).toBe('src/test.ts')
323
+ // Pipe format preserves leading whitespace
324
+ expect(edits[0]!.old_string).toBe(' hello')
325
+ expect(edits[0]!.new_string).toBe(' world')
326
+ })
327
+
328
+ it('parses create file (empty old)', () => {
329
+ const input = '```edit\nfile: src/new.ts\nold:\nnew: |\n const x = 1\n```'
330
+ const edits = parseMarkdownEdit(input)
331
+ expect(edits).toHaveLength(1)
332
+ expect(edits[0]!.file_path).toBe('src/new.ts')
333
+ expect(edits[0]!.old_string).toBe('')
334
+ // Pipe format preserves leading whitespace
335
+ expect(edits[0]!.new_string).toBe(' const x = 1')
336
+ })
337
+
338
+ it('parses create file with pipe (old: | empty content)', () => {
339
+ const input = [
340
+ '```edit',
341
+ 'file: src/new-pipe.ts',
342
+ 'old: |',
343
+ 'new: |',
344
+ ' const x = 1',
345
+ '```',
346
+ ].join('\n')
347
+ const edits = parseMarkdownEdit(input)
348
+ expect(edits).toHaveLength(1)
349
+ expect(edits[0]!.file_path).toBe('src/new-pipe.ts')
350
+ expect(edits[0]!.old_string).toBe('')
351
+ // Pipe format preserves leading whitespace
352
+ expect(edits[0]!.new_string).toBe(' const x = 1')
353
+ })
354
+
355
+ it('parses multiple edit blocks', () => {
356
+ const input = [
357
+ '```edit',
358
+ 'file: a.ts',
359
+ 'old: |',
360
+ ' foo',
361
+ 'new: |',
362
+ ' bar',
363
+ '```',
364
+ '```edit',
365
+ 'file: b.ts',
366
+ 'old: |',
367
+ ' baz',
368
+ 'new: |',
369
+ ' qux',
370
+ '```',
371
+ ].join('\n')
372
+ const edits = parseMarkdownEdit(input)
373
+ expect(edits).toHaveLength(2)
374
+ expect(edits[0]!.file_path).toBe('a.ts')
375
+ // Pipe format preserves leading whitespace
376
+ expect(edits[0]!.old_string).toBe(' foo')
377
+ expect(edits[0]!.new_string).toBe(' bar')
378
+ expect(edits[1]!.file_path).toBe('b.ts')
379
+ expect(edits[1]!.old_string).toBe(' baz')
380
+ expect(edits[1]!.new_string).toBe(' qux')
381
+ })
382
+
383
+ it('returns empty array when no edit blocks found', () => {
384
+ expect(parseMarkdownEdit('no code blocks here')).toEqual([])
385
+ })
386
+
387
+ it('returns empty array when block has no file path', () => {
388
+ const input = '```edit\nold: |\n a\nnew: |\n b\n```'
389
+ expect(parseMarkdownEdit(input)).toEqual([])
390
+ })
391
+
392
+ it('parses edit block without pipe (single line)', () => {
393
+ const input = '```edit\nfile: a.ts\nold: hello\nnew: world\n```'
394
+ const edits = parseMarkdownEdit(input)
395
+ expect(edits).toHaveLength(1)
396
+ // Non-pipe format: "old: hello" �?content after "old: " includes the leading space
397
+ expect(edits[0]!.old_string).toBe(' hello')
398
+ expect(edits[0]!.new_string).toBe(' world')
399
+ })
400
+
401
+ it('parses old with pipe-newline format', () => {
402
+ const input = '```edit\nfile: a.ts\nold: |\n multi\n line\nnew: |\n replaced\n```'
403
+ const edits = parseMarkdownEdit(input)
404
+ expect(edits).toHaveLength(1)
405
+ // Pipe format preserves leading whitespace (only strips the first \n)
406
+ expect(edits[0]!.old_string).toBe(' multi\n line')
407
+ expect(edits[0]!.new_string).toBe(' replaced')
408
+ })
409
+
410
+ it('handles extra whitespace after ```edit', () => {
411
+ const input = '```edit \nfile: a.ts\nold: |\n x\nnew: |\n y\n```'
412
+ const edits = parseMarkdownEdit(input)
413
+ expect(edits).toHaveLength(1)
414
+ expect(edits[0]!.file_path).toBe('a.ts')
415
+ })
416
+
417
+ it('ignores non-edit code blocks', () => {
418
+ const input = '```typescript\nconst x = 1\n```'
419
+ expect(parseMarkdownEdit(input)).toEqual([])
420
+ })
421
+
422
+ it('handles mixed content with edit blocks', () => {
423
+ const input = [
424
+ 'some text before',
425
+ '',
426
+ '```edit',
427
+ 'file: a.ts',
428
+ 'old: |',
429
+ ' hello',
430
+ 'new: |',
431
+ ' hi',
432
+ '```',
433
+ 'some text after',
434
+ ].join('\n')
435
+ const edits = parseMarkdownEdit(input)
436
+ expect(edits).toHaveLength(1)
437
+ expect(edits[0]!.file_path).toBe('a.ts')
438
+ })
439
+
440
+ it('handles early-``` closing (new: outside the block)', () => {
441
+ // Model may close ``` before new: �?common mistake
442
+ const input = [
443
+ '```edit',
444
+ 'file: a.ts',
445
+ 'old: |',
446
+ ' hello',
447
+ '```',
448
+ 'new: |',
449
+ ' world',
450
+ '```',
451
+ ].join('\n')
452
+ const edits = parseMarkdownEdit(input)
453
+ expect(edits).toHaveLength(1)
454
+ expect(edits[0]!.file_path).toBe('a.ts')
455
+ expect(edits[0]!.old_string).toBe(' hello')
456
+ expect(edits[0]!.new_string).toBe(' world')
457
+ })
458
+
459
+ it('handles raw file:/old:/new: without ``` markers', () => {
460
+ // Model outputs edit without ```edit / ``` wrappers
461
+ const input = 'file: a.ts\nold: |\n hello\nnew: |\n world\n'
462
+ const edits = parseMarkdownEdit(input)
463
+ expect(edits).toHaveLength(1)
464
+ expect(edits[0]!.file_path).toBe('a.ts')
465
+ expect(edits[0]!.old_string).toBe(' hello')
466
+ expect(edits[0]!.new_string).toBe(' world')
467
+ })
468
+
469
+ it('handles edit block without any ``` markers (raw file:/old:/new: content)', () => {
470
+ const input = 'file: a.ts\nold: |\n hello\nnew: |\n world\n'
471
+ const edits = parseMarkdownEdit(input)
472
+ expect(edits).toHaveLength(1)
473
+ expect(edits[0]!.file_path).toBe('a.ts')
474
+ expect(edits[0]!.old_string).toBe(' hello')
475
+ expect(edits[0]!.new_string).toBe(' world')
476
+ })
477
+
478
+ it('handles old:| then new: (no pipe on new)', () => {
479
+ const input = '```edit\nfile: a.ts\nold: |\n hello\nnew:\n world\n```'
480
+ const edits = parseMarkdownEdit(input)
481
+ expect(edits).toHaveLength(1)
482
+ expect(edits[0]!.file_path).toBe('a.ts')
483
+ expect(edits[0]!.new_string).toBe(' world')
484
+ })
485
+
486
+ it('handles |2 pipe-with-digit syntax', () => {
487
+ const input = '```edit\nfile: a.ts\nold: |2\n hello\n world\nnew: |1\n hi\n```'
488
+ const edits = parseMarkdownEdit(input)
489
+ expect(edits).toHaveLength(1)
490
+ expect(edits[0]!.file_path).toBe('a.ts')
491
+ expect(edits[0]!.old_string).toBe(' hello\n world')
492
+ expect(edits[0]!.new_string).toBe(' hi')
493
+ })
494
+
495
+ it('handles backticks inside edit content', () => {
496
+ const input = '```edit\nfile: a.ts\nold: |\n some `code` here\nnew: |\n replaced `code`\n```'
497
+ const edits = parseMarkdownEdit(input)
498
+ expect(edits).toHaveLength(1)
499
+ expect(edits[0]!.file_path).toBe('a.ts')
500
+ expect(edits[0]!.old_string).toBe(' some `code` here')
501
+ expect(edits[0]!.new_string).toBe(' replaced `code`')
502
+ })
503
+
504
+ it('handles old_string with trailing whitespace line', () => {
505
+ const input = '```edit\nfile: a.ts\nold: |\n hello\n \nnew: |\n hi\n```'
506
+ const edits = parseMarkdownEdit(input)
507
+ expect(edits).toHaveLength(1)
508
+ expect(edits[0]!.old_string).toBe(' hello\n ')
509
+ })
510
+
511
+ it('handles new_string with empty content (deletion)', () => {
512
+ const input = '```edit\nfile: a.ts\nold: |\n remove me\nnew:\n```'
513
+ const edits = parseMarkdownEdit(input)
514
+ expect(edits).toHaveLength(1)
515
+ expect(edits[0]!.old_string).toBe(' remove me')
516
+ expect(edits[0]!.new_string).toBe('')
517
+ })
518
+ })
519
+
520
+ // ── Markdown format integration tests ────────────────────────────────────
521
+ describe('edit tool �?markdown format', () => {
9
522
  let tmpDir: string
10
523
  let applier: FileReadTracker
11
524
 
12
525
  beforeAll(() => {
13
526
  tmpDir = makeTempDir()
14
527
  fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'line one\nline two\nline three\n')
15
- fs.writeFileSync(path.join(tmpDir, 'b.txt'), 'foo\nbar\nbaz\n')
16
- applier = new FileReadTracker()
528
+ applier = makeApplier()
529
+ applier.markRead(path.join(tmpDir, 'a.txt'))
17
530
  })
18
531
 
19
532
  afterAll(() => {
@@ -22,570 +535,917 @@ describe('edit tool', () => {
22
535
 
23
536
  const tool = () => createEditTool(applier, tmpDir)
24
537
 
25
- describe('single edit mode', () => {
26
- beforeAll(() => {
27
- fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'line one\nline two\nline three\n')
28
- fs.writeFileSync(path.join(tmpDir, 'dup.txt'), 'abc\ndef\nabc\n')
29
- })
538
+ it('replaces exact string via markdown format', async () => {
539
+ const input = '```edit\nfile: a.txt\nold: |\n line two\nnew: |\n line TWO\n```'
540
+ const r = await tool().execute({ content: input })
541
+ expect(r.success).toBe(true)
542
+ expect(fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8')).toContain('line TWO')
543
+ })
30
544
 
31
- it('replaces an exact string', async () => {
32
- const r = await tool().execute({
33
- edits: [{ file_path: 'a.txt', old_string: 'line two', new_string: 'line TWO' }],
34
- })
35
- expect(r.success).toBe(true)
36
- expect(fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8')).toContain('line TWO')
37
- })
545
+ it('replaces multi-line via markdown format', async () => {
546
+ // Use a fresh file to avoid cross-test contamination
547
+ const file = path.join(tmpDir, 'multi-md.txt')
548
+ fs.writeFileSync(file, 'line one\nline two\nline three\n')
549
+ applier.markRead(file)
38
550
 
39
- it('replaces multi-line string', async () => {
40
- const r = await tool().execute({
41
- edits: [
42
- {
43
- file_path: 'a.txt',
44
- old_string: 'line one\nline TWO',
45
- new_string: 'line 1\nline 2',
46
- },
47
- ],
48
- })
49
- expect(r.success).toBe(true)
50
- const content = fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8')
51
- expect(content).toContain('line 1')
52
- expect(content).toContain('line 2')
53
- })
551
+ const input = [
552
+ '```edit',
553
+ 'file: multi-md.txt',
554
+ 'old: |',
555
+ ' line one',
556
+ ' line two',
557
+ 'new: |',
558
+ ' line 1',
559
+ ' line 2',
560
+ '```',
561
+ ].join('\n')
562
+ const r = await tool().execute({ content: input })
563
+ expect(r.success).toBe(true)
564
+ // Pipe format preserves leading whitespace from the block
565
+ const content = fs.readFileSync(file, 'utf8')
566
+ expect(content).toContain(' line 1')
567
+ expect(content).toContain(' line 2')
568
+ })
54
569
 
55
- it('reports old_string not found', async () => {
56
- const r = await tool().execute({
57
- edits: [{ file_path: 'a.txt', old_string: 'nonexistent', new_string: 'x' }],
58
- })
59
- expect(r.success).toBe(false)
60
- expect(r.error).toContain('not found')
61
- })
570
+ it('creates file via markdown format', async () => {
571
+ const input = '```edit\nfile: markdown-new.txt\nold:\nnew: |\n created via markdown\n```'
572
+ const r = await tool().execute({ content: input })
573
+ expect(r.success).toBe(true)
574
+ // Pipe format preserves leading whitespace from the block
575
+ expect(fs.readFileSync(path.join(tmpDir, 'markdown-new.txt'), 'utf8')).toBe(
576
+ ' created via markdown',
577
+ )
578
+ })
62
579
 
63
- it('reports duplicate old_string', async () => {
64
- const r = await tool().execute({
65
- edits: [{ file_path: 'dup.txt', old_string: 'abc', new_string: 'xyz' }],
66
- })
67
- expect(r.success).toBe(false)
68
- expect(r.error).toContain('MULTIPLE times')
69
- })
580
+ it('reports parse error for malformed markdown', async () => {
581
+ const r = await tool().execute({ content: 'not a valid edit block' })
582
+ expect(r.success).toBe(false)
583
+ expect(r.error).toContain('Failed to parse')
584
+ })
70
585
 
71
- it('auto-corrects flat file_path/old_string/new_string into edits array', async () => {
72
- // The tool auto-corrects { file_path, old_string, new_string }
73
- // into { edits: [{ file_path, old_string, new_string }] }
74
- // So the error should be about file not found, not 'edits' missing
75
- const r = await tool().execute({ file_path: 'x', old_string: 'y', new_string: 'z' })
76
- expect(r.success).toBe(false)
77
- expect(r.error).toContain('not found')
78
- })
586
+ it('applies multiple markdown blocks', async () => {
587
+ fs.writeFileSync(path.join(tmpDir, 'multi-a.txt'), 'foo\nbar\n')
588
+ fs.writeFileSync(path.join(tmpDir, 'multi-b.txt'), 'baz\nqux\n')
589
+ applier.markRead(path.join(tmpDir, 'multi-a.txt'))
590
+ applier.markRead(path.join(tmpDir, 'multi-b.txt'))
79
591
 
80
- it('reports file not found', async () => {
81
- const r = await tool().execute({
82
- edits: [{ file_path: 'nonexistent.txt', old_string: 'x', new_string: 'y' }],
83
- })
84
- expect(r.success).toBe(false)
85
- expect(r.error).toContain('not found')
86
- })
592
+ const input = [
593
+ '```edit',
594
+ 'file: multi-a.txt',
595
+ 'old: |',
596
+ ' foo',
597
+ 'new: |',
598
+ ' FOO',
599
+ '```',
600
+ '```edit',
601
+ 'file: multi-b.txt',
602
+ 'old: |',
603
+ ' baz',
604
+ 'new: |',
605
+ ' BAZ',
606
+ '```',
607
+ ].join('\n')
608
+ const r = await tool().execute({ content: input })
609
+ expect(r.success).toBe(true)
610
+ expect(fs.readFileSync(path.join(tmpDir, 'multi-a.txt'), 'utf8')).toContain('FOO')
611
+ expect(fs.readFileSync(path.join(tmpDir, 'multi-b.txt'), 'utf8')).toContain('BAZ')
87
612
  })
613
+ })
88
614
 
89
- describe('validation', () => {
90
- beforeAll(() => {
91
- fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'test content\n')
92
- })
615
+ // ── Advanced edge cases ──────────────────────────────────────────────────
616
+ describe('edit tool �?edge cases', () => {
617
+ let tmpDir: string
618
+ let applier: FileReadTracker
93
619
 
94
- it('rejects edit missing old_string', async () => {
95
- const r = await tool().execute({
96
- edits: [{ file_path: 'a.txt', new_string: 'replacement' }],
97
- })
98
- expect(r.success).toBe(false)
99
- expect(r.error).toContain('missing')
100
- expect(r.error).toContain('old_string')
101
- })
620
+ beforeAll(() => {
621
+ tmpDir = makeTempDir()
622
+ applier = makeApplier()
623
+ })
102
624
 
103
- it('rejects edit missing new_string', async () => {
104
- const r = await tool().execute({
105
- edits: [{ file_path: 'a.txt', old_string: 'original' }],
106
- })
107
- expect(r.success).toBe(false)
108
- expect(r.error).toContain('missing')
109
- expect(r.error).toContain('new_string')
110
- })
625
+ afterAll(() => {
626
+ fs.rmSync(tmpDir, { recursive: true, force: true })
627
+ })
628
+
629
+ const tool = () => createEditTool(applier, tmpDir)
111
630
 
112
- it('rejects edit missing file_path', async () => {
631
+ describe('special characters', () => {
632
+ it('handles unicode characters', async () => {
633
+ const file = path.join(tmpDir, 'unicode.txt')
634
+ fs.writeFileSync(file, 'héllo wörld\n')
635
+ applier.markRead(file)
113
636
  const r = await tool().execute({
114
- edits: [{ old_string: 'a', new_string: 'b' }],
637
+ edits: [{ file_path: 'unicode.txt', old_string: 'héllo wörld', new_string: 'HELLO WORLD' }],
115
638
  })
116
- expect(r.success).toBe(false)
117
- expect(r.error).toContain('missing')
118
- expect(r.error).toContain('file_path')
639
+ expect(r.success).toBe(true)
640
+ expect(fs.readFileSync(file, 'utf8')).toBe('HELLO WORLD\n')
119
641
  })
120
642
 
121
- it('reports all missing fields in one error', async () => {
643
+ it('handles emoji characters', async () => {
644
+ const file = path.join(tmpDir, 'emoji.txt')
645
+ fs.writeFileSync(file, 'hello 👍 world\n')
646
+ applier.markRead(file)
122
647
  const r = await tool().execute({
123
648
  edits: [
124
- { file_path: 'a.txt', old_string: 'a', new_string: 'b' },
125
- { file_path: 'a.txt', new_string: 'c' },
126
- { file_path: 'a.txt', old_string: 'd' },
649
+ { file_path: 'emoji.txt', old_string: 'hello 👍 world', new_string: 'hello world' },
127
650
  ],
128
651
  })
129
- expect(r.success).toBe(false)
130
- // Should report BOTH errors, not just the first
131
- expect(r.error).toContain('2 of 3')
132
- expect(r.error).toContain('edit #2')
133
- expect(r.error).toContain('edit #3')
134
- // Should mention not to split across edits
135
- expect(r.error).toContain('COMPLETE')
652
+ expect(r.success).toBe(true)
653
+ expect(fs.readFileSync(file, 'utf8')).toBe('hello world\n')
136
654
  })
137
655
 
138
- it('reports which fields each malformed edit has', async () => {
656
+ it('handles Chinese characters', async () => {
657
+ const file = path.join(tmpDir, 'chinese.txt')
658
+ fs.writeFileSync(file, '你好世界\n')
659
+ applier.markRead(file)
139
660
  const r = await tool().execute({
140
- edits: [{ file_path: 'a.txt', new_string: 'c' }],
661
+ edits: [{ file_path: 'chinese.txt', old_string: '你好世界', new_string: 'Hello World' }],
141
662
  })
142
- expect(r.success).toBe(false)
143
- expect(r.error).toContain('has: file_path, new_string')
663
+ expect(r.success).toBe(true)
664
+ expect(fs.readFileSync(file, 'utf8')).toBe('Hello World\n')
144
665
  })
145
666
 
146
- it('includes rawInput in the error message', async () => {
667
+ it('handles tab characters', async () => {
668
+ const file = path.join(tmpDir, 'tabs.txt')
669
+ fs.writeFileSync(file, 'a\tb\nc\td\n')
670
+ applier.markRead(file)
147
671
  const r = await tool().execute({
148
- edits: [{ file_path: 'a.txt', new_string: 'c' }],
672
+ edits: [{ file_path: 'tabs.txt', old_string: 'a\tb', new_string: 'A\tB' }],
149
673
  })
150
- expect(r.success).toBe(false)
151
- expect(r.error).toContain('a.txt')
152
- expect(r.error).toContain('new_string')
674
+ expect(r.success).toBe(true)
675
+ expect(fs.readFileSync(file, 'utf8')).toBe('A\tB\nc\td\n')
153
676
  })
154
677
  })
155
678
 
156
- describe('auto-correction', () => {
157
- it('wraps top-level file_path/old_string/new_string into edits array', async () => {
679
+ describe('empty and boundary files', () => {
680
+ it('edits a file with single line', async () => {
681
+ const file = path.join(tmpDir, 'single.txt')
682
+ fs.writeFileSync(file, 'only line\n')
683
+ applier.markRead(file)
158
684
  const r = await tool().execute({
159
- file_path: 'nonexistent.ts',
160
- old_string: 'x',
161
- new_string: 'y',
685
+ edits: [{ file_path: 'single.txt', old_string: 'only line', new_string: 'replaced line' }],
162
686
  })
163
- // Should be auto-corrected so the error is about file not found, not about missing edits
164
- expect(r.success).toBe(false)
165
- expect(r.error).toContain('not found')
687
+ expect(r.success).toBe(true)
688
+ expect(fs.readFileSync(file, 'utf8')).toBe('replaced line\n')
166
689
  })
167
690
 
168
- it('wraps flat file_path + old_string (missing new_string) with default empty new_string', async () => {
691
+ it('edits a file with no trailing newline', async () => {
692
+ const file = path.join(tmpDir, 'no-nl.txt')
693
+ fs.writeFileSync(file, 'line one\nline two')
694
+ applier.markRead(file)
169
695
  const r = await tool().execute({
170
- file_path: path.join(tmpDir, 'new.txt'),
171
- old_string: '',
696
+ edits: [{ file_path: 'no-nl.txt', old_string: 'line two', new_string: 'line TWO' }],
172
697
  })
173
- // Auto-corrected to { edits: [{ file_path, old_string: '', new_string: '' }] }
174
- // This creates a file with empty content
175
698
  expect(r.success).toBe(true)
176
- expect(fs.existsSync(path.join(tmpDir, 'new.txt'))).toBe(true)
177
- expect(fs.readFileSync(path.join(tmpDir, 'new.txt'), 'utf8')).toBe('')
178
- fs.unlinkSync(path.join(tmpDir, 'new.txt'))
699
+ expect(fs.readFileSync(file, 'utf8')).toBe('line one\nline TWO')
179
700
  })
180
701
 
181
- it('handles edits passed directly as an array (not wrapped)', async () => {
182
- // @ts-expect-error runtime accepts array, TS expects object
183
- const r = await tool().execute([
184
- { file_path: 'a.txt', old_string: 'test', new_string: 'TEST' },
185
- ])
702
+ it('appends to end of file', async () => {
703
+ const file = path.join(tmpDir, 'append.txt')
704
+ fs.writeFileSync(file, 'line one\n')
705
+ applier.markRead(file)
706
+ const r = await tool().execute({
707
+ edits: [
708
+ {
709
+ file_path: 'append.txt',
710
+ old_string: 'line one',
711
+ new_string: 'line one\nline two',
712
+ },
713
+ ],
714
+ })
186
715
  expect(r.success).toBe(true)
716
+ expect(fs.readFileSync(file, 'utf8')).toBe('line one\nline two\n')
187
717
  })
188
718
 
189
- it('handles file_path + new_string (no old_string) at top level as create', async () => {
719
+ it('replaces entire file content', async () => {
720
+ const file = path.join(tmpDir, 'full-replace.txt')
721
+ fs.writeFileSync(file, 'old content\n')
722
+ applier.markRead(file)
190
723
  const r = await tool().execute({
191
- file_path: path.join(tmpDir, 'auto-create.txt'),
192
- new_string: 'auto created',
724
+ edits: [
725
+ {
726
+ file_path: 'full-replace.txt',
727
+ old_string: 'old content',
728
+ new_string: 'brand new content',
729
+ },
730
+ ],
193
731
  })
194
732
  expect(r.success).toBe(true)
195
- expect(fs.readFileSync(path.join(tmpDir, 'auto-create.txt'), 'utf8')).toBe('auto created')
196
- fs.unlinkSync(path.join(tmpDir, 'auto-create.txt'))
733
+ expect(fs.readFileSync(file, 'utf8')).toBe('brand new content\n')
197
734
  })
198
-
199
- it('handles only file_path at top level as empty file creation', async () => {
735
+ it('rejects empty old_string on existing non-empty file', async () => {
736
+ const file = path.join(tmpDir, 'empty-old-on-existing.txt')
737
+ fs.writeFileSync(file, 'some content\n')
738
+ applier.markRead(file)
200
739
  const r = await tool().execute({
201
- file_path: path.join(tmpDir, 'empty-file.txt'),
740
+ edits: [
741
+ {
742
+ file_path: 'empty-old-on-existing.txt',
743
+ old_string: '',
744
+ new_string: 'should not replace',
745
+ },
746
+ ],
202
747
  })
203
- expect(r.success).toBe(true)
204
- expect(fs.existsSync(path.join(tmpDir, 'empty-file.txt'))).toBe(true)
205
- expect(fs.readFileSync(path.join(tmpDir, 'empty-file.txt'), 'utf8')).toBe('')
206
- fs.unlinkSync(path.join(tmpDir, 'empty-file.txt'))
748
+ expect(r.success).toBe(false)
749
+ expect(r.error).toContain('already exists')
207
750
  })
208
751
 
209
- it('rejects nonsensical top-level keys with helpful error', async () => {
210
- const r = await tool().execute({ foo: 'bar' })
752
+ it('rejects create file when file already exists with empty content', async () => {
753
+ const file = path.join(tmpDir, 'empty-existing-file.txt')
754
+ fs.writeFileSync(file, '')
755
+ applier.markRead(file)
756
+ const r = await tool().execute({
757
+ edits: [
758
+ {
759
+ file_path: 'empty-existing-file.txt',
760
+ old_string: '',
761
+ new_string: 'add content',
762
+ },
763
+ ],
764
+ })
211
765
  expect(r.success).toBe(false)
212
- expect(r.error).toContain('edits')
213
- expect(r.error).toContain('array')
766
+ expect(r.error).toContain('already exists')
214
767
  })
215
768
  })
216
769
 
217
- describe('batch mode', () => {
218
- beforeAll(() => {
219
- fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'one\ntwo\nthree\nfour\n')
220
- fs.writeFileSync(path.join(tmpDir, 'b.txt'), 'foo\nbar\nbaz\n')
221
- })
222
-
223
- it('applies multiple edits to one file', async () => {
770
+ describe('file path edge cases', () => {
771
+ it('creates file in subdirectory that does not exist', async () => {
224
772
  const r = await tool().execute({
225
773
  edits: [
226
- { file_path: 'a.txt', old_string: 'one', new_string: 'ONE' },
227
- { file_path: 'a.txt', old_string: 'three', new_string: 'THREE' },
774
+ {
775
+ file_path: 'deep/nested/dir/new-file.txt',
776
+ old_string: '',
777
+ new_string: 'created in nested dir',
778
+ },
228
779
  ],
229
780
  })
230
781
  expect(r.success).toBe(true)
231
- const content = fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8')
232
- expect(content).toContain('ONE')
233
- expect(content).toContain('THREE')
234
- expect(content).toContain('two')
782
+ expect(fs.readFileSync(path.join(tmpDir, 'deep/nested/dir/new-file.txt'), 'utf8')).toBe(
783
+ 'created in nested dir',
784
+ )
235
785
  })
236
786
 
237
- it('applies edits across multiple files', async () => {
787
+ it('handles file path with special characters', async () => {
238
788
  const r = await tool().execute({
239
789
  edits: [
240
- { file_path: 'a.txt', old_string: 'ONE', new_string: 'one' },
241
- { file_path: 'b.txt', old_string: 'foo\nbar', new_string: 'FOO\nBAR' },
790
+ {
791
+ file_path: 'file-with-dashes_and_underscores.txt',
792
+ old_string: '',
793
+ new_string: 'special path',
794
+ },
242
795
  ],
243
796
  })
244
797
  expect(r.success).toBe(true)
245
- expect(fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8')).toContain('one')
246
- expect(fs.readFileSync(path.join(tmpDir, 'b.txt'), 'utf8')).toContain('FOO')
247
- expect(fs.readFileSync(path.join(tmpDir, 'b.txt'), 'utf8')).toContain('BAR')
798
+ expect(
799
+ fs.readFileSync(path.join(tmpDir, 'file-with-dashes_and_underscores.txt'), 'utf8'),
800
+ ).toBe('special path')
248
801
  })
249
802
 
250
- it('rolls back all changes on failure', async () => {
251
- const original = fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8')
252
-
803
+ it('rejects path traversal with Windows drive letter', async () => {
253
804
  const r = await tool().execute({
254
805
  edits: [
255
- { file_path: 'a.txt', old_string: 'THREE', new_string: 'three' },
256
- { file_path: 'a.txt', old_string: 'zzz_nonexistent_zzz', new_string: 'FAIL' },
806
+ {
807
+ file_path: 'C:\\Windows\\system32\\drivers\\etc\\hosts',
808
+ old_string: '',
809
+ new_string: 'should not write',
810
+ },
257
811
  ],
258
812
  })
259
813
  expect(r.success).toBe(false)
260
- expect(r.error).toContain('rolled back')
261
- expect(fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8')).toBe(original)
814
+ expect(r.error).toContain('traversal')
262
815
  })
816
+ })
263
817
 
264
- it('rolls back multi-file on any failure', async () => {
265
- const origA = fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8')
266
- const origB = fs.readFileSync(path.join(tmpDir, 'b.txt'), 'utf8')
267
-
818
+ describe('path traversal security', () => {
819
+ it('rejects path traversal with ../', async () => {
268
820
  const r = await tool().execute({
269
821
  edits: [
270
- { file_path: 'a.txt', old_string: 'four', new_string: 'FOUR' },
271
- { file_path: 'missing.txt', old_string: 'x', new_string: 'y' },
822
+ {
823
+ file_path: '../../outside-cwd.txt',
824
+ old_string: 'hello',
825
+ new_string: 'world',
826
+ },
272
827
  ],
273
828
  })
274
829
  expect(r.success).toBe(false)
275
- expect(fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8')).toBe(origA)
276
- expect(fs.readFileSync(path.join(tmpDir, 'b.txt'), 'utf8')).toBe(origB)
830
+ expect(r.error).toContain('traversal')
277
831
  })
278
832
 
279
- it('rejects empty edits array', async () => {
280
- const r = await tool().execute({ edits: [] })
281
- expect(r.success).toBe(false)
282
- expect(r.error).toContain('empty')
283
- })
284
- })
285
-
286
- describe('diff output', () => {
287
- beforeAll(() => {
288
- fs.writeFileSync(path.join(tmpDir, 'diff-test.txt'), 'line one\nline two\nline three\n')
289
- })
290
-
291
- it('shows green + for new file creation (no red - lines)', async () => {
833
+ it('rejects path traversal with deep nested ../', async () => {
292
834
  const r = await tool().execute({
293
- edits: [{ file_path: 'diff-new.txt', old_string: '', new_string: 'hello\nworld' }],
835
+ edits: [
836
+ {
837
+ file_path: 'subdir/../../../outside-cwd.txt',
838
+ old_string: '',
839
+ new_string: 'should not create',
840
+ },
841
+ ],
294
842
  })
295
- expect(r.success).toBe(true)
296
- // Should contain green ANSI code for added lines
297
- expect(r.output).toContain('\x1b[38;2;0;200;100m') // green
298
- expect(r.output).toContain('hello')
299
- expect(r.output).toContain('world')
300
- // Should NOT contain red ANSI code (no removed lines)
301
- expect(r.output).not.toContain('\x1b[38;2;255;80;80m')
302
- expect(r.output).toContain('Created')
843
+ expect(r.success).toBe(false)
844
+ expect(r.error).toContain('traversal')
303
845
  })
304
846
 
305
- it('shows red - for removed lines and green + for added lines on replacement', async () => {
847
+ it('rejects absolute path', async () => {
306
848
  const r = await tool().execute({
307
849
  edits: [
308
850
  {
309
- file_path: 'diff-test.txt',
310
- old_string: 'line two',
311
- new_string: 'line TWO',
851
+ file_path: '/etc/passwd',
852
+ old_string: '',
853
+ new_string: 'should not write',
312
854
  },
313
855
  ],
314
856
  })
315
- expect(r.success).toBe(true)
316
- // Should contain red ANSI code for removed content
317
- expect(r.output).toContain('\x1b[38;2;255;80;80m')
318
- expect(r.output).toContain('line two')
319
- // Should contain green ANSI code for added content
320
- expect(r.output).toContain('\x1b[38;2;0;200;100m')
321
- expect(r.output).toContain('line TWO')
322
- expect(r.output).toContain('Edited')
857
+ expect(r.success).toBe(false)
858
+ expect(r.error).toContain('traversal')
323
859
  })
324
860
 
325
- it('shows multi-line diff correctly', async () => {
861
+ it('allows normal paths inside cwd', async () => {
326
862
  const r = await tool().execute({
327
863
  edits: [
328
864
  {
329
- file_path: 'diff-test.txt',
330
- old_string: 'line one\nline TWO',
331
- new_string: 'line 1\nline 2',
865
+ file_path: 'safe-file.txt',
866
+ old_string: '',
867
+ new_string: 'safe content',
332
868
  },
333
869
  ],
334
870
  })
335
871
  expect(r.success).toBe(true)
336
- // Removed lines (red)
337
- expect(r.output).toContain('\x1b[38;2;255;80;80mline one')
338
- expect(r.output).toContain('\x1b[38;2;255;80;80mline TWO')
339
- // Added lines (green)
340
- expect(r.output).toContain('\x1b[38;2;0;200;100mline 1')
341
- expect(r.output).toContain('\x1b[38;2;0;200;100mline 2')
342
872
  })
343
873
 
344
- it('handles CRLF in old_string (Windows compatibility)', async () => {
345
- fs.writeFileSync(path.join(tmpDir, 'crlf-test.txt'), 'a\\r\\nb\\r\\nc\\r\\n')
874
+ it('rejects path traversal via markdown format', async () => {
875
+ const input = '```edit\nfile: ../../escape.txt\nold:\nnew: |\n hacked\n```'
876
+ const r = await tool().execute({ content: input })
877
+ expect(r.success).toBe(false)
878
+ expect(r.error).toContain('traversal')
879
+ })
880
+
881
+ it('rejects path traversal via symlink (if supported)', async () => {
882
+ let canSymlink = false
883
+ const externalDir = path.join(os.tmpdir(), 'lonny-symlink-target-' + Date.now())
884
+ const symLink = path.join(tmpDir, 'symlink-inside')
885
+ try {
886
+ fs.mkdirSync(externalDir, { recursive: true })
887
+ fs.writeFileSync(path.join(externalDir, 'malicious.txt'), 'evil')
888
+ fs.symlinkSync(externalDir, symLink, 'junction')
889
+ canSymlink = fs.existsSync(symLink)
890
+ } catch {
891
+ canSymlink = false
892
+ }
893
+ if (!canSymlink) return
894
+
346
895
  const r = await tool().execute({
347
896
  edits: [
348
897
  {
349
- file_path: 'crlf-test.txt',
350
- // Simulate AI sending old_string with \\r\\n (CRLF) on Windows
351
- old_string: 'b\\r\\n',
352
- new_string: 'B\\r\\n',
898
+ file_path: 'symlink-inside/malicious.txt',
899
+ old_string: 'evil',
900
+ new_string: 'hacked',
353
901
  },
354
902
  ],
355
903
  })
356
- expect(r.success).toBe(true)
357
- const content = fs.readFileSync(path.join(tmpDir, 'crlf-test.txt'), 'utf8')
358
- expect(content).toContain('B')
359
- // Should show the normalized diff (with \\n, not \\r\\n)
360
- expect(r.output).toContain('\x1b[38;2;255;80;80mb')
361
- expect(r.output).toContain('\x1b[38;2;0;200;100mB')
362
- })
904
+ expect(r.success).toBe(false)
905
+ expect(r.error).toContain('traversal')
363
906
 
364
- it('does not show empty red line when old_string is empty', async () => {
365
- const r = await tool().execute({
366
- edits: [{ file_path: 'empty-old.txt', old_string: '', new_string: 'only added' }],
367
- })
368
- expect(r.success).toBe(true)
369
- // Should have green colored line
370
- expect(r.output).toContain('\x1b[38;2;0;200;100monly added')
371
- // Count red-start markers — should be 0 (no removed content)
372
- const redCount = (r.output.match(/\x1b\[38;2;255;80;80m/g) || []).length
373
- expect(redCount).toBe(0)
907
+ // Cleanup
908
+ try {
909
+ fs.rmSync(externalDir, { recursive: true, force: true })
910
+ } catch {}
374
911
  })
375
912
  })
376
-
377
- describe('create mode', () => {
378
- const createdDir = () => path.join(tmpDir, 'created-test')
379
-
380
- afterAll(() => {
381
- fs.rmSync(createdDir(), { recursive: true, force: true })
382
- })
383
-
384
- it('creates a new file with empty old_string', async () => {
913
+ describe('error message quality', () => {
914
+ it('includes proximity hint when old_string not found', async () => {
915
+ const file = path.join(tmpDir, 'proximity.txt')
916
+ fs.writeFileSync(file, 'keep this line\nfind me\nkeep that line\n')
917
+ applier.markRead(file)
385
918
  const r = await tool().execute({
386
919
  edits: [
387
920
  {
388
- file_path: 'created-test/new.ts',
389
- old_string: '',
390
- new_string: 'const x = 1\nexport { x }',
921
+ file_path: 'proximity.txt',
922
+ old_string: 'find me\nnonexistent',
923
+ new_string: 'x',
391
924
  },
392
925
  ],
393
926
  })
394
- expect(r.success).toBe(true)
395
- const content = fs.readFileSync(path.join(createdDir(), 'new.ts'), 'utf8')
396
- expect(content).toBe('const x = 1\nexport { x }')
927
+ expect(r.success).toBe(false)
928
+ // Should include proximity info
929
+ expect(r.error).toContain('Near line')
930
+ expect(r.error).toContain('find me')
397
931
  })
398
932
 
399
- it('creates a file in batch mode', async () => {
933
+ it('includes file content preview when no close match found', async () => {
934
+ const file = path.join(tmpDir, 'preview.txt')
935
+ fs.writeFileSync(file, 'alpha\nbeta\ngamma\n')
936
+ applier.markRead(file)
400
937
  const r = await tool().execute({
401
938
  edits: [
402
- { file_path: 'created-test/a.ts', old_string: '', new_string: '// a' },
403
- { file_path: 'created-test/b.ts', old_string: '', new_string: '// b' },
939
+ {
940
+ file_path: 'preview.txt',
941
+ old_string: 'zzz_nonexistent_zzz',
942
+ new_string: 'x',
943
+ },
404
944
  ],
405
945
  })
406
- expect(r.success).toBe(true)
407
- expect(fs.readFileSync(path.join(createdDir(), 'a.ts'), 'utf8')).toBe('// a')
408
- expect(fs.readFileSync(path.join(createdDir(), 'b.ts'), 'utf8')).toBe('// b')
946
+ expect(r.success).toBe(false)
947
+ // Should include file content preview
948
+ expect(r.error).toContain('File content')
949
+ expect(r.error).toContain('alpha')
950
+ expect(r.error).toContain('beta')
409
951
  })
410
952
 
411
- it('rejects create when file already exists', async () => {
953
+ it('includes read warning when file not read first', async () => {
954
+ // Don't call markRead �?simulate stale content
955
+ const file = path.join(tmpDir, 'no-read.txt')
956
+ fs.writeFileSync(file, 'content\n')
412
957
  const r = await tool().execute({
413
958
  edits: [
414
959
  {
415
- file_path: 'created-test/new.ts',
416
- old_string: '',
960
+ file_path: 'no-read.txt',
961
+ old_string: 'zzz',
417
962
  new_string: 'x',
418
963
  },
419
964
  ],
420
965
  })
421
966
  expect(r.success).toBe(false)
422
- expect(r.error).toContain('already exists')
967
+ expect(r.error).toContain('was not read first')
423
968
  })
424
969
 
425
- it('rolls back on create failure in batch', async () => {
970
+ it('includes rawInput in error for easy debugging', async () => {
426
971
  const r = await tool().execute({
427
972
  edits: [
428
- { file_path: 'created-test/rollback-1.ts', old_string: '', new_string: 'keep me' },
429
- { file_path: 'created-test/rollback-1.ts', old_string: '', new_string: 'duplicate' },
973
+ {
974
+ file_path: 'nonexistent.txt',
975
+ old_string: 'hello',
976
+ new_string: 'world',
977
+ },
430
978
  ],
431
979
  })
432
980
  expect(r.success).toBe(false)
433
- expect(fs.existsSync(path.join(createdDir(), 'rollback-1.ts'))).toBe(false)
981
+ expect(r.error).toContain('nonexistent.txt')
982
+ expect(r.error).toContain('hello')
983
+ expect(r.error).toContain('world')
434
984
  })
435
985
  })
436
986
 
437
- describe('whitespace tolerance', () => {
438
- const wsDir = () => path.join(tmpDir, 'ws-test')
439
- const f = (name: string) => path.join(wsDir(), name)
440
-
441
- beforeAll(() => {
442
- fs.mkdirSync(wsDir(), { recursive: true })
987
+ describe('output format', () => {
988
+ it('prefixes output with "Edited" for modifications', async () => {
989
+ const file = path.join(tmpDir, 'output-test.txt')
990
+ fs.writeFileSync(file, 'original\n')
991
+ applier.markRead(file)
992
+ const r = await tool().execute({
993
+ edits: [{ file_path: 'output-test.txt', old_string: 'original', new_string: 'modified' }],
994
+ })
995
+ expect(r.success).toBe(true)
996
+ expect(r.output).toContain('Edited')
443
997
  })
444
998
 
445
- const tool = () => createEditTool(applier, wsDir())
446
-
447
- it('handles trailing space in file (AI omits trailing space)', async () => {
448
- fs.writeFileSync(f('trailing.txt'), 'hello \nworld\n')
999
+ it('prefixes output with "Created" for new files', async () => {
449
1000
  const r = await tool().execute({
450
- edits: [
451
- { file_path: 'trailing.txt', old_string: 'hello\nworld', new_string: 'HELLO\nWORLD' },
452
- ],
1001
+ edits: [{ file_path: 'output-create.txt', old_string: '', new_string: 'new file' }],
453
1002
  })
454
1003
  expect(r.success).toBe(true)
455
- expect(fs.readFileSync(f('trailing.txt'), 'utf8')).toBe('HELLO\nWORLD\n')
456
- // Should indicate whitespace-normalized in output
457
- expect(r.output).toContain('whitespace-normalized')
1004
+ expect(r.output).toContain('Created')
458
1005
  })
459
1006
 
460
- it('handles trailing space in old_string (AI adds extra trailing space)', async () => {
461
- fs.writeFileSync(f('trailing.txt'), 'hello \nworld\n')
1007
+ it('includes line count for created files', async () => {
462
1008
  const r = await tool().execute({
463
1009
  edits: [
464
- { file_path: 'trailing.txt', old_string: 'hello \nworld', new_string: 'HELLO\nWORLD' },
1010
+ {
1011
+ file_path: 'output-count.txt',
1012
+ old_string: '',
1013
+ new_string: 'line1\nline2\nline3',
1014
+ },
465
1015
  ],
466
1016
  })
467
1017
  expect(r.success).toBe(true)
468
- expect(fs.readFileSync(f('trailing.txt'), 'utf8')).toBe('HELLO\nWORLD\n')
1018
+ expect(r.output).toContain('3 lines')
469
1019
  })
1020
+ })
470
1021
 
471
- it('handles leading space in file (AI omits leading indent)', async () => {
472
- fs.writeFileSync(f('leading.txt'), ' hello\nworld\n')
1022
+ describe('concurrent edits to same line area', () => {
1023
+ it('applies edits bottom-to-top within same file', async () => {
1024
+ const file = path.join(tmpDir, 'concurrent.txt')
1025
+ fs.writeFileSync(file, 'first\nsecond\nthird\n')
1026
+ applier.markRead(file)
473
1027
  const r = await tool().execute({
474
1028
  edits: [
475
- { file_path: 'leading.txt', old_string: 'hello\nworld', new_string: 'HELLO\nWORLD' },
1029
+ { file_path: 'concurrent.txt', old_string: 'first', new_string: 'FIRST' },
1030
+ { file_path: 'concurrent.txt', old_string: 'third', new_string: 'THIRD' },
476
1031
  ],
477
1032
  })
478
1033
  expect(r.success).toBe(true)
479
- // Leading spaces in the original file become part of the matched block
480
- // and are preserved in the result
481
- expect(fs.readFileSync(f('leading.txt'), 'utf8')).toBe(' HELLO\nWORLD\n')
1034
+ const content = fs.readFileSync(file, 'utf8')
1035
+ expect(content).toBe('FIRST\nsecond\nTHIRD\n')
482
1036
  })
483
1037
 
484
- it('handles leading space in old_string (AI adds extra indent)', async () => {
485
- fs.writeFileSync(f('leading.txt'), ' hello\nworld\n')
1038
+ it('applies edits bottom-to-top to avoid line offset issues', async () => {
1039
+ const file = path.join(tmpDir, 'offset.txt')
1040
+ fs.writeFileSync(file, 'a\nb\nc\n')
1041
+ applier.markRead(file)
1042
+ // If applied top-to-bottom: after replacing "a" with "A1\nA2",
1043
+ // "c" would be at line 4, not line 2
486
1044
  const r = await tool().execute({
487
1045
  edits: [
488
- { file_path: 'leading.txt', old_string: ' hello\nworld', new_string: 'HELLO\nWORLD' },
1046
+ { file_path: 'offset.txt', old_string: 'a', new_string: 'A1\nA2' },
1047
+ { file_path: 'offset.txt', old_string: 'c', new_string: 'C' },
489
1048
  ],
490
1049
  })
491
1050
  expect(r.success).toBe(true)
492
- // The file's leading spaces are part of the matched block, so they
493
- // get replaced along with the rest of the matched text
494
- expect(fs.readFileSync(f('leading.txt'), 'utf8')).toBe('HELLO\nWORLD\n')
1051
+ const content = fs.readFileSync(file, 'utf8')
1052
+ expect(content).toBe('A1\nA2\nb\nC\n')
495
1053
  })
1054
+ })
496
1055
 
497
- it('handles extra internal spaces in file (AI sends single space)', async () => {
498
- fs.writeFileSync(f('internal.txt'), 'foo bar\nbaz\n')
1056
+ describe('CRLF handling', () => {
1057
+ it('handles CRLF in file content', async () => {
1058
+ const file = path.join(tmpDir, 'crlf-content.txt')
1059
+ fs.writeFileSync(file, 'hello\r\nworld\r\n')
1060
+ applier.markRead(file)
499
1061
  const r = await tool().execute({
500
1062
  edits: [
501
- { file_path: 'internal.txt', old_string: 'foo bar\nbaz', new_string: 'FOO BAR\nBAZ' },
1063
+ { file_path: 'crlf-content.txt', old_string: 'hello\nworld', new_string: 'HELLO\nWORLD' },
502
1064
  ],
503
1065
  })
504
1066
  expect(r.success).toBe(true)
505
- expect(fs.readFileSync(f('internal.txt'), 'utf8')).toBe('FOO BAR\nBAZ\n')
1067
+ const content = fs.readFileSync(file, 'utf8')
1068
+ expect(content).toBe('HELLO\nWORLD\n')
506
1069
  })
1070
+ })
1071
+
1072
+ describe('rollback behavior', () => {
1073
+ it('restores original file content on failure', async () => {
1074
+ const file = path.join(tmpDir, 'rollback-restore.txt')
1075
+ fs.writeFileSync(file, 'original content\n')
1076
+ applier.markRead(file)
1077
+ const original = fs.readFileSync(file, 'utf8')
507
1078
 
508
- it('handles extra internal spaces in old_string (AI adds double space)', async () => {
509
- fs.writeFileSync(f('internal.txt'), 'foo bar\nbaz\n')
510
1079
  const r = await tool().execute({
511
1080
  edits: [
512
- { file_path: 'internal.txt', old_string: 'foo bar\nbaz', new_string: 'FOO BAR\nBAZ' },
1081
+ {
1082
+ file_path: 'rollback-restore.txt',
1083
+ old_string: 'original content',
1084
+ new_string: 'changed',
1085
+ },
1086
+ { file_path: 'rollback-restore.txt', old_string: 'nonexistent', new_string: 'fail' },
513
1087
  ],
514
1088
  })
515
- expect(r.success).toBe(true)
516
- expect(fs.readFileSync(f('internal.txt'), 'utf8')).toBe('FOO BAR\nBAZ\n')
1089
+ expect(r.success).toBe(false)
1090
+ expect(r.error).toContain('rolled back')
1091
+ // File should be restored to original
1092
+ expect(fs.readFileSync(file, 'utf8')).toBe(original)
517
1093
  })
518
1094
 
519
- it('handles blank line with spaces matching empty blank line', async () => {
520
- fs.writeFileSync(f('blank.txt'), 'foo\n \nbar\n')
1095
+ it('deletes newly created file on rollback', async () => {
521
1096
  const r = await tool().execute({
522
- edits: [{ file_path: 'blank.txt', old_string: 'foo\n\nbar', new_string: 'FOO\n\nBAR' }],
1097
+ edits: [
1098
+ { file_path: 'rollback-new.txt', old_string: '', new_string: 'new file' },
1099
+ { file_path: 'rollback-new.txt', old_string: '', new_string: 'duplicate' },
1100
+ ],
523
1101
  })
524
- expect(r.success).toBe(true)
525
- // The blank line with spaces is part of the matched block and gets
526
- // replaced entirely by the new_string's blank line
527
- expect(fs.readFileSync(f('blank.txt'), 'utf8')).toBe('FOO\n\nBAR\n')
1102
+ expect(r.success).toBe(false)
1103
+ expect(fs.existsSync(path.join(tmpDir, 'rollback-new.txt'))).toBe(false)
528
1104
  })
1105
+ })
529
1106
 
530
- it('handles mixed whitespace across multiple lines', async () => {
531
- fs.writeFileSync(f('mixed.txt'), ' a \nb \nc\n')
532
- const r = await tool().execute({
533
- edits: [{ file_path: 'mixed.txt', old_string: 'a\nb\nc', new_string: 'A\nB\nC' }],
534
- })
535
- expect(r.success).toBe(true)
536
- expect(fs.readFileSync(f('mixed.txt'), 'utf8')).toBe('A\nB\nC\n')
1107
+ describe('multi-file scenarios', () => {
1108
+ beforeAll(() => {
1109
+ fs.writeFileSync(path.join(tmpDir, 'mf-a.txt'), 'edit me\n')
1110
+ fs.writeFileSync(path.join(tmpDir, 'mf-b.txt'), 'edit me too\n')
1111
+ applier.markRead(path.join(tmpDir, 'mf-a.txt'))
1112
+ applier.markRead(path.join(tmpDir, 'mf-b.txt'))
537
1113
  })
538
1114
 
539
- it('still fails when text content differs (not just whitespace)', async () => {
540
- fs.writeFileSync(f('trailing.txt'), 'hello \nworld\n')
1115
+ it('edits multiple existing files in one call', async () => {
541
1116
  const r = await tool().execute({
542
- edits: [{ file_path: 'trailing.txt', old_string: 'hello\nmundo', new_string: 'x\ny' }],
1117
+ edits: [
1118
+ { file_path: 'mf-a.txt', old_string: 'edit me', new_string: 'edited' },
1119
+ { file_path: 'mf-b.txt', old_string: 'edit me too', new_string: 'edited too' },
1120
+ ],
543
1121
  })
544
- expect(r.success).toBe(false)
545
- expect(r.error).toContain('not found')
1122
+ expect(r.success).toBe(true)
1123
+ expect(fs.readFileSync(path.join(tmpDir, 'mf-a.txt'), 'utf8')).toBe('edited\n')
1124
+ expect(fs.readFileSync(path.join(tmpDir, 'mf-b.txt'), 'utf8')).toBe('edited too\n')
546
1125
  })
547
1126
 
548
- it('still fails for completely wrong old_string', async () => {
549
- fs.writeFileSync(f('trailing.txt'), 'hello \nworld\n')
1127
+ it('mixes create and edit across files', async () => {
550
1128
  const r = await tool().execute({
551
- edits: [{ file_path: 'trailing.txt', old_string: 'zzz\nzzz', new_string: 'x\ny' }],
1129
+ edits: [
1130
+ { file_path: 'mf-a.txt', old_string: 'edited', new_string: 'edited again' },
1131
+ { file_path: 'mf-create-new.txt', old_string: '', new_string: 'newly created' },
1132
+ ],
552
1133
  })
553
- expect(r.success).toBe(false)
554
- expect(r.error).toContain('not found')
1134
+ expect(r.success).toBe(true)
1135
+ expect(fs.readFileSync(path.join(tmpDir, 'mf-a.txt'), 'utf8')).toBe('edited again\n')
1136
+ expect(fs.readFileSync(path.join(tmpDir, 'mf-create-new.txt'), 'utf8')).toBe('newly created')
555
1137
  })
556
1138
 
557
- it('reports duplicate in whitespace-normalized mode', async () => {
558
- fs.writeFileSync(f('duplicate.txt'), 'a \nb\na \nb\n')
1139
+ it('rolls back mixed create+edit on failure', async () => {
1140
+ const origA = fs.readFileSync(path.join(tmpDir, 'mf-a.txt'), 'utf8')
1141
+
559
1142
  const r = await tool().execute({
560
- edits: [{ file_path: 'duplicate.txt', old_string: 'a\nb', new_string: 'X\nY' }],
1143
+ edits: [
1144
+ { file_path: 'mf-a.txt', old_string: 'edited again', new_string: 'changed' },
1145
+ { file_path: 'mf-rollback-new.txt', old_string: '', new_string: 'will be deleted' },
1146
+ { file_path: 'mf-nonexistent.txt', old_string: 'nope', new_string: 'x' },
1147
+ ],
561
1148
  })
562
1149
  expect(r.success).toBe(false)
563
- expect(r.error).toContain('MULTIPLE times')
564
- expect(r.error).toContain('whitespace-normalized')
1150
+ expect(r.error).toContain('rolled back')
1151
+ // Existing file restored
1152
+ expect(fs.readFileSync(path.join(tmpDir, 'mf-a.txt'), 'utf8')).toBe(origA)
1153
+ // Created file should be deleted
1154
+ expect(fs.existsSync(path.join(tmpDir, 'mf-rollback-new.txt'))).toBe(false)
565
1155
  })
566
1156
 
567
- it('prefers exact match when available', async () => {
568
- fs.writeFileSync(f('exact-prefer.txt'), 'hello\nworld\n')
1157
+ it('creates multiple files in subdirectories', async () => {
569
1158
  const r = await tool().execute({
570
1159
  edits: [
571
- { file_path: 'exact-prefer.txt', old_string: 'hello\nworld', new_string: 'HELLO\nWORLD' },
1160
+ { file_path: 'multi-dir/sub1/x.ts', old_string: '', new_string: '// x' },
1161
+ { file_path: 'multi-dir/sub2/y.ts', old_string: '', new_string: '// y' },
572
1162
  ],
573
1163
  })
574
1164
  expect(r.success).toBe(true)
575
- expect(fs.readFileSync(f('exact-prefer.txt'), 'utf8')).toBe('HELLO\nWORLD\n')
576
- // Exact match should NOT have the whitespace-normalized note
577
- expect(r.output).not.toContain('whitespace-normalized')
1165
+ expect(fs.readFileSync(path.join(tmpDir, 'multi-dir/sub1/x.ts'), 'utf8')).toBe('// x')
1166
+ expect(fs.readFileSync(path.join(tmpDir, 'multi-dir/sub2/y.ts'), 'utf8')).toBe('// y')
578
1167
  })
579
1168
 
580
- it('provides context snippet when old_string not found', async () => {
581
- fs.writeFileSync(f('trailing.txt'), 'hello \nworld\n')
582
- const r = await tool().execute({
583
- edits: [{ file_path: 'trailing.txt', old_string: 'nonexistent_line_xyz', new_string: 'x' }],
584
- })
1169
+ it('creates multiple files with markdown format', async () => {
1170
+ const input = [
1171
+ '```edit',
1172
+ 'file: md-multi-1.txt',
1173
+ 'old:',
1174
+ 'new: |',
1175
+ ' first file',
1176
+ '```',
1177
+ '```edit',
1178
+ 'file: md-multi-2.txt',
1179
+ 'old:',
1180
+ 'new: |',
1181
+ ' second file',
1182
+ '```',
1183
+ ].join('\n')
1184
+ const r = await tool().execute({ content: input })
1185
+ expect(r.success).toBe(true)
1186
+ expect(fs.readFileSync(path.join(tmpDir, 'md-multi-1.txt'), 'utf8')).toBe(' first file')
1187
+ expect(fs.readFileSync(path.join(tmpDir, 'md-multi-2.txt'), 'utf8')).toBe(' second file')
1188
+ })
1189
+
1190
+ it('rolls back multi-file via markdown format on failure', async () => {
1191
+ const file = path.join(tmpDir, 'md-rollback-target.txt')
1192
+ fs.writeFileSync(file, 'preserve me\n')
1193
+ applier.markRead(file)
1194
+
1195
+ const input = [
1196
+ '```edit',
1197
+ 'file: md-rollback-target.txt',
1198
+ 'old: |',
1199
+ ' preserve me',
1200
+ 'new: |',
1201
+ ' changed',
1202
+ '```',
1203
+ '```edit',
1204
+ 'file: md-nonexistent.txt',
1205
+ 'old: |',
1206
+ ' not there',
1207
+ 'new: |',
1208
+ ' fail',
1209
+ '```',
1210
+ ].join('\n')
1211
+ const r = await tool().execute({ content: input })
585
1212
  expect(r.success).toBe(false)
586
- expect(r.error).toContain('not found')
587
- // Should contain a snippet with the file's actual content
588
- expect(r.error).toContain('hello')
1213
+ expect(r.error).toContain('rolled back')
1214
+ // Target file should be restored
1215
+ expect(fs.readFileSync(file, 'utf8')).toBe('preserve me\n')
589
1216
  })
590
1217
  })
591
1218
  })
1219
+
1220
+ // ── Model-generated edit format integration tests ──────────────────────
1221
+ // These tests call a real LLM (DeepSeek-V4-Flash via ~/.lonny/config.json)
1222
+ // to generate edit format content, then feed it into the edit tool.
1223
+
1224
+ const LONNY_CONFIG_PATH = path.join(os.homedir(), '.lonny', 'config.json')
1225
+
1226
+ interface LonnyConfig {
1227
+ apiKey?: string
1228
+ baseUrl?: string
1229
+ provider?: string
1230
+ model?: string
1231
+ }
1232
+
1233
+ function loadLonnyConfig(): LonnyConfig {
1234
+ try {
1235
+ return JSON.parse(fs.readFileSync(LONNY_CONFIG_PATH, 'utf-8'))
1236
+ } catch {
1237
+ return {}
1238
+ }
1239
+ }
1240
+
1241
+ const lonnyConfig = loadLonnyConfig()
1242
+ const hasApiKey = !!lonnyConfig.apiKey
1243
+
1244
+ /** Call the model with a prompt and return the full text response (non-streaming). */
1245
+ async function callModel(prompt: string, signal?: AbortSignal): Promise<string> {
1246
+ const { default: OpenAI } = await import('openai')
1247
+ const client = new OpenAI({
1248
+ apiKey: lonnyConfig.apiKey,
1249
+ baseURL: lonnyConfig.baseUrl,
1250
+ })
1251
+
1252
+ const response = await client.chat.completions.create(
1253
+ {
1254
+ model: lonnyConfig.model || 'deepseek-v4-flash',
1255
+ messages: [{ role: 'user', content: prompt }],
1256
+ stream: false,
1257
+ max_tokens: 4096,
1258
+ },
1259
+ signal ? { signal } : undefined,
1260
+ )
1261
+
1262
+ return response.choices[0]?.message?.content || ''
1263
+ }
1264
+
1265
+ describe.runIf(hasApiKey)('model-generated edit format', () => {
1266
+ const MODEL_TIMEOUT = 60_000 // Model API calls can take 10-30s
1267
+ let tmpDir: string
1268
+ let applier: FileReadTracker
1269
+
1270
+ beforeAll(() => {
1271
+ tmpDir = makeTempDir()
1272
+ applier = new FileReadTracker()
1273
+ })
1274
+
1275
+ afterAll(() => {
1276
+ fs.rmSync(tmpDir, { recursive: true, force: true })
1277
+ })
1278
+
1279
+ const tool = () => createEditTool(applier, tmpDir)
1280
+
1281
+ it('model replaces exact string in a file', { timeout: MODEL_TIMEOUT }, async () => {
1282
+ const file = path.join(tmpDir, 'simple.txt')
1283
+ fs.writeFileSync(file, 'hello world\nfoo bar\n')
1284
+ applier.markRead(file)
1285
+
1286
+ const prompt = [
1287
+ 'Output ONLY an edit code block (```edit ... ```) that replaces "hello world" with "hello universe" in file "simple.txt".',
1288
+ '',
1289
+ 'The EXACT format is:',
1290
+ '```edit',
1291
+ 'file: simple.txt',
1292
+ 'old: |',
1293
+ ' hello world',
1294
+ 'new: |',
1295
+ ' hello universe',
1296
+ '```',
1297
+ '',
1298
+ 'Do NOT add quotes, do NOT add backticks around the text inside old/new. Output ONLY the code block.',
1299
+ ].join('\n')
1300
+
1301
+ const modelOutput = await callModel(prompt)
1302
+ const edits = parseMarkdownEdit(modelOutput)
1303
+ expect(edits.length).toBeGreaterThanOrEqual(1)
1304
+
1305
+ const result = await tool().execute({ content: modelOutput })
1306
+ expect(result.success).toBe(true)
1307
+ const content = fs.readFileSync(file, 'utf8')
1308
+ expect(content).toContain('hello universe')
1309
+ expect(content).toContain('foo bar')
1310
+ })
1311
+
1312
+ it('model replaces multi-line text in a file', { timeout: MODEL_TIMEOUT }, async () => {
1313
+ const file = path.join(tmpDir, 'multiline.txt')
1314
+ fs.writeFileSync(file, 'line A\nline B\nline C\nline D\n')
1315
+ applier.markRead(file)
1316
+
1317
+ const prompt = [
1318
+ 'Output ONLY an edit code block that replaces:',
1319
+ '',
1320
+ ' line B',
1321
+ ' line C',
1322
+ '',
1323
+ 'with:',
1324
+ '',
1325
+ ' line X',
1326
+ ' line Y',
1327
+ '',
1328
+ `in file "multiline.txt".`,
1329
+ '',
1330
+ 'Format:',
1331
+ '```edit',
1332
+ 'file: multiline.txt',
1333
+ 'old: |',
1334
+ ' line B',
1335
+ ' line C',
1336
+ 'new: |',
1337
+ ' line X',
1338
+ ' line Y',
1339
+ '```',
1340
+ '',
1341
+ 'IMPORTANT: old: must contain EXACTLY "line B" and "line C" on separate lines with 2-space indent.',
1342
+ 'Do NOT add quotes around the text. Output ONLY the code block.',
1343
+ ].join('\n')
1344
+
1345
+ const modelOutput = await callModel(prompt)
1346
+ const edits = parseMarkdownEdit(modelOutput)
1347
+ expect(edits.length).toBeGreaterThanOrEqual(1)
1348
+
1349
+ const result = await tool().execute({ content: modelOutput })
1350
+ expect(result.success).toBe(true)
1351
+ const content = fs.readFileSync(file, 'utf8')
1352
+ expect(content).toContain('line X')
1353
+ expect(content).toContain('line Y')
1354
+ })
1355
+
1356
+ it('model creates a new file', { timeout: MODEL_TIMEOUT }, async () => {
1357
+ const prompt = [
1358
+ 'Output ONLY an edit code block that CREATES a new file called "fresh.txt" with content "hello world".',
1359
+ '',
1360
+ 'Format (note: old: has NO content - this means create):',
1361
+ '```edit',
1362
+ 'file: fresh.txt',
1363
+ 'old:',
1364
+ 'new: |',
1365
+ ' hello world',
1366
+ '```',
1367
+ '',
1368
+ 'Output ONLY the code block.',
1369
+ ].join('\n')
1370
+
1371
+ const modelOutput = await callModel(prompt)
1372
+ const edits = parseMarkdownEdit(modelOutput)
1373
+ expect(edits.length).toBeGreaterThanOrEqual(1)
1374
+ expect(edits[0]!.file_path).toBe('fresh.txt')
1375
+
1376
+ const result = await tool().execute({ content: modelOutput })
1377
+ expect(result.success).toBe(true)
1378
+ expect(fs.readFileSync(path.join(tmpDir, 'fresh.txt'), 'utf8')).toContain('hello world')
1379
+ })
1380
+
1381
+ it('model edits a file with special characters (double quotes)', {
1382
+ timeout: MODEL_TIMEOUT,
1383
+ }, async () => {
1384
+ const file = path.join(tmpDir, 'quotes.txt')
1385
+ fs.writeFileSync(file, 'alpha\nbeta\ngamma\n')
1386
+ applier.markRead(file)
1387
+
1388
+ const prompt = [
1389
+ 'Output ONLY an edit code block that replaces "beta" with "BETA" in file "quotes.txt".',
1390
+ '',
1391
+ '```edit',
1392
+ 'file: quotes.txt',
1393
+ 'old: |',
1394
+ ' beta',
1395
+ 'new: |',
1396
+ ' BETA',
1397
+ '```',
1398
+ '',
1399
+ 'Output ONLY the code block. No extra text.',
1400
+ ].join('\n')
1401
+
1402
+ const modelOutput = await callModel(prompt)
1403
+ const edits = parseMarkdownEdit(modelOutput)
1404
+ expect(edits.length).toBeGreaterThanOrEqual(1)
1405
+
1406
+ const result = await tool().execute({ content: modelOutput })
1407
+ expect(result.success).toBe(true)
1408
+ const content = fs.readFileSync(file, 'utf8')
1409
+ expect(content).toContain('BETA')
1410
+ expect(content).toContain('alpha')
1411
+ expect(content).toContain('gamma')
1412
+ })
1413
+
1414
+ it('model generates edit block that round-trips successfully', {
1415
+ timeout: MODEL_TIMEOUT,
1416
+ }, async () => {
1417
+ const file = path.join(tmpDir, 'roundtrip.txt')
1418
+ fs.writeFileSync(file, 'first\nsecond\nthird\nfourth\n')
1419
+ applier.markRead(file)
1420
+
1421
+ const prompt = [
1422
+ 'Output ONLY an edit code block that replaces "second" with "SECOND" in file "roundtrip.txt".',
1423
+ 'Include 1 line of context before and after to ensure uniqueness.',
1424
+ '',
1425
+ 'Format:',
1426
+ '```edit',
1427
+ 'file: roundtrip.txt',
1428
+ 'old: |',
1429
+ ' first',
1430
+ ' second',
1431
+ ' third',
1432
+ 'new: |',
1433
+ ' first',
1434
+ ' SECOND',
1435
+ ' third',
1436
+ '```',
1437
+ '',
1438
+ 'Output ONLY the code block.',
1439
+ ].join('\n')
1440
+
1441
+ const modelOutput = await callModel(prompt)
1442
+ const edits = parseMarkdownEdit(modelOutput)
1443
+ expect(edits.length).toBeGreaterThanOrEqual(1)
1444
+
1445
+ const result = await tool().execute({ content: modelOutput })
1446
+ expect(result.success).toBe(true)
1447
+ const content = fs.readFileSync(file, 'utf8')
1448
+ expect(content).toContain('SECOND')
1449
+ expect(content).toContain('fourth')
1450
+ })
1451
+ })