test_edit.py 11 KB

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