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,532 +1,1283 @@
|
|
|
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 { escapeHtml, generateDiff, generateDiffWithContext, renderDiffTerminal, } from '../edit/diff-render.js';
|
|
8
|
+
import { createEditTool } from '../edit/edit.js';
|
|
9
|
+
import { findAllLinesTolerant, normalizeLine } from '../edit/matcher.js';
|
|
10
|
+
import { parseMarkdownEdit } from '../edit/parser.js';
|
|
6
11
|
import { makeTempDir } from './helpers.js';
|
|
7
|
-
|
|
12
|
+
// ── Minimal mock for FileReadTracker ─────────────────────────────────────
|
|
13
|
+
function makeApplier() {
|
|
14
|
+
const readSet = new Set();
|
|
15
|
+
return {
|
|
16
|
+
markRead(f) {
|
|
17
|
+
readSet.add(f);
|
|
18
|
+
},
|
|
19
|
+
checkModified(f) {
|
|
20
|
+
return readSet.has(f) ? null : `Warning: file was not read first.`;
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
// ── Tests for pure functions ─────────────────────────────────────────────
|
|
25
|
+
describe('normalizeLine', () => {
|
|
26
|
+
it('trims leading whitespace', () => {
|
|
27
|
+
expect(normalizeLine(' hello')).toBe('hello');
|
|
28
|
+
});
|
|
29
|
+
it('trims trailing whitespace', () => {
|
|
30
|
+
expect(normalizeLine('hello ')).toBe('hello');
|
|
31
|
+
});
|
|
32
|
+
it('collapses internal space runs', () => {
|
|
33
|
+
expect(normalizeLine('a b')).toBe('a b');
|
|
34
|
+
});
|
|
35
|
+
it('collapses internal tab runs', () => {
|
|
36
|
+
expect(normalizeLine('a\t\tb')).toBe('a b');
|
|
37
|
+
});
|
|
38
|
+
it('handles mixed spaces and tabs', () => {
|
|
39
|
+
expect(normalizeLine(' a \t b ')).toBe('a b');
|
|
40
|
+
});
|
|
41
|
+
it('reduces blank line with spaces to empty string', () => {
|
|
42
|
+
expect(normalizeLine(' ')).toBe('');
|
|
43
|
+
});
|
|
44
|
+
it('preserves single spaces', () => {
|
|
45
|
+
expect(normalizeLine('a b c')).toBe('a b c');
|
|
46
|
+
});
|
|
47
|
+
it('handles empty string', () => {
|
|
48
|
+
expect(normalizeLine('')).toBe('');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe('findAllLinesTolerant', () => {
|
|
52
|
+
it('finds exact match', () => {
|
|
53
|
+
const result = findAllLinesTolerant('hello\nworld\nfoo\n', 'hello\nworld');
|
|
54
|
+
expect(result).toHaveLength(1);
|
|
55
|
+
expect(result[0].index).toBe(0);
|
|
56
|
+
expect(result[0].length).toBe('hello\nworld'.length);
|
|
57
|
+
});
|
|
58
|
+
it('finds match with trailing whitespace in content', () => {
|
|
59
|
+
const result = findAllLinesTolerant('hello \nworld\n', 'hello\nworld');
|
|
60
|
+
expect(result).toHaveLength(1);
|
|
61
|
+
// "hello \n" = 7 chars, "world" = 5 chars, total match = 12
|
|
62
|
+
expect(result[0].length).toBe(12);
|
|
63
|
+
});
|
|
64
|
+
it('finds match with leading whitespace in content', () => {
|
|
65
|
+
const result = findAllLinesTolerant(' hello\nworld\n', 'hello\nworld');
|
|
66
|
+
expect(result).toHaveLength(1);
|
|
67
|
+
// " hello\n" = 8 chars, "world" = 5, total = 13
|
|
68
|
+
expect(result[0].length).toBe(13);
|
|
69
|
+
});
|
|
70
|
+
it('finds match with extra internal spaces', () => {
|
|
71
|
+
const result = findAllLinesTolerant('foo bar\nbaz\n', 'foo bar\nbaz');
|
|
72
|
+
expect(result).toHaveLength(1);
|
|
73
|
+
});
|
|
74
|
+
it('returns empty array for empty oldString', () => {
|
|
75
|
+
expect(findAllLinesTolerant('abc', '')).toEqual([]);
|
|
76
|
+
});
|
|
77
|
+
it('returns empty when oldString longer than content', () => {
|
|
78
|
+
expect(findAllLinesTolerant('a', 'a\nb\nc')).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
it('returns empty when not found', () => {
|
|
81
|
+
expect(findAllLinesTolerant('abc\ndef\n', 'xyz')).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
it('finds multiple matches', () => {
|
|
84
|
+
const result = findAllLinesTolerant('a\nb\na\nb\n', 'a\nb');
|
|
85
|
+
expect(result).toHaveLength(2);
|
|
86
|
+
});
|
|
87
|
+
it('computes correct index for non-first match', () => {
|
|
88
|
+
const result = findAllLinesTolerant('x\na\nb\n', 'a\nb');
|
|
89
|
+
expect(result).toHaveLength(1);
|
|
90
|
+
expect(result[0].index).toBe(2); // "x\n" = 2 chars
|
|
91
|
+
});
|
|
92
|
+
it('computes correct length matching original whitespace', () => {
|
|
93
|
+
const result = findAllLinesTolerant('hello \nworld\n', 'hello\nworld');
|
|
94
|
+
expect(result).toHaveLength(1);
|
|
95
|
+
const match = contentSlice('hello \nworld\n', result[0]);
|
|
96
|
+
expect(match).toBe('hello \nworld');
|
|
97
|
+
});
|
|
98
|
+
it('handles CRLF content - correct position computation', () => {
|
|
99
|
+
const result = findAllLinesTolerant('hello\r\nworld\r\nfoo\r\n', 'hello\nworld');
|
|
100
|
+
expect(result).toHaveLength(1);
|
|
101
|
+
expect(result[0].index).toBe(0);
|
|
102
|
+
// 12 = 'hello\r' (6) + '\n' (1) + 'world' (5, trailing \r excluded)
|
|
103
|
+
expect(result[0].length).toBe(12);
|
|
104
|
+
const match = contentSlice('hello\r\nworld\r\nfoo\r\n', result[0]);
|
|
105
|
+
expect(match).toBe('hello\r\nworld');
|
|
106
|
+
});
|
|
107
|
+
it('handles mixed CRLF/LF content', () => {
|
|
108
|
+
const result = findAllLinesTolerant('hello\r\nworld\nfoo\r\n', 'hello\nworld');
|
|
109
|
+
expect(result).toHaveLength(1);
|
|
110
|
+
expect(result[0].index).toBe(0);
|
|
111
|
+
expect(result[0].length).toBe(12);
|
|
112
|
+
const match = contentSlice('hello\r\nworld\nfoo\r\n', result[0]);
|
|
113
|
+
expect(match).toBe('hello\r\nworld');
|
|
114
|
+
});
|
|
115
|
+
it('handles CRLF with whitespace normalization', () => {
|
|
116
|
+
const result = findAllLinesTolerant('hello \r\nworld\r\n', 'hello\nworld');
|
|
117
|
+
expect(result).toHaveLength(1);
|
|
118
|
+
// 13 = 'hello \r' (7) + '\n' (1) + 'world' (5, trailing \r excluded)
|
|
119
|
+
expect(result[0].length).toBe(13);
|
|
120
|
+
const match = contentSlice('hello \r\nworld\r\n', result[0]);
|
|
121
|
+
expect(match).toBe('hello \r\nworld');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
function contentSlice(s, m) {
|
|
125
|
+
return s.slice(m.index, m.index + m.length);
|
|
126
|
+
}
|
|
127
|
+
describe('computeDiff', () => {
|
|
128
|
+
it('returns empty array for empty strings', () => {
|
|
129
|
+
expect(computeDiff('', '')).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
it('returns delete lines when new is empty', () => {
|
|
132
|
+
const result = computeDiff('a\nb', '');
|
|
133
|
+
expect(result).toHaveLength(2);
|
|
134
|
+
expect(result.every(l => l.type === 'delete')).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
it('returns insert lines when old is empty', () => {
|
|
137
|
+
const result = computeDiff('', 'a\nb');
|
|
138
|
+
expect(result).toHaveLength(2);
|
|
139
|
+
expect(result.every(l => l.type === 'insert')).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
it('returns equal lines for unchanged content', () => {
|
|
142
|
+
const result = computeDiff('hello\nworld', 'hello\nworld');
|
|
143
|
+
expect(result).toHaveLength(2);
|
|
144
|
+
expect(result.every(l => l.type === 'equal')).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
it('produces mixed diff for changed content', () => {
|
|
147
|
+
const result = computeDiff('hello\nworld', 'hi\nworld');
|
|
148
|
+
// hello→hi should be delete+insert, world should be equal
|
|
149
|
+
expect(result).toHaveLength(3);
|
|
150
|
+
expect(result[0].type).toBe('delete');
|
|
151
|
+
expect(result[0].content).toBe('hello');
|
|
152
|
+
expect(result[1].type).toBe('insert');
|
|
153
|
+
expect(result[1].content).toBe('hi');
|
|
154
|
+
expect(result[2].type).toBe('equal');
|
|
155
|
+
expect(result[2].content).toBe('world');
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
describe('renderDiffTerminal', () => {
|
|
159
|
+
it('returns empty string for empty input', () => {
|
|
160
|
+
expect(renderDiffTerminal([])).toBe('');
|
|
161
|
+
});
|
|
162
|
+
it('renders delete lines in red with - prefix', () => {
|
|
163
|
+
const lines = computeDiff('old', '');
|
|
164
|
+
const output = renderDiffTerminal(lines);
|
|
165
|
+
expect(output).toContain('\x1b[38;2;255;80;80m');
|
|
166
|
+
expect(output).toContain('- 1 old');
|
|
167
|
+
expect(output).toContain('\x1b[0m');
|
|
168
|
+
});
|
|
169
|
+
it('renders insert lines in green with + prefix', () => {
|
|
170
|
+
const lines = computeDiff('', 'new');
|
|
171
|
+
const output = renderDiffTerminal(lines);
|
|
172
|
+
expect(output).toContain('\x1b[38;2;0;200;100m');
|
|
173
|
+
expect(output).toContain('+ 1 new');
|
|
174
|
+
});
|
|
175
|
+
it('renders equal lines in dim with space prefix', () => {
|
|
176
|
+
const lines = computeDiff('same', 'same');
|
|
177
|
+
const output = renderDiffTerminal(lines);
|
|
178
|
+
expect(output).toContain('\x1b[38;2;100;100;100m');
|
|
179
|
+
expect(output).toContain(' same');
|
|
180
|
+
expect(output).toContain('\x1b[0m');
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
describe('generateDiff', () => {
|
|
184
|
+
it('returns terminal-colored unified diff with line numbers', () => {
|
|
185
|
+
const output = generateDiff('old', 'new');
|
|
186
|
+
expect(output).toContain('\x1b[38;2;255;80;80m');
|
|
187
|
+
expect(output).toContain('- 1 old');
|
|
188
|
+
expect(output).toContain('\x1b[38;2;0;200;100m');
|
|
189
|
+
expect(output).toContain('+ 1 new');
|
|
190
|
+
expect(output).toContain('1');
|
|
191
|
+
});
|
|
192
|
+
it('returns terminal-colored unified diff with custom start line number', () => {
|
|
193
|
+
const output = generateDiff('old', 'new', 10);
|
|
194
|
+
expect(output).toContain('- 10 old');
|
|
195
|
+
expect(output).toContain('+ 10 new');
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
describe('generateDiffWithContext', () => {
|
|
199
|
+
it('returns context line before match', () => {
|
|
200
|
+
const output = generateDiffWithContext('keep\nreplace\nkeep', 'replace', 'REPLACE', 5, 7);
|
|
201
|
+
expect(output).toContain('keep');
|
|
202
|
+
expect(output).toContain('REPLACE');
|
|
203
|
+
});
|
|
204
|
+
it('returns no context before when match starts at first line', () => {
|
|
205
|
+
const output = generateDiffWithContext('line1\nline2', 'line1', 'LINE1', 0, 5);
|
|
206
|
+
expect(output).toContain('LINE1');
|
|
207
|
+
const dimCount = (output.match(/\x1b\[38;2;100;100;100m/g) || []).length;
|
|
208
|
+
expect(dimCount).toBe(1);
|
|
209
|
+
});
|
|
210
|
+
it('returns no context after when match ends at last line', () => {
|
|
211
|
+
const output = generateDiffWithContext('line1\nline2', 'line2', 'LINE2', 6, 5);
|
|
212
|
+
expect(output).toContain('LINE2');
|
|
213
|
+
const dimCount = (output.match(/\x1b\[38;2;100;100;100m/g) || []).length;
|
|
214
|
+
expect(dimCount).toBe(1);
|
|
215
|
+
});
|
|
216
|
+
it('handles matchIndex at exact end of content (append behavior)', () => {
|
|
217
|
+
const output = generateDiffWithContext('line1\nline2', '', 'appended', 11, 0);
|
|
218
|
+
expect(output).toContain('appended');
|
|
219
|
+
// Should show line 2 context (the last line)
|
|
220
|
+
expect(output).toContain('line2');
|
|
221
|
+
});
|
|
222
|
+
it('handles match spanning only the last line with no trailing newline', () => {
|
|
223
|
+
const output = generateDiffWithContext('first\nlast', 'last', 'LAST', 6, 4);
|
|
224
|
+
expect(output).toContain('LAST');
|
|
225
|
+
expect(output).toContain('first');
|
|
226
|
+
// No context after since match is on the last line
|
|
227
|
+
const dimCount = (output.match(/\x1b\[38;2;100;100;100m/g) || []).length;
|
|
228
|
+
expect(dimCount).toBe(1);
|
|
229
|
+
});
|
|
230
|
+
it('handles fullContent with no trailing newline', () => {
|
|
231
|
+
const output = generateDiffWithContext('line1\nline2\nline3', 'line2', 'LINE2', 6, 5);
|
|
232
|
+
expect(output).toContain('LINE2');
|
|
233
|
+
expect(output).toContain('line1');
|
|
234
|
+
expect(output).toContain('line3');
|
|
235
|
+
});
|
|
236
|
+
it('handles match on single-line content with no trailing newline', () => {
|
|
237
|
+
const output = generateDiffWithContext('onlyline', 'onlyline', 'REPLACED', 0, 8);
|
|
238
|
+
expect(output).toContain('REPLACED');
|
|
239
|
+
// No context dim lines, only the diff
|
|
240
|
+
const dimCount = (output.match(/\x1b\[38;2;100;100;100m/g) || []).length;
|
|
241
|
+
expect(dimCount).toBe(0);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
describe('escapeHtml', () => {
|
|
245
|
+
it('escapes ampersands', () => {
|
|
246
|
+
expect(escapeHtml('a&b')).toBe('a&b');
|
|
247
|
+
});
|
|
248
|
+
it('escapes less-than', () => {
|
|
249
|
+
expect(escapeHtml('<tag>')).toBe('<tag>');
|
|
250
|
+
});
|
|
251
|
+
it('escapes greater-than', () => {
|
|
252
|
+
expect(escapeHtml('a > b')).toBe('a > b');
|
|
253
|
+
});
|
|
254
|
+
it('escapes double quotes', () => {
|
|
255
|
+
expect(escapeHtml('say "hello"')).toBe('say "hello"');
|
|
256
|
+
});
|
|
257
|
+
it('handles empty string', () => {
|
|
258
|
+
expect(escapeHtml('')).toBe('');
|
|
259
|
+
});
|
|
260
|
+
it('handles string with no special chars', () => {
|
|
261
|
+
expect(escapeHtml('hello world')).toBe('hello world');
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
describe('parseMarkdownEdit', () => {
|
|
265
|
+
it('parses single edit block', () => {
|
|
266
|
+
const input = '```edit\nfile: src/test.ts\nold: |\n hello\nnew: |\n world\n```';
|
|
267
|
+
const edits = parseMarkdownEdit(input);
|
|
268
|
+
expect(edits).toHaveLength(1);
|
|
269
|
+
expect(edits[0].file_path).toBe('src/test.ts');
|
|
270
|
+
// Pipe format preserves leading whitespace
|
|
271
|
+
expect(edits[0].old_string).toBe(' hello');
|
|
272
|
+
expect(edits[0].new_string).toBe(' world');
|
|
273
|
+
});
|
|
274
|
+
it('parses create file (empty old)', () => {
|
|
275
|
+
const input = '```edit\nfile: src/new.ts\nold:\nnew: |\n const x = 1\n```';
|
|
276
|
+
const edits = parseMarkdownEdit(input);
|
|
277
|
+
expect(edits).toHaveLength(1);
|
|
278
|
+
expect(edits[0].file_path).toBe('src/new.ts');
|
|
279
|
+
expect(edits[0].old_string).toBe('');
|
|
280
|
+
// Pipe format preserves leading whitespace
|
|
281
|
+
expect(edits[0].new_string).toBe(' const x = 1');
|
|
282
|
+
});
|
|
283
|
+
it('parses create file with pipe (old: | empty content)', () => {
|
|
284
|
+
const input = [
|
|
285
|
+
'```edit',
|
|
286
|
+
'file: src/new-pipe.ts',
|
|
287
|
+
'old: |',
|
|
288
|
+
'new: |',
|
|
289
|
+
' const x = 1',
|
|
290
|
+
'```',
|
|
291
|
+
].join('\n');
|
|
292
|
+
const edits = parseMarkdownEdit(input);
|
|
293
|
+
expect(edits).toHaveLength(1);
|
|
294
|
+
expect(edits[0].file_path).toBe('src/new-pipe.ts');
|
|
295
|
+
expect(edits[0].old_string).toBe('');
|
|
296
|
+
// Pipe format preserves leading whitespace
|
|
297
|
+
expect(edits[0].new_string).toBe(' const x = 1');
|
|
298
|
+
});
|
|
299
|
+
it('parses multiple edit blocks', () => {
|
|
300
|
+
const input = [
|
|
301
|
+
'```edit',
|
|
302
|
+
'file: a.ts',
|
|
303
|
+
'old: |',
|
|
304
|
+
' foo',
|
|
305
|
+
'new: |',
|
|
306
|
+
' bar',
|
|
307
|
+
'```',
|
|
308
|
+
'```edit',
|
|
309
|
+
'file: b.ts',
|
|
310
|
+
'old: |',
|
|
311
|
+
' baz',
|
|
312
|
+
'new: |',
|
|
313
|
+
' qux',
|
|
314
|
+
'```',
|
|
315
|
+
].join('\n');
|
|
316
|
+
const edits = parseMarkdownEdit(input);
|
|
317
|
+
expect(edits).toHaveLength(2);
|
|
318
|
+
expect(edits[0].file_path).toBe('a.ts');
|
|
319
|
+
// Pipe format preserves leading whitespace
|
|
320
|
+
expect(edits[0].old_string).toBe(' foo');
|
|
321
|
+
expect(edits[0].new_string).toBe(' bar');
|
|
322
|
+
expect(edits[1].file_path).toBe('b.ts');
|
|
323
|
+
expect(edits[1].old_string).toBe(' baz');
|
|
324
|
+
expect(edits[1].new_string).toBe(' qux');
|
|
325
|
+
});
|
|
326
|
+
it('returns empty array when no edit blocks found', () => {
|
|
327
|
+
expect(parseMarkdownEdit('no code blocks here')).toEqual([]);
|
|
328
|
+
});
|
|
329
|
+
it('returns empty array when block has no file path', () => {
|
|
330
|
+
const input = '```edit\nold: |\n a\nnew: |\n b\n```';
|
|
331
|
+
expect(parseMarkdownEdit(input)).toEqual([]);
|
|
332
|
+
});
|
|
333
|
+
it('parses edit block without pipe (single line)', () => {
|
|
334
|
+
const input = '```edit\nfile: a.ts\nold: hello\nnew: world\n```';
|
|
335
|
+
const edits = parseMarkdownEdit(input);
|
|
336
|
+
expect(edits).toHaveLength(1);
|
|
337
|
+
// Non-pipe format: "old: hello" �?content after "old: " includes the leading space
|
|
338
|
+
expect(edits[0].old_string).toBe(' hello');
|
|
339
|
+
expect(edits[0].new_string).toBe(' world');
|
|
340
|
+
});
|
|
341
|
+
it('parses old with pipe-newline format', () => {
|
|
342
|
+
const input = '```edit\nfile: a.ts\nold: |\n multi\n line\nnew: |\n replaced\n```';
|
|
343
|
+
const edits = parseMarkdownEdit(input);
|
|
344
|
+
expect(edits).toHaveLength(1);
|
|
345
|
+
// Pipe format preserves leading whitespace (only strips the first \n)
|
|
346
|
+
expect(edits[0].old_string).toBe(' multi\n line');
|
|
347
|
+
expect(edits[0].new_string).toBe(' replaced');
|
|
348
|
+
});
|
|
349
|
+
it('handles extra whitespace after ```edit', () => {
|
|
350
|
+
const input = '```edit \nfile: a.ts\nold: |\n x\nnew: |\n y\n```';
|
|
351
|
+
const edits = parseMarkdownEdit(input);
|
|
352
|
+
expect(edits).toHaveLength(1);
|
|
353
|
+
expect(edits[0].file_path).toBe('a.ts');
|
|
354
|
+
});
|
|
355
|
+
it('ignores non-edit code blocks', () => {
|
|
356
|
+
const input = '```typescript\nconst x = 1\n```';
|
|
357
|
+
expect(parseMarkdownEdit(input)).toEqual([]);
|
|
358
|
+
});
|
|
359
|
+
it('handles mixed content with edit blocks', () => {
|
|
360
|
+
const input = [
|
|
361
|
+
'some text before',
|
|
362
|
+
'',
|
|
363
|
+
'```edit',
|
|
364
|
+
'file: a.ts',
|
|
365
|
+
'old: |',
|
|
366
|
+
' hello',
|
|
367
|
+
'new: |',
|
|
368
|
+
' hi',
|
|
369
|
+
'```',
|
|
370
|
+
'some text after',
|
|
371
|
+
].join('\n');
|
|
372
|
+
const edits = parseMarkdownEdit(input);
|
|
373
|
+
expect(edits).toHaveLength(1);
|
|
374
|
+
expect(edits[0].file_path).toBe('a.ts');
|
|
375
|
+
});
|
|
376
|
+
it('handles early-``` closing (new: outside the block)', () => {
|
|
377
|
+
// Model may close ``` before new: �?common mistake
|
|
378
|
+
const input = [
|
|
379
|
+
'```edit',
|
|
380
|
+
'file: a.ts',
|
|
381
|
+
'old: |',
|
|
382
|
+
' hello',
|
|
383
|
+
'```',
|
|
384
|
+
'new: |',
|
|
385
|
+
' world',
|
|
386
|
+
'```',
|
|
387
|
+
].join('\n');
|
|
388
|
+
const edits = parseMarkdownEdit(input);
|
|
389
|
+
expect(edits).toHaveLength(1);
|
|
390
|
+
expect(edits[0].file_path).toBe('a.ts');
|
|
391
|
+
expect(edits[0].old_string).toBe(' hello');
|
|
392
|
+
expect(edits[0].new_string).toBe(' world');
|
|
393
|
+
});
|
|
394
|
+
it('handles raw file:/old:/new: without ``` markers', () => {
|
|
395
|
+
// Model outputs edit without ```edit / ``` wrappers
|
|
396
|
+
const input = 'file: a.ts\nold: |\n hello\nnew: |\n world\n';
|
|
397
|
+
const edits = parseMarkdownEdit(input);
|
|
398
|
+
expect(edits).toHaveLength(1);
|
|
399
|
+
expect(edits[0].file_path).toBe('a.ts');
|
|
400
|
+
expect(edits[0].old_string).toBe(' hello');
|
|
401
|
+
expect(edits[0].new_string).toBe(' world');
|
|
402
|
+
});
|
|
403
|
+
it('handles edit block without any ``` markers (raw file:/old:/new: content)', () => {
|
|
404
|
+
const input = 'file: a.ts\nold: |\n hello\nnew: |\n world\n';
|
|
405
|
+
const edits = parseMarkdownEdit(input);
|
|
406
|
+
expect(edits).toHaveLength(1);
|
|
407
|
+
expect(edits[0].file_path).toBe('a.ts');
|
|
408
|
+
expect(edits[0].old_string).toBe(' hello');
|
|
409
|
+
expect(edits[0].new_string).toBe(' world');
|
|
410
|
+
});
|
|
411
|
+
it('handles old:| then new: (no pipe on new)', () => {
|
|
412
|
+
const input = '```edit\nfile: a.ts\nold: |\n hello\nnew:\n world\n```';
|
|
413
|
+
const edits = parseMarkdownEdit(input);
|
|
414
|
+
expect(edits).toHaveLength(1);
|
|
415
|
+
expect(edits[0].file_path).toBe('a.ts');
|
|
416
|
+
expect(edits[0].new_string).toBe(' world');
|
|
417
|
+
});
|
|
418
|
+
it('handles |2 pipe-with-digit syntax', () => {
|
|
419
|
+
const input = '```edit\nfile: a.ts\nold: |2\n hello\n world\nnew: |1\n hi\n```';
|
|
420
|
+
const edits = parseMarkdownEdit(input);
|
|
421
|
+
expect(edits).toHaveLength(1);
|
|
422
|
+
expect(edits[0].file_path).toBe('a.ts');
|
|
423
|
+
expect(edits[0].old_string).toBe(' hello\n world');
|
|
424
|
+
expect(edits[0].new_string).toBe(' hi');
|
|
425
|
+
});
|
|
426
|
+
it('handles backticks inside edit content', () => {
|
|
427
|
+
const input = '```edit\nfile: a.ts\nold: |\n some `code` here\nnew: |\n replaced `code`\n```';
|
|
428
|
+
const edits = parseMarkdownEdit(input);
|
|
429
|
+
expect(edits).toHaveLength(1);
|
|
430
|
+
expect(edits[0].file_path).toBe('a.ts');
|
|
431
|
+
expect(edits[0].old_string).toBe(' some `code` here');
|
|
432
|
+
expect(edits[0].new_string).toBe(' replaced `code`');
|
|
433
|
+
});
|
|
434
|
+
it('handles old_string with trailing whitespace line', () => {
|
|
435
|
+
const input = '```edit\nfile: a.ts\nold: |\n hello\n \nnew: |\n hi\n```';
|
|
436
|
+
const edits = parseMarkdownEdit(input);
|
|
437
|
+
expect(edits).toHaveLength(1);
|
|
438
|
+
expect(edits[0].old_string).toBe(' hello\n ');
|
|
439
|
+
});
|
|
440
|
+
it('handles new_string with empty content (deletion)', () => {
|
|
441
|
+
const input = '```edit\nfile: a.ts\nold: |\n remove me\nnew:\n```';
|
|
442
|
+
const edits = parseMarkdownEdit(input);
|
|
443
|
+
expect(edits).toHaveLength(1);
|
|
444
|
+
expect(edits[0].old_string).toBe(' remove me');
|
|
445
|
+
expect(edits[0].new_string).toBe('');
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
// ── Markdown format integration tests ────────────────────────────────────
|
|
449
|
+
describe('edit tool �?markdown format', () => {
|
|
8
450
|
let tmpDir;
|
|
9
451
|
let applier;
|
|
10
452
|
beforeAll(() => {
|
|
11
453
|
tmpDir = makeTempDir();
|
|
12
454
|
fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'line one\nline two\nline three\n');
|
|
13
|
-
|
|
14
|
-
applier
|
|
455
|
+
applier = makeApplier();
|
|
456
|
+
applier.markRead(path.join(tmpDir, 'a.txt'));
|
|
15
457
|
});
|
|
16
458
|
afterAll(() => {
|
|
17
459
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
18
460
|
});
|
|
19
461
|
const tool = () => createEditTool(applier, tmpDir);
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
462
|
+
it('replaces exact string via markdown format', async () => {
|
|
463
|
+
const input = '```edit\nfile: a.txt\nold: |\n line two\nnew: |\n line TWO\n```';
|
|
464
|
+
const r = await tool().execute({ content: input });
|
|
465
|
+
expect(r.success).toBe(true);
|
|
466
|
+
expect(fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8')).toContain('line TWO');
|
|
467
|
+
});
|
|
468
|
+
it('replaces multi-line via markdown format', async () => {
|
|
469
|
+
// Use a fresh file to avoid cross-test contamination
|
|
470
|
+
const file = path.join(tmpDir, 'multi-md.txt');
|
|
471
|
+
fs.writeFileSync(file, 'line one\nline two\nline three\n');
|
|
472
|
+
applier.markRead(file);
|
|
473
|
+
const input = [
|
|
474
|
+
'```edit',
|
|
475
|
+
'file: multi-md.txt',
|
|
476
|
+
'old: |',
|
|
477
|
+
' line one',
|
|
478
|
+
' line two',
|
|
479
|
+
'new: |',
|
|
480
|
+
' line 1',
|
|
481
|
+
' line 2',
|
|
482
|
+
'```',
|
|
483
|
+
].join('\n');
|
|
484
|
+
const r = await tool().execute({ content: input });
|
|
485
|
+
expect(r.success).toBe(true);
|
|
486
|
+
// Pipe format preserves leading whitespace from the block
|
|
487
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
488
|
+
expect(content).toContain(' line 1');
|
|
489
|
+
expect(content).toContain(' line 2');
|
|
490
|
+
});
|
|
491
|
+
it('creates file via markdown format', async () => {
|
|
492
|
+
const input = '```edit\nfile: markdown-new.txt\nold:\nnew: |\n created via markdown\n```';
|
|
493
|
+
const r = await tool().execute({ content: input });
|
|
494
|
+
expect(r.success).toBe(true);
|
|
495
|
+
// Pipe format preserves leading whitespace from the block
|
|
496
|
+
expect(fs.readFileSync(path.join(tmpDir, 'markdown-new.txt'), 'utf8')).toBe(' created via markdown');
|
|
497
|
+
});
|
|
498
|
+
it('reports parse error for malformed markdown', async () => {
|
|
499
|
+
const r = await tool().execute({ content: 'not a valid edit block' });
|
|
500
|
+
expect(r.success).toBe(false);
|
|
501
|
+
expect(r.error).toContain('Failed to parse');
|
|
502
|
+
});
|
|
503
|
+
it('applies multiple markdown blocks', async () => {
|
|
504
|
+
fs.writeFileSync(path.join(tmpDir, 'multi-a.txt'), 'foo\nbar\n');
|
|
505
|
+
fs.writeFileSync(path.join(tmpDir, 'multi-b.txt'), 'baz\nqux\n');
|
|
506
|
+
applier.markRead(path.join(tmpDir, 'multi-a.txt'));
|
|
507
|
+
applier.markRead(path.join(tmpDir, 'multi-b.txt'));
|
|
508
|
+
const input = [
|
|
509
|
+
'```edit',
|
|
510
|
+
'file: multi-a.txt',
|
|
511
|
+
'old: |',
|
|
512
|
+
' foo',
|
|
513
|
+
'new: |',
|
|
514
|
+
' FOO',
|
|
515
|
+
'```',
|
|
516
|
+
'```edit',
|
|
517
|
+
'file: multi-b.txt',
|
|
518
|
+
'old: |',
|
|
519
|
+
' baz',
|
|
520
|
+
'new: |',
|
|
521
|
+
' BAZ',
|
|
522
|
+
'```',
|
|
523
|
+
].join('\n');
|
|
524
|
+
const r = await tool().execute({ content: input });
|
|
525
|
+
expect(r.success).toBe(true);
|
|
526
|
+
expect(fs.readFileSync(path.join(tmpDir, 'multi-a.txt'), 'utf8')).toContain('FOO');
|
|
527
|
+
expect(fs.readFileSync(path.join(tmpDir, 'multi-b.txt'), 'utf8')).toContain('BAZ');
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
// ── Advanced edge cases ──────────────────────────────────────────────────
|
|
531
|
+
describe('edit tool �?edge cases', () => {
|
|
532
|
+
let tmpDir;
|
|
533
|
+
let applier;
|
|
534
|
+
beforeAll(() => {
|
|
535
|
+
tmpDir = makeTempDir();
|
|
536
|
+
applier = makeApplier();
|
|
537
|
+
});
|
|
538
|
+
afterAll(() => {
|
|
539
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
540
|
+
});
|
|
541
|
+
const tool = () => createEditTool(applier, tmpDir);
|
|
542
|
+
describe('special characters', () => {
|
|
543
|
+
it('handles unicode characters', async () => {
|
|
544
|
+
const file = path.join(tmpDir, 'unicode.txt');
|
|
545
|
+
fs.writeFileSync(file, 'héllo wörld\n');
|
|
546
|
+
applier.markRead(file);
|
|
26
547
|
const r = await tool().execute({
|
|
27
|
-
edits: [{ file_path: '
|
|
548
|
+
edits: [{ file_path: 'unicode.txt', old_string: 'héllo wörld', new_string: 'HELLO WORLD' }],
|
|
28
549
|
});
|
|
29
550
|
expect(r.success).toBe(true);
|
|
30
|
-
expect(fs.readFileSync(
|
|
551
|
+
expect(fs.readFileSync(file, 'utf8')).toBe('HELLO WORLD\n');
|
|
31
552
|
});
|
|
32
|
-
it('
|
|
553
|
+
it('handles emoji characters', async () => {
|
|
554
|
+
const file = path.join(tmpDir, 'emoji.txt');
|
|
555
|
+
fs.writeFileSync(file, 'hello 👍 world\n');
|
|
556
|
+
applier.markRead(file);
|
|
33
557
|
const r = await tool().execute({
|
|
34
558
|
edits: [
|
|
35
|
-
{
|
|
36
|
-
file_path: 'a.txt',
|
|
37
|
-
old_string: 'line one\nline TWO',
|
|
38
|
-
new_string: 'line 1\nline 2',
|
|
39
|
-
},
|
|
559
|
+
{ file_path: 'emoji.txt', old_string: 'hello 👍 world', new_string: 'hello world' },
|
|
40
560
|
],
|
|
41
561
|
});
|
|
42
562
|
expect(r.success).toBe(true);
|
|
43
|
-
|
|
44
|
-
expect(content).toContain('line 1');
|
|
45
|
-
expect(content).toContain('line 2');
|
|
563
|
+
expect(fs.readFileSync(file, 'utf8')).toBe('hello world\n');
|
|
46
564
|
});
|
|
47
|
-
it('
|
|
565
|
+
it('handles Chinese characters', async () => {
|
|
566
|
+
const file = path.join(tmpDir, 'chinese.txt');
|
|
567
|
+
fs.writeFileSync(file, '你好世界\n');
|
|
568
|
+
applier.markRead(file);
|
|
48
569
|
const r = await tool().execute({
|
|
49
|
-
edits: [{ file_path: '
|
|
570
|
+
edits: [{ file_path: 'chinese.txt', old_string: '你好世界', new_string: 'Hello World' }],
|
|
50
571
|
});
|
|
51
|
-
expect(r.success).toBe(
|
|
52
|
-
expect(
|
|
53
|
-
});
|
|
54
|
-
it('reports duplicate old_string', async () => {
|
|
55
|
-
const r = await tool().execute({
|
|
56
|
-
edits: [{ file_path: 'dup.txt', old_string: 'abc', new_string: 'xyz' }],
|
|
57
|
-
});
|
|
58
|
-
expect(r.success).toBe(false);
|
|
59
|
-
expect(r.error).toContain('MULTIPLE times');
|
|
60
|
-
});
|
|
61
|
-
it('auto-corrects flat file_path/old_string/new_string into edits array', async () => {
|
|
62
|
-
// The tool auto-corrects { file_path, old_string, new_string }
|
|
63
|
-
// into { edits: [{ file_path, old_string, new_string }] }
|
|
64
|
-
// So the error should be about file not found, not 'edits' missing
|
|
65
|
-
const r = await tool().execute({ file_path: 'x', old_string: 'y', new_string: 'z' });
|
|
66
|
-
expect(r.success).toBe(false);
|
|
67
|
-
expect(r.error).toContain('not found');
|
|
572
|
+
expect(r.success).toBe(true);
|
|
573
|
+
expect(fs.readFileSync(file, 'utf8')).toBe('Hello World\n');
|
|
68
574
|
});
|
|
69
|
-
it('
|
|
575
|
+
it('handles tab characters', async () => {
|
|
576
|
+
const file = path.join(tmpDir, 'tabs.txt');
|
|
577
|
+
fs.writeFileSync(file, 'a\tb\nc\td\n');
|
|
578
|
+
applier.markRead(file);
|
|
70
579
|
const r = await tool().execute({
|
|
71
|
-
edits: [{ file_path: '
|
|
580
|
+
edits: [{ file_path: 'tabs.txt', old_string: 'a\tb', new_string: 'A\tB' }],
|
|
72
581
|
});
|
|
73
|
-
expect(r.success).toBe(
|
|
74
|
-
expect(
|
|
582
|
+
expect(r.success).toBe(true);
|
|
583
|
+
expect(fs.readFileSync(file, 'utf8')).toBe('A\tB\nc\td\n');
|
|
75
584
|
});
|
|
76
585
|
});
|
|
77
|
-
describe('
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
586
|
+
describe('empty and boundary files', () => {
|
|
587
|
+
it('edits a file with single line', async () => {
|
|
588
|
+
const file = path.join(tmpDir, 'single.txt');
|
|
589
|
+
fs.writeFileSync(file, 'only line\n');
|
|
590
|
+
applier.markRead(file);
|
|
82
591
|
const r = await tool().execute({
|
|
83
|
-
edits: [{ file_path: '
|
|
592
|
+
edits: [{ file_path: 'single.txt', old_string: 'only line', new_string: 'replaced line' }],
|
|
84
593
|
});
|
|
85
|
-
expect(r.success).toBe(
|
|
86
|
-
expect(
|
|
87
|
-
expect(r.error).toContain('old_string');
|
|
88
|
-
});
|
|
89
|
-
it('rejects edit missing new_string', async () => {
|
|
90
|
-
const r = await tool().execute({
|
|
91
|
-
edits: [{ file_path: 'a.txt', old_string: 'original' }],
|
|
92
|
-
});
|
|
93
|
-
expect(r.success).toBe(false);
|
|
94
|
-
expect(r.error).toContain('missing');
|
|
95
|
-
expect(r.error).toContain('new_string');
|
|
594
|
+
expect(r.success).toBe(true);
|
|
595
|
+
expect(fs.readFileSync(file, 'utf8')).toBe('replaced line\n');
|
|
96
596
|
});
|
|
97
|
-
it('
|
|
597
|
+
it('edits a file with no trailing newline', async () => {
|
|
598
|
+
const file = path.join(tmpDir, 'no-nl.txt');
|
|
599
|
+
fs.writeFileSync(file, 'line one\nline two');
|
|
600
|
+
applier.markRead(file);
|
|
98
601
|
const r = await tool().execute({
|
|
99
|
-
edits: [{ old_string: '
|
|
602
|
+
edits: [{ file_path: 'no-nl.txt', old_string: 'line two', new_string: 'line TWO' }],
|
|
100
603
|
});
|
|
101
|
-
expect(r.success).toBe(
|
|
102
|
-
expect(
|
|
103
|
-
expect(r.error).toContain('file_path');
|
|
604
|
+
expect(r.success).toBe(true);
|
|
605
|
+
expect(fs.readFileSync(file, 'utf8')).toBe('line one\nline TWO');
|
|
104
606
|
});
|
|
105
|
-
it('
|
|
607
|
+
it('appends to end of file', async () => {
|
|
608
|
+
const file = path.join(tmpDir, 'append.txt');
|
|
609
|
+
fs.writeFileSync(file, 'line one\n');
|
|
610
|
+
applier.markRead(file);
|
|
106
611
|
const r = await tool().execute({
|
|
107
612
|
edits: [
|
|
108
|
-
{
|
|
109
|
-
|
|
110
|
-
|
|
613
|
+
{
|
|
614
|
+
file_path: 'append.txt',
|
|
615
|
+
old_string: 'line one',
|
|
616
|
+
new_string: 'line one\nline two',
|
|
617
|
+
},
|
|
111
618
|
],
|
|
112
619
|
});
|
|
113
|
-
expect(r.success).toBe(
|
|
114
|
-
|
|
115
|
-
expect(r.error).toContain('2 of 3');
|
|
116
|
-
expect(r.error).toContain('edit #2');
|
|
117
|
-
expect(r.error).toContain('edit #3');
|
|
118
|
-
// Should mention not to split across edits
|
|
119
|
-
expect(r.error).toContain('COMPLETE');
|
|
120
|
-
});
|
|
121
|
-
it('reports which fields each malformed edit has', async () => {
|
|
122
|
-
const r = await tool().execute({
|
|
123
|
-
edits: [{ file_path: 'a.txt', new_string: 'c' }],
|
|
124
|
-
});
|
|
125
|
-
expect(r.success).toBe(false);
|
|
126
|
-
expect(r.error).toContain('has: file_path, new_string');
|
|
127
|
-
});
|
|
128
|
-
it('includes rawInput in the error message', async () => {
|
|
129
|
-
const r = await tool().execute({
|
|
130
|
-
edits: [{ file_path: 'a.txt', new_string: 'c' }],
|
|
131
|
-
});
|
|
132
|
-
expect(r.success).toBe(false);
|
|
133
|
-
expect(r.error).toContain('a.txt');
|
|
134
|
-
expect(r.error).toContain('new_string');
|
|
135
|
-
});
|
|
136
|
-
});
|
|
137
|
-
describe('auto-correction', () => {
|
|
138
|
-
it('wraps top-level file_path/old_string/new_string into edits array', async () => {
|
|
139
|
-
const r = await tool().execute({
|
|
140
|
-
file_path: 'nonexistent.ts',
|
|
141
|
-
old_string: 'x',
|
|
142
|
-
new_string: 'y',
|
|
143
|
-
});
|
|
144
|
-
// Should be auto-corrected so the error is about file not found, not about missing edits
|
|
145
|
-
expect(r.success).toBe(false);
|
|
146
|
-
expect(r.error).toContain('not found');
|
|
620
|
+
expect(r.success).toBe(true);
|
|
621
|
+
expect(fs.readFileSync(file, 'utf8')).toBe('line one\nline two\n');
|
|
147
622
|
});
|
|
148
|
-
it('
|
|
623
|
+
it('replaces entire file content', async () => {
|
|
624
|
+
const file = path.join(tmpDir, 'full-replace.txt');
|
|
625
|
+
fs.writeFileSync(file, 'old content\n');
|
|
626
|
+
applier.markRead(file);
|
|
149
627
|
const r = await tool().execute({
|
|
150
|
-
|
|
151
|
-
|
|
628
|
+
edits: [
|
|
629
|
+
{
|
|
630
|
+
file_path: 'full-replace.txt',
|
|
631
|
+
old_string: 'old content',
|
|
632
|
+
new_string: 'brand new content',
|
|
633
|
+
},
|
|
634
|
+
],
|
|
152
635
|
});
|
|
153
|
-
// Auto-corrected to { edits: [{ file_path, old_string: '', new_string: '' }] }
|
|
154
|
-
// This creates a file with empty content
|
|
155
|
-
expect(r.success).toBe(true);
|
|
156
|
-
expect(fs.existsSync(path.join(tmpDir, 'new.txt'))).toBe(true);
|
|
157
|
-
expect(fs.readFileSync(path.join(tmpDir, 'new.txt'), 'utf8')).toBe('');
|
|
158
|
-
fs.unlinkSync(path.join(tmpDir, 'new.txt'));
|
|
159
|
-
});
|
|
160
|
-
it('handles edits passed directly as an array (not wrapped)', async () => {
|
|
161
|
-
// @ts-expect-error — runtime accepts array, TS expects object
|
|
162
|
-
const r = await tool().execute([
|
|
163
|
-
{ file_path: 'a.txt', old_string: 'test', new_string: 'TEST' },
|
|
164
|
-
]);
|
|
165
636
|
expect(r.success).toBe(true);
|
|
637
|
+
expect(fs.readFileSync(file, 'utf8')).toBe('brand new content\n');
|
|
166
638
|
});
|
|
167
|
-
it('
|
|
639
|
+
it('rejects empty old_string on existing non-empty file', async () => {
|
|
640
|
+
const file = path.join(tmpDir, 'empty-old-on-existing.txt');
|
|
641
|
+
fs.writeFileSync(file, 'some content\n');
|
|
642
|
+
applier.markRead(file);
|
|
168
643
|
const r = await tool().execute({
|
|
169
|
-
|
|
170
|
-
|
|
644
|
+
edits: [
|
|
645
|
+
{
|
|
646
|
+
file_path: 'empty-old-on-existing.txt',
|
|
647
|
+
old_string: '',
|
|
648
|
+
new_string: 'should not replace',
|
|
649
|
+
},
|
|
650
|
+
],
|
|
171
651
|
});
|
|
172
|
-
expect(r.success).toBe(
|
|
173
|
-
expect(
|
|
174
|
-
fs.unlinkSync(path.join(tmpDir, 'auto-create.txt'));
|
|
652
|
+
expect(r.success).toBe(false);
|
|
653
|
+
expect(r.error).toContain('already exists');
|
|
175
654
|
});
|
|
176
|
-
it('
|
|
655
|
+
it('rejects create file when file already exists with empty content', async () => {
|
|
656
|
+
const file = path.join(tmpDir, 'empty-existing-file.txt');
|
|
657
|
+
fs.writeFileSync(file, '');
|
|
658
|
+
applier.markRead(file);
|
|
177
659
|
const r = await tool().execute({
|
|
178
|
-
|
|
660
|
+
edits: [
|
|
661
|
+
{
|
|
662
|
+
file_path: 'empty-existing-file.txt',
|
|
663
|
+
old_string: '',
|
|
664
|
+
new_string: 'add content',
|
|
665
|
+
},
|
|
666
|
+
],
|
|
179
667
|
});
|
|
180
|
-
expect(r.success).toBe(true);
|
|
181
|
-
expect(fs.existsSync(path.join(tmpDir, 'empty-file.txt'))).toBe(true);
|
|
182
|
-
expect(fs.readFileSync(path.join(tmpDir, 'empty-file.txt'), 'utf8')).toBe('');
|
|
183
|
-
fs.unlinkSync(path.join(tmpDir, 'empty-file.txt'));
|
|
184
|
-
});
|
|
185
|
-
it('rejects nonsensical top-level keys with helpful error', async () => {
|
|
186
|
-
const r = await tool().execute({ foo: 'bar' });
|
|
187
668
|
expect(r.success).toBe(false);
|
|
188
|
-
expect(r.error).toContain('
|
|
189
|
-
expect(r.error).toContain('array');
|
|
669
|
+
expect(r.error).toContain('already exists');
|
|
190
670
|
});
|
|
191
671
|
});
|
|
192
|
-
describe('
|
|
193
|
-
|
|
194
|
-
fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'one\ntwo\nthree\nfour\n');
|
|
195
|
-
fs.writeFileSync(path.join(tmpDir, 'b.txt'), 'foo\nbar\nbaz\n');
|
|
196
|
-
});
|
|
197
|
-
it('applies multiple edits to one file', async () => {
|
|
672
|
+
describe('file path edge cases', () => {
|
|
673
|
+
it('creates file in subdirectory that does not exist', async () => {
|
|
198
674
|
const r = await tool().execute({
|
|
199
675
|
edits: [
|
|
200
|
-
{
|
|
201
|
-
|
|
676
|
+
{
|
|
677
|
+
file_path: 'deep/nested/dir/new-file.txt',
|
|
678
|
+
old_string: '',
|
|
679
|
+
new_string: 'created in nested dir',
|
|
680
|
+
},
|
|
202
681
|
],
|
|
203
682
|
});
|
|
204
683
|
expect(r.success).toBe(true);
|
|
205
|
-
|
|
206
|
-
expect(content).toContain('ONE');
|
|
207
|
-
expect(content).toContain('THREE');
|
|
208
|
-
expect(content).toContain('two');
|
|
684
|
+
expect(fs.readFileSync(path.join(tmpDir, 'deep/nested/dir/new-file.txt'), 'utf8')).toBe('created in nested dir');
|
|
209
685
|
});
|
|
210
|
-
it('
|
|
686
|
+
it('handles file path with special characters', async () => {
|
|
211
687
|
const r = await tool().execute({
|
|
212
688
|
edits: [
|
|
213
|
-
{
|
|
214
|
-
|
|
689
|
+
{
|
|
690
|
+
file_path: 'file-with-dashes_and_underscores.txt',
|
|
691
|
+
old_string: '',
|
|
692
|
+
new_string: 'special path',
|
|
693
|
+
},
|
|
215
694
|
],
|
|
216
695
|
});
|
|
217
696
|
expect(r.success).toBe(true);
|
|
218
|
-
expect(fs.readFileSync(path.join(tmpDir, '
|
|
219
|
-
expect(fs.readFileSync(path.join(tmpDir, 'b.txt'), 'utf8')).toContain('FOO');
|
|
220
|
-
expect(fs.readFileSync(path.join(tmpDir, 'b.txt'), 'utf8')).toContain('BAR');
|
|
697
|
+
expect(fs.readFileSync(path.join(tmpDir, 'file-with-dashes_and_underscores.txt'), 'utf8')).toBe('special path');
|
|
221
698
|
});
|
|
222
|
-
it('
|
|
223
|
-
const original = fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8');
|
|
699
|
+
it('rejects path traversal with Windows drive letter', async () => {
|
|
224
700
|
const r = await tool().execute({
|
|
225
701
|
edits: [
|
|
226
|
-
{
|
|
227
|
-
|
|
702
|
+
{
|
|
703
|
+
file_path: 'C:\\Windows\\system32\\drivers\\etc\\hosts',
|
|
704
|
+
old_string: '',
|
|
705
|
+
new_string: 'should not write',
|
|
706
|
+
},
|
|
228
707
|
],
|
|
229
708
|
});
|
|
230
709
|
expect(r.success).toBe(false);
|
|
231
|
-
expect(r.error).toContain('
|
|
232
|
-
expect(fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8')).toBe(original);
|
|
710
|
+
expect(r.error).toContain('traversal');
|
|
233
711
|
});
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
712
|
+
});
|
|
713
|
+
describe('path traversal security', () => {
|
|
714
|
+
it('rejects path traversal with ../', async () => {
|
|
237
715
|
const r = await tool().execute({
|
|
238
716
|
edits: [
|
|
239
|
-
{
|
|
240
|
-
|
|
717
|
+
{
|
|
718
|
+
file_path: '../../outside-cwd.txt',
|
|
719
|
+
old_string: 'hello',
|
|
720
|
+
new_string: 'world',
|
|
721
|
+
},
|
|
241
722
|
],
|
|
242
723
|
});
|
|
243
724
|
expect(r.success).toBe(false);
|
|
244
|
-
expect(
|
|
245
|
-
expect(fs.readFileSync(path.join(tmpDir, 'b.txt'), 'utf8')).toBe(origB);
|
|
246
|
-
});
|
|
247
|
-
it('rejects empty edits array', async () => {
|
|
248
|
-
const r = await tool().execute({ edits: [] });
|
|
249
|
-
expect(r.success).toBe(false);
|
|
250
|
-
expect(r.error).toContain('empty');
|
|
725
|
+
expect(r.error).toContain('traversal');
|
|
251
726
|
});
|
|
252
|
-
|
|
253
|
-
describe('diff output', () => {
|
|
254
|
-
beforeAll(() => {
|
|
255
|
-
fs.writeFileSync(path.join(tmpDir, 'diff-test.txt'), 'line one\nline two\nline three\n');
|
|
256
|
-
});
|
|
257
|
-
it('shows green + for new file creation (no red - lines)', async () => {
|
|
258
|
-
const r = await tool().execute({
|
|
259
|
-
edits: [{ file_path: 'diff-new.txt', old_string: '', new_string: 'hello\nworld' }],
|
|
260
|
-
});
|
|
261
|
-
expect(r.success).toBe(true);
|
|
262
|
-
// Should contain green ANSI code for added lines
|
|
263
|
-
expect(r.output).toContain('\x1b[38;2;0;200;100m'); // green
|
|
264
|
-
expect(r.output).toContain('hello');
|
|
265
|
-
expect(r.output).toContain('world');
|
|
266
|
-
// Should NOT contain red ANSI code (no removed lines)
|
|
267
|
-
expect(r.output).not.toContain('\x1b[38;2;255;80;80m');
|
|
268
|
-
expect(r.output).toContain('Created');
|
|
269
|
-
});
|
|
270
|
-
it('shows red - for removed lines and green + for added lines on replacement', async () => {
|
|
727
|
+
it('rejects path traversal with deep nested ../', async () => {
|
|
271
728
|
const r = await tool().execute({
|
|
272
729
|
edits: [
|
|
273
730
|
{
|
|
274
|
-
file_path: '
|
|
275
|
-
old_string: '
|
|
276
|
-
new_string: '
|
|
731
|
+
file_path: 'subdir/../../../outside-cwd.txt',
|
|
732
|
+
old_string: '',
|
|
733
|
+
new_string: 'should not create',
|
|
277
734
|
},
|
|
278
735
|
],
|
|
279
736
|
});
|
|
280
|
-
expect(r.success).toBe(
|
|
281
|
-
|
|
282
|
-
expect(r.output).toContain('\x1b[38;2;255;80;80m');
|
|
283
|
-
expect(r.output).toContain('line two');
|
|
284
|
-
// Should contain green ANSI code for added content
|
|
285
|
-
expect(r.output).toContain('\x1b[38;2;0;200;100m');
|
|
286
|
-
expect(r.output).toContain('line TWO');
|
|
287
|
-
expect(r.output).toContain('Edited');
|
|
737
|
+
expect(r.success).toBe(false);
|
|
738
|
+
expect(r.error).toContain('traversal');
|
|
288
739
|
});
|
|
289
|
-
it('
|
|
740
|
+
it('rejects absolute path', async () => {
|
|
290
741
|
const r = await tool().execute({
|
|
291
742
|
edits: [
|
|
292
743
|
{
|
|
293
|
-
file_path: '
|
|
294
|
-
old_string: '
|
|
295
|
-
new_string: '
|
|
744
|
+
file_path: '/etc/passwd',
|
|
745
|
+
old_string: '',
|
|
746
|
+
new_string: 'should not write',
|
|
296
747
|
},
|
|
297
748
|
],
|
|
298
749
|
});
|
|
299
|
-
expect(r.success).toBe(
|
|
300
|
-
|
|
301
|
-
expect(r.output).toContain('\x1b[38;2;255;80;80mline one');
|
|
302
|
-
expect(r.output).toContain('\x1b[38;2;255;80;80mline TWO');
|
|
303
|
-
// Added lines (green)
|
|
304
|
-
expect(r.output).toContain('\x1b[38;2;0;200;100mline 1');
|
|
305
|
-
expect(r.output).toContain('\x1b[38;2;0;200;100mline 2');
|
|
750
|
+
expect(r.success).toBe(false);
|
|
751
|
+
expect(r.error).toContain('traversal');
|
|
306
752
|
});
|
|
307
|
-
it('
|
|
308
|
-
fs.writeFileSync(path.join(tmpDir, 'crlf-test.txt'), 'a\\r\\nb\\r\\nc\\r\\n');
|
|
753
|
+
it('allows normal paths inside cwd', async () => {
|
|
309
754
|
const r = await tool().execute({
|
|
310
755
|
edits: [
|
|
311
756
|
{
|
|
312
|
-
file_path: '
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
new_string: 'B\\r\\n',
|
|
757
|
+
file_path: 'safe-file.txt',
|
|
758
|
+
old_string: '',
|
|
759
|
+
new_string: 'safe content',
|
|
316
760
|
},
|
|
317
761
|
],
|
|
318
762
|
});
|
|
319
763
|
expect(r.success).toBe(true);
|
|
320
|
-
const content = fs.readFileSync(path.join(tmpDir, 'crlf-test.txt'), 'utf8');
|
|
321
|
-
expect(content).toContain('B');
|
|
322
|
-
// Should show the normalized diff (with \\n, not \\r\\n)
|
|
323
|
-
expect(r.output).toContain('\x1b[38;2;255;80;80mb');
|
|
324
|
-
expect(r.output).toContain('\x1b[38;2;0;200;100mB');
|
|
325
764
|
});
|
|
326
|
-
it('
|
|
765
|
+
it('rejects path traversal via markdown format', async () => {
|
|
766
|
+
const input = '```edit\nfile: ../../escape.txt\nold:\nnew: |\n hacked\n```';
|
|
767
|
+
const r = await tool().execute({ content: input });
|
|
768
|
+
expect(r.success).toBe(false);
|
|
769
|
+
expect(r.error).toContain('traversal');
|
|
770
|
+
});
|
|
771
|
+
it('rejects path traversal via symlink (if supported)', async () => {
|
|
772
|
+
let canSymlink = false;
|
|
773
|
+
const externalDir = path.join(os.tmpdir(), 'lonny-symlink-target-' + Date.now());
|
|
774
|
+
const symLink = path.join(tmpDir, 'symlink-inside');
|
|
775
|
+
try {
|
|
776
|
+
fs.mkdirSync(externalDir, { recursive: true });
|
|
777
|
+
fs.writeFileSync(path.join(externalDir, 'malicious.txt'), 'evil');
|
|
778
|
+
fs.symlinkSync(externalDir, symLink, 'junction');
|
|
779
|
+
canSymlink = fs.existsSync(symLink);
|
|
780
|
+
}
|
|
781
|
+
catch {
|
|
782
|
+
canSymlink = false;
|
|
783
|
+
}
|
|
784
|
+
if (!canSymlink)
|
|
785
|
+
return;
|
|
327
786
|
const r = await tool().execute({
|
|
328
|
-
edits: [
|
|
787
|
+
edits: [
|
|
788
|
+
{
|
|
789
|
+
file_path: 'symlink-inside/malicious.txt',
|
|
790
|
+
old_string: 'evil',
|
|
791
|
+
new_string: 'hacked',
|
|
792
|
+
},
|
|
793
|
+
],
|
|
329
794
|
});
|
|
330
|
-
expect(r.success).toBe(
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
795
|
+
expect(r.success).toBe(false);
|
|
796
|
+
expect(r.error).toContain('traversal');
|
|
797
|
+
// Cleanup
|
|
798
|
+
try {
|
|
799
|
+
fs.rmSync(externalDir, { recursive: true, force: true });
|
|
800
|
+
}
|
|
801
|
+
catch { }
|
|
336
802
|
});
|
|
337
803
|
});
|
|
338
|
-
describe('
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
fs.
|
|
342
|
-
|
|
343
|
-
it('creates a new file with empty old_string', async () => {
|
|
804
|
+
describe('error message quality', () => {
|
|
805
|
+
it('includes proximity hint when old_string not found', async () => {
|
|
806
|
+
const file = path.join(tmpDir, 'proximity.txt');
|
|
807
|
+
fs.writeFileSync(file, 'keep this line\nfind me\nkeep that line\n');
|
|
808
|
+
applier.markRead(file);
|
|
344
809
|
const r = await tool().execute({
|
|
345
810
|
edits: [
|
|
346
811
|
{
|
|
347
|
-
file_path: '
|
|
348
|
-
old_string: '',
|
|
349
|
-
new_string: '
|
|
812
|
+
file_path: 'proximity.txt',
|
|
813
|
+
old_string: 'find me\nnonexistent',
|
|
814
|
+
new_string: 'x',
|
|
350
815
|
},
|
|
351
816
|
],
|
|
352
817
|
});
|
|
353
|
-
expect(r.success).toBe(
|
|
354
|
-
|
|
355
|
-
expect(
|
|
818
|
+
expect(r.success).toBe(false);
|
|
819
|
+
// Should include proximity info
|
|
820
|
+
expect(r.error).toContain('Near line');
|
|
821
|
+
expect(r.error).toContain('find me');
|
|
356
822
|
});
|
|
357
|
-
it('
|
|
823
|
+
it('includes file content preview when no close match found', async () => {
|
|
824
|
+
const file = path.join(tmpDir, 'preview.txt');
|
|
825
|
+
fs.writeFileSync(file, 'alpha\nbeta\ngamma\n');
|
|
826
|
+
applier.markRead(file);
|
|
358
827
|
const r = await tool().execute({
|
|
359
828
|
edits: [
|
|
360
|
-
{
|
|
361
|
-
|
|
829
|
+
{
|
|
830
|
+
file_path: 'preview.txt',
|
|
831
|
+
old_string: 'zzz_nonexistent_zzz',
|
|
832
|
+
new_string: 'x',
|
|
833
|
+
},
|
|
362
834
|
],
|
|
363
835
|
});
|
|
364
|
-
expect(r.success).toBe(
|
|
365
|
-
|
|
366
|
-
expect(
|
|
836
|
+
expect(r.success).toBe(false);
|
|
837
|
+
// Should include file content preview
|
|
838
|
+
expect(r.error).toContain('File content');
|
|
839
|
+
expect(r.error).toContain('alpha');
|
|
840
|
+
expect(r.error).toContain('beta');
|
|
367
841
|
});
|
|
368
|
-
it('
|
|
842
|
+
it('includes read warning when file not read first', async () => {
|
|
843
|
+
// Don't call markRead �?simulate stale content
|
|
844
|
+
const file = path.join(tmpDir, 'no-read.txt');
|
|
845
|
+
fs.writeFileSync(file, 'content\n');
|
|
369
846
|
const r = await tool().execute({
|
|
370
847
|
edits: [
|
|
371
848
|
{
|
|
372
|
-
file_path: '
|
|
373
|
-
old_string: '',
|
|
849
|
+
file_path: 'no-read.txt',
|
|
850
|
+
old_string: 'zzz',
|
|
374
851
|
new_string: 'x',
|
|
375
852
|
},
|
|
376
853
|
],
|
|
377
854
|
});
|
|
378
855
|
expect(r.success).toBe(false);
|
|
379
|
-
expect(r.error).toContain('
|
|
856
|
+
expect(r.error).toContain('was not read first');
|
|
380
857
|
});
|
|
381
|
-
it('
|
|
858
|
+
it('includes rawInput in error for easy debugging', async () => {
|
|
382
859
|
const r = await tool().execute({
|
|
383
860
|
edits: [
|
|
384
|
-
{
|
|
385
|
-
|
|
861
|
+
{
|
|
862
|
+
file_path: 'nonexistent.txt',
|
|
863
|
+
old_string: 'hello',
|
|
864
|
+
new_string: 'world',
|
|
865
|
+
},
|
|
386
866
|
],
|
|
387
867
|
});
|
|
388
868
|
expect(r.success).toBe(false);
|
|
389
|
-
expect(
|
|
869
|
+
expect(r.error).toContain('nonexistent.txt');
|
|
870
|
+
expect(r.error).toContain('hello');
|
|
871
|
+
expect(r.error).toContain('world');
|
|
390
872
|
});
|
|
391
873
|
});
|
|
392
|
-
describe('
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
});
|
|
398
|
-
const tool = () => createEditTool(applier, wsDir());
|
|
399
|
-
it('handles trailing space in file (AI omits trailing space)', async () => {
|
|
400
|
-
fs.writeFileSync(f('trailing.txt'), 'hello \nworld\n');
|
|
874
|
+
describe('output format', () => {
|
|
875
|
+
it('prefixes output with "Edited" for modifications', async () => {
|
|
876
|
+
const file = path.join(tmpDir, 'output-test.txt');
|
|
877
|
+
fs.writeFileSync(file, 'original\n');
|
|
878
|
+
applier.markRead(file);
|
|
401
879
|
const r = await tool().execute({
|
|
402
|
-
edits: [
|
|
403
|
-
{ file_path: 'trailing.txt', old_string: 'hello\nworld', new_string: 'HELLO\nWORLD' },
|
|
404
|
-
],
|
|
880
|
+
edits: [{ file_path: 'output-test.txt', old_string: 'original', new_string: 'modified' }],
|
|
405
881
|
});
|
|
406
882
|
expect(r.success).toBe(true);
|
|
407
|
-
expect(
|
|
408
|
-
// Should indicate whitespace-normalized in output
|
|
409
|
-
expect(r.output).toContain('whitespace-normalized');
|
|
883
|
+
expect(r.output).toContain('Edited');
|
|
410
884
|
});
|
|
411
|
-
it('
|
|
412
|
-
fs.writeFileSync(f('trailing.txt'), 'hello \nworld\n');
|
|
885
|
+
it('prefixes output with "Created" for new files', async () => {
|
|
413
886
|
const r = await tool().execute({
|
|
414
|
-
edits: [
|
|
415
|
-
{ file_path: 'trailing.txt', old_string: 'hello \nworld', new_string: 'HELLO\nWORLD' },
|
|
416
|
-
],
|
|
887
|
+
edits: [{ file_path: 'output-create.txt', old_string: '', new_string: 'new file' }],
|
|
417
888
|
});
|
|
418
889
|
expect(r.success).toBe(true);
|
|
419
|
-
expect(
|
|
890
|
+
expect(r.output).toContain('Created');
|
|
420
891
|
});
|
|
421
|
-
it('
|
|
422
|
-
fs.writeFileSync(f('leading.txt'), ' hello\nworld\n');
|
|
892
|
+
it('includes line count for created files', async () => {
|
|
423
893
|
const r = await tool().execute({
|
|
424
894
|
edits: [
|
|
425
|
-
{
|
|
895
|
+
{
|
|
896
|
+
file_path: 'output-count.txt',
|
|
897
|
+
old_string: '',
|
|
898
|
+
new_string: 'line1\nline2\nline3',
|
|
899
|
+
},
|
|
426
900
|
],
|
|
427
901
|
});
|
|
428
902
|
expect(r.success).toBe(true);
|
|
429
|
-
|
|
430
|
-
// and are preserved in the result
|
|
431
|
-
expect(fs.readFileSync(f('leading.txt'), 'utf8')).toBe(' HELLO\nWORLD\n');
|
|
903
|
+
expect(r.output).toContain('3 lines');
|
|
432
904
|
});
|
|
433
|
-
|
|
434
|
-
|
|
905
|
+
});
|
|
906
|
+
describe('concurrent edits to same line area', () => {
|
|
907
|
+
it('applies edits bottom-to-top within same file', async () => {
|
|
908
|
+
const file = path.join(tmpDir, 'concurrent.txt');
|
|
909
|
+
fs.writeFileSync(file, 'first\nsecond\nthird\n');
|
|
910
|
+
applier.markRead(file);
|
|
435
911
|
const r = await tool().execute({
|
|
436
912
|
edits: [
|
|
437
|
-
{ file_path: '
|
|
913
|
+
{ file_path: 'concurrent.txt', old_string: 'first', new_string: 'FIRST' },
|
|
914
|
+
{ file_path: 'concurrent.txt', old_string: 'third', new_string: 'THIRD' },
|
|
438
915
|
],
|
|
439
916
|
});
|
|
440
917
|
expect(r.success).toBe(true);
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
expect(fs.readFileSync(f('leading.txt'), 'utf8')).toBe('HELLO\nWORLD\n');
|
|
918
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
919
|
+
expect(content).toBe('FIRST\nsecond\nTHIRD\n');
|
|
444
920
|
});
|
|
445
|
-
it('
|
|
446
|
-
|
|
921
|
+
it('applies edits bottom-to-top to avoid line offset issues', async () => {
|
|
922
|
+
const file = path.join(tmpDir, 'offset.txt');
|
|
923
|
+
fs.writeFileSync(file, 'a\nb\nc\n');
|
|
924
|
+
applier.markRead(file);
|
|
925
|
+
// If applied top-to-bottom: after replacing "a" with "A1\nA2",
|
|
926
|
+
// "c" would be at line 4, not line 2
|
|
447
927
|
const r = await tool().execute({
|
|
448
928
|
edits: [
|
|
449
|
-
{ file_path: '
|
|
929
|
+
{ file_path: 'offset.txt', old_string: 'a', new_string: 'A1\nA2' },
|
|
930
|
+
{ file_path: 'offset.txt', old_string: 'c', new_string: 'C' },
|
|
450
931
|
],
|
|
451
932
|
});
|
|
452
933
|
expect(r.success).toBe(true);
|
|
453
|
-
|
|
934
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
935
|
+
expect(content).toBe('A1\nA2\nb\nC\n');
|
|
454
936
|
});
|
|
455
|
-
|
|
456
|
-
|
|
937
|
+
});
|
|
938
|
+
describe('CRLF handling', () => {
|
|
939
|
+
it('handles CRLF in file content', async () => {
|
|
940
|
+
const file = path.join(tmpDir, 'crlf-content.txt');
|
|
941
|
+
fs.writeFileSync(file, 'hello\r\nworld\r\n');
|
|
942
|
+
applier.markRead(file);
|
|
457
943
|
const r = await tool().execute({
|
|
458
944
|
edits: [
|
|
459
|
-
{ file_path: '
|
|
945
|
+
{ file_path: 'crlf-content.txt', old_string: 'hello\nworld', new_string: 'HELLO\nWORLD' },
|
|
460
946
|
],
|
|
461
947
|
});
|
|
462
948
|
expect(r.success).toBe(true);
|
|
463
|
-
|
|
949
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
950
|
+
expect(content).toBe('HELLO\nWORLD\n');
|
|
464
951
|
});
|
|
465
|
-
|
|
466
|
-
|
|
952
|
+
});
|
|
953
|
+
describe('rollback behavior', () => {
|
|
954
|
+
it('restores original file content on failure', async () => {
|
|
955
|
+
const file = path.join(tmpDir, 'rollback-restore.txt');
|
|
956
|
+
fs.writeFileSync(file, 'original content\n');
|
|
957
|
+
applier.markRead(file);
|
|
958
|
+
const original = fs.readFileSync(file, 'utf8');
|
|
467
959
|
const r = await tool().execute({
|
|
468
|
-
edits: [
|
|
960
|
+
edits: [
|
|
961
|
+
{
|
|
962
|
+
file_path: 'rollback-restore.txt',
|
|
963
|
+
old_string: 'original content',
|
|
964
|
+
new_string: 'changed',
|
|
965
|
+
},
|
|
966
|
+
{ file_path: 'rollback-restore.txt', old_string: 'nonexistent', new_string: 'fail' },
|
|
967
|
+
],
|
|
469
968
|
});
|
|
470
|
-
expect(r.success).toBe(
|
|
471
|
-
|
|
472
|
-
//
|
|
473
|
-
expect(fs.readFileSync(
|
|
969
|
+
expect(r.success).toBe(false);
|
|
970
|
+
expect(r.error).toContain('rolled back');
|
|
971
|
+
// File should be restored to original
|
|
972
|
+
expect(fs.readFileSync(file, 'utf8')).toBe(original);
|
|
474
973
|
});
|
|
475
|
-
it('
|
|
476
|
-
fs.writeFileSync(f('mixed.txt'), ' a \nb \nc\n');
|
|
974
|
+
it('deletes newly created file on rollback', async () => {
|
|
477
975
|
const r = await tool().execute({
|
|
478
|
-
edits: [
|
|
976
|
+
edits: [
|
|
977
|
+
{ file_path: 'rollback-new.txt', old_string: '', new_string: 'new file' },
|
|
978
|
+
{ file_path: 'rollback-new.txt', old_string: '', new_string: 'duplicate' },
|
|
979
|
+
],
|
|
479
980
|
});
|
|
480
|
-
expect(r.success).toBe(
|
|
481
|
-
expect(fs.
|
|
981
|
+
expect(r.success).toBe(false);
|
|
982
|
+
expect(fs.existsSync(path.join(tmpDir, 'rollback-new.txt'))).toBe(false);
|
|
482
983
|
});
|
|
483
|
-
|
|
484
|
-
|
|
984
|
+
});
|
|
985
|
+
describe('multi-file scenarios', () => {
|
|
986
|
+
beforeAll(() => {
|
|
987
|
+
fs.writeFileSync(path.join(tmpDir, 'mf-a.txt'), 'edit me\n');
|
|
988
|
+
fs.writeFileSync(path.join(tmpDir, 'mf-b.txt'), 'edit me too\n');
|
|
989
|
+
applier.markRead(path.join(tmpDir, 'mf-a.txt'));
|
|
990
|
+
applier.markRead(path.join(tmpDir, 'mf-b.txt'));
|
|
991
|
+
});
|
|
992
|
+
it('edits multiple existing files in one call', async () => {
|
|
485
993
|
const r = await tool().execute({
|
|
486
|
-
edits: [
|
|
994
|
+
edits: [
|
|
995
|
+
{ file_path: 'mf-a.txt', old_string: 'edit me', new_string: 'edited' },
|
|
996
|
+
{ file_path: 'mf-b.txt', old_string: 'edit me too', new_string: 'edited too' },
|
|
997
|
+
],
|
|
487
998
|
});
|
|
488
|
-
expect(r.success).toBe(
|
|
489
|
-
expect(
|
|
999
|
+
expect(r.success).toBe(true);
|
|
1000
|
+
expect(fs.readFileSync(path.join(tmpDir, 'mf-a.txt'), 'utf8')).toBe('edited\n');
|
|
1001
|
+
expect(fs.readFileSync(path.join(tmpDir, 'mf-b.txt'), 'utf8')).toBe('edited too\n');
|
|
490
1002
|
});
|
|
491
|
-
it('
|
|
492
|
-
fs.writeFileSync(f('trailing.txt'), 'hello \nworld\n');
|
|
1003
|
+
it('mixes create and edit across files', async () => {
|
|
493
1004
|
const r = await tool().execute({
|
|
494
|
-
edits: [
|
|
1005
|
+
edits: [
|
|
1006
|
+
{ file_path: 'mf-a.txt', old_string: 'edited', new_string: 'edited again' },
|
|
1007
|
+
{ file_path: 'mf-create-new.txt', old_string: '', new_string: 'newly created' },
|
|
1008
|
+
],
|
|
495
1009
|
});
|
|
496
|
-
expect(r.success).toBe(
|
|
497
|
-
expect(
|
|
1010
|
+
expect(r.success).toBe(true);
|
|
1011
|
+
expect(fs.readFileSync(path.join(tmpDir, 'mf-a.txt'), 'utf8')).toBe('edited again\n');
|
|
1012
|
+
expect(fs.readFileSync(path.join(tmpDir, 'mf-create-new.txt'), 'utf8')).toBe('newly created');
|
|
498
1013
|
});
|
|
499
|
-
it('
|
|
500
|
-
fs.
|
|
1014
|
+
it('rolls back mixed create+edit on failure', async () => {
|
|
1015
|
+
const origA = fs.readFileSync(path.join(tmpDir, 'mf-a.txt'), 'utf8');
|
|
501
1016
|
const r = await tool().execute({
|
|
502
|
-
edits: [
|
|
1017
|
+
edits: [
|
|
1018
|
+
{ file_path: 'mf-a.txt', old_string: 'edited again', new_string: 'changed' },
|
|
1019
|
+
{ file_path: 'mf-rollback-new.txt', old_string: '', new_string: 'will be deleted' },
|
|
1020
|
+
{ file_path: 'mf-nonexistent.txt', old_string: 'nope', new_string: 'x' },
|
|
1021
|
+
],
|
|
503
1022
|
});
|
|
504
1023
|
expect(r.success).toBe(false);
|
|
505
|
-
expect(r.error).toContain('
|
|
506
|
-
|
|
1024
|
+
expect(r.error).toContain('rolled back');
|
|
1025
|
+
// Existing file restored
|
|
1026
|
+
expect(fs.readFileSync(path.join(tmpDir, 'mf-a.txt'), 'utf8')).toBe(origA);
|
|
1027
|
+
// Created file should be deleted
|
|
1028
|
+
expect(fs.existsSync(path.join(tmpDir, 'mf-rollback-new.txt'))).toBe(false);
|
|
507
1029
|
});
|
|
508
|
-
it('
|
|
509
|
-
fs.writeFileSync(f('exact-prefer.txt'), 'hello\nworld\n');
|
|
1030
|
+
it('creates multiple files in subdirectories', async () => {
|
|
510
1031
|
const r = await tool().execute({
|
|
511
1032
|
edits: [
|
|
512
|
-
{ file_path: '
|
|
1033
|
+
{ file_path: 'multi-dir/sub1/x.ts', old_string: '', new_string: '// x' },
|
|
1034
|
+
{ file_path: 'multi-dir/sub2/y.ts', old_string: '', new_string: '// y' },
|
|
513
1035
|
],
|
|
514
1036
|
});
|
|
515
1037
|
expect(r.success).toBe(true);
|
|
516
|
-
expect(fs.readFileSync(
|
|
517
|
-
|
|
518
|
-
expect(r.output).not.toContain('whitespace-normalized');
|
|
1038
|
+
expect(fs.readFileSync(path.join(tmpDir, 'multi-dir/sub1/x.ts'), 'utf8')).toBe('// x');
|
|
1039
|
+
expect(fs.readFileSync(path.join(tmpDir, 'multi-dir/sub2/y.ts'), 'utf8')).toBe('// y');
|
|
519
1040
|
});
|
|
520
|
-
it('
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
1041
|
+
it('creates multiple files with markdown format', async () => {
|
|
1042
|
+
const input = [
|
|
1043
|
+
'```edit',
|
|
1044
|
+
'file: md-multi-1.txt',
|
|
1045
|
+
'old:',
|
|
1046
|
+
'new: |',
|
|
1047
|
+
' first file',
|
|
1048
|
+
'```',
|
|
1049
|
+
'```edit',
|
|
1050
|
+
'file: md-multi-2.txt',
|
|
1051
|
+
'old:',
|
|
1052
|
+
'new: |',
|
|
1053
|
+
' second file',
|
|
1054
|
+
'```',
|
|
1055
|
+
].join('\n');
|
|
1056
|
+
const r = await tool().execute({ content: input });
|
|
1057
|
+
expect(r.success).toBe(true);
|
|
1058
|
+
expect(fs.readFileSync(path.join(tmpDir, 'md-multi-1.txt'), 'utf8')).toBe(' first file');
|
|
1059
|
+
expect(fs.readFileSync(path.join(tmpDir, 'md-multi-2.txt'), 'utf8')).toBe(' second file');
|
|
1060
|
+
});
|
|
1061
|
+
it('rolls back multi-file via markdown format on failure', async () => {
|
|
1062
|
+
const file = path.join(tmpDir, 'md-rollback-target.txt');
|
|
1063
|
+
fs.writeFileSync(file, 'preserve me\n');
|
|
1064
|
+
applier.markRead(file);
|
|
1065
|
+
const input = [
|
|
1066
|
+
'```edit',
|
|
1067
|
+
'file: md-rollback-target.txt',
|
|
1068
|
+
'old: |',
|
|
1069
|
+
' preserve me',
|
|
1070
|
+
'new: |',
|
|
1071
|
+
' changed',
|
|
1072
|
+
'```',
|
|
1073
|
+
'```edit',
|
|
1074
|
+
'file: md-nonexistent.txt',
|
|
1075
|
+
'old: |',
|
|
1076
|
+
' not there',
|
|
1077
|
+
'new: |',
|
|
1078
|
+
' fail',
|
|
1079
|
+
'```',
|
|
1080
|
+
].join('\n');
|
|
1081
|
+
const r = await tool().execute({ content: input });
|
|
525
1082
|
expect(r.success).toBe(false);
|
|
526
|
-
expect(r.error).toContain('
|
|
527
|
-
//
|
|
528
|
-
expect(
|
|
1083
|
+
expect(r.error).toContain('rolled back');
|
|
1084
|
+
// Target file should be restored
|
|
1085
|
+
expect(fs.readFileSync(file, 'utf8')).toBe('preserve me\n');
|
|
529
1086
|
});
|
|
530
1087
|
});
|
|
531
1088
|
});
|
|
1089
|
+
// ── Model-generated edit format integration tests ──────────────────────
|
|
1090
|
+
// These tests call a real LLM (DeepSeek-V4-Flash via ~/.lonny/config.json)
|
|
1091
|
+
// to generate edit format content, then feed it into the edit tool.
|
|
1092
|
+
const LONNY_CONFIG_PATH = path.join(os.homedir(), '.lonny', 'config.json');
|
|
1093
|
+
function loadLonnyConfig() {
|
|
1094
|
+
try {
|
|
1095
|
+
return JSON.parse(fs.readFileSync(LONNY_CONFIG_PATH, 'utf-8'));
|
|
1096
|
+
}
|
|
1097
|
+
catch {
|
|
1098
|
+
return {};
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
const lonnyConfig = loadLonnyConfig();
|
|
1102
|
+
const hasApiKey = !!lonnyConfig.apiKey;
|
|
1103
|
+
/** Call the model with a prompt and return the full text response (non-streaming). */
|
|
1104
|
+
async function callModel(prompt, signal) {
|
|
1105
|
+
const { default: OpenAI } = await import('openai');
|
|
1106
|
+
const client = new OpenAI({
|
|
1107
|
+
apiKey: lonnyConfig.apiKey,
|
|
1108
|
+
baseURL: lonnyConfig.baseUrl,
|
|
1109
|
+
});
|
|
1110
|
+
const response = await client.chat.completions.create({
|
|
1111
|
+
model: lonnyConfig.model || 'deepseek-v4-flash',
|
|
1112
|
+
messages: [{ role: 'user', content: prompt }],
|
|
1113
|
+
stream: false,
|
|
1114
|
+
max_tokens: 4096,
|
|
1115
|
+
}, signal ? { signal } : undefined);
|
|
1116
|
+
return response.choices[0]?.message?.content || '';
|
|
1117
|
+
}
|
|
1118
|
+
describe.runIf(hasApiKey)('model-generated edit format', () => {
|
|
1119
|
+
const MODEL_TIMEOUT = 60_000; // Model API calls can take 10-30s
|
|
1120
|
+
let tmpDir;
|
|
1121
|
+
let applier;
|
|
1122
|
+
beforeAll(() => {
|
|
1123
|
+
tmpDir = makeTempDir();
|
|
1124
|
+
applier = new FileReadTracker();
|
|
1125
|
+
});
|
|
1126
|
+
afterAll(() => {
|
|
1127
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
1128
|
+
});
|
|
1129
|
+
const tool = () => createEditTool(applier, tmpDir);
|
|
1130
|
+
it('model replaces exact string in a file', { timeout: MODEL_TIMEOUT }, async () => {
|
|
1131
|
+
const file = path.join(tmpDir, 'simple.txt');
|
|
1132
|
+
fs.writeFileSync(file, 'hello world\nfoo bar\n');
|
|
1133
|
+
applier.markRead(file);
|
|
1134
|
+
const prompt = [
|
|
1135
|
+
'Output ONLY an edit code block (```edit ... ```) that replaces "hello world" with "hello universe" in file "simple.txt".',
|
|
1136
|
+
'',
|
|
1137
|
+
'The EXACT format is:',
|
|
1138
|
+
'```edit',
|
|
1139
|
+
'file: simple.txt',
|
|
1140
|
+
'old: |',
|
|
1141
|
+
' hello world',
|
|
1142
|
+
'new: |',
|
|
1143
|
+
' hello universe',
|
|
1144
|
+
'```',
|
|
1145
|
+
'',
|
|
1146
|
+
'Do NOT add quotes, do NOT add backticks around the text inside old/new. Output ONLY the code block.',
|
|
1147
|
+
].join('\n');
|
|
1148
|
+
const modelOutput = await callModel(prompt);
|
|
1149
|
+
const edits = parseMarkdownEdit(modelOutput);
|
|
1150
|
+
expect(edits.length).toBeGreaterThanOrEqual(1);
|
|
1151
|
+
const result = await tool().execute({ content: modelOutput });
|
|
1152
|
+
expect(result.success).toBe(true);
|
|
1153
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
1154
|
+
expect(content).toContain('hello universe');
|
|
1155
|
+
expect(content).toContain('foo bar');
|
|
1156
|
+
});
|
|
1157
|
+
it('model replaces multi-line text in a file', { timeout: MODEL_TIMEOUT }, async () => {
|
|
1158
|
+
const file = path.join(tmpDir, 'multiline.txt');
|
|
1159
|
+
fs.writeFileSync(file, 'line A\nline B\nline C\nline D\n');
|
|
1160
|
+
applier.markRead(file);
|
|
1161
|
+
const prompt = [
|
|
1162
|
+
'Output ONLY an edit code block that replaces:',
|
|
1163
|
+
'',
|
|
1164
|
+
' line B',
|
|
1165
|
+
' line C',
|
|
1166
|
+
'',
|
|
1167
|
+
'with:',
|
|
1168
|
+
'',
|
|
1169
|
+
' line X',
|
|
1170
|
+
' line Y',
|
|
1171
|
+
'',
|
|
1172
|
+
`in file "multiline.txt".`,
|
|
1173
|
+
'',
|
|
1174
|
+
'Format:',
|
|
1175
|
+
'```edit',
|
|
1176
|
+
'file: multiline.txt',
|
|
1177
|
+
'old: |',
|
|
1178
|
+
' line B',
|
|
1179
|
+
' line C',
|
|
1180
|
+
'new: |',
|
|
1181
|
+
' line X',
|
|
1182
|
+
' line Y',
|
|
1183
|
+
'```',
|
|
1184
|
+
'',
|
|
1185
|
+
'IMPORTANT: old: must contain EXACTLY "line B" and "line C" on separate lines with 2-space indent.',
|
|
1186
|
+
'Do NOT add quotes around the text. Output ONLY the code block.',
|
|
1187
|
+
].join('\n');
|
|
1188
|
+
const modelOutput = await callModel(prompt);
|
|
1189
|
+
const edits = parseMarkdownEdit(modelOutput);
|
|
1190
|
+
expect(edits.length).toBeGreaterThanOrEqual(1);
|
|
1191
|
+
const result = await tool().execute({ content: modelOutput });
|
|
1192
|
+
expect(result.success).toBe(true);
|
|
1193
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
1194
|
+
expect(content).toContain('line X');
|
|
1195
|
+
expect(content).toContain('line Y');
|
|
1196
|
+
});
|
|
1197
|
+
it('model creates a new file', { timeout: MODEL_TIMEOUT }, async () => {
|
|
1198
|
+
const prompt = [
|
|
1199
|
+
'Output ONLY an edit code block that CREATES a new file called "fresh.txt" with content "hello world".',
|
|
1200
|
+
'',
|
|
1201
|
+
'Format (note: old: has NO content - this means create):',
|
|
1202
|
+
'```edit',
|
|
1203
|
+
'file: fresh.txt',
|
|
1204
|
+
'old:',
|
|
1205
|
+
'new: |',
|
|
1206
|
+
' hello world',
|
|
1207
|
+
'```',
|
|
1208
|
+
'',
|
|
1209
|
+
'Output ONLY the code block.',
|
|
1210
|
+
].join('\n');
|
|
1211
|
+
const modelOutput = await callModel(prompt);
|
|
1212
|
+
const edits = parseMarkdownEdit(modelOutput);
|
|
1213
|
+
expect(edits.length).toBeGreaterThanOrEqual(1);
|
|
1214
|
+
expect(edits[0].file_path).toBe('fresh.txt');
|
|
1215
|
+
const result = await tool().execute({ content: modelOutput });
|
|
1216
|
+
expect(result.success).toBe(true);
|
|
1217
|
+
expect(fs.readFileSync(path.join(tmpDir, 'fresh.txt'), 'utf8')).toContain('hello world');
|
|
1218
|
+
});
|
|
1219
|
+
it('model edits a file with special characters (double quotes)', {
|
|
1220
|
+
timeout: MODEL_TIMEOUT,
|
|
1221
|
+
}, async () => {
|
|
1222
|
+
const file = path.join(tmpDir, 'quotes.txt');
|
|
1223
|
+
fs.writeFileSync(file, 'alpha\nbeta\ngamma\n');
|
|
1224
|
+
applier.markRead(file);
|
|
1225
|
+
const prompt = [
|
|
1226
|
+
'Output ONLY an edit code block that replaces "beta" with "BETA" in file "quotes.txt".',
|
|
1227
|
+
'',
|
|
1228
|
+
'```edit',
|
|
1229
|
+
'file: quotes.txt',
|
|
1230
|
+
'old: |',
|
|
1231
|
+
' beta',
|
|
1232
|
+
'new: |',
|
|
1233
|
+
' BETA',
|
|
1234
|
+
'```',
|
|
1235
|
+
'',
|
|
1236
|
+
'Output ONLY the code block. No extra text.',
|
|
1237
|
+
].join('\n');
|
|
1238
|
+
const modelOutput = await callModel(prompt);
|
|
1239
|
+
const edits = parseMarkdownEdit(modelOutput);
|
|
1240
|
+
expect(edits.length).toBeGreaterThanOrEqual(1);
|
|
1241
|
+
const result = await tool().execute({ content: modelOutput });
|
|
1242
|
+
expect(result.success).toBe(true);
|
|
1243
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
1244
|
+
expect(content).toContain('BETA');
|
|
1245
|
+
expect(content).toContain('alpha');
|
|
1246
|
+
expect(content).toContain('gamma');
|
|
1247
|
+
});
|
|
1248
|
+
it('model generates edit block that round-trips successfully', {
|
|
1249
|
+
timeout: MODEL_TIMEOUT,
|
|
1250
|
+
}, async () => {
|
|
1251
|
+
const file = path.join(tmpDir, 'roundtrip.txt');
|
|
1252
|
+
fs.writeFileSync(file, 'first\nsecond\nthird\nfourth\n');
|
|
1253
|
+
applier.markRead(file);
|
|
1254
|
+
const prompt = [
|
|
1255
|
+
'Output ONLY an edit code block that replaces "second" with "SECOND" in file "roundtrip.txt".',
|
|
1256
|
+
'Include 1 line of context before and after to ensure uniqueness.',
|
|
1257
|
+
'',
|
|
1258
|
+
'Format:',
|
|
1259
|
+
'```edit',
|
|
1260
|
+
'file: roundtrip.txt',
|
|
1261
|
+
'old: |',
|
|
1262
|
+
' first',
|
|
1263
|
+
' second',
|
|
1264
|
+
' third',
|
|
1265
|
+
'new: |',
|
|
1266
|
+
' first',
|
|
1267
|
+
' SECOND',
|
|
1268
|
+
' third',
|
|
1269
|
+
'```',
|
|
1270
|
+
'',
|
|
1271
|
+
'Output ONLY the code block.',
|
|
1272
|
+
].join('\n');
|
|
1273
|
+
const modelOutput = await callModel(prompt);
|
|
1274
|
+
const edits = parseMarkdownEdit(modelOutput);
|
|
1275
|
+
expect(edits.length).toBeGreaterThanOrEqual(1);
|
|
1276
|
+
const result = await tool().execute({ content: modelOutput });
|
|
1277
|
+
expect(result.success).toBe(true);
|
|
1278
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
1279
|
+
expect(content).toContain('SECOND');
|
|
1280
|
+
expect(content).toContain('fourth');
|
|
1281
|
+
});
|
|
1282
|
+
});
|
|
532
1283
|
//# sourceMappingURL=edit.test.js.map
|