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.
- package/.github/workflows/ci.yml +21 -0
- package/AGENTS.md +106 -0
- package/CHANGELOG.md +24 -0
- package/README.md +326 -326
- package/dist/agent/__tests__/session-restore.test.d.ts +2 -0
- package/dist/agent/__tests__/session-restore.test.d.ts.map +1 -0
- package/dist/agent/__tests__/session-restore.test.js +260 -0
- package/dist/agent/__tests__/session-restore.test.js.map +1 -0
- package/dist/agent/commands.d.ts +26 -0
- package/dist/agent/commands.d.ts.map +1 -0
- package/dist/agent/commands.js +253 -0
- package/dist/agent/commands.js.map +1 -0
- package/dist/agent/compaction.js +8 -1
- package/dist/agent/compaction.js.map +1 -1
- package/dist/agent/index.d.ts +2 -1
- package/dist/agent/index.d.ts.map +1 -1
- package/dist/agent/index.js +2 -2
- package/dist/agent/index.js.map +1 -1
- package/dist/agent/llm.d.ts +4 -0
- package/dist/agent/llm.d.ts.map +1 -1
- package/dist/agent/memory.d.ts +12 -0
- package/dist/agent/memory.d.ts.map +1 -0
- package/dist/agent/memory.js +51 -0
- package/dist/agent/memory.js.map +1 -0
- package/dist/agent/prompt-builder.d.ts +6 -1
- package/dist/agent/prompt-builder.d.ts.map +1 -1
- package/dist/agent/prompt-builder.js +100 -33
- package/dist/agent/prompt-builder.js.map +1 -1
- package/dist/agent/providers/google.d.ts.map +1 -1
- package/dist/agent/providers/google.js.map +1 -1
- package/dist/agent/providers/openai.d.ts +1 -0
- package/dist/agent/providers/openai.d.ts.map +1 -1
- package/dist/agent/providers/openai.js +59 -6
- package/dist/agent/providers/openai.js.map +1 -1
- package/dist/agent/session-chat.d.ts +3 -0
- package/dist/agent/session-chat.d.ts.map +1 -0
- package/dist/agent/session-chat.js +384 -0
- package/dist/agent/session-chat.js.map +1 -0
- package/dist/agent/session-display.d.ts +29 -0
- package/dist/agent/session-display.d.ts.map +1 -0
- package/dist/agent/session-display.js +179 -0
- package/dist/agent/session-display.js.map +1 -0
- package/dist/agent/session-persistence.d.ts +55 -0
- package/dist/agent/session-persistence.d.ts.map +1 -0
- package/dist/agent/session-persistence.js +101 -0
- package/dist/agent/session-persistence.js.map +1 -0
- package/dist/agent/session-utils.d.ts +5 -0
- package/dist/agent/session-utils.d.ts.map +1 -0
- package/dist/agent/session-utils.js +51 -0
- package/dist/agent/session-utils.js.map +1 -0
- package/dist/agent/session.d.ts +19 -17
- package/dist/agent/session.d.ts.map +1 -1
- package/dist/agent/session.js +234 -578
- package/dist/agent/session.js.map +1 -1
- package/dist/cli/index.d.ts +4 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +67 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/config/index.d.ts +2 -2
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +7 -1
- package/dist/config/index.js.map +1 -1
- package/dist/index.js +24 -3
- package/dist/index.js.map +1 -1
- package/dist/tools/__tests__/bash.test.js +169 -2
- package/dist/tools/__tests__/bash.test.js.map +1 -1
- package/dist/tools/__tests__/edit.test.js +1091 -340
- package/dist/tools/__tests__/edit.test.js.map +1 -1
- package/dist/tools/__tests__/fetch.test.js +1 -1
- package/dist/tools/__tests__/fetch.test.js.map +1 -1
- package/dist/tools/__tests__/glob.test.js +1 -1
- package/dist/tools/__tests__/glob.test.js.map +1 -1
- package/dist/tools/__tests__/grep.test.js +33 -1
- package/dist/tools/__tests__/grep.test.js.map +1 -1
- package/dist/tools/__tests__/ls.test.js +1 -1
- package/dist/tools/__tests__/ls.test.js.map +1 -1
- package/dist/tools/__tests__/read.test.js +1 -1
- package/dist/tools/__tests__/read.test.js.map +1 -1
- package/dist/tools/__tests__/registry.test.js +313 -0
- package/dist/tools/__tests__/registry.test.js.map +1 -1
- package/dist/tools/__tests__/sed.test.d.ts +2 -0
- package/dist/tools/__tests__/sed.test.d.ts.map +1 -0
- package/dist/tools/__tests__/sed.test.js +228 -0
- package/dist/tools/__tests__/sed.test.js.map +1 -0
- package/dist/tools/__tests__/write_plan.test.js +1 -1
- package/dist/tools/__tests__/write_plan.test.js.map +1 -1
- package/dist/tools/bash.d.ts.map +1 -1
- package/dist/tools/bash.js +711 -18
- package/dist/tools/bash.js.map +1 -1
- package/dist/tools/codebase/ast/__tests__/ast-edit-demo.d.ts +2 -0
- package/dist/tools/codebase/ast/__tests__/ast-edit-demo.d.ts.map +1 -0
- package/dist/tools/codebase/ast/__tests__/ast-edit-demo.js +7 -0
- package/dist/tools/codebase/ast/__tests__/ast-edit-demo.js.map +1 -0
- package/dist/tools/codebase/ast/__tests__/ast-edit-final.d.ts +6 -0
- package/dist/tools/codebase/ast/__tests__/ast-edit-final.d.ts.map +1 -0
- package/dist/tools/codebase/ast/__tests__/ast-edit-final.js +11 -0
- package/dist/tools/codebase/ast/__tests__/ast-edit-final.js.map +1 -0
- package/dist/tools/codebase/ast/__tests__/ast-edit-test.d.ts +2 -0
- package/dist/tools/codebase/ast/__tests__/ast-edit-test.d.ts.map +1 -0
- package/dist/tools/codebase/ast/__tests__/ast-edit-test.js +9 -0
- package/dist/tools/codebase/ast/__tests__/ast-edit-test.js.map +1 -0
- package/dist/tools/codebase/ast/__tests__/integration.test.d.ts +2 -0
- package/dist/tools/codebase/ast/__tests__/integration.test.d.ts.map +1 -0
- package/dist/tools/codebase/ast/__tests__/integration.test.js +415 -0
- package/dist/tools/codebase/ast/__tests__/integration.test.js.map +1 -0
- package/dist/tools/codebase/ast/__tests__/tree-sitter.test.d.ts +2 -0
- package/dist/tools/codebase/ast/__tests__/tree-sitter.test.d.ts.map +1 -0
- package/dist/tools/codebase/ast/__tests__/tree-sitter.test.js +414 -0
- package/dist/tools/codebase/ast/__tests__/tree-sitter.test.js.map +1 -0
- package/dist/tools/codebase/ast/adapter.d.ts +23 -0
- package/dist/tools/codebase/ast/adapter.d.ts.map +1 -0
- package/dist/tools/codebase/ast/adapter.js +11 -0
- package/dist/tools/codebase/ast/adapter.js.map +1 -0
- package/dist/tools/codebase/ast/tools.d.ts +3 -0
- package/dist/tools/codebase/ast/tools.d.ts.map +1 -0
- package/dist/tools/codebase/ast/tools.js +286 -0
- package/dist/tools/codebase/ast/tools.js.map +1 -0
- package/dist/tools/codebase/ast/tree-sitter-adapter.d.ts +3 -0
- package/dist/tools/codebase/ast/tree-sitter-adapter.d.ts.map +1 -0
- package/dist/tools/codebase/ast/tree-sitter-adapter.js +412 -0
- package/dist/tools/codebase/ast/tree-sitter-adapter.js.map +1 -0
- package/dist/tools/codebase/ast/tree-sitter-converters.d.ts +4 -0
- package/dist/tools/codebase/ast/tree-sitter-converters.d.ts.map +1 -0
- package/dist/tools/codebase/ast/tree-sitter-converters.js +447 -0
- package/dist/tools/codebase/ast/tree-sitter-converters.js.map +1 -0
- package/dist/tools/codebase/ast/types.d.ts +103 -0
- package/dist/tools/codebase/ast/types.d.ts.map +1 -0
- package/dist/tools/codebase/ast/types.js +2 -0
- package/dist/tools/codebase/ast/types.js.map +1 -0
- package/dist/tools/codebase/ast/wasm/tree-sitter-python.wasm +0 -0
- package/dist/tools/codebase/ast/wasm/tree-sitter-tsx.wasm +0 -0
- package/dist/tools/codebase/ast/wasm/tree-sitter-typescript.wasm +0 -0
- package/dist/tools/codebase/ast/wasm/tree-sitter.wasm +0 -0
- package/dist/tools/codebase/ast/wasm/web-tree-sitter.wasm +0 -0
- package/dist/tools/codebase/find.d.ts +3 -0
- package/dist/tools/codebase/find.d.ts.map +1 -0
- package/dist/tools/codebase/find.js +61 -0
- package/dist/tools/codebase/find.js.map +1 -0
- package/dist/tools/codebase/glob.d.ts +3 -0
- package/dist/tools/codebase/glob.d.ts.map +1 -0
- package/dist/tools/codebase/glob.js +37 -0
- package/dist/tools/codebase/glob.js.map +1 -0
- package/dist/tools/codebase/grep.d.ts +3 -0
- package/dist/tools/codebase/grep.d.ts.map +1 -0
- package/dist/tools/codebase/grep.js +169 -0
- package/dist/tools/codebase/grep.js.map +1 -0
- package/dist/tools/codebase/ls.d.ts +3 -0
- package/dist/tools/codebase/ls.d.ts.map +1 -0
- package/dist/tools/codebase/ls.js +39 -0
- package/dist/tools/codebase/ls.js.map +1 -0
- package/dist/tools/codebase/read.d.ts +10 -0
- package/dist/tools/codebase/read.d.ts.map +1 -0
- package/dist/tools/codebase/read.js +81 -0
- package/dist/tools/codebase/read.js.map +1 -0
- package/dist/tools/delete_memory.d.ts +3 -0
- package/dist/tools/delete_memory.d.ts.map +1 -0
- package/dist/tools/delete_memory.js +46 -0
- package/dist/tools/delete_memory.js.map +1 -0
- package/dist/tools/edit/diff-compute.d.ts +4 -0
- package/dist/tools/edit/diff-compute.d.ts.map +1 -0
- package/dist/tools/edit/diff-compute.js +18 -0
- package/dist/tools/edit/diff-compute.js.map +1 -0
- package/dist/tools/edit/diff-render.d.ts +18 -0
- package/dist/tools/edit/diff-render.d.ts.map +1 -0
- package/dist/tools/edit/diff-render.js +128 -0
- package/dist/tools/edit/diff-render.js.map +1 -0
- package/dist/tools/edit/edit.d.ts +4 -0
- package/dist/tools/edit/edit.d.ts.map +1 -0
- package/dist/tools/edit/edit.js +440 -0
- package/dist/tools/edit/edit.js.map +1 -0
- package/dist/tools/edit/matcher.d.ts +24 -0
- package/dist/tools/edit/matcher.d.ts.map +1 -0
- package/dist/tools/edit/matcher.js +66 -0
- package/dist/tools/edit/matcher.js.map +1 -0
- package/dist/tools/edit/parser.d.ts +10 -0
- package/dist/tools/edit/parser.d.ts.map +1 -0
- package/dist/tools/edit/parser.js +120 -0
- package/dist/tools/edit/parser.js.map +1 -0
- package/dist/tools/edit/types.d.ts +16 -0
- package/dist/tools/edit/types.d.ts.map +1 -0
- package/dist/tools/edit/types.js +2 -0
- package/dist/tools/edit/types.js.map +1 -0
- package/dist/tools/edit/write_plan.d.ts +5 -0
- package/dist/tools/edit/write_plan.d.ts.map +1 -0
- package/dist/tools/edit/write_plan.js +98 -0
- package/dist/tools/edit/write_plan.js.map +1 -0
- package/dist/tools/edit.d.ts +50 -9
- package/dist/tools/edit.d.ts.map +1 -1
- package/dist/tools/edit.js +394 -159
- package/dist/tools/edit.js.map +1 -1
- package/dist/tools/execute/bash/constants.d.ts +24 -0
- package/dist/tools/execute/bash/constants.d.ts.map +1 -0
- package/dist/tools/execute/bash/constants.js +88 -0
- package/dist/tools/execute/bash/constants.js.map +1 -0
- package/dist/tools/execute/bash/errors.d.ts +39 -0
- package/dist/tools/execute/bash/errors.d.ts.map +1 -0
- package/dist/tools/execute/bash/errors.js +249 -0
- package/dist/tools/execute/bash/errors.js.map +1 -0
- package/dist/tools/execute/bash/execution.d.ts +18 -0
- package/dist/tools/execute/bash/execution.d.ts.map +1 -0
- package/dist/tools/execute/bash/execution.js +207 -0
- package/dist/tools/execute/bash/execution.js.map +1 -0
- package/dist/tools/execute/bash/index.d.ts +3 -0
- package/dist/tools/execute/bash/index.d.ts.map +1 -0
- package/dist/tools/execute/bash/index.js +117 -0
- package/dist/tools/execute/bash/index.js.map +1 -0
- package/dist/tools/execute/bash/platform.d.ts +6 -0
- package/dist/tools/execute/bash/platform.d.ts.map +1 -0
- package/dist/tools/execute/bash/platform.js +64 -0
- package/dist/tools/execute/bash/platform.js.map +1 -0
- package/dist/tools/execute/bash/security.d.ts +12 -0
- package/dist/tools/execute/bash/security.d.ts.map +1 -0
- package/dist/tools/execute/bash/security.js +26 -0
- package/dist/tools/execute/bash/security.js.map +1 -0
- package/dist/tools/execute/bash/validation.d.ts +16 -0
- package/dist/tools/execute/bash/validation.d.ts.map +1 -0
- package/dist/tools/execute/bash/validation.js +57 -0
- package/dist/tools/execute/bash/validation.js.map +1 -0
- package/dist/tools/execute/bash.d.ts +3 -0
- package/dist/tools/execute/bash.d.ts.map +1 -0
- package/dist/tools/execute/bash.js +728 -0
- package/dist/tools/execute/bash.js.map +1 -0
- package/dist/tools/execute/git.d.ts +3 -0
- package/dist/tools/execute/git.d.ts.map +1 -0
- package/dist/tools/execute/git.js +183 -0
- package/dist/tools/execute/git.js.map +1 -0
- package/dist/tools/execute/task_complete.d.ts +7 -0
- package/dist/tools/execute/task_complete.d.ts.map +1 -0
- package/dist/tools/execute/task_complete.js +38 -0
- package/dist/tools/execute/task_complete.js.map +1 -0
- package/dist/tools/fetch.d.ts.map +1 -1
- package/dist/tools/fetch.js +2 -0
- package/dist/tools/fetch.js.map +1 -1
- package/dist/tools/find.d.ts.map +1 -1
- package/dist/tools/find.js +2 -0
- package/dist/tools/find.js.map +1 -1
- package/dist/tools/git.d.ts.map +1 -1
- package/dist/tools/git.js +2 -0
- package/dist/tools/git.js.map +1 -1
- package/dist/tools/glob.d.ts.map +1 -1
- package/dist/tools/glob.js +2 -0
- package/dist/tools/glob.js.map +1 -1
- package/dist/tools/grep.d.ts.map +1 -1
- package/dist/tools/grep.js +56 -30
- package/dist/tools/grep.js.map +1 -1
- package/dist/tools/install/clawhub.d.ts +26 -0
- package/dist/tools/install/clawhub.d.ts.map +1 -0
- package/dist/tools/install/clawhub.js +182 -0
- package/dist/tools/install/clawhub.js.map +1 -0
- package/dist/tools/install/constants.d.ts +5 -0
- package/dist/tools/install/constants.d.ts.map +1 -0
- package/dist/tools/install/constants.js +5 -0
- package/dist/tools/install/constants.js.map +1 -0
- package/dist/tools/install/install_skill.d.ts +3 -0
- package/dist/tools/install/install_skill.d.ts.map +1 -0
- package/dist/tools/install/install_skill.js +132 -0
- package/dist/tools/install/install_skill.js.map +1 -0
- package/dist/tools/install/npm.d.ts +8 -0
- package/dist/tools/install/npm.d.ts.map +1 -0
- package/dist/tools/install/npm.js +52 -0
- package/dist/tools/install/npm.js.map +1 -0
- package/dist/tools/install/skill-content.d.ts +9 -0
- package/dist/tools/install/skill-content.d.ts.map +1 -0
- package/dist/tools/install/skill-content.js +89 -0
- package/dist/tools/install/skill-content.js.map +1 -0
- package/dist/tools/install_skill.d.ts.map +1 -1
- package/dist/tools/install_skill.js +2 -0
- package/dist/tools/install_skill.js.map +1 -1
- package/dist/tools/list_memory.d.ts +3 -0
- package/dist/tools/list_memory.d.ts.map +1 -0
- package/dist/tools/list_memory.js +55 -0
- package/dist/tools/list_memory.js.map +1 -0
- package/dist/tools/ls.d.ts.map +1 -1
- package/dist/tools/ls.js +2 -0
- package/dist/tools/ls.js.map +1 -1
- package/dist/tools/memory/delete_memory.d.ts +3 -0
- package/dist/tools/memory/delete_memory.d.ts.map +1 -0
- package/dist/tools/memory/delete_memory.js +46 -0
- package/dist/tools/memory/delete_memory.js.map +1 -0
- package/dist/tools/memory/list_memory.d.ts +3 -0
- package/dist/tools/memory/list_memory.d.ts.map +1 -0
- package/dist/tools/memory/list_memory.js +55 -0
- package/dist/tools/memory/list_memory.js.map +1 -0
- package/dist/tools/memory/save_memory.d.ts +3 -0
- package/dist/tools/memory/save_memory.d.ts.map +1 -0
- package/dist/tools/memory/save_memory.js +45 -0
- package/dist/tools/memory/save_memory.js.map +1 -0
- package/dist/tools/read.d.ts.map +1 -1
- package/dist/tools/read.js +2 -0
- package/dist/tools/read.js.map +1 -1
- package/dist/tools/registry.d.ts +7 -8
- package/dist/tools/registry.d.ts.map +1 -1
- package/dist/tools/registry.js +114 -46
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/save_memory.d.ts +3 -0
- package/dist/tools/save_memory.d.ts.map +1 -0
- package/dist/tools/save_memory.js +45 -0
- package/dist/tools/save_memory.js.map +1 -0
- package/dist/tools/search.d.ts.map +1 -1
- package/dist/tools/search.js +2 -0
- package/dist/tools/search.js.map +1 -1
- package/dist/tools/sed.d.ts +4 -0
- package/dist/tools/sed.d.ts.map +1 -0
- package/dist/tools/sed.js +121 -0
- package/dist/tools/sed.js.map +1 -0
- package/dist/tools/tree.d.ts +12 -0
- package/dist/tools/tree.d.ts.map +1 -0
- package/dist/tools/tree.js +87 -0
- package/dist/tools/tree.js.map +1 -0
- package/dist/tools/types.d.ts +12 -0
- package/dist/tools/types.d.ts.map +1 -1
- package/dist/tools/web/fetch.d.ts +3 -0
- package/dist/tools/web/fetch.d.ts.map +1 -0
- package/dist/tools/web/fetch.js +49 -0
- package/dist/tools/web/fetch.js.map +1 -0
- package/dist/tools/web/search.d.ts +3 -0
- package/dist/tools/web/search.d.ts.map +1 -0
- package/dist/tools/web/search.js +134 -0
- package/dist/tools/web/search.js.map +1 -0
- package/dist/tools/write_plan.d.ts.map +1 -1
- package/dist/tools/write_plan.js +2 -0
- package/dist/tools/write_plan.js.map +1 -1
- package/dist/tui/commands.d.ts +4 -0
- package/dist/tui/commands.d.ts.map +1 -0
- package/dist/tui/commands.js +118 -0
- package/dist/tui/commands.js.map +1 -0
- package/dist/tui/components/colors.d.ts +36 -0
- package/dist/tui/components/colors.d.ts.map +1 -0
- package/dist/tui/components/colors.js +48 -0
- package/dist/tui/components/colors.js.map +1 -0
- package/dist/tui/components/header-bar.d.ts +26 -0
- package/dist/tui/components/header-bar.d.ts.map +1 -0
- package/dist/tui/components/header-bar.js +78 -0
- package/dist/tui/components/header-bar.js.map +1 -0
- package/dist/tui/components/index.d.ts +9 -0
- package/dist/tui/components/index.d.ts.map +1 -0
- package/dist/tui/components/index.js +9 -0
- package/dist/tui/components/index.js.map +1 -0
- package/dist/tui/components/landing-screen.d.ts +12 -0
- package/dist/tui/components/landing-screen.d.ts.map +1 -0
- package/dist/tui/components/landing-screen.js +53 -0
- package/dist/tui/components/landing-screen.js.map +1 -0
- package/dist/tui/components/pixel-logo.d.ts +5 -0
- package/dist/tui/components/pixel-logo.d.ts.map +1 -0
- package/dist/tui/components/pixel-logo.js +27 -0
- package/dist/tui/components/pixel-logo.js.map +1 -0
- package/dist/tui/components/plan-utils.d.ts +11 -0
- package/dist/tui/components/plan-utils.d.ts.map +1 -0
- package/dist/tui/components/plan-utils.js +62 -0
- package/dist/tui/components/plan-utils.js.map +1 -0
- package/dist/tui/components/plans-list.d.ts +18 -0
- package/dist/tui/components/plans-list.d.ts.map +1 -0
- package/dist/tui/components/plans-list.js +46 -0
- package/dist/tui/components/plans-list.js.map +1 -0
- package/dist/tui/components/rich-footer.d.ts +29 -0
- package/dist/tui/components/rich-footer.d.ts.map +1 -0
- package/dist/tui/components/rich-footer.js +124 -0
- package/dist/tui/components/rich-footer.js.map +1 -0
- package/dist/tui/components/todo-panel.d.ts +14 -0
- package/dist/tui/components/todo-panel.d.ts.map +1 -0
- package/dist/tui/components/todo-panel.js +83 -0
- package/dist/tui/components/todo-panel.js.map +1 -0
- package/dist/tui/components.d.ts.map +1 -1
- package/dist/tui/components.js +14 -7
- package/dist/tui/components.js.map +1 -1
- package/dist/tui/index.d.ts +2 -1
- package/dist/tui/index.d.ts.map +1 -1
- package/dist/tui/index.js +99 -505
- package/dist/tui/index.js.map +1 -1
- package/dist/tui/overlays.d.ts +28 -0
- package/dist/tui/overlays.d.ts.map +1 -0
- package/dist/tui/overlays.js +154 -0
- package/dist/tui/overlays.js.map +1 -0
- package/dist/tui/themes.d.ts +5 -0
- package/dist/tui/themes.d.ts.map +1 -0
- package/dist/tui/themes.js +38 -0
- package/dist/tui/themes.js.map +1 -0
- package/dist/web/index.d.ts.map +1 -1
- package/dist/web/index.js +62 -101
- package/dist/web/index.js.map +1 -1
- package/dist/web/public/app.js +5 -855
- package/dist/web/public/confirm.js +70 -0
- package/dist/web/public/index.html +121 -120
- package/dist/web/public/input.js +107 -0
- package/dist/web/public/messages.js +411 -0
- package/dist/web/public/sidebar.js +82 -0
- package/dist/web/public/state.js +72 -0
- package/dist/web/public/style.css +247 -70
- package/dist/web/public/utils.js +37 -0
- package/dist/web/public/websocket.js +267 -0
- package/dist/web/public/ws.js +19 -0
- package/dist/web/session-bridge.d.ts.map +1 -1
- package/dist/web/session-bridge.js +85 -10
- package/dist/web/session-bridge.js.map +1 -1
- package/docs/ast-tools.md +117 -0
- package/package.json +11 -5
- package/scripts/check-line-count.mjs +53 -0
- package/scripts/copy-ast-wasm.mjs +32 -0
- package/scripts/copy-native.mjs +24 -0
- package/scripts/copy-web.mjs +15 -0
- package/src/agent/__tests__/session-restore.test.ts +284 -0
- package/src/agent/commands.ts +274 -0
- package/src/agent/compaction.ts +9 -2
- package/src/agent/index.ts +7 -2
- package/src/agent/llm.ts +4 -0
- package/src/agent/memory.ts +60 -0
- package/src/agent/prompt-builder.ts +111 -33
- package/src/agent/providers/google.ts +0 -1
- package/src/agent/providers/openai.ts +79 -8
- package/src/agent/session-chat.ts +480 -0
- package/src/agent/session-display.ts +195 -0
- package/src/agent/session-persistence.ts +146 -0
- package/src/agent/session-utils.ts +54 -0
- package/src/agent/session.ts +269 -670
- package/src/cli/index.ts +73 -4
- package/src/config/index.ts +14 -7
- package/src/index.ts +30 -3
- package/src/tools/__tests__/bash.test.ts +188 -2
- package/src/tools/__tests__/edit.test.ts +1216 -356
- package/src/tools/__tests__/fetch.test.ts +1 -1
- package/src/tools/__tests__/glob.test.ts +1 -1
- package/src/tools/__tests__/grep.test.ts +36 -1
- package/src/tools/__tests__/ls.test.ts +1 -1
- package/src/tools/__tests__/read.test.ts +1 -1
- package/src/tools/__tests__/registry.test.ts +332 -0
- package/src/tools/__tests__/write_plan.test.ts +1 -1
- package/src/tools/codebase/ast/__tests__/integration.test.ts +452 -0
- package/src/tools/codebase/ast/__tests__/tree-sitter.test.ts +466 -0
- package/src/tools/codebase/ast/adapter.ts +44 -0
- package/src/tools/codebase/ast/tools.ts +322 -0
- package/src/tools/codebase/ast/tree-sitter-adapter.ts +468 -0
- package/src/tools/codebase/ast/tree-sitter-converters.ts +480 -0
- package/src/tools/codebase/ast/types.ts +102 -0
- package/src/tools/codebase/ast/wasm/tree-sitter-python.wasm +0 -0
- package/src/tools/codebase/ast/wasm/tree-sitter-tsx.wasm +0 -0
- package/src/tools/codebase/ast/wasm/tree-sitter-typescript.wasm +0 -0
- package/src/tools/codebase/ast/wasm/tree-sitter.wasm +0 -0
- package/src/tools/codebase/ast/wasm/web-tree-sitter.wasm +0 -0
- package/src/tools/{find.ts → codebase/find.ts} +4 -2
- package/src/tools/{glob.ts → codebase/glob.ts} +4 -2
- package/src/tools/{grep.ts → codebase/grep.ts} +61 -32
- package/src/tools/{ls.ts → codebase/ls.ts} +4 -2
- package/src/tools/{read.ts → codebase/read.ts} +5 -3
- package/src/tools/edit/diff-compute.ts +21 -0
- package/src/tools/edit/diff-render.ts +145 -0
- package/src/tools/edit/edit.ts +468 -0
- package/src/tools/edit/matcher.ts +70 -0
- package/src/tools/edit/parser.ts +131 -0
- package/src/tools/edit/types.ts +19 -0
- package/src/tools/{write_plan.ts → edit/write_plan.ts} +4 -2
- package/src/tools/execute/bash/constants.ts +93 -0
- package/src/tools/execute/bash/errors.ts +304 -0
- package/src/tools/execute/bash/execution.ts +211 -0
- package/src/tools/execute/bash/index.ts +131 -0
- package/src/tools/execute/bash/platform.ts +65 -0
- package/src/tools/execute/bash/security.ts +28 -0
- package/src/tools/execute/bash/validation.ts +78 -0
- package/src/tools/execute/git.ts +209 -0
- package/src/tools/execute/task_complete.ts +40 -0
- package/src/tools/install/clawhub.ts +222 -0
- package/src/tools/install/constants.ts +4 -0
- package/src/tools/install/install_skill.ts +150 -0
- package/src/tools/install/npm.ts +60 -0
- package/src/tools/install/skill-content.ts +108 -0
- package/src/tools/memory/delete_memory.ts +46 -0
- package/src/tools/memory/list_memory.ts +53 -0
- package/src/tools/memory/save_memory.ts +45 -0
- package/src/tools/registry.ts +121 -50
- package/src/tools/tree.ts +103 -0
- package/src/tools/types.ts +13 -0
- package/src/tools/{fetch.ts → web/fetch.ts} +4 -2
- package/src/tools/{search.ts → web/search.ts} +4 -2
- package/src/tui/commands.ts +135 -0
- package/src/tui/components/colors.ts +52 -0
- package/src/tui/components/header-bar.ts +88 -0
- package/src/tui/components/index.ts +8 -0
- package/src/tui/components/landing-screen.ts +67 -0
- package/src/tui/components/pixel-logo.ts +33 -0
- package/src/tui/components/plan-utils.ts +69 -0
- package/src/tui/components/plans-list.ts +55 -0
- package/src/tui/components/rich-footer.ts +146 -0
- package/src/tui/components/todo-panel.ts +95 -0
- package/src/tui/index.ts +110 -561
- package/src/tui/overlays.ts +203 -0
- package/src/tui/themes.ts +41 -0
- package/src/web/index.ts +66 -107
- package/src/web/public/app.js +5 -855
- package/src/web/public/confirm.js +70 -0
- package/src/web/public/index.html +121 -120
- package/src/web/public/input.js +107 -0
- package/src/web/public/messages.js +411 -0
- package/src/web/public/sidebar.js +82 -0
- package/src/web/public/state.js +72 -0
- package/src/web/public/style.css +247 -70
- package/src/web/public/utils.js +37 -0
- package/src/web/public/websocket.js +267 -0
- package/src/web/public/ws.js +19 -0
- package/src/web/session-bridge.ts +90 -12
- package/.claude/settings.local.json +0 -14
- package/.kilo/plans/1780064105789-mighty-pixel.md +0 -171
- package/.kilo/plans/1780293725888-quick-wizard.md +0 -62
- package/.lonny/plan-web-cwd-status.md +0 -38
- package/AGENT.md +0 -99
- package/src/tools/bash.ts +0 -39
- package/src/tools/edit.ts +0 -554
- package/src/tools/exec.ts +0 -348
- package/src/tools/git.ts +0 -76
- package/src/tools/install_skill.ts +0 -539
- 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 {
|
|
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
|
-
|
|
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&b')
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('escapes less-than', () => {
|
|
297
|
+
expect(escapeHtml('<tag>')).toBe('<tag>')
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('escapes greater-than', () => {
|
|
301
|
+
expect(escapeHtml('a > b')).toBe('a > b')
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('escapes double quotes', () => {
|
|
305
|
+
expect(escapeHtml('say "hello"')).toBe('say "hello"')
|
|
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
|
-
|
|
16
|
-
applier
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
615
|
+
// ── Advanced edge cases ──────────────────────────────────────────────────
|
|
616
|
+
describe('edit tool �?edge cases', () => {
|
|
617
|
+
let tmpDir: string
|
|
618
|
+
let applier: FileReadTracker
|
|
93
619
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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: '
|
|
637
|
+
edits: [{ file_path: 'unicode.txt', old_string: 'héllo wörld', new_string: 'HELLO WORLD' }],
|
|
115
638
|
})
|
|
116
|
-
expect(r.success).toBe(
|
|
117
|
-
expect(
|
|
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('
|
|
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: '
|
|
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(
|
|
130
|
-
|
|
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('
|
|
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: '
|
|
661
|
+
edits: [{ file_path: 'chinese.txt', old_string: '你好世界', new_string: 'Hello World' }],
|
|
141
662
|
})
|
|
142
|
-
expect(r.success).toBe(
|
|
143
|
-
expect(
|
|
663
|
+
expect(r.success).toBe(true)
|
|
664
|
+
expect(fs.readFileSync(file, 'utf8')).toBe('Hello World\n')
|
|
144
665
|
})
|
|
145
666
|
|
|
146
|
-
it('
|
|
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: '
|
|
672
|
+
edits: [{ file_path: 'tabs.txt', old_string: 'a\tb', new_string: 'A\tB' }],
|
|
149
673
|
})
|
|
150
|
-
expect(r.success).toBe(
|
|
151
|
-
expect(
|
|
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('
|
|
157
|
-
it('
|
|
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: '
|
|
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
|
-
|
|
164
|
-
expect(
|
|
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('
|
|
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:
|
|
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.
|
|
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('
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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('
|
|
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
|
-
|
|
192
|
-
|
|
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(
|
|
196
|
-
fs.unlinkSync(path.join(tmpDir, 'auto-create.txt'))
|
|
733
|
+
expect(fs.readFileSync(file, 'utf8')).toBe('brand new content\n')
|
|
197
734
|
})
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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(
|
|
204
|
-
expect(
|
|
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
|
|
210
|
-
const
|
|
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('
|
|
213
|
-
expect(r.error).toContain('array')
|
|
766
|
+
expect(r.error).toContain('already exists')
|
|
214
767
|
})
|
|
215
768
|
})
|
|
216
769
|
|
|
217
|
-
describe('
|
|
218
|
-
|
|
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
|
-
{
|
|
227
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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('
|
|
787
|
+
it('handles file path with special characters', async () => {
|
|
238
788
|
const r = await tool().execute({
|
|
239
789
|
edits: [
|
|
240
|
-
{
|
|
241
|
-
|
|
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(
|
|
246
|
-
|
|
247
|
-
|
|
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('
|
|
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
|
-
{
|
|
256
|
-
|
|
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('
|
|
261
|
-
expect(fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8')).toBe(original)
|
|
814
|
+
expect(r.error).toContain('traversal')
|
|
262
815
|
})
|
|
816
|
+
})
|
|
263
817
|
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
{
|
|
271
|
-
|
|
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(
|
|
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
|
|
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: [
|
|
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(
|
|
296
|
-
|
|
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('
|
|
847
|
+
it('rejects absolute path', async () => {
|
|
306
848
|
const r = await tool().execute({
|
|
307
849
|
edits: [
|
|
308
850
|
{
|
|
309
|
-
file_path: '
|
|
310
|
-
old_string: '
|
|
311
|
-
new_string: '
|
|
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(
|
|
316
|
-
|
|
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('
|
|
861
|
+
it('allows normal paths inside cwd', async () => {
|
|
326
862
|
const r = await tool().execute({
|
|
327
863
|
edits: [
|
|
328
864
|
{
|
|
329
|
-
file_path: '
|
|
330
|
-
old_string: '
|
|
331
|
-
new_string: '
|
|
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('
|
|
345
|
-
|
|
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: '
|
|
350
|
-
|
|
351
|
-
|
|
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(
|
|
357
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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: '
|
|
389
|
-
old_string: '',
|
|
390
|
-
new_string: '
|
|
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(
|
|
395
|
-
|
|
396
|
-
expect(
|
|
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('
|
|
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
|
-
{
|
|
403
|
-
|
|
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(
|
|
407
|
-
|
|
408
|
-
expect(
|
|
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('
|
|
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: '
|
|
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('
|
|
967
|
+
expect(r.error).toContain('was not read first')
|
|
423
968
|
})
|
|
424
969
|
|
|
425
|
-
it('
|
|
970
|
+
it('includes rawInput in error for easy debugging', async () => {
|
|
426
971
|
const r = await tool().execute({
|
|
427
972
|
edits: [
|
|
428
|
-
{
|
|
429
|
-
|
|
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(
|
|
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('
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
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(
|
|
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('
|
|
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
|
-
{
|
|
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(
|
|
1018
|
+
expect(r.output).toContain('3 lines')
|
|
469
1019
|
})
|
|
1020
|
+
})
|
|
470
1021
|
|
|
471
|
-
|
|
472
|
-
|
|
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: '
|
|
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
|
-
|
|
480
|
-
|
|
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('
|
|
485
|
-
|
|
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: '
|
|
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
|
-
|
|
493
|
-
|
|
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
|
-
|
|
498
|
-
|
|
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: '
|
|
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
|
-
|
|
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
|
-
{
|
|
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(
|
|
516
|
-
expect(
|
|
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('
|
|
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: [
|
|
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(
|
|
525
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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('
|
|
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: [
|
|
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(
|
|
545
|
-
expect(
|
|
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('
|
|
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: [
|
|
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(
|
|
554
|
-
expect(
|
|
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('
|
|
558
|
-
fs.
|
|
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: [
|
|
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('
|
|
564
|
-
|
|
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('
|
|
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: '
|
|
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(
|
|
576
|
-
|
|
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('
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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('
|
|
587
|
-
//
|
|
588
|
-
expect(
|
|
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
|
+
})
|