@namzu/sdk 1.4.0 → 2.0.0

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 (85) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/registry/tool/execute.d.ts.map +1 -1
  3. package/dist/registry/tool/execute.js +40 -10
  4. package/dist/registry/tool/execute.js.map +1 -1
  5. package/dist/registry/tool/execute.test.js +93 -7
  6. package/dist/registry/tool/execute.test.js.map +1 -1
  7. package/dist/registry/toolset/catalog.d.ts.map +1 -1
  8. package/dist/registry/toolset/catalog.js +17 -4
  9. package/dist/registry/toolset/catalog.js.map +1 -1
  10. package/dist/registry/toolset/catalog.test.js +101 -0
  11. package/dist/registry/toolset/catalog.test.js.map +1 -1
  12. package/dist/runtime/query/__tests__/capability-negotiation.test.js +79 -0
  13. package/dist/runtime/query/__tests__/capability-negotiation.test.js.map +1 -1
  14. package/dist/runtime/query/__tests__/deferred-tools.test.js +100 -0
  15. package/dist/runtime/query/__tests__/deferred-tools.test.js.map +1 -1
  16. package/dist/runtime/query/__tests__/long-document-flow.test.js +12 -12
  17. package/dist/runtime/query/__tests__/long-document-flow.test.js.map +1 -1
  18. package/dist/runtime/query/__tests__/stream-recovery.test.js +1 -1
  19. package/dist/runtime/query/__tests__/stream-recovery.test.js.map +1 -1
  20. package/dist/runtime/query/executor.js +2 -2
  21. package/dist/runtime/query/executor.js.map +1 -1
  22. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  23. package/dist/runtime/query/iteration/index.js +10 -0
  24. package/dist/runtime/query/iteration/index.js.map +1 -1
  25. package/dist/tools/builtins/__tests__/edit.test.js +206 -54
  26. package/dist/tools/builtins/__tests__/edit.test.js.map +1 -1
  27. package/dist/tools/builtins/__tests__/payload-budget.test.js +30 -16
  28. package/dist/tools/builtins/__tests__/payload-budget.test.js.map +1 -1
  29. package/dist/tools/builtins/__tests__/write-file.test.js +37 -13
  30. package/dist/tools/builtins/__tests__/write-file.test.js.map +1 -1
  31. package/dist/tools/builtins/atomic-write-file.d.ts +13 -0
  32. package/dist/tools/builtins/atomic-write-file.d.ts.map +1 -0
  33. package/dist/tools/builtins/atomic-write-file.js +46 -0
  34. package/dist/tools/builtins/atomic-write-file.js.map +1 -0
  35. package/dist/tools/builtins/bash.js +2 -2
  36. package/dist/tools/builtins/bash.js.map +1 -1
  37. package/dist/tools/builtins/edit.d.ts +2 -5
  38. package/dist/tools/builtins/edit.d.ts.map +1 -1
  39. package/dist/tools/builtins/edit.js +107 -125
  40. package/dist/tools/builtins/edit.js.map +1 -1
  41. package/dist/tools/builtins/file-mutation-lock.d.ts +7 -0
  42. package/dist/tools/builtins/file-mutation-lock.d.ts.map +1 -0
  43. package/dist/tools/builtins/file-mutation-lock.js +25 -0
  44. package/dist/tools/builtins/file-mutation-lock.js.map +1 -0
  45. package/dist/tools/builtins/index.js +2 -2
  46. package/dist/tools/builtins/index.js.map +1 -1
  47. package/dist/tools/builtins/write-file.d.ts +1 -2
  48. package/dist/tools/builtins/write-file.d.ts.map +1 -1
  49. package/dist/tools/builtins/write-file.js +66 -40
  50. package/dist/tools/builtins/write-file.js.map +1 -1
  51. package/dist/tools/defineTool.d.ts +2 -0
  52. package/dist/tools/defineTool.d.ts.map +1 -1
  53. package/dist/tools/defineTool.js +2 -0
  54. package/dist/tools/defineTool.js.map +1 -1
  55. package/dist/types/provider/chat.d.ts +9 -0
  56. package/dist/types/provider/chat.d.ts.map +1 -1
  57. package/dist/types/sandbox/index.d.ts +4 -0
  58. package/dist/types/sandbox/index.d.ts.map +1 -1
  59. package/dist/types/sandbox/index.js.map +1 -1
  60. package/dist/types/tool/index.d.ts +14 -0
  61. package/dist/types/tool/index.d.ts.map +1 -1
  62. package/package.json +2 -1
  63. package/src/registry/tool/execute.test.ts +107 -7
  64. package/src/registry/tool/execute.ts +45 -10
  65. package/src/registry/toolset/catalog.test.ts +118 -0
  66. package/src/registry/toolset/catalog.ts +26 -4
  67. package/src/runtime/query/__tests__/capability-negotiation.test.ts +88 -0
  68. package/src/runtime/query/__tests__/deferred-tools.test.ts +111 -0
  69. package/src/runtime/query/__tests__/long-document-flow.test.ts +12 -12
  70. package/src/runtime/query/__tests__/stream-recovery.test.ts +1 -1
  71. package/src/runtime/query/executor.ts +2 -2
  72. package/src/runtime/query/iteration/index.ts +15 -0
  73. package/src/tools/builtins/__tests__/edit.test.ts +248 -64
  74. package/src/tools/builtins/__tests__/payload-budget.test.ts +40 -20
  75. package/src/tools/builtins/__tests__/write-file.test.ts +52 -13
  76. package/src/tools/builtins/atomic-write-file.ts +58 -0
  77. package/src/tools/builtins/bash.ts +2 -2
  78. package/src/tools/builtins/edit.ts +117 -163
  79. package/src/tools/builtins/file-mutation-lock.ts +27 -0
  80. package/src/tools/builtins/index.ts +2 -2
  81. package/src/tools/builtins/write-file.ts +68 -44
  82. package/src/tools/defineTool.ts +4 -0
  83. package/src/types/provider/chat.ts +14 -1
  84. package/src/types/sandbox/index.ts +4 -0
  85. package/src/types/tool/index.ts +14 -0
@@ -1,10 +1,11 @@
1
- import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
1
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
2
2
  import { tmpdir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import { describe, expect, it } from 'vitest'
5
- import { zodToJsonSchema } from 'zod-to-json-schema'
6
5
  import { ToolRegistry } from '../../../registry/tool/execute.js'
6
+ import type { Sandbox } from '../../../types/sandbox/index.js'
7
7
  import type { ToolContext } from '../../../types/tool/index.js'
8
+ import { atomicWriteFile } from '../atomic-write-file.js'
8
9
  import { EditTool } from '../edit.js'
9
10
 
10
11
  function makeContext(workingDirectory: string): ToolContext {
@@ -18,12 +19,75 @@ function makeContext(workingDirectory: string): ToolContext {
18
19
  }
19
20
 
20
21
  describe('EditTool', () => {
21
- it('accepts oldStr/newStr aliases for string replacement', async () => {
22
+ it('publishes one closed canonical replacement contract', () => {
23
+ const schema = EditTool.modelInputSchema
24
+ expect(schema).toEqual({
25
+ type: 'object',
26
+ properties: {
27
+ path: {
28
+ type: 'string',
29
+ description: 'Path to the file to edit. Must not be empty.',
30
+ },
31
+ old_string: {
32
+ type: 'string',
33
+ description:
34
+ 'Exact unique text from the file, without read-tool line-number prefixes. Must not be empty.',
35
+ },
36
+ new_string: {
37
+ type: 'string',
38
+ description:
39
+ 'Exact replacement text. May be empty to delete old_string. Keep under 12000 characters.',
40
+ },
41
+ replace_all: {
42
+ type: 'boolean',
43
+ description: 'Replace every occurrence instead of requiring one unique match.',
44
+ },
45
+ },
46
+ required: ['path', 'old_string', 'new_string'],
47
+ additionalProperties: false,
48
+ })
49
+
50
+ expect(
51
+ EditTool.inputSchema.safeParse({
52
+ path: 'doc.md',
53
+ old_string: 'old',
54
+ new_string: 'new',
55
+ replace_all: true,
56
+ }).success,
57
+ ).toBe(true)
58
+
59
+ for (const legacy of [
60
+ { path: 'doc.md', oldStr: 'old', newStr: 'new' },
61
+ { path: 'doc.md', insertLine: 'end', new_string: 'append' },
62
+ {
63
+ path: 'doc.md',
64
+ old_string: 'old',
65
+ new_string: 'new',
66
+ newStr: 'legacy',
67
+ },
68
+ ]) {
69
+ expect(EditTool.inputSchema.safeParse(legacy).success, JSON.stringify(legacy)).toBe(false)
70
+ }
71
+ expect(
72
+ EditTool.inputSchema.safeParse({
73
+ path: ' ',
74
+ old_string: 'old',
75
+ new_string: 'new',
76
+ }).success,
77
+ ).toBe(false)
78
+ })
79
+
80
+ it('replaces one exact unique string', async () => {
22
81
  const dir = mkdtempSync(join(tmpdir(), 'namzu-edit-'))
23
82
  writeFileSync(join(dir, 'doc.md'), 'alpha\nbeta\n')
24
83
 
25
84
  const result = await EditTool.execute(
26
- { path: 'doc.md', oldStr: 'beta', newStr: 'gamma', replace_all: false },
85
+ {
86
+ path: 'doc.md',
87
+ old_string: 'beta',
88
+ new_string: 'gamma',
89
+ replace_all: false,
90
+ },
27
91
  makeContext(dir),
28
92
  )
29
93
 
@@ -31,25 +95,36 @@ describe('EditTool', () => {
31
95
  expect(readFileSync(join(dir, 'doc.md'), 'utf-8')).toBe('alpha\ngamma\n')
32
96
  })
33
97
 
34
- it('inserts content after a 1-indexed line number', async () => {
98
+ it('validates a path without rewriting edge whitespace', async () => {
35
99
  const dir = mkdtempSync(join(tmpdir(), 'namzu-edit-'))
36
- writeFileSync(join(dir, 'doc.md'), 'alpha\nbeta\n')
100
+ writeFileSync(join(dir, ' doc.md '), 'alpha\n')
37
101
 
38
102
  const result = await EditTool.execute(
39
- { path: 'doc.md', insertLine: 1, newStr: 'inserted', replace_all: false },
103
+ {
104
+ path: ' doc.md ',
105
+ old_string: 'alpha',
106
+ new_string: 'beta',
107
+ replace_all: false,
108
+ },
40
109
  makeContext(dir),
41
110
  )
42
111
 
43
112
  expect(result.success).toBe(true)
44
- expect(readFileSync(join(dir, 'doc.md'), 'utf-8')).toBe('alpha\ninserted\nbeta\n')
113
+ expect(readFileSync(join(dir, ' doc.md '), 'utf-8')).toBe('beta\n')
114
+ expect(existsSync(join(dir, 'doc.md'))).toBe(false)
45
115
  })
46
116
 
47
- it('inserts content at the end with insertLine=end', async () => {
117
+ it('allows deletion with an empty new_string', async () => {
48
118
  const dir = mkdtempSync(join(tmpdir(), 'namzu-edit-'))
49
- writeFileSync(join(dir, 'doc.md'), 'alpha\n')
119
+ writeFileSync(join(dir, 'doc.md'), 'alpha\nremove me\nomega\n')
50
120
 
51
121
  const result = await EditTool.execute(
52
- { path: 'doc.md', insertLine: 'end', newStr: 'omega', replace_all: false },
122
+ {
123
+ path: 'doc.md',
124
+ old_string: 'remove me\n',
125
+ new_string: '',
126
+ replace_all: false,
127
+ },
53
128
  makeContext(dir),
54
129
  )
55
130
 
@@ -57,77 +132,186 @@ describe('EditTool', () => {
57
132
  expect(readFileSync(join(dir, 'doc.md'), 'utf-8')).toBe('alpha\nomega\n')
58
133
  })
59
134
 
60
- it('publishes only the insertLine values the executor can apply', () => {
61
- for (const insertLine of ['## Progress', null, '', '1', -1]) {
62
- const parsed = EditTool.inputSchema.safeParse({
135
+ it('replaces every exact occurrence only when replace_all is true', async () => {
136
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-edit-'))
137
+ writeFileSync(join(dir, 'doc.md'), 'alpha alpha alpha')
138
+
139
+ const result = await EditTool.execute(
140
+ {
63
141
  path: 'doc.md',
64
- insertLine,
65
- newStr: 'inserted',
66
- })
67
- expect(parsed.success, JSON.stringify(insertLine)).toBe(false)
68
- }
142
+ old_string: 'alpha',
143
+ new_string: 'beta',
144
+ replace_all: true,
145
+ },
146
+ makeContext(dir),
147
+ )
148
+
149
+ expect(result.success).toBe(true)
150
+ expect(result.data).toMatchObject({ replacements: 3 })
151
+ expect(readFileSync(join(dir, 'doc.md'), 'utf-8')).toBe('beta beta beta')
152
+ })
153
+
154
+ it('refuses ambiguous exact matches instead of guessing', async () => {
155
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-edit-'))
156
+ writeFileSync(join(dir, 'doc.md'), 'alpha\nalpha\n')
69
157
 
70
- for (const insertLine of [0, 1, 'end']) {
71
- const parsed = EditTool.inputSchema.safeParse({
158
+ const result = await EditTool.execute(
159
+ {
72
160
  path: 'doc.md',
73
- insertLine,
74
- newStr: 'inserted',
75
- })
76
- expect(parsed.success, JSON.stringify(insertLine)).toBe(true)
77
- }
161
+ old_string: 'alpha',
162
+ new_string: 'beta',
163
+ replace_all: false,
164
+ },
165
+ makeContext(dir),
166
+ )
78
167
 
79
- const json = zodToJsonSchema(EditTool.inputSchema, {
80
- target: 'jsonSchema7',
81
- $refStrategy: 'none',
82
- }) as {
83
- properties?: {
84
- insertLine?: {
85
- anyOf?: Record<string, unknown>[]
86
- }
87
- }
88
- }
89
- expect(json.properties?.insertLine?.anyOf).toEqual([
90
- { type: 'integer', minimum: 0 },
91
- { type: 'string', const: 'end' },
92
- ])
168
+ expect(result.success).toBe(false)
169
+ expect(result.error).toContain('old_string is not unique')
170
+ expect(readFileSync(join(dir, 'doc.md'), 'utf-8')).toBe('alpha\nalpha\n')
93
171
  })
94
172
 
95
- it('refuses invalid insertLine values even when a caller bypasses the schema', async () => {
96
- for (const insertLine of ['## Progress', null, '', '1']) {
97
- const dir = mkdtempSync(join(tmpdir(), 'namzu-edit-'))
98
- writeFileSync(join(dir, 'doc.md'), 'alpha\nbeta\n')
99
-
100
- const result = await EditTool.execute(
101
- {
102
- path: 'doc.md',
103
- insertLine,
104
- newStr: 'inserted',
105
- replace_all: false,
106
- } as never,
107
- makeContext(dir),
108
- )
109
-
110
- expect(result.success, JSON.stringify(insertLine)).toBe(false)
111
- expect(result.error).toBe('insertLine must be a non-negative line number or "end".')
112
- expect(readFileSync(join(dir, 'doc.md'), 'utf-8')).toBe('alpha\nbeta\n')
173
+ it('normalizes consistent CRLF/LF boundaries without fuzzy matching', async () => {
174
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-edit-'))
175
+ writeFileSync(join(dir, 'doc.md'), 'alpha\r\nbeta\r\nomega\r\n')
176
+
177
+ const result = await EditTool.execute(
178
+ {
179
+ path: 'doc.md',
180
+ old_string: 'alpha\nbeta',
181
+ new_string: 'gamma\ndelta',
182
+ replace_all: false,
183
+ },
184
+ makeContext(dir),
185
+ )
186
+
187
+ expect(result.success).toBe(true)
188
+ expect(readFileSync(join(dir, 'doc.md'), 'utf-8')).toBe('gamma\r\ndelta\r\nomega\r\n')
189
+ })
190
+
191
+ it('rejects legacy and extra fields even when execute is called directly', async () => {
192
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-edit-'))
193
+ writeFileSync(join(dir, 'doc.md'), 'alpha\n')
194
+
195
+ for (const input of [
196
+ { path: 'doc.md', oldStr: 'alpha', newStr: 'beta' },
197
+ {
198
+ path: 'doc.md',
199
+ old_string: 'alpha',
200
+ new_string: 'beta',
201
+ insertLine: 'end',
202
+ },
203
+ ]) {
204
+ const result = await EditTool.execute(input as never, makeContext(dir))
205
+ expect(result.success, JSON.stringify(input)).toBe(false)
206
+ expect(result.error).toContain('Invalid edit input')
113
207
  }
208
+
209
+ expect(readFileSync(join(dir, 'doc.md'), 'utf-8')).toBe('alpha\n')
114
210
  })
115
211
 
116
- it('returns complete recovery shapes when path is missing and insertLine is invalid', async () => {
212
+ it('returns one canonical recovery shape after registry validation fails', async () => {
117
213
  const registry = new ToolRegistry()
118
214
  registry.register(EditTool)
119
215
 
120
216
  const result = await registry.execute(
121
217
  'edit',
122
- { insertLine: '## Progress' },
218
+ { insertLine: '"end"', newStr: 'content' },
123
219
  makeContext('/tmp'),
124
220
  )
125
221
 
126
222
  expect(result.success).toBe(false)
127
223
  expect(result.error).toContain('Validation failed for "edit":')
128
- expect(result.error).toContain('insertLine: Invalid input')
129
- expect(result.error).toContain('Required: path: string — Path to the file to edit.')
130
- expect(result.error).toContain('{"path":"file.md","insertLine":"end","new_string":"text"}')
131
- expect(result.error).toContain('{"path":"file.md","old_string":"old","new_string":"new"}')
224
+ expect(result.error).toContain('Required: path: string')
225
+ expect(result.error).toContain(
226
+ '{"path":"file.md","old_string":"exact unique text","new_string":"replacement text"}',
227
+ )
228
+ expect(result.error).not.toContain('Accepted shapes')
229
+ })
230
+
231
+ it('serializes independent EditTool read-modify-write cycles for one sandbox path', async () => {
232
+ let body = Buffer.from('alpha')
233
+ let reads = 0
234
+ let writes = 0
235
+ let releaseFirstWrite = () => {}
236
+ let markFirstWriteStarted = () => {}
237
+ const firstWriteStarted = new Promise<void>((resolve) => {
238
+ markFirstWriteStarted = resolve
239
+ })
240
+ const firstWriteGate = new Promise<void>((resolve) => {
241
+ releaseFirstWrite = resolve
242
+ })
243
+ const sandbox = {
244
+ id: 'sbx_edit_lock',
245
+ status: 'ready',
246
+ rootDir: '/workspace',
247
+ environment: 'basic',
248
+ async readFile() {
249
+ reads += 1
250
+ return Buffer.from(body)
251
+ },
252
+ async writeFile(_path: string, content: string | Buffer) {
253
+ writes += 1
254
+ if (writes === 1) {
255
+ markFirstWriteStarted()
256
+ await firstWriteGate
257
+ }
258
+ body = Buffer.isBuffer(content) ? Buffer.from(content) : Buffer.from(content)
259
+ },
260
+ async exec() {
261
+ throw new Error('not used')
262
+ },
263
+ async listFiles() {
264
+ return []
265
+ },
266
+ async destroy() {},
267
+ } as Sandbox
268
+ const firstContext = { ...makeContext('/workspace'), sandbox }
269
+ const secondContext = { ...makeContext('/workspace'), sandbox }
270
+
271
+ const first = EditTool.execute(
272
+ {
273
+ path: 'doc.md',
274
+ old_string: 'alpha',
275
+ new_string: 'beta',
276
+ replace_all: false,
277
+ },
278
+ firstContext,
279
+ )
280
+ await firstWriteStarted
281
+ const second = EditTool.execute(
282
+ {
283
+ path: 'doc.md',
284
+ old_string: 'beta',
285
+ new_string: 'gamma',
286
+ replace_all: false,
287
+ },
288
+ secondContext,
289
+ )
290
+ await Promise.resolve()
291
+ expect(reads).toBe(1)
292
+
293
+ releaseFirstWrite()
294
+ const results = await Promise.all([first, second])
295
+ expect(results.every((result) => result.success)).toBe(true)
296
+ expect(reads).toBe(2)
297
+ expect(writes).toBe(2)
298
+ expect(body.toString('utf-8')).toBe('gamma')
299
+ })
300
+
301
+ it('leaves the original intact when an atomic local write fails before commit', async () => {
302
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-atomic-write-'))
303
+ const filePath = join(dir, 'doc.md')
304
+ writeFileSync(filePath, 'original')
305
+
306
+ await expect(
307
+ atomicWriteFile(filePath, 'replacement', {
308
+ beforeCommit: async () => {
309
+ throw new Error('injected pre-commit failure')
310
+ },
311
+ }),
312
+ ).rejects.toThrow('injected pre-commit failure')
313
+
314
+ expect(readFileSync(filePath, 'utf-8')).toBe('original')
315
+ expect(readdirSync(dir).filter((entry) => entry.includes('.namzu-'))).toEqual([])
132
316
  })
133
317
  })
@@ -17,8 +17,8 @@ describe('filesystem tool payload budgeting', () => {
17
17
  expect(() =>
18
18
  EditTool.inputSchema.parse({
19
19
  path: 'outputs/long.md',
20
- oldStr: '{{SECTION}}',
21
- newStr: oversized,
20
+ old_string: '{{SECTION}}',
21
+ new_string: oversized,
22
22
  replace_all: false,
23
23
  }),
24
24
  ).not.toThrow()
@@ -31,7 +31,7 @@ describe('filesystem tool payload budgeting', () => {
31
31
  expect(names).not.toContain('append')
32
32
  })
33
33
 
34
- it('assembles long documents with bounded write plus edit insert chunks', async () => {
34
+ it('assembles replay-safe long documents by advancing an exact marker', async () => {
35
35
  const dir = mkdtempSync(join(tmpdir(), 'namzu-long-doc-'))
36
36
  const ctx: ToolContext = {
37
37
  runId: 'run_test' as ToolContext['runId'],
@@ -40,12 +40,12 @@ describe('filesystem tool payload budgeting', () => {
40
40
  env: {},
41
41
  log: () => {},
42
42
  }
43
- const opening = '# Long document regression\n\n{{BODY}}\n'
43
+ const opening = '# Long document regression\n\n{{CHUNK_001}}\n'
44
44
  const chunks = Array.from({ length: 6 }, (_, sectionIndex) => {
45
45
  const lines = Array.from({ length: 45 }, (_, lineIndex) => {
46
46
  const section = sectionIndex + 1
47
47
  const line = lineIndex + 1
48
- return `Section ${section}.${line}: this bounded paragraph proves long-form output grows through edit insertions rather than one oversized JSON tool argument.`
48
+ return `Section ${section}.${line}: this bounded paragraph proves long-form output grows through exact marker edits rather than one oversized JSON tool argument.`
49
49
  })
50
50
  const chunk = [`## Section ${sectionIndex + 1}`, ...lines, ''].join('\n')
51
51
  expect(chunk.length).toBeLessThan(12_000)
@@ -58,32 +58,52 @@ describe('filesystem tool payload budgeting', () => {
58
58
  )
59
59
  expect(writeResult.success).toBe(true)
60
60
 
61
- const firstEdit = await EditTool.execute(
62
- {
63
- path: 'outputs/regression-long-document.md',
64
- oldStr: '{{BODY}}',
65
- newStr: chunks[0],
66
- replace_all: false,
67
- },
68
- ctx,
69
- )
70
- expect(firstEdit.success).toBe(true)
71
-
72
- for (const chunk of chunks.slice(1)) {
61
+ for (const [index, chunk] of chunks.entries()) {
62
+ const currentMarker = `{{CHUNK_${String(index + 1).padStart(3, '0')}}}`
63
+ const nextMarker = `{{CHUNK_${String(index + 2).padStart(3, '0')}}}`
73
64
  const result = await EditTool.execute(
74
65
  {
75
66
  path: 'outputs/regression-long-document.md',
76
- insertLine: 'end',
77
- newStr: chunk,
67
+ old_string: currentMarker,
68
+ new_string: `${chunk}\n${nextMarker}`,
78
69
  replace_all: false,
79
70
  },
80
71
  ctx,
81
72
  )
82
73
  expect(result.success).toBe(true)
74
+
75
+ if (index === 0) {
76
+ const beforeReplay = readFileSync(join(dir, 'outputs/regression-long-document.md'), 'utf-8')
77
+ const replay = await EditTool.execute(
78
+ {
79
+ path: 'outputs/regression-long-document.md',
80
+ old_string: currentMarker,
81
+ new_string: `${chunk}\n${nextMarker}`,
82
+ replace_all: false,
83
+ },
84
+ ctx,
85
+ )
86
+ expect(replay.success).toBe(false)
87
+ expect(readFileSync(join(dir, 'outputs/regression-long-document.md'), 'utf-8')).toBe(
88
+ beforeReplay,
89
+ )
90
+ }
83
91
  }
84
92
 
93
+ const finalMarker = `{{CHUNK_${String(chunks.length + 1).padStart(3, '0')}}}`
94
+ const finalize = await EditTool.execute(
95
+ {
96
+ path: 'outputs/regression-long-document.md',
97
+ old_string: finalMarker,
98
+ new_string: '',
99
+ replace_all: false,
100
+ },
101
+ ctx,
102
+ )
103
+ expect(finalize.success).toBe(true)
104
+
85
105
  const final = readFileSync(join(dir, 'outputs/regression-long-document.md'), 'utf-8')
86
- expect(final).not.toContain('{{BODY}}')
106
+ expect(final).not.toContain('{{CHUNK_')
87
107
  expect(final.split('\n').length).toBeGreaterThan(250)
88
108
  expect(final).toContain('## Section 1')
89
109
  expect(final).toContain('## Section 6')
@@ -8,10 +8,10 @@ import { WriteFileTool } from '../write-file.js'
8
8
  function makeTracker(): FileReadTracker & { keys(): string[] } {
9
9
  const set = new Set<string>()
10
10
  return {
11
- recordRead: (k) => {
12
- set.add(k)
11
+ recordRead: (key) => {
12
+ set.add(key)
13
13
  },
14
- hasRead: (k) => set.has(k),
14
+ hasRead: (key) => set.has(key),
15
15
  keys: () => Array.from(set),
16
16
  }
17
17
  }
@@ -27,7 +27,33 @@ function makeContext(workingDirectory: string, tracker?: FileReadTracker): ToolC
27
27
  }
28
28
  }
29
29
 
30
- describe('WriteFileTool — read-before-overwrite invariant', () => {
30
+ describe('WriteFileTool — canonical contract and read-before-overwrite invariant', () => {
31
+ it('publishes one closed path + content contract', () => {
32
+ expect(WriteFileTool.modelInputSchema).toEqual({
33
+ type: 'object',
34
+ properties: {
35
+ path: {
36
+ type: 'string',
37
+ description: 'Non-empty path to the file to write.',
38
+ },
39
+ content: {
40
+ type: 'string',
41
+ description:
42
+ 'Complete bounded file body. May be empty only for an intentionally empty file.',
43
+ },
44
+ },
45
+ required: ['path', 'content'],
46
+ additionalProperties: false,
47
+ })
48
+ expect(WriteFileTool.inputSchema.safeParse({ path: 'doc.md', content: '' }).success).toBe(true)
49
+ expect(WriteFileTool.inputSchema.safeParse({ path: 'doc.md', newStr: 'legacy' }).success).toBe(
50
+ false,
51
+ )
52
+ expect(WriteFileTool.inputSchema.safeParse({ path: ' ', content: 'body' }).success).toBe(
53
+ false,
54
+ )
55
+ })
56
+
31
57
  it('writes a new file without requiring a prior read', async () => {
32
58
  const dir = mkdtempSync(join(tmpdir(), 'namzu-write-'))
33
59
  const tracker = makeTracker()
@@ -40,15 +66,28 @@ describe('WriteFileTool — read-before-overwrite invariant', () => {
40
66
  expect(tracker.hasRead(join(dir, 'fresh.txt'))).toBe(true)
41
67
  })
42
68
 
43
- it('accepts newStr as the canonical create/write content alias', async () => {
69
+ it('preserves edge whitespace in a valid path', async () => {
44
70
  const dir = mkdtempSync(join(tmpdir(), 'namzu-write-'))
45
- const tracker = makeTracker()
46
- const ctx = makeContext(dir, tracker)
47
71
 
48
- const result = await WriteFileTool.execute({ path: 'fresh.txt', newStr: 'hello' }, ctx)
72
+ const result = await WriteFileTool.execute(
73
+ { path: ' fresh.txt ', content: 'hello' },
74
+ makeContext(dir),
75
+ )
49
76
 
50
77
  expect(result.success).toBe(true)
51
- expect(readFileSync(join(dir, 'fresh.txt'), 'utf-8')).toBe('hello')
78
+ expect(readFileSync(join(dir, ' fresh.txt '), 'utf-8')).toBe('hello')
79
+ })
80
+
81
+ it('rejects legacy content aliases even when execute is called directly', async () => {
82
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-write-'))
83
+
84
+ const result = await WriteFileTool.execute(
85
+ { path: 'fresh.txt', newStr: 'legacy' } as never,
86
+ makeContext(dir),
87
+ )
88
+
89
+ expect(result.success).toBe(false)
90
+ expect(result.error).toContain('Invalid write input')
52
91
  })
53
92
 
54
93
  it('refuses to overwrite an existing file the agent has not read', async () => {
@@ -84,14 +123,14 @@ describe('WriteFileTool — read-before-overwrite invariant', () => {
84
123
  expect(readFileSync(filePath, 'utf-8')).toBe('replaced')
85
124
  })
86
125
 
87
- it('falls back to legacy behaviour when no fileReadTracker is provided (back-compat)', async () => {
126
+ it('preserves existing hosts without a file read tracker', async () => {
88
127
  const dir = mkdtempSync(join(tmpdir(), 'namzu-write-'))
89
- writeFileSync(join(dir, 'legacy.txt'), 'before')
128
+ writeFileSync(join(dir, 'existing.txt'), 'before')
90
129
  const ctx = makeContext(dir)
91
130
 
92
- const result = await WriteFileTool.execute({ path: 'legacy.txt', content: 'after' }, ctx)
131
+ const result = await WriteFileTool.execute({ path: 'existing.txt', content: 'after' }, ctx)
93
132
 
94
133
  expect(result.success).toBe(true)
95
- expect(readFileSync(join(dir, 'legacy.txt'), 'utf-8')).toBe('after')
134
+ expect(readFileSync(join(dir, 'existing.txt'), 'utf-8')).toBe('after')
96
135
  })
97
136
  })
@@ -0,0 +1,58 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { type FileHandle, mkdir, open, rename, stat, unlink } from 'node:fs/promises'
3
+ import { basename, dirname, join } from 'node:path'
4
+
5
+ interface AtomicWriteHooks {
6
+ /** Internal fault-injection seam; production callers never pass this. */
7
+ beforeCommit?: () => Promise<void>
8
+ }
9
+
10
+ /**
11
+ * Commit a complete file body without exposing a truncated destination.
12
+ *
13
+ * The temp file lives beside the destination so rename stays on one
14
+ * filesystem. A failed pre-commit write leaves the original untouched.
15
+ */
16
+ export async function atomicWriteFile(
17
+ filePath: string,
18
+ content: string,
19
+ hooks: AtomicWriteHooks = {},
20
+ ): Promise<void> {
21
+ const directory = dirname(filePath)
22
+ await mkdir(directory, { recursive: true })
23
+
24
+ const existingMode = await stat(filePath)
25
+ .then((value) => value.mode)
26
+ .catch(() => undefined)
27
+ const tempPath = join(
28
+ directory,
29
+ `.${basename(filePath)}.namzu-${process.pid}-${randomUUID()}.tmp`,
30
+ )
31
+ let handle: FileHandle | undefined
32
+ let committed = false
33
+
34
+ try {
35
+ handle = await open(tempPath, 'wx', existingMode ?? 0o666)
36
+ await handle.writeFile(content, 'utf-8')
37
+ await handle.sync()
38
+ await handle.close()
39
+ handle = undefined
40
+ await hooks.beforeCommit?.()
41
+ await rename(tempPath, filePath)
42
+ committed = true
43
+ await syncDirectory(directory)
44
+ } finally {
45
+ await handle?.close().catch(() => undefined)
46
+ if (!committed) await unlink(tempPath).catch(() => undefined)
47
+ }
48
+ }
49
+
50
+ async function syncDirectory(directory: string): Promise<void> {
51
+ let handle: FileHandle | undefined
52
+ try {
53
+ handle = await open(directory, 'r')
54
+ await handle.sync()
55
+ } finally {
56
+ await handle?.close().catch(() => undefined)
57
+ }
58
+ }
@@ -22,7 +22,7 @@ const inputSchema = z.object({
22
22
  .string()
23
23
  .min(1)
24
24
  .describe(
25
- 'The bash command to execute. Required, non-empty. Single command per call (use `&&` / `;` chaining for compound commands). Avoid heredocs that span more than a few hundred bytes — large content should be created with `write`, then extended with `edit` insertLine: "end", not piped into bash.',
25
+ 'The bash command to execute. Required, non-empty. Single command per call (use `&&` / `;` chaining for compound commands). Avoid heredocs that span more than a few hundred bytes — create large content with bounded write plus exact marker-advancing edit calls instead.',
26
26
  ),
27
27
  timeout: z
28
28
  .preprocess(
@@ -41,7 +41,7 @@ function isDangerousCommand(command: string): boolean {
41
41
  export const BashTool = defineTool({
42
42
  name: 'bash',
43
43
  description:
44
- 'Executes a bash command and returns stdout/stderr output. Command timeout is configurable. The `command` parameter is required — never call this tool with empty arguments. For very long content (e.g. building a large file), prefer `write` for the opening and `edit` with insertLine: "end" for follow-up chunks over a heredoc to avoid hitting the output token limit mid-stream.',
44
+ 'Executes a bash command and returns stdout/stderr output. Command timeout is configurable. The `command` parameter is required — never call this tool with empty arguments. For very long content, prefer a bounded write with a deterministic marker followed by exact marker-advancing edit calls over a heredoc.',
45
45
  inputSchema,
46
46
  category: 'shell',
47
47
  permissions: ['shell_execute'],