1919from pydantic import SecretStr
2020
2121from openhands .sdk .agent import Agent
22+ from openhands .sdk .agent .utils import normalize_tool_call
2223from openhands .sdk .conversation import Conversation , LocalConversation
2324from openhands .sdk .event import ActionEvent , AgentErrorEvent , ObservationEvent
2425from openhands .sdk .llm import LLM , Message , TextContent
@@ -103,6 +104,9 @@ def __call__(
103104 updated = path .read_text ().replace (action .old_str , action .new_str or "" , 1 )
104105 path .write_text (updated )
105106 return _FileEditorObservation .from_text ("replaced" )
107+ if action .command == "create" :
108+ path .write_text (action .file_text or "" )
109+ return _FileEditorObservation .from_text ("created" )
106110 if action .command == "view" :
107111 return _FileEditorObservation .from_text (path .read_text ())
108112 raise ValueError (f"Unsupported file_editor command: { action .command } " )
@@ -233,6 +237,77 @@ def test_str_replace_alias_infers_file_editor_command(tmp_path):
233237 assert test_file .read_text () == "value = 'new'\n "
234238
235239
240+ def test_malformed_str_replace_tool_name_is_sanitized (tmp_path ):
241+ test_file = tmp_path / "sample.py"
242+ test_file .write_text ("value = 'old'\n " )
243+
244+ events = _run_tool_call (
245+ tmp_path ,
246+ tool_name = "str_replace\n </parameter" ,
247+ arguments = {
248+ "path" : str (test_file ),
249+ "old_str" : "'old'" ,
250+ "new_str" : "'new'" ,
251+ },
252+ tool_names = (FILE_EDITOR_TOOL_SPEC ,),
253+ )
254+
255+ action_event = next (e for e in events if isinstance (e , ActionEvent ))
256+ errors = [e for e in events if isinstance (e , AgentErrorEvent )]
257+
258+ assert not errors
259+ assert action_event .tool_name == FILE_EDITOR_TOOL_NAME
260+ assert action_event .tool_call .name == FILE_EDITOR_TOOL_NAME
261+ assert action_event .action is not None
262+ assert getattr (action_event .action , "command" ) == "str_replace"
263+ assert test_file .read_text () == "value = 'new'\n "
264+
265+
266+ @pytest .mark .parametrize ("tool_name" , ["run" , "straight" ])
267+ def test_terminal_aliases_execute_terminal_tool (tmp_path , tool_name ):
268+ events = _run_tool_call (
269+ tmp_path ,
270+ tool_name = tool_name ,
271+ arguments = {"command" : "printf hello" },
272+ tool_names = (TERMINAL_TOOL_SPEC ,),
273+ )
274+
275+ action_event = next (e for e in events if isinstance (e , ActionEvent ))
276+ observation_event = next (e for e in events if isinstance (e , ObservationEvent ))
277+ errors = [e for e in events if isinstance (e , AgentErrorEvent )]
278+
279+ assert not errors
280+ assert action_event .tool_name == TERMINAL_TOOL_NAME
281+ assert action_event .tool_call .name == TERMINAL_TOOL_NAME
282+ assert action_event .action is not None
283+ assert getattr (action_event .action , "command" ) == "printf hello"
284+ assert "hello" in observation_event .observation .text
285+
286+
287+ def test_write_alias_infers_file_editor_create (tmp_path ):
288+ created_file = tmp_path / "created.py"
289+
290+ events = _run_tool_call (
291+ tmp_path ,
292+ tool_name = "write" ,
293+ arguments = {
294+ "path" : str (created_file ),
295+ "file_text" : "print('hello')\n " ,
296+ },
297+ tool_names = (FILE_EDITOR_TOOL_SPEC ,),
298+ )
299+
300+ action_event = next (e for e in events if isinstance (e , ActionEvent ))
301+ errors = [e for e in events if isinstance (e , AgentErrorEvent )]
302+
303+ assert not errors
304+ assert action_event .tool_name == FILE_EDITOR_TOOL_NAME
305+ assert action_event .tool_call .name == FILE_EDITOR_TOOL_NAME
306+ assert action_event .action is not None
307+ assert getattr (action_event .action , "command" ) == "create"
308+ assert created_file .read_text () == "print('hello')\n "
309+
310+
236311def test_shell_tool_name_falls_back_to_terminal (tmp_path ):
237312 events = _run_tool_call (
238313 tmp_path ,
@@ -447,10 +522,6 @@ def test_explicitly_registered_tool_not_hijacked_by_alias():
447522 rather than aliased to 'terminal'. This prevents legitimate tools from being
448523 silently overridden by the compatibility shim.
449524 """
450- from openhands .sdk .agent .utils import normalize_tool_call
451-
452- # When 'bash' is explicitly registered alongside 'terminal',
453- # normalize_tool_call should preserve 'bash', not alias to 'terminal'
454525 available_tools = {"bash" , "terminal" , "file_editor" }
455526
456527 # Test with 'bash' tool name - should NOT be aliased since it's registered
@@ -465,8 +536,52 @@ def test_explicitly_registered_tool_not_hijacked_by_alias():
465536 tool_name , args = normalize_tool_call ("ls" , {}, available_tools )
466537 assert tool_name == "terminal" , "Unknown 'ls' should fallback to terminal"
467538
468- # Test with 'str_replace' - should be aliased (alias target is registered)
539+ # Test with malformed XML-ish suffixes - should sanitize, then alias.
469540 tool_name , args = normalize_tool_call (
470- "str_replace" , {"old_str" : "x" , "new_str" : "y" }, available_tools
541+ "str_replace\n </function" ,
542+ {"path" : "/tmp/example.py" , "old_str" : "x" , "new_str" : "y" },
543+ available_tools ,
544+ )
545+ assert tool_name == "file_editor"
546+ assert args ["command" ] == "str_replace"
547+
548+
549+ @pytest .mark .parametrize (
550+ ("tool_name" , "arguments" , "expected_name" , "expected_command" ),
551+ [
552+ (
553+ "write" ,
554+ {"path" : "/tmp/example.py" , "file_text" : "print('hi')\n " },
555+ "file_editor" ,
556+ "create" ,
557+ ),
558+ ("str_view" , {"path" : "/tmp/example.py" }, "file_editor" , "view" ),
559+ (
560+ "file_editor" ,
561+ {"command" : "view\n </parameter" , "path" : "/tmp/example.py" },
562+ "file_editor" ,
563+ "view" ,
564+ ),
565+ ],
566+ )
567+ def test_file_editor_compatibility_normalization (
568+ tool_name ,
569+ arguments ,
570+ expected_name ,
571+ expected_command ,
572+ ):
573+ normalized_name , normalized_args = normalize_tool_call (
574+ tool_name ,
575+ arguments ,
576+ {"file_editor" , "terminal" , "think" },
471577 )
472- assert tool_name == "file_editor" , "str_replace alias should map to file_editor"
578+
579+ assert normalized_name == expected_name
580+ assert normalized_args ["command" ] == expected_command
581+
582+
583+ def test_empty_think_arguments_are_normalized ():
584+ tool_name , args = normalize_tool_call ("think" , {}, {"think" })
585+
586+ assert tool_name == "think"
587+ assert args == {"thought" : "" }
0 commit comments