kimi-code/packages/agent-core/test/tools/edit.test.ts

436 lines
15 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { type EditInput, EditInputSchema, EditTool } from '../../src/tools/builtin/file/edit';
import { createFakeKaos, PERMISSIVE_WORKSPACE } from './fixtures/fake-kaos';
import { executeTool } from './fixtures/execute-tool';
const signal = new AbortController().signal;
function context(args: EditInput) {
return { turnId: '0', toolCallId: 'call_edit', args, signal };
}
describe('EditTool', () => {
it('exposes before/after on the file_io display so the approval panel can render a diff', () => {
const tool = new EditTool(createFakeKaos(), PERMISSIVE_WORKSPACE);
const execution = tool.resolveExecution({
path: '/tmp/foo.ts',
old_string: 'a\nb\nc',
new_string: 'a\nB\nc',
});
if (execution.isError === true) {
throw new TypeError('expected runnable execution');
}
expect(execution.display).toEqual({
kind: 'file_io',
operation: 'edit',
path: '/tmp/foo.ts',
before: 'a\nb\nc',
after: 'a\nB\nc',
});
});
it('exposes current metadata and schema', () => {
const tool = new EditTool(createFakeKaos(), PERMISSIVE_WORKSPACE);
expect(tool.name).toBe('Edit');
expect(tool.description).toContain('Read the target file before every Edit');
expect(tool.description).toContain('DO NOT call Edit from memory');
expect(tool.description).toContain('Read output view');
expect(tool.description).toContain('line-number prefix');
expect(tool.description).toContain('`old_string` must be unique');
expect(tool.description).toContain('only when they do not target the same file');
expect(tool.description).toContain('DO NOT issue consecutive Edit calls on the same file');
// Editing files should go through Edit, not Write and not a Bash `sed`
// command. The prompt names both alternatives explicitly.
expect(tool.description).toContain('DO NOT use Write or Bash `sed`');
// Parallel Edit calls on the same file are serialized and applied in
// response order; mismatched old_string fails explicitly.
expect(tool.description).toContain('same-file edits in response order');
expect(tool.description).toContain('old_string not found');
expect(tool.parameters).toMatchObject({
type: 'object',
properties: {
path: {
type: 'string',
description: expect.stringContaining('working directory'),
},
old_string: {
type: 'string',
description: expect.stringContaining('without the line-number prefix'),
},
new_string: {
type: 'string',
description: expect.stringContaining('same Read output view'),
},
},
});
expect(
EditInputSchema.safeParse({
path: '/tmp/a.txt',
old_string: 'old',
new_string: 'new',
}).success,
).toBe(true);
expect(
EditInputSchema.safeParse({
path: '/tmp/a.txt',
old_string: '',
new_string: 'new',
}).success,
).toBe(false);
});
it('replaces a unique first occurrence and writes the updated content', async () => {
const writeText = vi.fn().mockResolvedValue(0);
const tool = new EditTool(
createFakeKaos({
readText: vi.fn().mockResolvedValue('alpha beta'),
writeText,
}),
PERMISSIVE_WORKSPACE,
);
const result = await executeTool(tool,
context({ path: '/tmp/a.txt', old_string: 'beta', new_string: 'gamma' }),
);
expect(result.output).toContain('Replaced 1 occurrence');
expect(writeText).toHaveBeenCalledWith('/tmp/a.txt', 'alpha gamma');
});
it('expands leading tilde paths using the kaos home directory', async () => {
const readText = vi.fn().mockResolvedValue('alpha beta');
const writeText = vi.fn().mockResolvedValue(0);
const tool = new EditTool(createFakeKaos({ readText, writeText }), PERMISSIVE_WORKSPACE);
const result = await executeTool(tool,
context({ path: '~/notes/today.txt', old_string: 'beta', new_string: 'gamma' }),
);
expect(result.output).toContain('Replaced 1 occurrence');
expect(readText).toHaveBeenCalledWith('/home/test/notes/today.txt');
expect(writeText).toHaveBeenCalledWith('/home/test/notes/today.txt', 'alpha gamma');
});
it('treats replacement dollar sequences literally for single edits', async () => {
const writeText = vi.fn().mockResolvedValue(0);
const tool = new EditTool(
createFakeKaos({
readText: vi.fn().mockResolvedValue('alpha beta gamma'),
writeText,
}),
PERMISSIVE_WORKSPACE,
);
const result = await executeTool(tool,
context({ path: '/tmp/a.txt', old_string: 'beta', new_string: "$& $$ $` $'" }),
);
expect(result.output).toContain('Replaced 1 occurrence');
expect(writeText).toHaveBeenCalledWith('/tmp/a.txt', "alpha $& $$ $` $' gamma");
});
it('treats replacement dollar sequences literally for replace_all edits', async () => {
const writeText = vi.fn().mockResolvedValue(0);
const tool = new EditTool(
createFakeKaos({
readText: vi.fn().mockResolvedValue('a b a'),
writeText,
}),
PERMISSIVE_WORKSPACE,
);
const result = await executeTool(tool,
context({ path: '/tmp/a.txt', old_string: 'a', new_string: '$&', replace_all: true }),
);
expect(result.output).toContain('Replaced 2 occurrences');
expect(writeText).toHaveBeenCalledWith('/tmp/a.txt', '$& b $&');
});
it('matches pure CRLF files through the LF model view and writes back CRLF', async () => {
const writeText = vi.fn().mockResolvedValue(0);
const tool = new EditTool(
createFakeKaos({
readText: vi.fn().mockResolvedValue('alpha\r\nbeta\r\ngamma\r\n'),
writeText,
}),
PERMISSIVE_WORKSPACE,
);
const result = await executeTool(tool,
context({ path: '/tmp/a.txt', old_string: 'alpha\nbeta', new_string: 'one\ntwo' }),
);
expect(result.output).toContain('Replaced 1 occurrence');
expect(writeText).toHaveBeenCalledWith('/tmp/a.txt', 'one\r\ntwo\r\ngamma\r\n');
});
it('does not double carriage returns when editing pure CRLF files', async () => {
const writeText = vi.fn().mockResolvedValue(0);
const tool = new EditTool(
createFakeKaos({
readText: vi.fn().mockResolvedValue('alpha\r\nbeta\r\n'),
writeText,
}),
PERMISSIVE_WORKSPACE,
);
const result = await executeTool(tool,
context({ path: '/tmp/a.txt', old_string: 'alpha\nbeta', new_string: 'one\r\ntwo' }),
);
expect(result.output).toContain('Replaced 1 occurrence');
expect(writeText).toHaveBeenCalledWith('/tmp/a.txt', 'one\r\ntwo\r\n');
});
it('keeps mixed line ending files on the raw exact path', async () => {
const writeText = vi.fn().mockResolvedValue(0);
const tool = new EditTool(
createFakeKaos({
readText: vi.fn().mockResolvedValue('alpha\r\nbeta\ngamma\r\n'),
writeText,
}),
PERMISSIVE_WORKSPACE,
);
const result = await executeTool(tool,
context({ path: '/tmp/a.txt', old_string: 'alpha\nbeta', new_string: 'one\ntwo' }),
);
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('old_string not found');
expect(writeText).not.toHaveBeenCalled();
});
it('allows exact raw edits in mixed line ending files without normalizing the rest', async () => {
const writeText = vi.fn().mockResolvedValue(0);
const tool = new EditTool(
createFakeKaos({
readText: vi.fn().mockResolvedValue('alpha\r\nbeta\ngamma\r\n'),
writeText,
}),
PERMISSIVE_WORKSPACE,
);
const result = await executeTool(tool,
context({ path: '/tmp/a.txt', old_string: 'alpha\r\nbeta', new_string: 'one\r\ntwo' }),
);
expect(result.output).toContain('Replaced 1 occurrence');
expect(writeText).toHaveBeenCalledWith('/tmp/a.txt', 'one\r\ntwo\ngamma\r\n');
});
it('replace_all replaces every occurrence', async () => {
const writeText = vi.fn().mockResolvedValue(0);
const tool = new EditTool(
createFakeKaos({
readText: vi.fn().mockResolvedValue('a b a'),
writeText,
}),
PERMISSIVE_WORKSPACE,
);
const result = await executeTool(tool,
context({ path: '/tmp/a.txt', old_string: 'a', new_string: 'x', replace_all: true }),
);
expect(result.output).toContain('Replaced 2 occurrences');
expect(writeText).toHaveBeenCalledWith('/tmp/a.txt', 'x b x');
});
it('rejects no-op edits before file I/O', async () => {
const readText = vi.fn().mockResolvedValue('same');
const writeText = vi.fn().mockResolvedValue(0);
const tool = new EditTool(createFakeKaos({ readText, writeText }), PERMISSIVE_WORKSPACE);
const result = await executeTool(tool,
context({
path: '/tmp/a.txt',
old_string: 'same',
new_string: 'same',
replace_all: true,
}),
);
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('No changes to make');
expect(readText).not.toHaveBeenCalled();
expect(writeText).not.toHaveBeenCalled();
});
it('errors when old_string is missing', async () => {
const writeText = vi.fn().mockResolvedValue(0);
const tool = new EditTool(
createFakeKaos({
readText: vi.fn().mockResolvedValue('alpha beta'),
writeText,
}),
PERMISSIVE_WORKSPACE,
);
const result = await executeTool(tool,
context({ path: '/tmp/a.txt', old_string: 'delta', new_string: 'gamma' }),
);
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('old_string not found');
expect(writeText).not.toHaveBeenCalled();
});
it('errors when old_string is not unique and replace_all is false', async () => {
const writeText = vi.fn().mockResolvedValue(0);
const tool = new EditTool(
createFakeKaos({
readText: vi.fn().mockResolvedValue('same same'),
writeText,
}),
PERMISSIVE_WORKSPACE,
);
const result = await executeTool(tool,
context({ path: '/tmp/a.txt', old_string: 'same', new_string: 'other' }),
);
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('not unique');
expect(result.output).toContain('set replace_all=true');
expect(result.output).toContain('include more surrounding context');
expect(writeText).not.toHaveBeenCalled();
});
it('rejects relative traversal edits before reading', async () => {
const readText = vi.fn().mockResolvedValue('secret');
const tool = new EditTool(createFakeKaos({ readText }), {
workspaceDir: '/workspace/project',
additionalDirs: [],
});
const result = await executeTool(tool,
context({ path: '../outside.txt', old_string: 'secret', new_string: 'x' }),
);
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('absolute path');
expect(readText).not.toHaveBeenCalled();
});
it('replaces unicode strings (CJK) and round-trips the surrounding text', async () => {
const writeText = vi.fn().mockResolvedValue(0);
const tool = new EditTool(
createFakeKaos({
readText: vi.fn().mockResolvedValue('Hello 世界! café'),
writeText,
}),
PERMISSIVE_WORKSPACE,
);
const result = await executeTool(tool,
context({ path: '/tmp/u.txt', old_string: '世界', new_string: '地球' }),
);
expect(result.output).toContain('Replaced 1 occurrence');
expect(writeText).toHaveBeenCalledWith('/tmp/u.txt', 'Hello 地球! café');
});
it('leaves the file byte-identical when old_string is not present', async () => {
const writeText = vi.fn().mockResolvedValue(0);
const original = 'Hello world!';
const tool = new EditTool(
createFakeKaos({
readText: vi.fn().mockResolvedValue(original),
writeText,
}),
PERMISSIVE_WORKSPACE,
);
const result = await executeTool(tool,
context({ path: '/tmp/n.txt', old_string: 'notfound', new_string: 'replacement' }),
);
expect(result.isError).toBe(true);
// Lockdown the negative side-effect: no write should have been issued.
expect(writeText).not.toHaveBeenCalled();
});
it('errors with an is-not-a-file phrasing when the path resolves to a directory', async () => {
// py wording is "is not a file"; TS currently relies on readText to fail.
// fake-kaos's notImplemented() defaults make this surface a generic
// readText error today — fail-divergent until the path-type check moves
// upstream of read.
const tool = new EditTool(
createFakeKaos({
readText: vi.fn().mockRejectedValue(
Object.assign(new Error('EISDIR: illegal operation on a directory'), {
code: 'EISDIR',
}),
),
}),
PERMISSIVE_WORKSPACE,
);
const result = await executeTool(tool,
context({ path: '/tmp/dir', old_string: 'old', new_string: 'new' }),
);
expect(result.isError).toBe(true);
expect(result.output).toContain('is not a file');
});
it('replaces a substring with an empty new_string (deletion)', async () => {
const writeText = vi.fn().mockResolvedValue(0);
const tool = new EditTool(
createFakeKaos({
readText: vi.fn().mockResolvedValue('Hello world!'),
writeText,
}),
PERMISSIVE_WORKSPACE,
);
const result = await executeTool(tool,
context({ path: '/tmp/e.txt', old_string: 'world', new_string: '' }),
);
expect(result.output).toContain('Replaced 1 occurrence');
expect(writeText).toHaveBeenCalledWith('/tmp/e.txt', 'Hello !');
});
it('allows absolute edits outside the workspace under default policy', async () => {
const writeText = vi.fn().mockResolvedValue(0);
const tool = new EditTool(
createFakeKaos({
readText: vi.fn().mockResolvedValue('old content'),
writeText,
}),
{ workspaceDir: '/workspace', additionalDirs: [] },
);
const result = await executeTool(tool,
context({ path: '/tmp/outside.txt', old_string: 'old', new_string: 'new' }),
);
expect(result.isError).toBeFalsy();
expect(writeText).toHaveBeenCalledWith('/tmp/outside.txt', 'new content');
});
it('allows absolute edits to a sibling dir that merely shares the work-dir prefix', async () => {
// /workspace-sneaky/* is outside /workspace — string prefix check must not
// mistake "shares a prefix" for "inside workspace".
const writeText = vi.fn().mockResolvedValue(0);
const tool = new EditTool(
createFakeKaos({
readText: vi.fn().mockResolvedValue('content'),
writeText,
}),
{ workspaceDir: '/workspace', additionalDirs: [] },
);
const result = await executeTool(tool,
context({ path: '/workspace-sneaky/test.txt', old_string: 'content', new_string: 'new' }),
);
expect(result.isError).toBeFalsy();
expect(writeText).toHaveBeenCalledWith('/workspace-sneaky/test.txt', 'new');
});
});