diff --git a/src/anthropic/lib/tools/_beta_builtin_memory_tool.py b/src/anthropic/lib/tools/_beta_builtin_memory_tool.py index b995fbb3d..5242469fc 100644 --- a/src/anthropic/lib/tools/_beta_builtin_memory_tool.py +++ b/src/anthropic/lib/tools/_beta_builtin_memory_tool.py @@ -547,7 +547,7 @@ def insert(self, command: BetaMemoryTool20250818InsertCommand) -> str: raise ToolError(f"The path {command.path} is not a file.") content = _read_file_content(full_path, command.path) - lines = content.splitlines() + lines = content.split("\n") if command.insert_line < 0 or command.insert_line > len(lines): raise ToolError( @@ -848,7 +848,7 @@ async def insert(self, command: BetaMemoryTool20250818InsertCommand) -> str: raise ToolError(f"The path {command.path} is not a file.") content = await _async_read_file_content(full_path, command.path) - lines = content.splitlines() + lines = content.split("\n") if command.insert_line < 0 or command.insert_line > len(lines): raise ToolError( diff --git a/tests/lib/tools/memory_tools/test_filesystem.py b/tests/lib/tools/memory_tools/test_filesystem.py index 0ab3c8fa9..1f0fb083c 100644 --- a/tests/lib/tools/memory_tools/test_filesystem.py +++ b/tests/lib/tools/memory_tools/test_filesystem.py @@ -372,6 +372,32 @@ def test_insert_error_for_invalid_insert_line( ) ) + def test_insert_preserves_non_newline_line_boundaries( + self, sync_local_filesystem_tool: BetaLocalFilesystemMemoryTool + ) -> None: + # U+2028 and form-feed are line boundaries for str.splitlines() but not for "\n". + # view()/str_replace() split on "\n", so insert() must too, otherwise these + # characters are silently rewritten to "\n" and the text lands at the wrong line. + sync_local_filesystem_tool.create( + BetaMemoryTool20250818CreateCommand( + command="create", file_text="alpha\u2028beta\x0cgamma", path="/memories/insert_unicode_test.txt" + ) + ) + + view_result = sync_local_filesystem_tool.view( + BetaMemoryTool20250818ViewCommand(command="view", path="/memories/insert_unicode_test.txt") + ) + assert len(view_result.split(":\n", 1)[1].split("\n")) == 1 + + sync_local_filesystem_tool.insert( + BetaMemoryTool20250818InsertCommand( + command="insert", path="/memories/insert_unicode_test.txt", insert_line=1, insert_text="INSERTED" + ) + ) + + dir_snapshot = get_directory_snapshot(str(sync_local_filesystem_tool.base_path)) + assert dir_snapshot == {"memories/insert_unicode_test.txt": "alpha\u2028beta\x0cgamma\nINSERTED\n"} + def test_delete(self, sync_local_filesystem_tool: BetaLocalFilesystemMemoryTool) -> None: sync_local_filesystem_tool.create( BetaMemoryTool20250818CreateCommand( @@ -862,6 +888,32 @@ async def test_insert_error_for_invalid_insert_line( ) ) + async def test_insert_preserves_non_newline_line_boundaries( + self, async_local_filesystem_tool: BetaAsyncLocalFilesystemMemoryTool + ) -> None: + # U+2028 and form-feed are line boundaries for str.splitlines() but not for "\n". + # view()/str_replace() split on "\n", so insert() must too, otherwise these + # characters are silently rewritten to "\n" and the text lands at the wrong line. + await async_local_filesystem_tool.create( + BetaMemoryTool20250818CreateCommand( + command="create", file_text="alpha\u2028beta\x0cgamma", path="/memories/insert_unicode_test.txt" + ) + ) + + view_result = await async_local_filesystem_tool.view( + BetaMemoryTool20250818ViewCommand(command="view", path="/memories/insert_unicode_test.txt") + ) + assert len(view_result.split(":\n", 1)[1].split("\n")) == 1 + + await async_local_filesystem_tool.insert( + BetaMemoryTool20250818InsertCommand( + command="insert", path="/memories/insert_unicode_test.txt", insert_line=1, insert_text="INSERTED" + ) + ) + + dir_snapshot = get_directory_snapshot(str(async_local_filesystem_tool.base_path)) + assert dir_snapshot == {"memories/insert_unicode_test.txt": "alpha\u2028beta\x0cgamma\nINSERTED\n"} + async def test_delete(self, async_local_filesystem_tool: BetaAsyncLocalFilesystemMemoryTool) -> None: await async_local_filesystem_tool.create( BetaMemoryTool20250818CreateCommand(