@stonerzju/opencode 1.2.17 → 1.2.18

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.
Files changed (261) hide show
  1. package/bin/opencode +29 -157
  2. package/package.json +29 -29
  3. package/src/acp/agent.ts +4 -4
  4. package/src/acp/session.ts +1 -1
  5. package/src/agent/agent.ts +3 -3
  6. package/src/bun/index.ts +2 -2
  7. package/src/cli/cmd/acp.ts +3 -3
  8. package/src/cli/cmd/debug/file.ts +1 -1
  9. package/src/cli/cmd/github.ts +2 -2
  10. package/src/cli/cmd/pr.ts +1 -1
  11. package/src/cli/cmd/tui/app.tsx +24 -24
  12. package/src/cli/cmd/tui/attach.ts +3 -3
  13. package/src/cli/cmd/tui/component/dialog-agent.tsx +3 -3
  14. package/src/cli/cmd/tui/component/dialog-command.tsx +3 -3
  15. package/src/cli/cmd/tui/component/dialog-mcp.tsx +5 -5
  16. package/src/cli/cmd/tui/component/dialog-model.tsx +4 -4
  17. package/src/cli/cmd/tui/component/dialog-provider.tsx +4 -4
  18. package/src/cli/cmd/tui/component/dialog-session-list.tsx +5 -5
  19. package/src/cli/cmd/tui/component/dialog-session-rename.tsx +3 -3
  20. package/src/cli/cmd/tui/component/dialog-skill.tsx +3 -3
  21. package/src/cli/cmd/tui/component/dialog-stash.tsx +3 -3
  22. package/src/cli/cmd/tui/component/dialog-status.tsx +2 -2
  23. package/src/cli/cmd/tui/component/dialog-tag.tsx +3 -3
  24. package/src/cli/cmd/tui/component/logo.tsx +2 -2
  25. package/src/cli/cmd/tui/component/prompt/autocomplete.tsx +6 -6
  26. package/src/cli/cmd/tui/component/prompt/frecency.tsx +2 -2
  27. package/src/cli/cmd/tui/component/prompt/history.tsx +2 -2
  28. package/src/cli/cmd/tui/component/prompt/index.tsx +14 -14
  29. package/src/cli/cmd/tui/component/prompt/stash.tsx +2 -2
  30. package/src/cli/cmd/tui/component/textarea-keybindings.ts +1 -1
  31. package/src/cli/cmd/tui/component/tips.tsx +1 -1
  32. package/src/cli/cmd/tui/context/directory.ts +1 -1
  33. package/src/cli/cmd/tui/context/exit.tsx +1 -1
  34. package/src/cli/cmd/tui/context/keybind.tsx +2 -2
  35. package/src/cli/cmd/tui/context/kv.tsx +2 -2
  36. package/src/cli/cmd/tui/context/local.tsx +6 -6
  37. package/src/cli/cmd/tui/context/sync.tsx +4 -4
  38. package/src/cli/cmd/tui/context/theme/opencode.json +245 -0
  39. package/src/cli/cmd/tui/context/theme.tsx +2 -2
  40. package/src/cli/cmd/tui/context/tui-config.tsx +1 -1
  41. package/src/cli/cmd/tui/event.ts +2 -2
  42. package/src/cli/cmd/tui/routes/home.tsx +6 -6
  43. package/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx +6 -6
  44. package/src/cli/cmd/tui/routes/session/dialog-message.tsx +6 -6
  45. package/src/cli/cmd/tui/routes/session/dialog-subagent.tsx +2 -2
  46. package/src/cli/cmd/tui/routes/session/dialog-timeline.tsx +3 -3
  47. package/src/cli/cmd/tui/routes/session/header.tsx +5 -5
  48. package/src/cli/cmd/tui/routes/session/index.tsx +32 -32
  49. package/src/cli/cmd/tui/routes/session/permission.tsx +4 -4
  50. package/src/cli/cmd/tui/routes/session/sidebar.tsx +4 -4
  51. package/src/cli/cmd/tui/thread.ts +9 -9
  52. package/src/cli/cmd/tui/ui/dialog-confirm.tsx +1 -1
  53. package/src/cli/cmd/tui/ui/dialog-help.tsx +2 -2
  54. package/src/cli/cmd/tui/ui/dialog-select.tsx +5 -5
  55. package/src/cli/cmd/tui/ui/dialog.tsx +3 -3
  56. package/src/cli/cmd/tui/ui/toast.tsx +1 -1
  57. package/src/cli/cmd/tui/util/editor.ts +3 -3
  58. package/src/cli/cmd/tui/util/transcript.ts +1 -1
  59. package/src/cli/cmd/tui/worker.ts +10 -10
  60. package/src/cli/error.ts +1 -1
  61. package/src/cli/ui.ts +1 -1
  62. package/src/cli/upgrade.ts +4 -4
  63. package/src/command/index.ts +1 -1
  64. package/src/config/config.ts +10 -10
  65. package/src/config/markdown.ts +1 -1
  66. package/src/config/migrate-tui-config.ts +5 -5
  67. package/src/config/paths.ts +4 -4
  68. package/src/config/tui.ts +4 -4
  69. package/src/control/control.sql.ts +1 -1
  70. package/src/control/index.ts +1 -1
  71. package/src/control-plane/adaptors/worktree.ts +1 -1
  72. package/src/control-plane/session-proxy-middleware.ts +1 -1
  73. package/src/control-plane/workspace.sql.ts +1 -1
  74. package/src/control-plane/workspace.ts +7 -7
  75. package/src/file/index.ts +1 -1
  76. package/src/file/ripgrep.ts +2 -2
  77. package/src/file/watcher.ts +5 -5
  78. package/src/format/formatter.ts +1 -1
  79. package/src/ide/index.ts +3 -3
  80. package/src/index.ts +1 -1
  81. package/src/installation/index.ts +3 -3
  82. package/src/lsp/client.ts +3 -3
  83. package/src/lsp/index.ts +3 -3
  84. package/src/mcp/index.ts +4 -4
  85. package/src/permission/index.ts +2 -2
  86. package/src/permission/next.ts +10 -10
  87. package/src/plugin/codex.ts +1 -1
  88. package/src/plugin/copilot.ts +2 -2
  89. package/src/plugin/index.ts +1 -1
  90. package/src/project/bootstrap.ts +2 -2
  91. package/src/project/instance.ts +4 -4
  92. package/src/project/project.sql.ts +1 -1
  93. package/src/project/project.ts +5 -5
  94. package/src/project/state.ts +1 -1
  95. package/src/project/vcs.ts +4 -4
  96. package/src/provider/auth.ts +4 -4
  97. package/src/provider/error.ts +1 -1
  98. package/src/provider/models-snapshot.ts +2 -0
  99. package/src/provider/models.ts +1 -1
  100. package/src/provider/provider.ts +2 -2
  101. package/src/provider/transform.ts +2 -2
  102. package/src/pty/index.ts +5 -5
  103. package/src/question/index.ts +5 -5
  104. package/src/server/event.ts +1 -1
  105. package/src/server/mdns.ts +1 -1
  106. package/src/server/routes/global.ts +3 -3
  107. package/src/server/routes/permission.ts +1 -1
  108. package/src/server/routes/pty.ts +1 -1
  109. package/src/server/routes/session.ts +4 -4
  110. package/src/server/routes/tui.ts +1 -1
  111. package/src/server/server.ts +3 -3
  112. package/src/session/compaction.ts +7 -7
  113. package/src/session/index.ts +10 -10
  114. package/src/session/instruction.ts +1 -1
  115. package/src/session/llm.ts +11 -11
  116. package/src/session/message-v2.ts +10 -10
  117. package/src/session/message.ts +1 -1
  118. package/src/session/processor.ts +10 -10
  119. package/src/session/prompt.ts +8 -8
  120. package/src/session/retry.ts +2 -2
  121. package/src/session/revert.ts +1 -1
  122. package/src/session/session.sql.ts +3 -3
  123. package/src/session/status.ts +3 -3
  124. package/src/session/summary.ts +5 -5
  125. package/src/session/system.ts +1 -1
  126. package/src/session/todo.ts +2 -2
  127. package/src/share/share-next.ts +7 -7
  128. package/src/share/share.sql.ts +1 -1
  129. package/src/shell/shell.ts +3 -3
  130. package/src/skill/skill.ts +6 -6
  131. package/src/storage/db.ts +1 -1
  132. package/src/storage/storage.ts +1 -1
  133. package/src/tool/bash.ts +6 -6
  134. package/src/tool/edit.ts +1 -1
  135. package/src/tool/registry.ts +2 -2
  136. package/src/tool/skill.ts +1 -1
  137. package/src/tool/task.ts +3 -3
  138. package/src/util/array.ts +10 -0
  139. package/src/util/binary.ts +41 -0
  140. package/src/util/encode.ts +51 -0
  141. package/src/util/error.ts +54 -0
  142. package/src/util/identifier.ts +48 -0
  143. package/src/util/lazy.ts +4 -16
  144. package/src/util/path.ts +37 -0
  145. package/src/util/retry.ts +41 -0
  146. package/src/util/slug.ts +74 -0
  147. package/src/worktree/index.ts +3 -3
  148. package/AGENTS.md +0 -10
  149. package/BUN_SHELL_MIGRATION_PLAN.md +0 -136
  150. package/Dockerfile +0 -18
  151. package/README.md +0 -15
  152. package/bunfig.toml +0 -7
  153. package/drizzle.config.ts +0 -10
  154. package/script/build.ts +0 -224
  155. package/script/check-migrations.ts +0 -16
  156. package/script/postinstall.mjs +0 -131
  157. package/script/publish.ts +0 -181
  158. package/script/schema.ts +0 -63
  159. package/script/seed-e2e.ts +0 -50
  160. package/sst-env.d.ts +0 -10
  161. package/test/AGENTS.md +0 -81
  162. package/test/acp/agent-interface.test.ts +0 -51
  163. package/test/acp/event-subscription.test.ts +0 -683
  164. package/test/agent/agent.test.ts +0 -689
  165. package/test/bun.test.ts +0 -53
  166. package/test/cli/github-action.test.ts +0 -197
  167. package/test/cli/github-remote.test.ts +0 -80
  168. package/test/cli/import.test.ts +0 -38
  169. package/test/cli/plugin-auth-picker.test.ts +0 -120
  170. package/test/cli/tui/transcript.test.ts +0 -322
  171. package/test/config/agent-color.test.ts +0 -71
  172. package/test/config/config.test.ts +0 -1886
  173. package/test/config/fixtures/empty-frontmatter.md +0 -4
  174. package/test/config/fixtures/frontmatter.md +0 -28
  175. package/test/config/fixtures/markdown-header.md +0 -11
  176. package/test/config/fixtures/no-frontmatter.md +0 -1
  177. package/test/config/fixtures/weird-model-id.md +0 -13
  178. package/test/config/markdown.test.ts +0 -228
  179. package/test/config/tui.test.ts +0 -510
  180. package/test/control-plane/session-proxy-middleware.test.ts +0 -147
  181. package/test/control-plane/sse.test.ts +0 -56
  182. package/test/control-plane/workspace-server-sse.test.ts +0 -65
  183. package/test/control-plane/workspace-sync.test.ts +0 -97
  184. package/test/file/ignore.test.ts +0 -10
  185. package/test/file/index.test.ts +0 -394
  186. package/test/file/path-traversal.test.ts +0 -198
  187. package/test/file/ripgrep.test.ts +0 -39
  188. package/test/file/time.test.ts +0 -361
  189. package/test/fixture/db.ts +0 -11
  190. package/test/fixture/fixture.ts +0 -45
  191. package/test/fixture/lsp/fake-lsp-server.js +0 -77
  192. package/test/fixture/skills/agents-sdk/SKILL.md +0 -152
  193. package/test/fixture/skills/agents-sdk/references/callable.md +0 -92
  194. package/test/fixture/skills/cloudflare/SKILL.md +0 -211
  195. package/test/fixture/skills/index.json +0 -6
  196. package/test/ide/ide.test.ts +0 -82
  197. package/test/keybind.test.ts +0 -421
  198. package/test/lsp/client.test.ts +0 -95
  199. package/test/mcp/headers.test.ts +0 -153
  200. package/test/mcp/oauth-browser.test.ts +0 -249
  201. package/test/memory/abort-leak.test.ts +0 -136
  202. package/test/patch/patch.test.ts +0 -348
  203. package/test/permission/arity.test.ts +0 -33
  204. package/test/permission/next.test.ts +0 -689
  205. package/test/permission-task.test.ts +0 -319
  206. package/test/plugin/auth-override.test.ts +0 -44
  207. package/test/plugin/codex.test.ts +0 -123
  208. package/test/preload.ts +0 -80
  209. package/test/project/project.test.ts +0 -348
  210. package/test/project/worktree-remove.test.ts +0 -65
  211. package/test/provider/amazon-bedrock.test.ts +0 -446
  212. package/test/provider/copilot/convert-to-copilot-messages.test.ts +0 -523
  213. package/test/provider/copilot/copilot-chat-model.test.ts +0 -592
  214. package/test/provider/gitlab-duo.test.ts +0 -262
  215. package/test/provider/provider.test.ts +0 -2220
  216. package/test/provider/transform.test.ts +0 -2353
  217. package/test/pty/pty-output-isolation.test.ts +0 -140
  218. package/test/question/question.test.ts +0 -300
  219. package/test/scheduler.test.ts +0 -73
  220. package/test/server/global-session-list.test.ts +0 -89
  221. package/test/server/session-list.test.ts +0 -90
  222. package/test/server/session-select.test.ts +0 -78
  223. package/test/session/compaction.test.ts +0 -423
  224. package/test/session/instruction.test.ts +0 -170
  225. package/test/session/llm.test.ts +0 -667
  226. package/test/session/message-v2.test.ts +0 -924
  227. package/test/session/prompt.test.ts +0 -211
  228. package/test/session/retry.test.ts +0 -188
  229. package/test/session/revert-compact.test.ts +0 -285
  230. package/test/session/session.test.ts +0 -71
  231. package/test/session/structured-output-integration.test.ts +0 -233
  232. package/test/session/structured-output.test.ts +0 -385
  233. package/test/skill/discovery.test.ts +0 -110
  234. package/test/skill/skill.test.ts +0 -388
  235. package/test/snapshot/snapshot.test.ts +0 -1180
  236. package/test/storage/json-migration.test.ts +0 -846
  237. package/test/tool/__snapshots__/tool.test.ts.snap +0 -9
  238. package/test/tool/apply_patch.test.ts +0 -566
  239. package/test/tool/bash.test.ts +0 -402
  240. package/test/tool/edit.test.ts +0 -496
  241. package/test/tool/external-directory.test.ts +0 -127
  242. package/test/tool/fixtures/large-image.png +0 -0
  243. package/test/tool/fixtures/models-api.json +0 -38413
  244. package/test/tool/grep.test.ts +0 -110
  245. package/test/tool/question.test.ts +0 -107
  246. package/test/tool/read.test.ts +0 -504
  247. package/test/tool/registry.test.ts +0 -122
  248. package/test/tool/skill.test.ts +0 -112
  249. package/test/tool/truncation.test.ts +0 -160
  250. package/test/tool/webfetch.test.ts +0 -100
  251. package/test/tool/write.test.ts +0 -348
  252. package/test/util/filesystem.test.ts +0 -443
  253. package/test/util/format.test.ts +0 -59
  254. package/test/util/glob.test.ts +0 -164
  255. package/test/util/iife.test.ts +0 -36
  256. package/test/util/lazy.test.ts +0 -50
  257. package/test/util/lock.test.ts +0 -72
  258. package/test/util/process.test.ts +0 -59
  259. package/test/util/timeout.test.ts +0 -21
  260. package/test/util/wildcard.test.ts +0 -90
  261. package/tsconfig.json +0 -16
@@ -1,496 +0,0 @@
1
- import { describe, test, expect } from "bun:test"
2
- import path from "path"
3
- import fs from "fs/promises"
4
- import { EditTool } from "../../src/tool/edit"
5
- import { Instance } from "../../src/project/instance"
6
- import { tmpdir } from "../fixture/fixture"
7
- import { FileTime } from "../../src/file/time"
8
-
9
- const ctx = {
10
- sessionID: "test-edit-session",
11
- messageID: "",
12
- callID: "",
13
- agent: "build",
14
- abort: AbortSignal.any([]),
15
- messages: [],
16
- metadata: () => {},
17
- ask: async () => {},
18
- }
19
-
20
- describe("tool.edit", () => {
21
- describe("creating new files", () => {
22
- test("creates new file when oldString is empty", async () => {
23
- await using tmp = await tmpdir()
24
- const filepath = path.join(tmp.path, "newfile.txt")
25
-
26
- await Instance.provide({
27
- directory: tmp.path,
28
- fn: async () => {
29
- const edit = await EditTool.init()
30
- const result = await edit.execute(
31
- {
32
- filePath: filepath,
33
- oldString: "",
34
- newString: "new content",
35
- },
36
- ctx,
37
- )
38
-
39
- expect(result.metadata.diff).toContain("new content")
40
-
41
- const content = await fs.readFile(filepath, "utf-8")
42
- expect(content).toBe("new content")
43
- },
44
- })
45
- })
46
-
47
- test("creates new file with nested directories", async () => {
48
- await using tmp = await tmpdir()
49
- const filepath = path.join(tmp.path, "nested", "dir", "file.txt")
50
-
51
- await Instance.provide({
52
- directory: tmp.path,
53
- fn: async () => {
54
- const edit = await EditTool.init()
55
- await edit.execute(
56
- {
57
- filePath: filepath,
58
- oldString: "",
59
- newString: "nested file",
60
- },
61
- ctx,
62
- )
63
-
64
- const content = await fs.readFile(filepath, "utf-8")
65
- expect(content).toBe("nested file")
66
- },
67
- })
68
- })
69
-
70
- test("emits add event for new files", async () => {
71
- await using tmp = await tmpdir()
72
- const filepath = path.join(tmp.path, "new.txt")
73
-
74
- await Instance.provide({
75
- directory: tmp.path,
76
- fn: async () => {
77
- const { Bus } = await import("../../src/bus")
78
- const { File } = await import("../../src/file")
79
- const { FileWatcher } = await import("../../src/file/watcher")
80
-
81
- const events: string[] = []
82
- const unsubEdited = Bus.subscribe(File.Event.Edited, () => events.push("edited"))
83
- const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
84
-
85
- const edit = await EditTool.init()
86
- await edit.execute(
87
- {
88
- filePath: filepath,
89
- oldString: "",
90
- newString: "content",
91
- },
92
- ctx,
93
- )
94
-
95
- expect(events).toContain("edited")
96
- expect(events).toContain("updated")
97
- unsubEdited()
98
- unsubUpdated()
99
- },
100
- })
101
- })
102
- })
103
-
104
- describe("editing existing files", () => {
105
- test("replaces text in existing file", async () => {
106
- await using tmp = await tmpdir()
107
- const filepath = path.join(tmp.path, "existing.txt")
108
- await fs.writeFile(filepath, "old content here", "utf-8")
109
-
110
- await Instance.provide({
111
- directory: tmp.path,
112
- fn: async () => {
113
- FileTime.read(ctx.sessionID, filepath)
114
-
115
- const edit = await EditTool.init()
116
- const result = await edit.execute(
117
- {
118
- filePath: filepath,
119
- oldString: "old content",
120
- newString: "new content",
121
- },
122
- ctx,
123
- )
124
-
125
- expect(result.output).toContain("Edit applied successfully")
126
-
127
- const content = await fs.readFile(filepath, "utf-8")
128
- expect(content).toBe("new content here")
129
- },
130
- })
131
- })
132
-
133
- test("throws error when file does not exist", async () => {
134
- await using tmp = await tmpdir()
135
- const filepath = path.join(tmp.path, "nonexistent.txt")
136
-
137
- await Instance.provide({
138
- directory: tmp.path,
139
- fn: async () => {
140
- FileTime.read(ctx.sessionID, filepath)
141
-
142
- const edit = await EditTool.init()
143
- await expect(
144
- edit.execute(
145
- {
146
- filePath: filepath,
147
- oldString: "old",
148
- newString: "new",
149
- },
150
- ctx,
151
- ),
152
- ).rejects.toThrow("not found")
153
- },
154
- })
155
- })
156
-
157
- test("throws error when oldString equals newString", async () => {
158
- await using tmp = await tmpdir()
159
- const filepath = path.join(tmp.path, "file.txt")
160
- await fs.writeFile(filepath, "content", "utf-8")
161
-
162
- await Instance.provide({
163
- directory: tmp.path,
164
- fn: async () => {
165
- const edit = await EditTool.init()
166
- await expect(
167
- edit.execute(
168
- {
169
- filePath: filepath,
170
- oldString: "same",
171
- newString: "same",
172
- },
173
- ctx,
174
- ),
175
- ).rejects.toThrow("identical")
176
- },
177
- })
178
- })
179
-
180
- test("throws error when oldString not found in file", async () => {
181
- await using tmp = await tmpdir()
182
- const filepath = path.join(tmp.path, "file.txt")
183
- await fs.writeFile(filepath, "actual content", "utf-8")
184
-
185
- await Instance.provide({
186
- directory: tmp.path,
187
- fn: async () => {
188
- FileTime.read(ctx.sessionID, filepath)
189
-
190
- const edit = await EditTool.init()
191
- await expect(
192
- edit.execute(
193
- {
194
- filePath: filepath,
195
- oldString: "not in file",
196
- newString: "replacement",
197
- },
198
- ctx,
199
- ),
200
- ).rejects.toThrow()
201
- },
202
- })
203
- })
204
-
205
- test("throws error when file was not read first (FileTime)", async () => {
206
- await using tmp = await tmpdir()
207
- const filepath = path.join(tmp.path, "file.txt")
208
- await fs.writeFile(filepath, "content", "utf-8")
209
-
210
- await Instance.provide({
211
- directory: tmp.path,
212
- fn: async () => {
213
- const edit = await EditTool.init()
214
- await expect(
215
- edit.execute(
216
- {
217
- filePath: filepath,
218
- oldString: "content",
219
- newString: "modified",
220
- },
221
- ctx,
222
- ),
223
- ).rejects.toThrow("You must read file")
224
- },
225
- })
226
- })
227
-
228
- test("throws error when file has been modified since read", async () => {
229
- await using tmp = await tmpdir()
230
- const filepath = path.join(tmp.path, "file.txt")
231
- await fs.writeFile(filepath, "original content", "utf-8")
232
-
233
- await Instance.provide({
234
- directory: tmp.path,
235
- fn: async () => {
236
- // Read first
237
- FileTime.read(ctx.sessionID, filepath)
238
-
239
- // Wait a bit to ensure different timestamps
240
- await new Promise((resolve) => setTimeout(resolve, 100))
241
-
242
- // Simulate external modification
243
- await fs.writeFile(filepath, "modified externally", "utf-8")
244
-
245
- // Try to edit with the new content
246
- const edit = await EditTool.init()
247
- await expect(
248
- edit.execute(
249
- {
250
- filePath: filepath,
251
- oldString: "modified externally",
252
- newString: "edited",
253
- },
254
- ctx,
255
- ),
256
- ).rejects.toThrow("modified since it was last read")
257
- },
258
- })
259
- })
260
-
261
- test("replaces all occurrences with replaceAll option", async () => {
262
- await using tmp = await tmpdir()
263
- const filepath = path.join(tmp.path, "file.txt")
264
- await fs.writeFile(filepath, "foo bar foo baz foo", "utf-8")
265
-
266
- await Instance.provide({
267
- directory: tmp.path,
268
- fn: async () => {
269
- FileTime.read(ctx.sessionID, filepath)
270
-
271
- const edit = await EditTool.init()
272
- await edit.execute(
273
- {
274
- filePath: filepath,
275
- oldString: "foo",
276
- newString: "qux",
277
- replaceAll: true,
278
- },
279
- ctx,
280
- )
281
-
282
- const content = await fs.readFile(filepath, "utf-8")
283
- expect(content).toBe("qux bar qux baz qux")
284
- },
285
- })
286
- })
287
-
288
- test("emits change event for existing files", async () => {
289
- await using tmp = await tmpdir()
290
- const filepath = path.join(tmp.path, "file.txt")
291
- await fs.writeFile(filepath, "original", "utf-8")
292
-
293
- await Instance.provide({
294
- directory: tmp.path,
295
- fn: async () => {
296
- FileTime.read(ctx.sessionID, filepath)
297
-
298
- const { Bus } = await import("../../src/bus")
299
- const { File } = await import("../../src/file")
300
- const { FileWatcher } = await import("../../src/file/watcher")
301
-
302
- const events: string[] = []
303
- const unsubEdited = Bus.subscribe(File.Event.Edited, () => events.push("edited"))
304
- const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
305
-
306
- const edit = await EditTool.init()
307
- await edit.execute(
308
- {
309
- filePath: filepath,
310
- oldString: "original",
311
- newString: "modified",
312
- },
313
- ctx,
314
- )
315
-
316
- expect(events).toContain("edited")
317
- expect(events).toContain("updated")
318
- unsubEdited()
319
- unsubUpdated()
320
- },
321
- })
322
- })
323
- })
324
-
325
- describe("edge cases", () => {
326
- test("handles multiline replacements", async () => {
327
- await using tmp = await tmpdir()
328
- const filepath = path.join(tmp.path, "file.txt")
329
- await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
330
-
331
- await Instance.provide({
332
- directory: tmp.path,
333
- fn: async () => {
334
- FileTime.read(ctx.sessionID, filepath)
335
-
336
- const edit = await EditTool.init()
337
- await edit.execute(
338
- {
339
- filePath: filepath,
340
- oldString: "line2",
341
- newString: "new line 2\nextra line",
342
- },
343
- ctx,
344
- )
345
-
346
- const content = await fs.readFile(filepath, "utf-8")
347
- expect(content).toBe("line1\nnew line 2\nextra line\nline3")
348
- },
349
- })
350
- })
351
-
352
- test("handles CRLF line endings", async () => {
353
- await using tmp = await tmpdir()
354
- const filepath = path.join(tmp.path, "file.txt")
355
- await fs.writeFile(filepath, "line1\r\nold\r\nline3", "utf-8")
356
-
357
- await Instance.provide({
358
- directory: tmp.path,
359
- fn: async () => {
360
- FileTime.read(ctx.sessionID, filepath)
361
-
362
- const edit = await EditTool.init()
363
- await edit.execute(
364
- {
365
- filePath: filepath,
366
- oldString: "old",
367
- newString: "new",
368
- },
369
- ctx,
370
- )
371
-
372
- const content = await fs.readFile(filepath, "utf-8")
373
- expect(content).toBe("line1\r\nnew\r\nline3")
374
- },
375
- })
376
- })
377
-
378
- test("throws error when oldString equals newString", async () => {
379
- await using tmp = await tmpdir()
380
- const filepath = path.join(tmp.path, "file.txt")
381
- await fs.writeFile(filepath, "content", "utf-8")
382
-
383
- await Instance.provide({
384
- directory: tmp.path,
385
- fn: async () => {
386
- const edit = await EditTool.init()
387
- await expect(
388
- edit.execute(
389
- {
390
- filePath: filepath,
391
- oldString: "",
392
- newString: "",
393
- },
394
- ctx,
395
- ),
396
- ).rejects.toThrow("identical")
397
- },
398
- })
399
- })
400
-
401
- test("throws error when path is directory", async () => {
402
- await using tmp = await tmpdir()
403
- const dirpath = path.join(tmp.path, "adir")
404
- await fs.mkdir(dirpath)
405
-
406
- await Instance.provide({
407
- directory: tmp.path,
408
- fn: async () => {
409
- FileTime.read(ctx.sessionID, dirpath)
410
-
411
- const edit = await EditTool.init()
412
- await expect(
413
- edit.execute(
414
- {
415
- filePath: dirpath,
416
- oldString: "old",
417
- newString: "new",
418
- },
419
- ctx,
420
- ),
421
- ).rejects.toThrow("directory")
422
- },
423
- })
424
- })
425
-
426
- test("tracks file diff statistics", async () => {
427
- await using tmp = await tmpdir()
428
- const filepath = path.join(tmp.path, "file.txt")
429
- await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
430
-
431
- await Instance.provide({
432
- directory: tmp.path,
433
- fn: async () => {
434
- FileTime.read(ctx.sessionID, filepath)
435
-
436
- const edit = await EditTool.init()
437
- const result = await edit.execute(
438
- {
439
- filePath: filepath,
440
- oldString: "line2",
441
- newString: "new line a\nnew line b",
442
- },
443
- ctx,
444
- )
445
-
446
- expect(result.metadata.filediff).toBeDefined()
447
- expect(result.metadata.filediff.file).toBe(filepath)
448
- expect(result.metadata.filediff.additions).toBeGreaterThan(0)
449
- },
450
- })
451
- })
452
- })
453
-
454
- describe("concurrent editing", () => {
455
- test("serializes concurrent edits to same file", async () => {
456
- await using tmp = await tmpdir()
457
- const filepath = path.join(tmp.path, "file.txt")
458
- await fs.writeFile(filepath, "0", "utf-8")
459
-
460
- await Instance.provide({
461
- directory: tmp.path,
462
- fn: async () => {
463
- FileTime.read(ctx.sessionID, filepath)
464
-
465
- const edit = await EditTool.init()
466
-
467
- // Two concurrent edits
468
- const promise1 = edit.execute(
469
- {
470
- filePath: filepath,
471
- oldString: "0",
472
- newString: "1",
473
- },
474
- ctx,
475
- )
476
-
477
- // Need to read again since FileTime tracks per-session
478
- FileTime.read(ctx.sessionID, filepath)
479
-
480
- const promise2 = edit.execute(
481
- {
482
- filePath: filepath,
483
- oldString: "0",
484
- newString: "2",
485
- },
486
- ctx,
487
- )
488
-
489
- // Both should complete without error (though one might fail due to content mismatch)
490
- const results = await Promise.allSettled([promise1, promise2])
491
- expect(results.some((r) => r.status === "fulfilled")).toBe(true)
492
- },
493
- })
494
- })
495
- })
496
- })
@@ -1,127 +0,0 @@
1
- import { describe, expect, test } from "bun:test"
2
- import path from "path"
3
- import type { Tool } from "../../src/tool/tool"
4
- import { Instance } from "../../src/project/instance"
5
- import { assertExternalDirectory } from "../../src/tool/external-directory"
6
- import type { PermissionNext } from "../../src/permission/next"
7
-
8
- const baseCtx: Omit<Tool.Context, "ask"> = {
9
- sessionID: "test",
10
- messageID: "",
11
- callID: "",
12
- agent: "build",
13
- abort: AbortSignal.any([]),
14
- messages: [],
15
- metadata: () => {},
16
- }
17
-
18
- describe("tool.assertExternalDirectory", () => {
19
- test("no-ops for empty target", async () => {
20
- const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
21
- const ctx: Tool.Context = {
22
- ...baseCtx,
23
- ask: async (req) => {
24
- requests.push(req)
25
- },
26
- }
27
-
28
- await Instance.provide({
29
- directory: "/tmp",
30
- fn: async () => {
31
- await assertExternalDirectory(ctx)
32
- },
33
- })
34
-
35
- expect(requests.length).toBe(0)
36
- })
37
-
38
- test("no-ops for paths inside Instance.directory", async () => {
39
- const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
40
- const ctx: Tool.Context = {
41
- ...baseCtx,
42
- ask: async (req) => {
43
- requests.push(req)
44
- },
45
- }
46
-
47
- await Instance.provide({
48
- directory: "/tmp/project",
49
- fn: async () => {
50
- await assertExternalDirectory(ctx, path.join("/tmp/project", "file.txt"))
51
- },
52
- })
53
-
54
- expect(requests.length).toBe(0)
55
- })
56
-
57
- test("asks with a single canonical glob", async () => {
58
- const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
59
- const ctx: Tool.Context = {
60
- ...baseCtx,
61
- ask: async (req) => {
62
- requests.push(req)
63
- },
64
- }
65
-
66
- const directory = "/tmp/project"
67
- const target = "/tmp/outside/file.txt"
68
- const expected = path.join(path.dirname(target), "*").replaceAll("\\", "/")
69
-
70
- await Instance.provide({
71
- directory,
72
- fn: async () => {
73
- await assertExternalDirectory(ctx, target)
74
- },
75
- })
76
-
77
- const req = requests.find((r) => r.permission === "external_directory")
78
- expect(req).toBeDefined()
79
- expect(req!.patterns).toEqual([expected])
80
- expect(req!.always).toEqual([expected])
81
- })
82
-
83
- test("uses target directory when kind=directory", async () => {
84
- const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
85
- const ctx: Tool.Context = {
86
- ...baseCtx,
87
- ask: async (req) => {
88
- requests.push(req)
89
- },
90
- }
91
-
92
- const directory = "/tmp/project"
93
- const target = "/tmp/outside"
94
- const expected = path.join(target, "*").replaceAll("\\", "/")
95
-
96
- await Instance.provide({
97
- directory,
98
- fn: async () => {
99
- await assertExternalDirectory(ctx, target, { kind: "directory" })
100
- },
101
- })
102
-
103
- const req = requests.find((r) => r.permission === "external_directory")
104
- expect(req).toBeDefined()
105
- expect(req!.patterns).toEqual([expected])
106
- expect(req!.always).toEqual([expected])
107
- })
108
-
109
- test("skips prompting when bypass=true", async () => {
110
- const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
111
- const ctx: Tool.Context = {
112
- ...baseCtx,
113
- ask: async (req) => {
114
- requests.push(req)
115
- },
116
- }
117
-
118
- await Instance.provide({
119
- directory: "/tmp/project",
120
- fn: async () => {
121
- await assertExternalDirectory(ctx, "/tmp/outside/file.txt", { bypass: true })
122
- },
123
- })
124
-
125
- expect(requests.length).toBe(0)
126
- })
127
- })
Binary file