@namzu/sdk 3.0.0 → 3.1.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.
- package/CHANGELOG.md +46 -0
- package/dist/public-runtime.d.ts +1 -1
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +6 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/runtime/query/__tests__/capability-negotiation.test.js +59 -0
- package/dist/runtime/query/__tests__/capability-negotiation.test.js.map +1 -1
- package/dist/runtime/query/executor.d.ts +1 -0
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +10 -1
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +22 -0
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/tools/builtins/__tests__/edit-commits-atomically.test.d.ts +2 -0
- package/dist/tools/builtins/__tests__/edit-commits-atomically.test.d.ts.map +1 -0
- package/dist/tools/builtins/__tests__/edit-commits-atomically.test.js +78 -0
- package/dist/tools/builtins/__tests__/edit-commits-atomically.test.js.map +1 -0
- package/dist/tools/builtins/__tests__/edit.test.js +242 -1
- package/dist/tools/builtins/__tests__/edit.test.js.map +1 -1
- package/dist/tools/builtins/__tests__/stale-file-refusal.test.d.ts +2 -0
- package/dist/tools/builtins/__tests__/stale-file-refusal.test.d.ts.map +1 -0
- package/dist/tools/builtins/__tests__/stale-file-refusal.test.js +187 -0
- package/dist/tools/builtins/__tests__/stale-file-refusal.test.js.map +1 -0
- package/dist/tools/builtins/atomic-write-file.d.ts +1 -1
- package/dist/tools/builtins/atomic-write-file.d.ts.map +1 -1
- package/dist/tools/builtins/atomic-write-file.js +31 -2
- package/dist/tools/builtins/atomic-write-file.js.map +1 -1
- package/dist/tools/builtins/content-fingerprint.d.ts +23 -0
- package/dist/tools/builtins/content-fingerprint.d.ts.map +1 -0
- package/dist/tools/builtins/content-fingerprint.js +30 -0
- package/dist/tools/builtins/content-fingerprint.js.map +1 -0
- package/dist/tools/builtins/edit.d.ts.map +1 -1
- package/dist/tools/builtins/edit.js +172 -31
- package/dist/tools/builtins/edit.js.map +1 -1
- package/dist/tools/builtins/read-file.d.ts.map +1 -1
- package/dist/tools/builtins/read-file.js +5 -2
- package/dist/tools/builtins/read-file.js.map +1 -1
- package/dist/tools/builtins/write-file.d.ts.map +1 -1
- package/dist/tools/builtins/write-file.js +75 -27
- package/dist/tools/builtins/write-file.js.map +1 -1
- package/dist/tools/coordinator/__tests__/ask-user-question.test.js +55 -0
- package/dist/tools/coordinator/__tests__/ask-user-question.test.js.map +1 -1
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +62 -4
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/types/tool/index.d.ts +17 -1
- package/dist/types/tool/index.d.ts.map +1 -1
- package/dist/types/tool/index.js.map +1 -1
- package/package.json +1 -1
- package/src/public-runtime.ts +8 -1
- package/src/runtime/query/__tests__/capability-negotiation.test.ts +69 -0
- package/src/runtime/query/executor.ts +9 -1
- package/src/runtime/query/iteration/index.ts +27 -0
- package/src/tools/builtins/__tests__/edit-commits-atomically.test.ts +103 -0
- package/src/tools/builtins/__tests__/edit.test.ts +324 -1
- package/src/tools/builtins/__tests__/stale-file-refusal.test.ts +258 -0
- package/src/tools/builtins/atomic-write-file.ts +30 -2
- package/src/tools/builtins/content-fingerprint.ts +33 -0
- package/src/tools/builtins/edit.ts +188 -36
- package/src/tools/builtins/read-file.ts +5 -2
- package/src/tools/builtins/write-file.ts +79 -28
- package/src/tools/coordinator/__tests__/ask-user-question.test.ts +68 -0
- package/src/tools/coordinator/index.ts +102 -41
- package/src/types/tool/index.ts +17 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/types/tool/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAC5B,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,uBAAuB,CAAA;AACnD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAA;AAC5C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAC7D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAA;AAC5D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAElD,MAAM,WAAW,eAAe;IAC/B,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,cAAc,EAAE,CAAA;IAC/C,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAAA;IAC/B,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,gBAAgB,CAAA;CAC/C;AAED;;;;;;GAMG;AACH,MAAM,WAAW,eAAe;IAC/B,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/types/tool/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/types/tool/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/types/tool/index.ts"],"names":[],"mappings":"AAyYA,cAAc,aAAa,CAAA"}
|
package/package.json
CHANGED
package/src/public-runtime.ts
CHANGED
|
@@ -215,7 +215,14 @@ export type { LocalSandboxProviderOptions } from './sandbox/index.js'
|
|
|
215
215
|
// The classified provider-failure surface: a driver states what went wrong
|
|
216
216
|
// first-hand, and the run boundary reads it to choose between a pause and a
|
|
217
217
|
// failure.
|
|
218
|
-
|
|
218
|
+
// `classifyProviderHttpStatus` and `bodySaysContextOverflow` are here
|
|
219
|
+
// because a driver outside this repo needs the same classification the
|
|
220
|
+
// first-party ones use — a status code alone does not separate a context
|
|
221
|
+
// overflow from an ordinary bad request, and re-deriving that per driver
|
|
222
|
+
// is how the classifications drift apart.
|
|
223
|
+
export {
|
|
224
|
+
bodySaysContextOverflow,
|
|
225
|
+
classifyProviderHttpStatus,
|
|
219
226
|
isCallerAbortError,
|
|
220
227
|
isProviderRequestError,
|
|
221
228
|
providerHttpError,
|
|
@@ -44,6 +44,26 @@ const NO_VISION_CAPABILITIES: ProviderCapabilities = {
|
|
|
44
44
|
supportsVision: false,
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* A tool that opts in to constrained generation, which is the only thing
|
|
49
|
+
* that makes `enforceToolInputSchema` non-empty on the wire.
|
|
50
|
+
*/
|
|
51
|
+
function registerEnforcedTool(tools: ToolRegistry, name: string): void {
|
|
52
|
+
tools.register({
|
|
53
|
+
name,
|
|
54
|
+
description: `${name} tool`,
|
|
55
|
+
inputSchema: z.object({ new_string: z.string().optional(), newStr: z.string().optional() }),
|
|
56
|
+
modelInputSchema: {
|
|
57
|
+
type: 'object',
|
|
58
|
+
properties: { new_string: { type: 'string' } },
|
|
59
|
+
required: ['new_string'],
|
|
60
|
+
additionalProperties: false,
|
|
61
|
+
},
|
|
62
|
+
enforceModelInput: true,
|
|
63
|
+
execute: async () => ({ success: true, output: 'ok' }),
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
47
67
|
function registerEchoTool(tools: ToolRegistry): void {
|
|
48
68
|
tools.register({
|
|
49
69
|
name: 'echo',
|
|
@@ -229,4 +249,53 @@ describe('query() capability negotiation', () => {
|
|
|
229
249
|
}),
|
|
230
250
|
).rejects.toThrow(/supportsVision: false/)
|
|
231
251
|
})
|
|
252
|
+
|
|
253
|
+
it('names the enforced tools on the request, so a driver can constrain them', async () => {
|
|
254
|
+
const provider = capturingProvider()
|
|
255
|
+
const tools = new ToolRegistry()
|
|
256
|
+
registerEnforcedTool(tools, 'edit')
|
|
257
|
+
registerEnforcedTool(tools, 'write')
|
|
258
|
+
registerEchoTool(tools)
|
|
259
|
+
|
|
260
|
+
const run = await drainQuery({
|
|
261
|
+
...baseParams(provider, tools, await mkWorkdir()),
|
|
262
|
+
messages: [createUserMessage('hello')],
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
expect(run.status).toBe('completed')
|
|
266
|
+
// The producer was deleted, so this was undefined on every request and
|
|
267
|
+
// the three drivers that read it were reading a field nothing ever
|
|
268
|
+
// set — `enforceModelInput` on a tool meant nothing end to end.
|
|
269
|
+
expect(provider.requests.at(-1)?.enforceToolInputSchema).toEqual(['edit', 'write'])
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('omits the field entirely when no tool opts in', async () => {
|
|
273
|
+
const provider = capturingProvider()
|
|
274
|
+
const tools = new ToolRegistry()
|
|
275
|
+
registerEchoTool(tools)
|
|
276
|
+
|
|
277
|
+
await drainQuery({
|
|
278
|
+
...baseParams(provider, tools, await mkWorkdir()),
|
|
279
|
+
messages: [createUserMessage('hello')],
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
// An empty array would read as "enforce nothing" rather than "nothing
|
|
283
|
+
// asked", and a driver cannot tell those two apart.
|
|
284
|
+
expect(provider.requests.at(-1)?.enforceToolInputSchema).toBeUndefined()
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('follows the allowed set rather than everything registered', async () => {
|
|
288
|
+
const provider = capturingProvider()
|
|
289
|
+
const tools = new ToolRegistry()
|
|
290
|
+
registerEnforcedTool(tools, 'edit')
|
|
291
|
+
registerEnforcedTool(tools, 'excluded')
|
|
292
|
+
|
|
293
|
+
await drainQuery({
|
|
294
|
+
...baseParams(provider, tools, await mkWorkdir()),
|
|
295
|
+
allowedTools: ['edit'],
|
|
296
|
+
messages: [createUserMessage('hello')],
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
expect(provider.requests.at(-1)?.enforceToolInputSchema).toEqual(['edit'])
|
|
300
|
+
})
|
|
232
301
|
})
|
|
@@ -7,6 +7,7 @@ import { ProbeVetoError } from '../../probe/errors.js'
|
|
|
7
7
|
import { type ProbeRegistry, probe as defaultProbeRegistry } from '../../probe/registry.js'
|
|
8
8
|
import { renderToolSchema } from '../../registry/tool/schema.js'
|
|
9
9
|
import type { ActivityStore } from '../../store/activity/memory.js'
|
|
10
|
+
import { fingerprintContent } from '../../tools/builtins/content-fingerprint.js'
|
|
10
11
|
import type { RunId, ToolUseId } from '../../types/ids/index.js'
|
|
11
12
|
import type { InvocationState } from '../../types/invocation/index.js'
|
|
12
13
|
import {
|
|
@@ -205,11 +206,18 @@ export class ToolExecutor {
|
|
|
205
206
|
private probes: ProbeRegistry
|
|
206
207
|
private parentSpan?: Span
|
|
207
208
|
private readonly readPaths: Set<string> = new Set()
|
|
209
|
+
private readonly readFingerprints: Map<string, string> = new Map()
|
|
208
210
|
private readonly fileReadTracker: FileReadTracker = {
|
|
209
|
-
recordRead: (key: string) => {
|
|
211
|
+
recordRead: (key: string, content?: string) => {
|
|
210
212
|
this.readPaths.add(key)
|
|
213
|
+
// Only when the reader had the body. A tool that records a read
|
|
214
|
+
// without one leaves the previous fingerprint alone rather than
|
|
215
|
+
// clearing it, so a later write is still checked against the last
|
|
216
|
+
// body anyone actually saw.
|
|
217
|
+
if (content !== undefined) this.readFingerprints.set(key, fingerprintContent(content))
|
|
211
218
|
},
|
|
212
219
|
hasRead: (key: string) => this.readPaths.has(key),
|
|
220
|
+
fingerprint: (key: string) => this.readFingerprints.get(key),
|
|
213
221
|
}
|
|
214
222
|
|
|
215
223
|
constructor(
|
|
@@ -30,6 +30,7 @@ import type {
|
|
|
30
30
|
StepResult,
|
|
31
31
|
StopReason,
|
|
32
32
|
} from '../../../types/run/index.js'
|
|
33
|
+
import type { LLMToolSchema, ToolRegistryContract } from '../../../types/tool/index.js'
|
|
33
34
|
import { toErrorMessage } from '../../../utils/error.js'
|
|
34
35
|
import { generateMessageId } from '../../../utils/id.js'
|
|
35
36
|
import type { ToolCallOutcome } from '../executor.js'
|
|
@@ -200,6 +201,7 @@ export class IterationOrchestrator {
|
|
|
200
201
|
const step = await this.prepareStep(iterationNum)
|
|
201
202
|
|
|
202
203
|
const llmTools = this.ctx.tools.toLLMTools(step.allowedTools ?? this.ctx.allowedTools)
|
|
204
|
+
const enforceToolInputSchema = enforcedModelInputToolNames(this.ctx.tools, llmTools)
|
|
203
205
|
const stepModel = step.model ?? model
|
|
204
206
|
|
|
205
207
|
const baseMessages = forceFinalize
|
|
@@ -266,6 +268,7 @@ export class IterationOrchestrator {
|
|
|
266
268
|
model: stepModel,
|
|
267
269
|
messages,
|
|
268
270
|
tools: llmTools.length > 0 ? llmTools : undefined,
|
|
271
|
+
...(enforceToolInputSchema ? { enforceToolInputSchema } : {}),
|
|
269
272
|
toolChoice: forceFinalize && llmTools.length > 0 ? 'none' : undefined,
|
|
270
273
|
temperature: step.temperature ?? runConfig.temperature,
|
|
271
274
|
maxTokens: step.maxResponseTokens ?? runConfig.maxResponseTokens,
|
|
@@ -1042,11 +1045,13 @@ export class IterationOrchestrator {
|
|
|
1042
1045
|
// tools param identical to prior iterations (cache prefix intact,
|
|
1043
1046
|
// no 400 on tool blocks in history) and forbid use via tool_choice.
|
|
1044
1047
|
const finalTools = this.ctx.tools.toLLMTools(this.ctx.allowedTools)
|
|
1048
|
+
const finalEnforced = enforcedModelInputToolNames(this.ctx.tools, finalTools)
|
|
1045
1049
|
const response = await collect(
|
|
1046
1050
|
this.ctx.provider.chatStream({
|
|
1047
1051
|
model,
|
|
1048
1052
|
messages: finalMessages,
|
|
1049
1053
|
tools: finalTools.length > 0 ? finalTools : undefined,
|
|
1054
|
+
...(finalEnforced ? { enforceToolInputSchema: finalEnforced } : {}),
|
|
1050
1055
|
toolChoice: finalTools.length > 0 ? 'none' : undefined,
|
|
1051
1056
|
temperature: this.ctx.runConfig.temperature,
|
|
1052
1057
|
maxTokens: this.ctx.runConfig.maxResponseTokens,
|
|
@@ -1102,3 +1107,25 @@ function subtractUsage(after: TokenUsage, before: TokenUsage): TokenUsage {
|
|
|
1102
1107
|
function round6(n: number): number {
|
|
1103
1108
|
return Math.round(n * 1e6) / 1e6
|
|
1104
1109
|
}
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* Which of the tools going out on this request have a closed model schema.
|
|
1113
|
+
*
|
|
1114
|
+
* A driver reads `enforceToolInputSchema` to decide which tool schemas to
|
|
1115
|
+
* constrain generation against. Nothing populated it, so every driver that
|
|
1116
|
+
* consumed it — three of them — was reading a permanently undefined field
|
|
1117
|
+
* and `enforceModelInput: true` on a tool meant nothing end to end.
|
|
1118
|
+
*
|
|
1119
|
+
* Computed per request rather than once, because the allowed set changes:
|
|
1120
|
+
* a deferred tool activated mid-run has to start being enforced from the
|
|
1121
|
+
* next call, not the next process.
|
|
1122
|
+
*/
|
|
1123
|
+
function enforcedModelInputToolNames(
|
|
1124
|
+
registry: ToolRegistryContract,
|
|
1125
|
+
tools: readonly LLMToolSchema[],
|
|
1126
|
+
): readonly string[] | undefined {
|
|
1127
|
+
const names = tools
|
|
1128
|
+
.map((tool) => tool.function.name)
|
|
1129
|
+
.filter((name) => registry.get(name)?.enforceModelInput === true)
|
|
1130
|
+
return names.length > 0 ? names : undefined
|
|
1131
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join, resolve } from 'node:path'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import type { ToolContext } from '../../../types/tool/index.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* That the atomic writer works is tested next door. This tests the thing
|
|
10
|
+
* that actually regressed: whether the TOOL goes through it.
|
|
11
|
+
*
|
|
12
|
+
* `edit` and `write` were reverted to a bare `writeFile`, and the existing
|
|
13
|
+
* atomicity test kept passing because it called `atomicWriteFile` directly —
|
|
14
|
+
* proving the module, never the caller. A write that fails partway then
|
|
15
|
+
* truncates the user's file, which is the one outcome `edit` exists to
|
|
16
|
+
* avoid.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const atomicWriteFile = vi.fn(async () => {})
|
|
20
|
+
vi.mock('../atomic-write-file.js', () => ({ atomicWriteFile }))
|
|
21
|
+
|
|
22
|
+
function makeContext(workingDirectory: string): ToolContext {
|
|
23
|
+
return {
|
|
24
|
+
runId: 'run_atomic' as ToolContext['runId'],
|
|
25
|
+
workingDirectory,
|
|
26
|
+
abortSignal: new AbortController().signal,
|
|
27
|
+
env: {},
|
|
28
|
+
log: () => {},
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('the file-mutating tools commit through the atomic writer', () => {
|
|
33
|
+
it('edit routes its local write through it', async () => {
|
|
34
|
+
const { EditTool } = await import('../edit.js')
|
|
35
|
+
atomicWriteFile.mockClear()
|
|
36
|
+
|
|
37
|
+
const dir = mkdtempSync(join(tmpdir(), 'namzu-atomic-tool-'))
|
|
38
|
+
writeFileSync(join(dir, 'doc.md'), 'alpha\nbeta\n')
|
|
39
|
+
|
|
40
|
+
const result = await EditTool.execute(
|
|
41
|
+
{ path: 'doc.md', old_string: 'beta', new_string: 'gamma', replace_all: false },
|
|
42
|
+
makeContext(dir),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
expect(result.success).toBe(true)
|
|
46
|
+
expect(atomicWriteFile).toHaveBeenCalledTimes(1)
|
|
47
|
+
expect(atomicWriteFile).toHaveBeenCalledWith(resolve(dir, 'doc.md'), 'alpha\ngamma\n')
|
|
48
|
+
// The mock swallowed the write, so the file is untouched — which is
|
|
49
|
+
// itself the proof that nothing else wrote it.
|
|
50
|
+
expect(readFileSync(join(dir, 'doc.md'), 'utf-8')).toBe('alpha\nbeta\n')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('write routes its local write through it', async () => {
|
|
54
|
+
const { WriteFileTool } = await import('../write-file.js')
|
|
55
|
+
atomicWriteFile.mockClear()
|
|
56
|
+
|
|
57
|
+
const dir = mkdtempSync(join(tmpdir(), 'namzu-atomic-tool-'))
|
|
58
|
+
|
|
59
|
+
const result = await WriteFileTool.execute(
|
|
60
|
+
{ path: 'out.md', content: 'a complete body' },
|
|
61
|
+
makeContext(dir),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
expect(result.success).toBe(true)
|
|
65
|
+
expect(atomicWriteFile).toHaveBeenCalledTimes(1)
|
|
66
|
+
expect(atomicWriteFile).toHaveBeenCalledWith(resolve(dir, 'out.md'), 'a complete body')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('edit does not use it for a sandboxed write, which the sandbox owns', async () => {
|
|
70
|
+
const { EditTool } = await import('../edit.js')
|
|
71
|
+
atomicWriteFile.mockClear()
|
|
72
|
+
|
|
73
|
+
let body = 'alpha\nbeta\n'
|
|
74
|
+
const sandbox = {
|
|
75
|
+
id: 'sbx',
|
|
76
|
+
status: 'ready',
|
|
77
|
+
rootDir: '/workspace',
|
|
78
|
+
environment: 'basic',
|
|
79
|
+
async readFile() {
|
|
80
|
+
return Buffer.from(body)
|
|
81
|
+
},
|
|
82
|
+
async writeFile(_path: string, content: string | Buffer) {
|
|
83
|
+
body = content.toString()
|
|
84
|
+
},
|
|
85
|
+
async exec() {
|
|
86
|
+
throw new Error('not used')
|
|
87
|
+
},
|
|
88
|
+
async listFiles() {
|
|
89
|
+
return []
|
|
90
|
+
},
|
|
91
|
+
async destroy() {},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const result = await EditTool.execute(
|
|
95
|
+
{ path: 'doc.md', old_string: 'beta', new_string: 'gamma', replace_all: false },
|
|
96
|
+
{ ...makeContext('/workspace'), sandbox } as ToolContext,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
expect(result.success).toBe(true)
|
|
100
|
+
expect(body).toBe('alpha\ngamma\n')
|
|
101
|
+
expect(atomicWriteFile).not.toHaveBeenCalled()
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -1,8 +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 { ToolRegistry } from '../../../registry/tool/execute.js'
|
|
6
|
+
import type { Sandbox } from '../../../types/sandbox/index.js'
|
|
5
7
|
import type { ToolContext } from '../../../types/tool/index.js'
|
|
8
|
+
import { atomicWriteFile } from '../atomic-write-file.js'
|
|
6
9
|
import { EditTool } from '../edit.js'
|
|
7
10
|
|
|
8
11
|
function makeContext(workingDirectory: string): ToolContext {
|
|
@@ -16,6 +19,326 @@ function makeContext(workingDirectory: string): ToolContext {
|
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
describe('EditTool', () => {
|
|
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
|
+
// The MODEL schema above is the closed canonical one. The HOST schema
|
|
60
|
+
// is wider on purpose — aliases and line insertion are declared — and
|
|
61
|
+
// the point of `.strict()` is that wider still is not open.
|
|
62
|
+
for (const supported of [
|
|
63
|
+
{ path: 'doc.md', oldStr: 'old', newStr: 'new' },
|
|
64
|
+
{ path: 'doc.md', insertLine: 'end', new_string: 'append' },
|
|
65
|
+
{ path: 'doc.md', old_string: 'old', new_string: 'new', newStr: 'legacy' },
|
|
66
|
+
]) {
|
|
67
|
+
expect(EditTool.inputSchema.safeParse(supported).success, JSON.stringify(supported)).toBe(
|
|
68
|
+
true,
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const unknown of [
|
|
73
|
+
{ path: 'doc.md', old_str: 'old', new_string: 'new' },
|
|
74
|
+
{ path: 'doc.md', old_string: 'old', new_string: 'new', replaceAll: true },
|
|
75
|
+
]) {
|
|
76
|
+
expect(EditTool.inputSchema.safeParse(unknown).success, JSON.stringify(unknown)).toBe(false)
|
|
77
|
+
}
|
|
78
|
+
expect(
|
|
79
|
+
EditTool.inputSchema.safeParse({
|
|
80
|
+
path: ' ',
|
|
81
|
+
old_string: 'old',
|
|
82
|
+
new_string: 'new',
|
|
83
|
+
}).success,
|
|
84
|
+
).toBe(false)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('replaces one exact unique string', async () => {
|
|
88
|
+
const dir = mkdtempSync(join(tmpdir(), 'namzu-edit-'))
|
|
89
|
+
writeFileSync(join(dir, 'doc.md'), 'alpha\nbeta\n')
|
|
90
|
+
|
|
91
|
+
const result = await EditTool.execute(
|
|
92
|
+
{
|
|
93
|
+
path: 'doc.md',
|
|
94
|
+
old_string: 'beta',
|
|
95
|
+
new_string: 'gamma',
|
|
96
|
+
replace_all: false,
|
|
97
|
+
},
|
|
98
|
+
makeContext(dir),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
expect(result.success).toBe(true)
|
|
102
|
+
expect(readFileSync(join(dir, 'doc.md'), 'utf-8')).toBe('alpha\ngamma\n')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('validates a path without rewriting edge whitespace', async () => {
|
|
106
|
+
const dir = mkdtempSync(join(tmpdir(), 'namzu-edit-'))
|
|
107
|
+
writeFileSync(join(dir, ' doc.md '), 'alpha\n')
|
|
108
|
+
|
|
109
|
+
const result = await EditTool.execute(
|
|
110
|
+
{
|
|
111
|
+
path: ' doc.md ',
|
|
112
|
+
old_string: 'alpha',
|
|
113
|
+
new_string: 'beta',
|
|
114
|
+
replace_all: false,
|
|
115
|
+
},
|
|
116
|
+
makeContext(dir),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
expect(result.success).toBe(true)
|
|
120
|
+
expect(readFileSync(join(dir, ' doc.md '), 'utf-8')).toBe('beta\n')
|
|
121
|
+
expect(existsSync(join(dir, 'doc.md'))).toBe(false)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('allows deletion with an empty new_string', async () => {
|
|
125
|
+
const dir = mkdtempSync(join(tmpdir(), 'namzu-edit-'))
|
|
126
|
+
writeFileSync(join(dir, 'doc.md'), 'alpha\nremove me\nomega\n')
|
|
127
|
+
|
|
128
|
+
const result = await EditTool.execute(
|
|
129
|
+
{
|
|
130
|
+
path: 'doc.md',
|
|
131
|
+
old_string: 'remove me\n',
|
|
132
|
+
new_string: '',
|
|
133
|
+
replace_all: false,
|
|
134
|
+
},
|
|
135
|
+
makeContext(dir),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
expect(result.success).toBe(true)
|
|
139
|
+
expect(readFileSync(join(dir, 'doc.md'), 'utf-8')).toBe('alpha\nomega\n')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('replaces every exact occurrence only when replace_all is true', async () => {
|
|
143
|
+
const dir = mkdtempSync(join(tmpdir(), 'namzu-edit-'))
|
|
144
|
+
writeFileSync(join(dir, 'doc.md'), 'alpha alpha alpha')
|
|
145
|
+
|
|
146
|
+
const result = await EditTool.execute(
|
|
147
|
+
{
|
|
148
|
+
path: 'doc.md',
|
|
149
|
+
old_string: 'alpha',
|
|
150
|
+
new_string: 'beta',
|
|
151
|
+
replace_all: true,
|
|
152
|
+
},
|
|
153
|
+
makeContext(dir),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
expect(result.success).toBe(true)
|
|
157
|
+
expect(result.data).toMatchObject({ replacements: 3 })
|
|
158
|
+
expect(readFileSync(join(dir, 'doc.md'), 'utf-8')).toBe('beta beta beta')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('refuses ambiguous exact matches instead of guessing', async () => {
|
|
162
|
+
const dir = mkdtempSync(join(tmpdir(), 'namzu-edit-'))
|
|
163
|
+
writeFileSync(join(dir, 'doc.md'), 'alpha\nalpha\n')
|
|
164
|
+
|
|
165
|
+
const result = await EditTool.execute(
|
|
166
|
+
{
|
|
167
|
+
path: 'doc.md',
|
|
168
|
+
old_string: 'alpha',
|
|
169
|
+
new_string: 'beta',
|
|
170
|
+
replace_all: false,
|
|
171
|
+
},
|
|
172
|
+
makeContext(dir),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
expect(result.success).toBe(false)
|
|
176
|
+
expect(result.error).toContain('is not unique')
|
|
177
|
+
expect(readFileSync(join(dir, 'doc.md'), 'utf-8')).toBe('alpha\nalpha\n')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('normalizes consistent CRLF/LF boundaries without fuzzy matching', async () => {
|
|
181
|
+
const dir = mkdtempSync(join(tmpdir(), 'namzu-edit-'))
|
|
182
|
+
writeFileSync(join(dir, 'doc.md'), 'alpha\r\nbeta\r\nomega\r\n')
|
|
183
|
+
|
|
184
|
+
const result = await EditTool.execute(
|
|
185
|
+
{
|
|
186
|
+
path: 'doc.md',
|
|
187
|
+
old_string: 'alpha\nbeta',
|
|
188
|
+
new_string: 'gamma\ndelta',
|
|
189
|
+
replace_all: false,
|
|
190
|
+
},
|
|
191
|
+
makeContext(dir),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
expect(result.success).toBe(true)
|
|
195
|
+
expect(readFileSync(join(dir, 'doc.md'), 'utf-8')).toBe('gamma\r\ndelta\r\nomega\r\n')
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('rejects a field it does not accept, even when execute is called directly', async () => {
|
|
199
|
+
const dir = mkdtempSync(join(tmpdir(), 'namzu-edit-'))
|
|
200
|
+
writeFileSync(join(dir, 'doc.md'), 'alpha\n')
|
|
201
|
+
|
|
202
|
+
// Without `.strict()` zod STRIPS an unrecognised key, so each of these
|
|
203
|
+
// would run as if the caller had never written it — a misspelling
|
|
204
|
+
// becomes a silent no-op rather than an error anyone can act on.
|
|
205
|
+
for (const input of [
|
|
206
|
+
{ path: 'doc.md', old_str: 'alpha', new_string: 'beta' },
|
|
207
|
+
{ path: 'doc.md', old_string: 'alpha', new_string: 'beta', overwrite: true },
|
|
208
|
+
{ path: 'doc.md', old_string: 'alpha', new_string: 'beta', replaceAll: true },
|
|
209
|
+
]) {
|
|
210
|
+
const result = await EditTool.execute(input as never, makeContext(dir))
|
|
211
|
+
expect(result.success, JSON.stringify(input)).toBe(false)
|
|
212
|
+
expect(result.error).toContain('Invalid edit input')
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
expect(readFileSync(join(dir, 'doc.md'), 'utf-8')).toBe('alpha\n')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('accepts the host aliases the schema does declare', async () => {
|
|
219
|
+
const dir = mkdtempSync(join(tmpdir(), 'namzu-edit-'))
|
|
220
|
+
writeFileSync(join(dir, 'doc.md'), 'alpha\n')
|
|
221
|
+
|
|
222
|
+
// The contract is closed, not narrow. `oldStr`/`newStr` and
|
|
223
|
+
// `insertLine` are in the schema for hosts that speak those names, so
|
|
224
|
+
// strictness must reject the unknown without rejecting these.
|
|
225
|
+
const result = await EditTool.execute(
|
|
226
|
+
{ path: 'doc.md', oldStr: 'alpha', newStr: 'beta' } as never,
|
|
227
|
+
makeContext(dir),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
expect(result.success).toBe(true)
|
|
231
|
+
expect(readFileSync(join(dir, 'doc.md'), 'utf-8')).toBe('beta\n')
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('returns one canonical recovery shape after registry validation fails', async () => {
|
|
235
|
+
const registry = new ToolRegistry()
|
|
236
|
+
registry.register(EditTool)
|
|
237
|
+
|
|
238
|
+
const result = await registry.execute(
|
|
239
|
+
'edit',
|
|
240
|
+
{ insertLine: '"end"', newStr: 'content' },
|
|
241
|
+
makeContext('/tmp'),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
expect(result.success).toBe(false)
|
|
245
|
+
expect(result.error).toContain('Validation failed for "edit":')
|
|
246
|
+
expect(result.error).toContain('Required: path: string')
|
|
247
|
+
expect(result.error).toContain(
|
|
248
|
+
'{"path":"file.md","old_string":"exact unique text","new_string":"replacement text"}',
|
|
249
|
+
)
|
|
250
|
+
expect(result.error).not.toContain('Accepted shapes')
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('serializes independent EditTool read-modify-write cycles for one sandbox path', async () => {
|
|
254
|
+
let body = Buffer.from('alpha')
|
|
255
|
+
let reads = 0
|
|
256
|
+
let writes = 0
|
|
257
|
+
let releaseFirstWrite = () => {}
|
|
258
|
+
let markFirstWriteStarted = () => {}
|
|
259
|
+
const firstWriteStarted = new Promise<void>((resolve) => {
|
|
260
|
+
markFirstWriteStarted = resolve
|
|
261
|
+
})
|
|
262
|
+
const firstWriteGate = new Promise<void>((resolve) => {
|
|
263
|
+
releaseFirstWrite = resolve
|
|
264
|
+
})
|
|
265
|
+
const sandbox = {
|
|
266
|
+
id: 'sbx_edit_lock',
|
|
267
|
+
status: 'ready',
|
|
268
|
+
rootDir: '/workspace',
|
|
269
|
+
environment: 'basic',
|
|
270
|
+
async readFile() {
|
|
271
|
+
reads += 1
|
|
272
|
+
return Buffer.from(body)
|
|
273
|
+
},
|
|
274
|
+
async writeFile(_path: string, content: string | Buffer) {
|
|
275
|
+
writes += 1
|
|
276
|
+
if (writes === 1) {
|
|
277
|
+
markFirstWriteStarted()
|
|
278
|
+
await firstWriteGate
|
|
279
|
+
}
|
|
280
|
+
body = Buffer.isBuffer(content) ? Buffer.from(content) : Buffer.from(content)
|
|
281
|
+
},
|
|
282
|
+
async exec() {
|
|
283
|
+
throw new Error('not used')
|
|
284
|
+
},
|
|
285
|
+
async listFiles() {
|
|
286
|
+
return []
|
|
287
|
+
},
|
|
288
|
+
async destroy() {},
|
|
289
|
+
} as Sandbox
|
|
290
|
+
const firstContext = { ...makeContext('/workspace'), sandbox }
|
|
291
|
+
const secondContext = { ...makeContext('/workspace'), sandbox }
|
|
292
|
+
|
|
293
|
+
const first = EditTool.execute(
|
|
294
|
+
{
|
|
295
|
+
path: 'doc.md',
|
|
296
|
+
old_string: 'alpha',
|
|
297
|
+
new_string: 'beta',
|
|
298
|
+
replace_all: false,
|
|
299
|
+
},
|
|
300
|
+
firstContext,
|
|
301
|
+
)
|
|
302
|
+
await firstWriteStarted
|
|
303
|
+
const second = EditTool.execute(
|
|
304
|
+
{
|
|
305
|
+
path: 'doc.md',
|
|
306
|
+
old_string: 'beta',
|
|
307
|
+
new_string: 'gamma',
|
|
308
|
+
replace_all: false,
|
|
309
|
+
},
|
|
310
|
+
secondContext,
|
|
311
|
+
)
|
|
312
|
+
await Promise.resolve()
|
|
313
|
+
expect(reads).toBe(1)
|
|
314
|
+
|
|
315
|
+
releaseFirstWrite()
|
|
316
|
+
const results = await Promise.all([first, second])
|
|
317
|
+
expect(results.every((result) => result.success)).toBe(true)
|
|
318
|
+
expect(reads).toBe(2)
|
|
319
|
+
expect(writes).toBe(2)
|
|
320
|
+
expect(body.toString('utf-8')).toBe('gamma')
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('leaves the original intact when an atomic local write fails before commit', async () => {
|
|
324
|
+
const dir = mkdtempSync(join(tmpdir(), 'namzu-atomic-write-'))
|
|
325
|
+
const filePath = join(dir, 'doc.md')
|
|
326
|
+
writeFileSync(filePath, 'original')
|
|
327
|
+
|
|
328
|
+
await expect(
|
|
329
|
+
atomicWriteFile(filePath, 'replacement', {
|
|
330
|
+
beforeCommit: async () => {
|
|
331
|
+
throw new Error('injected pre-commit failure')
|
|
332
|
+
},
|
|
333
|
+
}),
|
|
334
|
+
).rejects.toThrow('injected pre-commit failure')
|
|
335
|
+
|
|
336
|
+
expect(readFileSync(filePath, 'utf-8')).toBe('original')
|
|
337
|
+
expect(readdirSync(dir).filter((entry) => entry.includes('.namzu-'))).toEqual([])
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
// ── the alias + insertion surface this build adds on top ──────────
|
|
341
|
+
|
|
19
342
|
it('accepts oldStr/newStr aliases for string replacement', async () => {
|
|
20
343
|
const dir = mkdtempSync(join(tmpdir(), 'namzu-edit-'))
|
|
21
344
|
writeFileSync(join(dir, 'doc.md'), 'alpha\nbeta\n')
|