snow-flow 10.0.185 → 10.0.186-dev.682

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 (62) hide show
  1. package/bin/index.js.map +9 -9
  2. package/bin/worker.js.map +7 -7
  3. package/mcp/servicenow-unified.js +116 -116
  4. package/package.json +1 -1
  5. package/parsers-config.ts +2 -1
  6. package/src/bun/index.ts +10 -9
  7. package/src/cli/cmd/agent.ts +3 -3
  8. package/src/cli/cmd/auth.ts +46 -0
  9. package/src/cli/cmd/import.ts +2 -2
  10. package/src/cli/cmd/session.ts +9 -12
  11. package/src/cli/cmd/tui/component/prompt/autocomplete.tsx +2 -1
  12. package/src/cli/cmd/tui/component/prompt/index.tsx +19 -6
  13. package/src/cli/cmd/tui/component/spinner.tsx +24 -0
  14. package/src/cli/cmd/tui/context/exit.tsx +1 -1
  15. package/src/cli/cmd/tui/routes/home.tsx +16 -2
  16. package/src/cli/cmd/tui/routes/session/index.tsx +122 -53
  17. package/src/cli/cmd/tui/routes/session/permission.tsx +9 -1
  18. package/src/cli/cmd/tui/routes/session/sidebar.tsx +9 -1
  19. package/src/cli/cmd/tui/thread.ts +4 -1
  20. package/src/cli/cmd/tui/ui/dialog-export-options.tsx +1 -1
  21. package/src/cli/cmd/tui/util/clipboard.ts +3 -3
  22. package/src/cli/cmd/tui/worker.ts +6 -1
  23. package/src/config/config.ts +28 -0
  24. package/src/context/context-db.ts +437 -0
  25. package/src/format/formatter.ts +14 -5
  26. package/src/global/index.ts +3 -4
  27. package/src/mcp/index.ts +7 -2
  28. package/src/mcp/oauth-callback.ts +7 -15
  29. package/src/mcp/oauth-provider.ts +34 -3
  30. package/src/project/project.ts +8 -4
  31. package/src/provider/models.ts +1 -1
  32. package/src/provider/provider.ts +88 -9
  33. package/src/provider/transform.ts +7 -2
  34. package/src/servicenow/servicenow-mcp-unified/tools/agile/snow_agile_capacity_plan.ts +20 -7
  35. package/src/servicenow/servicenow-mcp-unified/tools/agile/snow_agile_retrospective.ts +6 -8
  36. package/src/servicenow/servicenow-mcp-unified/tools/agile/snow_agile_sprint_manage.ts +46 -28
  37. package/src/servicenow/servicenow-mcp-unified/tools/agile/snow_agile_team_manage.ts +53 -41
  38. package/src/servicenow/servicenow-mcp-unified/tools/agile/snow_agile_velocity_report.ts +8 -1
  39. package/src/servicenow/servicenow-mcp-unified/tools/automation/snow_schedule_script_job.ts +388 -243
  40. package/src/session/compaction.ts +126 -23
  41. package/src/session/message-v2.ts +33 -10
  42. package/src/session/processor.ts +29 -17
  43. package/src/session/prompt.ts +34 -6
  44. package/src/share/share-next.ts +2 -2
  45. package/src/shell/shell.ts +2 -1
  46. package/src/tool/edit.ts +15 -1
  47. package/src/tool/registry.ts +9 -1
  48. package/src/tool/truncation.ts +17 -0
  49. package/src/tool/websearch.ts +1 -1
  50. package/src/tool/websearch.txt +2 -2
  51. package/src/tool/write.ts +3 -4
  52. package/src/util/filesystem.ts +36 -7
  53. package/src/util/keybind.ts +1 -1
  54. package/src/util/log.ts +8 -5
  55. package/src/util/token.ts +28 -0
  56. package/test/cli/plugin-auth-picker.test.ts +120 -0
  57. package/test/fixture/fixture.ts +3 -0
  58. package/test/mcp/oauth-auto-connect.test.ts +197 -0
  59. package/test/project/project.test.ts +47 -0
  60. package/test/provider/provider.test.ts +2 -0
  61. package/test/provider/transform.test.ts +32 -0
  62. package/test/tool/edit.test.ts +679 -0
@@ -0,0 +1,679 @@
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("line endings", () => {
455
+ const old = "alpha\nbeta\ngamma"
456
+ const next = "alpha\nbeta-updated\ngamma"
457
+ const alt = "alpha\nbeta\nomega"
458
+
459
+ const normalize = (text: string, ending: "\n" | "\r\n") => {
460
+ const normalized = text.replaceAll("\r\n", "\n")
461
+ if (ending === "\n") return normalized
462
+ return normalized.replaceAll("\n", "\r\n")
463
+ }
464
+
465
+ const count = (content: string) => {
466
+ const crlf = content.match(/\r\n/g)?.length ?? 0
467
+ const lf = content.match(/\n/g)?.length ?? 0
468
+ return {
469
+ crlf,
470
+ lf: lf - crlf,
471
+ }
472
+ }
473
+
474
+ const expectLf = (content: string) => {
475
+ const counts = count(content)
476
+ expect(counts.crlf).toBe(0)
477
+ expect(counts.lf).toBeGreaterThan(0)
478
+ }
479
+
480
+ const expectCrlf = (content: string) => {
481
+ const counts = count(content)
482
+ expect(counts.lf).toBe(0)
483
+ expect(counts.crlf).toBeGreaterThan(0)
484
+ }
485
+
486
+ type Input = {
487
+ content: string
488
+ oldString: string
489
+ newString: string
490
+ replaceAll?: boolean
491
+ }
492
+
493
+ const apply = async (input: Input) => {
494
+ await using tmp = await tmpdir({
495
+ init: async (dir) => {
496
+ await Bun.write(path.join(dir, "test.txt"), input.content)
497
+ },
498
+ })
499
+
500
+ return await Instance.provide({
501
+ directory: tmp.path,
502
+ fn: async () => {
503
+ const edit = await EditTool.init()
504
+ const filePath = path.join(tmp.path, "test.txt")
505
+ FileTime.read(ctx.sessionID, filePath)
506
+ await edit.execute(
507
+ {
508
+ filePath,
509
+ oldString: input.oldString,
510
+ newString: input.newString,
511
+ replaceAll: input.replaceAll,
512
+ },
513
+ ctx,
514
+ )
515
+ return await Bun.file(filePath).text()
516
+ },
517
+ })
518
+ }
519
+
520
+ test("preserves LF with LF multi-line strings", async () => {
521
+ const content = normalize(old + "\n", "\n")
522
+ const output = await apply({
523
+ content,
524
+ oldString: normalize(old, "\n"),
525
+ newString: normalize(next, "\n"),
526
+ })
527
+ expect(output).toBe(normalize(next + "\n", "\n"))
528
+ expectLf(output)
529
+ })
530
+
531
+ test("preserves CRLF with CRLF multi-line strings", async () => {
532
+ const content = normalize(old + "\n", "\r\n")
533
+ const output = await apply({
534
+ content,
535
+ oldString: normalize(old, "\r\n"),
536
+ newString: normalize(next, "\r\n"),
537
+ })
538
+ expect(output).toBe(normalize(next + "\n", "\r\n"))
539
+ expectCrlf(output)
540
+ })
541
+
542
+ test("preserves LF when old/new use CRLF", async () => {
543
+ const content = normalize(old + "\n", "\n")
544
+ const output = await apply({
545
+ content,
546
+ oldString: normalize(old, "\r\n"),
547
+ newString: normalize(next, "\r\n"),
548
+ })
549
+ expect(output).toBe(normalize(next + "\n", "\n"))
550
+ expectLf(output)
551
+ })
552
+
553
+ test("preserves CRLF when old/new use LF", async () => {
554
+ const content = normalize(old + "\n", "\r\n")
555
+ const output = await apply({
556
+ content,
557
+ oldString: normalize(old, "\n"),
558
+ newString: normalize(next, "\n"),
559
+ })
560
+ expect(output).toBe(normalize(next + "\n", "\r\n"))
561
+ expectCrlf(output)
562
+ })
563
+
564
+ test("preserves LF when newString uses CRLF", async () => {
565
+ const content = normalize(old + "\n", "\n")
566
+ const output = await apply({
567
+ content,
568
+ oldString: normalize(old, "\n"),
569
+ newString: normalize(next, "\r\n"),
570
+ })
571
+ expect(output).toBe(normalize(next + "\n", "\n"))
572
+ expectLf(output)
573
+ })
574
+
575
+ test("preserves CRLF when newString uses LF", async () => {
576
+ const content = normalize(old + "\n", "\r\n")
577
+ const output = await apply({
578
+ content,
579
+ oldString: normalize(old, "\r\n"),
580
+ newString: normalize(next, "\n"),
581
+ })
582
+ expect(output).toBe(normalize(next + "\n", "\r\n"))
583
+ expectCrlf(output)
584
+ })
585
+
586
+ test("preserves LF with mixed old/new line endings", async () => {
587
+ const content = normalize(old + "\n", "\n")
588
+ const output = await apply({
589
+ content,
590
+ oldString: "alpha\nbeta\r\ngamma",
591
+ newString: "alpha\r\nbeta\nomega",
592
+ })
593
+ expect(output).toBe(normalize(alt + "\n", "\n"))
594
+ expectLf(output)
595
+ })
596
+
597
+ test("preserves CRLF with mixed old/new line endings", async () => {
598
+ const content = normalize(old + "\n", "\r\n")
599
+ const output = await apply({
600
+ content,
601
+ oldString: "alpha\r\nbeta\ngamma",
602
+ newString: "alpha\nbeta\r\nomega",
603
+ })
604
+ expect(output).toBe(normalize(alt + "\n", "\r\n"))
605
+ expectCrlf(output)
606
+ })
607
+
608
+ test("replaceAll preserves LF for multi-line blocks", async () => {
609
+ const blockOld = "alpha\nbeta"
610
+ const blockNew = "alpha\nbeta-updated"
611
+ const content = normalize(blockOld + "\n" + blockOld + "\n", "\n")
612
+ const output = await apply({
613
+ content,
614
+ oldString: normalize(blockOld, "\n"),
615
+ newString: normalize(blockNew, "\n"),
616
+ replaceAll: true,
617
+ })
618
+ expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\n"))
619
+ expectLf(output)
620
+ })
621
+
622
+ test("replaceAll preserves CRLF for multi-line blocks", async () => {
623
+ const blockOld = "alpha\nbeta"
624
+ const blockNew = "alpha\nbeta-updated"
625
+ const content = normalize(blockOld + "\n" + blockOld + "\n", "\r\n")
626
+ const output = await apply({
627
+ content,
628
+ oldString: normalize(blockOld, "\r\n"),
629
+ newString: normalize(blockNew, "\r\n"),
630
+ replaceAll: true,
631
+ })
632
+ expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\r\n"))
633
+ expectCrlf(output)
634
+ })
635
+ })
636
+
637
+ describe("concurrent editing", () => {
638
+ test("serializes concurrent edits to same file", async () => {
639
+ await using tmp = await tmpdir()
640
+ const filepath = path.join(tmp.path, "file.txt")
641
+ await fs.writeFile(filepath, "0", "utf-8")
642
+
643
+ await Instance.provide({
644
+ directory: tmp.path,
645
+ fn: async () => {
646
+ FileTime.read(ctx.sessionID, filepath)
647
+
648
+ const edit = await EditTool.init()
649
+
650
+ // Two concurrent edits
651
+ const promise1 = edit.execute(
652
+ {
653
+ filePath: filepath,
654
+ oldString: "0",
655
+ newString: "1",
656
+ },
657
+ ctx,
658
+ )
659
+
660
+ // Need to read again since FileTime tracks per-session
661
+ FileTime.read(ctx.sessionID, filepath)
662
+
663
+ const promise2 = edit.execute(
664
+ {
665
+ filePath: filepath,
666
+ oldString: "0",
667
+ newString: "2",
668
+ },
669
+ ctx,
670
+ )
671
+
672
+ // Both should complete without error (though one might fail due to content mismatch)
673
+ const results = await Promise.allSettled([promise1, promise2])
674
+ expect(results.some((r) => r.status === "fulfilled")).toBe(true)
675
+ },
676
+ })
677
+ })
678
+ })
679
+ })