test_edit.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. """Edit-related tests for the EventStreamRuntime."""
  2. import os
  3. import pytest
  4. from conftest import TEST_IN_CI, _close_test_runtime, _load_runtime
  5. from openhands_aci.utils.diff import get_diff
  6. from openhands.core.logger import openhands_logger as logger
  7. from openhands.events.action import FileEditAction, FileReadAction
  8. from openhands.events.observation import FileEditObservation
  9. ORGINAL = """from flask import Flask
  10. app = Flask(__name__)
  11. @app.route('/')
  12. def index():
  13. numbers = list(range(1, 11))
  14. return str(numbers)
  15. if __name__ == '__main__':
  16. app.run(port=5000)
  17. """
  18. @pytest.mark.skipif(
  19. TEST_IN_CI != 'True',
  20. reason='This test requires LLM to run.',
  21. )
  22. def test_edit_from_scratch(temp_dir, runtime_cls, run_as_openhands):
  23. runtime = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
  24. try:
  25. action = FileEditAction(
  26. content=ORGINAL,
  27. start=-1,
  28. path=os.path.join('/workspace', 'app.py'),
  29. )
  30. logger.info(action, extra={'msg_type': 'ACTION'})
  31. obs = runtime.run_action(action)
  32. logger.info(obs, extra={'msg_type': 'OBSERVATION'})
  33. assert isinstance(
  34. obs, FileEditObservation
  35. ), 'The observation should be a FileEditObservation.'
  36. action = FileReadAction(
  37. path=os.path.join('/workspace', 'app.py'),
  38. )
  39. obs = runtime.run_action(action)
  40. logger.info(obs, extra={'msg_type': 'OBSERVATION'})
  41. assert obs.content.strip() == ORGINAL.strip()
  42. finally:
  43. _close_test_runtime(runtime)
  44. EDIT = """# above stays the same
  45. @app.route('/')
  46. def index():
  47. numbers = list(range(1, 11))
  48. return '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'
  49. # below stays the same
  50. """
  51. @pytest.mark.skipif(
  52. TEST_IN_CI != 'True',
  53. reason='This test requires LLM to run.',
  54. )
  55. def test_edit(temp_dir, runtime_cls, run_as_openhands):
  56. runtime = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
  57. try:
  58. action = FileEditAction(
  59. content=ORGINAL,
  60. path=os.path.join('/workspace', 'app.py'),
  61. )
  62. logger.info(action, extra={'msg_type': 'ACTION'})
  63. obs = runtime.run_action(action)
  64. logger.info(obs, extra={'msg_type': 'OBSERVATION'})
  65. assert isinstance(
  66. obs, FileEditObservation
  67. ), 'The observation should be a FileEditObservation.'
  68. action = FileReadAction(
  69. path=os.path.join('/workspace', 'app.py'),
  70. )
  71. obs = runtime.run_action(action)
  72. logger.info(obs, extra={'msg_type': 'OBSERVATION'})
  73. assert obs.content.strip() == ORGINAL.strip()
  74. action = FileEditAction(
  75. content=EDIT,
  76. path=os.path.join('/workspace', 'app.py'),
  77. )
  78. obs = runtime.run_action(action)
  79. logger.info(obs, extra={'msg_type': 'OBSERVATION'})
  80. assert (
  81. obs.content.strip()
  82. == (
  83. '--- /workspace/app.py\n'
  84. '+++ /workspace/app.py\n'
  85. '@@ -4,7 +4,7 @@\n'
  86. " @app.route('/')\n"
  87. ' def index():\n'
  88. ' numbers = list(range(1, 11))\n'
  89. '- return str(numbers)\n'
  90. "+ return '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'\n"
  91. '\n'
  92. " if __name__ == '__main__':\n"
  93. ' app.run(port=5000)\n'
  94. ).strip()
  95. )
  96. finally:
  97. _close_test_runtime(runtime)
  98. ORIGINAL_LONG = '\n'.join([f'This is line {i}' for i in range(1, 1000)])
  99. EDIT_LONG = """
  100. This is line 100 + 10
  101. This is line 101 + 10
  102. """
  103. @pytest.mark.skipif(
  104. TEST_IN_CI != 'True',
  105. reason='This test requires LLM to run.',
  106. )
  107. def test_edit_long_file(temp_dir, runtime_cls, run_as_openhands):
  108. runtime = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
  109. try:
  110. action = FileEditAction(
  111. content=ORIGINAL_LONG,
  112. path=os.path.join('/workspace', 'app.py'),
  113. start=-1,
  114. )
  115. logger.info(action, extra={'msg_type': 'ACTION'})
  116. obs = runtime.run_action(action)
  117. logger.info(obs, extra={'msg_type': 'OBSERVATION'})
  118. assert isinstance(
  119. obs, FileEditObservation
  120. ), 'The observation should be a FileEditObservation.'
  121. action = FileReadAction(
  122. path=os.path.join('/workspace', 'app.py'),
  123. )
  124. obs = runtime.run_action(action)
  125. logger.info(obs, extra={'msg_type': 'OBSERVATION'})
  126. assert obs.content.strip() == ORIGINAL_LONG.strip()
  127. action = FileEditAction(
  128. content=EDIT_LONG,
  129. path=os.path.join('/workspace', 'app.py'),
  130. start=100,
  131. end=200,
  132. )
  133. obs = runtime.run_action(action)
  134. logger.info(obs, extra={'msg_type': 'OBSERVATION'})
  135. assert (
  136. obs.content.strip()
  137. == (
  138. '--- /workspace/app.py\n'
  139. '+++ /workspace/app.py\n'
  140. '@@ -97,8 +97,8 @@\n'
  141. ' This is line 97\n'
  142. ' This is line 98\n'
  143. ' This is line 99\n'
  144. '-This is line 100\n'
  145. '-This is line 101\n'
  146. '+This is line 100 + 10\n'
  147. '+This is line 101 + 10\n'
  148. ' This is line 102\n'
  149. ' This is line 103\n'
  150. ' This is line 104\n'
  151. ).strip()
  152. )
  153. finally:
  154. _close_test_runtime(runtime)
  155. # ======================================================================================
  156. # Test FileEditObservation (things that are displayed to the agent)
  157. # ======================================================================================
  158. def test_edit_obs_insert_only():
  159. EDIT_LONG_INSERT_ONLY = (
  160. '\n'.join([f'This is line {i}' for i in range(1, 100)])
  161. + EDIT_LONG
  162. + '\n'.join([f'This is line {i}' for i in range(100, 1000)])
  163. )
  164. diff = get_diff(ORIGINAL_LONG, EDIT_LONG_INSERT_ONLY, '/workspace/app.py')
  165. obs = FileEditObservation(
  166. content=diff,
  167. path='/workspace/app.py',
  168. prev_exist=True,
  169. old_content=ORIGINAL_LONG,
  170. new_content=EDIT_LONG_INSERT_ONLY,
  171. )
  172. assert (
  173. str(obs).strip()
  174. == """
  175. [Existing file /workspace/app.py is edited with 1 changes.]
  176. [begin of edit 1 / 1]
  177. (content before edit)
  178. 98|This is line 98
  179. 99|This is line 99
  180. 100|This is line 100
  181. 101|This is line 101
  182. (content after edit)
  183. 98|This is line 98
  184. 99|This is line 99
  185. +100|This is line 100 + 10
  186. +101|This is line 101 + 10
  187. 102|This is line 100
  188. 103|This is line 101
  189. [end of edit 1 / 1]
  190. """.strip()
  191. )
  192. def test_edit_obs_replace():
  193. _new_content = (
  194. '\n'.join([f'This is line {i}' for i in range(1, 100)])
  195. + EDIT_LONG
  196. + '\n'.join([f'This is line {i}' for i in range(102, 1000)])
  197. )
  198. diff = get_diff(ORIGINAL_LONG, _new_content, '/workspace/app.py')
  199. obs = FileEditObservation(
  200. content=diff,
  201. path='/workspace/app.py',
  202. prev_exist=True,
  203. old_content=ORIGINAL_LONG,
  204. new_content=_new_content,
  205. )
  206. print(str(obs))
  207. assert (
  208. str(obs).strip()
  209. == """
  210. [Existing file /workspace/app.py is edited with 1 changes.]
  211. [begin of edit 1 / 1]
  212. (content before edit)
  213. 98|This is line 98
  214. 99|This is line 99
  215. -100|This is line 100
  216. -101|This is line 101
  217. 102|This is line 102
  218. 103|This is line 103
  219. (content after edit)
  220. 98|This is line 98
  221. 99|This is line 99
  222. +100|This is line 100 + 10
  223. +101|This is line 101 + 10
  224. 102|This is line 102
  225. 103|This is line 103
  226. [end of edit 1 / 1]
  227. """.strip()
  228. )
  229. def test_edit_obs_replace_with_empty_line():
  230. _new_content = (
  231. '\n'.join([f'This is line {i}' for i in range(1, 100)])
  232. + '\n'
  233. + EDIT_LONG
  234. + '\n'.join([f'This is line {i}' for i in range(102, 1000)])
  235. )
  236. diff = get_diff(ORIGINAL_LONG, _new_content, '/workspace/app.py')
  237. obs = FileEditObservation(
  238. content=diff,
  239. path='/workspace/app.py',
  240. prev_exist=True,
  241. old_content=ORIGINAL_LONG,
  242. new_content=_new_content,
  243. )
  244. print(str(obs))
  245. assert (
  246. str(obs).strip()
  247. == """
  248. [Existing file /workspace/app.py is edited with 1 changes.]
  249. [begin of edit 1 / 1]
  250. (content before edit)
  251. 98|This is line 98
  252. 99|This is line 99
  253. -100|This is line 100
  254. -101|This is line 101
  255. 102|This is line 102
  256. 103|This is line 103
  257. (content after edit)
  258. 98|This is line 98
  259. 99|This is line 99
  260. +100|
  261. +101|This is line 100 + 10
  262. +102|This is line 101 + 10
  263. 103|This is line 102
  264. 104|This is line 103
  265. [end of edit 1 / 1]
  266. """.strip()
  267. )
  268. def test_edit_obs_multiple_edits():
  269. _new_content = (
  270. '\n'.join([f'This is line {i}' for i in range(1, 50)])
  271. + '\nbalabala\n'
  272. + '\n'.join([f'This is line {i}' for i in range(50, 100)])
  273. + EDIT_LONG
  274. + '\n'.join([f'This is line {i}' for i in range(102, 1000)])
  275. )
  276. diff = get_diff(ORIGINAL_LONG, _new_content, '/workspace/app.py')
  277. obs = FileEditObservation(
  278. content=diff,
  279. path='/workspace/app.py',
  280. prev_exist=True,
  281. old_content=ORIGINAL_LONG,
  282. new_content=_new_content,
  283. )
  284. assert (
  285. str(obs).strip()
  286. == """
  287. [Existing file /workspace/app.py is edited with 2 changes.]
  288. [begin of edit 1 / 2]
  289. (content before edit)
  290. 48|This is line 48
  291. 49|This is line 49
  292. 50|This is line 50
  293. 51|This is line 51
  294. (content after edit)
  295. 48|This is line 48
  296. 49|This is line 49
  297. +50|balabala
  298. 51|This is line 50
  299. 52|This is line 51
  300. [end of edit 1 / 2]
  301. -------------------------
  302. [begin of edit 2 / 2]
  303. (content before edit)
  304. 98|This is line 98
  305. 99|This is line 99
  306. -100|This is line 100
  307. -101|This is line 101
  308. 102|This is line 102
  309. 103|This is line 103
  310. (content after edit)
  311. 99|This is line 98
  312. 100|This is line 99
  313. +101|This is line 100 + 10
  314. +102|This is line 101 + 10
  315. 103|This is line 102
  316. 104|This is line 103
  317. [end of edit 2 / 2]
  318. """.strip()
  319. )
  320. def test_edit_visualize_failed_edit():
  321. _new_content = (
  322. '\n'.join([f'This is line {i}' for i in range(1, 50)])
  323. + '\nbalabala\n'
  324. + '\n'.join([f'This is line {i}' for i in range(50, 100)])
  325. + EDIT_LONG
  326. + '\n'.join([f'This is line {i}' for i in range(102, 1000)])
  327. )
  328. diff = get_diff(ORIGINAL_LONG, _new_content, '/workspace/app.py')
  329. obs = FileEditObservation(
  330. content=diff,
  331. path='/workspace/app.py',
  332. prev_exist=True,
  333. old_content=ORIGINAL_LONG,
  334. new_content=_new_content,
  335. )
  336. assert (
  337. obs.visualize_diff(change_applied=False).strip()
  338. == """
  339. [Changes are NOT applied to /workspace/app.py - Here's how the file looks like if changes are applied.]
  340. [begin of ATTEMPTED edit 1 / 2]
  341. (content before ATTEMPTED edit)
  342. 48|This is line 48
  343. 49|This is line 49
  344. 50|This is line 50
  345. 51|This is line 51
  346. (content after ATTEMPTED edit)
  347. 48|This is line 48
  348. 49|This is line 49
  349. +50|balabala
  350. 51|This is line 50
  351. 52|This is line 51
  352. [end of ATTEMPTED edit 1 / 2]
  353. -------------------------
  354. [begin of ATTEMPTED edit 2 / 2]
  355. (content before ATTEMPTED edit)
  356. 98|This is line 98
  357. 99|This is line 99
  358. -100|This is line 100
  359. -101|This is line 101
  360. 102|This is line 102
  361. 103|This is line 103
  362. (content after ATTEMPTED edit)
  363. 99|This is line 98
  364. 100|This is line 99
  365. +101|This is line 100 + 10
  366. +102|This is line 101 + 10
  367. 103|This is line 102
  368. 104|This is line 103
  369. [end of ATTEMPTED edit 2 / 2]
  370. """.strip()
  371. )