import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { FsVersion } from '@deepseek-ai/dsh-fs' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox' import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor' const contexts: Context[] = [] const roots: string[] = [] let callNumber = 0 afterEach(async () => { for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) function agent(ctx: Context, cwd: string): Agent { const id = SessionId(`str-replace-editor-owner-${callNumber}`) const scope = ctx.plugin(() => {}) const session = Session.create(id, [], { version: 0, id, createdAt: 0, cwd }) const value: Agent = { id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle', ctx: scope.ctx, send: () => {}, followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, cancel() {}, runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } ctx.agents.register(value) return value } function text(result: { content: { type: string; text?: string }[] }): string { return result.content.filter(block => block.type === 'text').map(block => block.text).join('') } function call(ctx: Context, owner: Agent | undefined, args: unknown) { return ctx.tools.execute({ signal: new AbortController().signal, callId: CallId(`str-replace-editor-${++callNumber}`), name: 'str_replace_editor', arguments: args, ...owner === undefined ? {} : { agent: owner }, }) } async function setup( config: ToolStrReplaceEditor.Config = {}, options: { fsPolicy?: boolean; sandboxMode?: 'read-only' | 'workspace-write' | 'danger-full-access' } = {}, ) { const root = await mkdtemp(join(tmpdir(), 'dsh-tool-str-replace-editor-')) roots.push(root) const ctx = new Context() contexts.push(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) if (options.sandboxMode === undefined) { await ctx.plugin(LocalFileSystem, { cwd: root }) } else { await ctx.plugin(SandboxPolicy, { mode: options.sandboxMode, workspaceRoot: root }) await ctx.plugin(SandboxedFileSystem, { cwd: root }) } if (options.fsPolicy === true) await ctx.plugin(FsPolicy) const fiber = await ctx.plugin(ToolStrReplaceEditor, config) return { ctx, root, fiber, owner: agent(ctx, root) } } describe('tool-str-replace-editor', () => { it('registers the standalone schema and configurable description', async () => { const { ctx, fiber } = await setup({ description: 'custom editor description' }) const schema = ctx.tools.schemas()[0] expect(ctx.tools.schemas().map(item => item.name)).toEqual(['str_replace_editor']) expect(schema?.description).toBe('custom editor description') const properties = (schema?.parameters as { properties: Record }).properties expect(properties).not.toHaveProperty('replace_all') expect(properties.insert_line?.type).toBe('integer') expect(properties.view_range?.items?.type).toBe('integer') expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ command: 'view', path: '/workspace/a.txt', })).toMatchObject({ card: 'generic', kind: 'read', locations: [{ path: '/workspace/a.txt' }], }) expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ command: 'create', path: '/workspace/a.txt', file_text: 'hello', })).toMatchObject({ card: 'diff', diffs: [{ path: '/workspace/a.txt', oldText: null, newText: 'hello' }], }) expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ command: 'str_replace', path: '/workspace/a.txt', old_str: 'old', new_str: 'new', })).toMatchObject({ card: 'diff', diffs: [{ path: '/workspace/a.txt', oldText: 'old', newText: 'new' }], }) expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ command: 'insert', path: '/workspace/a.txt', insert_line: 0, new_str: 'x', })).toMatchObject({ card: 'generic', kind: 'edit', locations: [{ path: '/workspace/a.txt', line: 1 }], }) expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ command: 'create', path: '/workspace/empty.txt', })).toMatchObject({ diffs: [{ path: '/workspace/empty.txt', oldText: null, newText: '' }], }) expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ command: 'str_replace', path: '/workspace/a.txt', })).toMatchObject({ diffs: [{ path: '/workspace/a.txt', oldText: null, newText: '' }], }) expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ command: 'insert', path: '/workspace/a.txt', })).toMatchObject({ locations: [{ path: '/workspace/a.txt' }], }) await fiber.dispose() expect(ctx.tools.schemas()).toEqual([]) expect(ctx.tools.get('str_replace_editor')).toBeUndefined() }) it('creates, views, replaces, and inserts with the canonical model-facing output', async () => { const { ctx, root, owner } = await setup() const sample = join(root, 'sample.txt') expect(text(await call(ctx, owner, { command: 'create', path: sample, file_text: 'one\ntwo\nthree\n', }))).toBe(`New file created successfully at: ${sample}`) expect(text(await call(ctx, owner, { command: 'view', path: sample, view_range: [2, -1], }))).toBe([ `Here's the content of ${sample} with line numbers (which has a total of 4 lines) with view_range=[2, -1]:`, ' 2 two', ' 3 three', ' 4 ', '', ].join('\n')) expect(text(await call(ctx, owner, { command: 'str_replace', path: sample, old_str: 'two', new_str: 'TWO', }))).toBe(`The file ${sample} has been edited successfully.`) expect(text(await call(ctx, owner, { command: 'str_replace', path: sample, old_str: 'TWO', }))).toBe(`The file ${sample} has been edited successfully.`) expect(text(await call(ctx, owner, { command: 'insert', path: sample, insert_line: 1, new_str: 'between', }))).toBe(`The file ${sample} has been edited successfully.`) expect(await readFile(sample, 'utf8')).toBe('one\nbetween\n\nthree\n') }) it('writes replacement text literally', async () => { const { ctx, root, owner } = await setup() const sample = join(root, 'literal.txt') const replacement = "$&|$`|$'|$$" await writeFile(sample, 'before OLD after') expect((await call(ctx, owner, { command: 'str_replace', path: sample, old_str: 'OLD', new_str: replacement, })).isError).toBe(false) expect(await readFile(sample, 'utf8')).toBe(`before ${replacement} after`) }) it('lists visible entries to depth two and clips at the configured view limit', async () => { const { ctx, root, owner } = await setup({ maxOutputChars: 10_000 }) await mkdir(join(root, 'dir', 'nested', 'third'), { recursive: true }) await mkdir(join(root, 'dir', 'node_modules', 'pkg'), { recursive: true }) await mkdir(join(root, 'dir', 'node_modules_old'), { recursive: true }) await mkdir(join(root, 'dir', '__pycache__'), { recursive: true }) await mkdir(join(root, 'dir', '__pycache__backup'), { recursive: true }) await writeFile(join(root, 'dir', 'visible.txt'), 'ok') await writeFile(join(root, 'dir', '.hidden'), 'hidden') await writeFile(join(root, 'dir', 'nested', 'child.txt'), 'child') await writeFile(join(root, 'dir', 'nested', 'third', 'too-deep.txt'), 'deep') await writeFile(join(root, 'dir', 'node_modules', 'pkg', 'index.js'), 'hidden dependency') await writeFile(join(root, 'dir', 'node_modules_old', 'kept.js'), 'visible source') await writeFile(join(root, 'dir', '__pycache__', 'module.pyc'), 'cache') await writeFile(join(root, 'dir', '__pycache__backup', 'kept.py'), 'visible source') const listDir = ctx.fs.listDir.bind(ctx.fs) const otherTarget = await ctx.fs.resolve(join(root, 'dir', 'other')) ctx.fs.listDir = async (target, signal) => { const entries = await listDir(target, signal) return target.displayPath === join(root, 'dir') ? [ { name: 'same-target', type: 'other', target: otherTarget }, { name: 'other', type: 'other', target: otherTarget }, ...entries.toReversed(), ] : entries } const listing = text(await call(ctx, owner, { command: 'view', path: join(root, 'dir') })) expect(listing).not.toContain('.hidden') expect(listing).not.toContain('too-deep.txt') expect(listing).not.toContain('index.js') expect(listing).not.toContain('module.pyc') // The listing carries absolute display paths; the POSIX-style substrings // only match on Linux, so assert with platform separators. expect(listing).toContain(join('node_modules_old', 'kept.js')) expect(listing).toContain(join('__pycache__backup', 'kept.py')) const clipped = await setup({ maxOutputChars: 10 }) await writeFile(join(clipped.root, 'large.txt'), 'x'.repeat(100)) expect(text(await call(clipped.ctx, clipped.owner, { command: 'view', path: join(clipped.root, 'large.txt'), }))) .toContain('') }) it('matches canonical empty-line, range, and end-insert behavior', async () => { const { ctx, root, owner } = await setup() const empty = join(root, 'empty.txt') const newline = join(root, 'newline.txt') const plain = join(root, 'plain.txt') await writeFile(empty, '') await writeFile(newline, '\n') await writeFile(plain, 'one\ntwo') expect(text(await call(ctx, owner, { command: 'view', path: empty }))) .toContain('(which has a total of 1 lines):\n 1 \n') expect(text(await call(ctx, owner, { command: 'view', path: newline }))) .toContain('(which has a total of 2 lines):\n 1 \n 2 \n') expect(text(await call(ctx, owner, { command: 'view', path: plain, view_range: [1, 2], }))).toContain(' 2 two') expect(text(await call(ctx, undefined, { command: 'view', path: plain, }))).toContain(' 1 one') expect((await call(ctx, undefined, { command: 'create', path: join(root, 'ownerless.txt'), file_text: 'ownerless', })).isError).toBe(false) await call(ctx, owner, { command: 'insert', path: plain, insert_line: 2, new_str: 'three', }) expect(await readFile(plain, 'utf8')).toBe('one\ntwo\nthree') await writeFile(newline, 'one\n') await call(ctx, owner, { command: 'insert', path: newline, insert_line: 2, new_str: 'three', }) expect(await readFile(newline, 'utf8')).toBe('one\n\nthree') }) it('uses old_str-only replacement failures and rejects relative paths', async () => { const { ctx, root, owner } = await setup() const ambiguous = join(root, 'ambiguous.txt') await writeFile(ambiguous, 'same\nother\nsame') const missing = await call(ctx, owner, { command: 'str_replace', path: ambiguous, old_str: 'absent', new_str: 'x', }) expect(missing.isError).toBe(true) expect(text(missing)).toContain(`old_str \`absent\` did not appear verbatim in ${ambiguous}`) expect(text(missing)).not.toContain('old_string') const repeated = await call(ctx, owner, { command: 'str_replace', path: ambiguous, old_str: 'same', new_str: 'x', }) expect(repeated.isError).toBe(true) expect(text(repeated)).toContain('Multiple occurrences of old_str `same` in lines [1, 3]') expect(text(repeated)).not.toContain('replace_all') await writeFile(ambiguous, 'alpha\nbeta\nmiddle\nalpha\nbeta') const repeatedMultiline = await call(ctx, owner, { command: 'str_replace', path: ambiguous, old_str: 'alpha\nbeta', new_str: 'x', }) expect(text(repeatedMultiline)) .toContain('Multiple occurrences of old_str `alpha\nbeta` in lines [1, 4]') const mixedEol = join(root, 'mixed-eol.txt') await writeFile(mixedEol, 'alpha\r\nbeta\nmiddle\nalpha\nbeta') expect((await call(ctx, owner, { command: 'str_replace', path: mixedEol, old_str: 'alpha\r\nbeta', new_str: 'replaced', })).isError).toBe(false) expect(await readFile(mixedEol, 'utf8')).toBe('replaced\nmiddle\nalpha\nbeta') const relative = await call(ctx, owner, { command: 'view', path: 'ambiguous.txt' }) expect(relative.isError).toBe(true) expect(text(relative)).toContain('is not an absolute path') expect(await readFile(ambiguous, 'utf8')).toBe('alpha\nbeta\nmiddle\nalpha\nbeta') }) it('reports invalid commands or arguments without mutating files', async () => { const { ctx, root, owner } = await setup() const ambiguous = join(root, 'ambiguous.txt') const empty = join(root, 'empty.txt') const trailingNewline = join(root, 'trailing-newline.txt') const threeLines = join(root, 'three-lines.txt') const directory = join(root, 'directory') await writeFile(ambiguous, 'same same') await writeFile(empty, '') await writeFile(trailingNewline, 'one\n') await writeFile(threeLines, 'one\ntwo\nthree') await mkdir(directory) const cases = [ { command: 'view', path: '' }, { command: 'view', path: join(root, 'missing.txt') }, { command: 'view', path: ambiguous, view_range: [1] }, { command: 'view', path: ambiguous, view_range: [0, 1] }, { command: 'view', path: ambiguous, view_range: [1.5, 2] }, { command: 'view', path: threeLines, view_range: [1, 99] }, { command: 'view', path: threeLines, view_range: [2, 1] }, { command: 'view', path: directory, view_range: [1, 1] }, { command: 'create', path: join(root, 'new.txt') }, { command: 'create', path: ambiguous, file_text: 'overwrite' }, { command: 'str_replace', path: ambiguous, new_str: 'x' }, { command: 'str_replace', path: ambiguous, old_str: '', new_str: 'x' }, { command: 'insert', path: ambiguous, new_str: 'x' }, { command: 'insert', path: ambiguous, insert_line: -1, new_str: 'x' }, { command: 'insert', path: ambiguous, insert_line: 1.5, new_str: 'x' }, { command: 'insert', path: ambiguous, insert_line: 99, new_str: 'x' }, { command: 'insert', path: empty, insert_line: 2, new_str: 'x' }, { command: 'insert', path: directory, insert_line: 0, new_str: 'x' }, ] for (const args of cases) { expect((await call(ctx, owner, args)).isError).toBe(true) } expect(await readFile(ambiguous, 'utf8')).toBe('same same') ctx.fs.stat = async () => ({ version: FsVersion('special'), type: 'other' }) const special = await call(ctx, owner, { command: 'view', path: join(root, 'special') }) expect(special.isError).toBe(true) expect(special.error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } }) expect((await call(ctx, owner, { command: 'str_replace', path: join(root, 'special'), old_str: 'x', new_str: 'y', })).error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } }) expect((await call(ctx, owner, { command: 'insert', path: join(root, 'special'), insert_line: 0, new_str: 'x', })).error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } }) }) it('delegates read-before-edit decisions to fs-policy', async () => { const { ctx, root, owner } = await setup({}, { fsPolicy: true }) const existing = join(root, 'existing.txt') const created = join(root, 'created.txt') await writeFile(existing, 'before') const blindEdit = await call(ctx, owner, { command: 'str_replace', path: existing, old_str: 'before', new_str: 'after', }) expect(blindEdit.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } }) expect(await readFile(existing, 'utf8')).toBe('before') await call(ctx, owner, { command: 'view', path: existing }) expect((await call(ctx, owner, { command: 'str_replace', path: existing, old_str: 'before', new_str: 'after', })).isError).toBe(false) expect(await readFile(existing, 'utf8')).toBe('after') expect((await call(ctx, owner, { command: 'insert', path: existing, insert_line: 1, new_str: 'tail', })).isError).toBe(false) expect(await readFile(existing, 'utf8')).toBe('after\ntail') expect((await call(ctx, owner, { command: 'create', path: created, file_text: 'new', })).isError).toBe(false) expect(await readFile(created, 'utf8')).toBe('new') }) it('passes the session sandbox policy to every mutation', async () => { const { ctx, root, owner } = await setup({}, { sandboxMode: 'read-only' }) const path = join(root, 'blocked.txt') const result = await call(ctx, owner, { command: 'create', path, file_text: 'blocked', }) expect(result.error).toMatchObject({ info: { code: 'FS_SANDBOX_DENIED' } }) expect(text(result)).toContain('[sandbox: file access denied under read-only mode]') const ownerless = await call(ctx, undefined, { command: 'create', path: join(root, 'ownerless-blocked.txt'), file_text: 'blocked', }) expect(ownerless.error).toMatchObject({ info: { code: 'FS_SANDBOX_DENIED' } }) }) it('preserves tabs outside the edited region', async () => { const { ctx, root, owner } = await setup() const path = join(root, 'Makefile') await writeFile(path, 'target:\n\told\nremove\n') expect(text(await call(ctx, owner, { command: 'view', path }))) .toContain(' 2 \told') await call(ctx, owner, { command: 'str_replace', path, old_str: '\told', new_str: '\tnew', }) await call(ctx, owner, { command: 'str_replace', path, old_str: 'remove\n', }) await call(ctx, owner, { command: 'insert', path, insert_line: 1, new_str: '\tkept', }) expect(await readFile(path, 'utf8')).toBe('target:\n\tkept\n\tnew\n') }) it('reports missing sandbox-policy composition during plugin startup', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-tool-str-replace-editor-missing-policy-')) roots.push(root) const ctx = new Context() contexts.push(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalFileSystem, { cwd: root }) Object.defineProperty(ctx.fs, 'sandboxMode', { value: 'read-only' }) await expect(ctx.plugin(ToolStrReplaceEditor)) .rejects.toThrow('the mounted filesystem confines but ctx.sandboxPolicy is missing') }) it('maps unexpected backend write failures for replace and insert', async () => { const { ctx, root, owner } = await setup() const path = join(root, 'backend-error.txt') await writeFile(path, 'old\n') const failWrite = async (): Promise => { throw new Error('backend write failed') } ctx.fs.writeText = failWrite const replace = await call(ctx, owner, { command: 'str_replace', path, old_str: 'old', new_str: 'new', }) expect(replace.isError).toBe(true) expect(text(replace)).toContain('backend write failed') const insert = await call(ctx, owner, { command: 'insert', path, insert_line: 1, new_str: 'new', }) expect(insert.isError).toBe(true) expect(text(insert)).toContain('backend write failed') }) it('rejects invalid plugin config', () => { expect(() => { ToolStrReplaceEditor.apply(new Context(), { maxOutputChars: 0 }) }).toThrow('maxOutputChars must be a positive safe integer') expect(() => { ToolStrReplaceEditor.apply(new Context(), { description: ' ' }) }).toThrow('description must be non-empty') }) })