mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-20 06:05:36 +00:00
- route read/write/grep/glob through host fs and process services - add TextModel/EditService with an os-backed FileEditService - update file tool tests, exports, layer rules, and DI docs
443 lines
17 KiB
TypeScript
443 lines
17 KiB
TypeScript
/**
|
|
* WriteTool tests for the v2 fileTools domain.
|
|
*
|
|
* Ported from v1 (`packages/agent-core/test/tools/write.test.ts`) and adapted
|
|
* to the v2 constructor `(fs, env, workspace)`. Self-contained: builds a
|
|
* minimal fake `IHostFileSystem` inline so the tool can be exercised without
|
|
* the composition root.
|
|
*
|
|
* The v1 append path used `kaos.writeText(path, data, { mode: 'a' })`. v2's
|
|
* `IHostFileSystem.writeText` has no mode flag, so append is implemented as
|
|
* `readText` (treating a missing file as empty) followed by `writeText` of the
|
|
* concatenation. The append-call assertions below reflect that mechanic.
|
|
*/
|
|
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import { PathSecurityError } from '../../src/_base/tools/policies/path-access';
|
|
import type { HostFileStat, IHostFileSystem } from '#/os/interface/hostFileSystem';
|
|
import { stubWorkspaceContext } from './stub-workspace-context';
|
|
import { type WriteInput, WriteInputSchema, WriteTool } from '#/os/backends/node-local/tools/write';
|
|
import type { IHostEnvironment } from '#/os/interface/hostEnvironment';
|
|
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/agent/tool';
|
|
|
|
const signal = new AbortController().signal;
|
|
const PERMISSIVE_WORKSPACE = stubWorkspaceContext('/');
|
|
|
|
function toolContentString(result: ExecutableToolResult): string {
|
|
const c = result.output;
|
|
if (typeof c !== 'string') {
|
|
throw new TypeError(`expected string content, got ${typeof c}`);
|
|
}
|
|
return c;
|
|
}
|
|
|
|
function createTestEnv(home = '/home'): IHostEnvironment {
|
|
return {
|
|
_serviceBrand: undefined,
|
|
osKind: 'Linux',
|
|
osArch: 'x86_64',
|
|
osVersion: 'test',
|
|
shellName: 'bash',
|
|
shellPath: '/bin/bash',
|
|
pathClass: 'posix',
|
|
homeDir: home,
|
|
ready: Promise.resolve(),
|
|
};
|
|
}
|
|
|
|
interface WriteFsOptions {
|
|
/** Override readText. Default rejects with ENOENT (file missing). */
|
|
readText?: (path: string) => Promise<string>;
|
|
/** Override writeText. Default no-op. */
|
|
writeText?: (path: string, data: string) => Promise<void>;
|
|
/** Override stat. Default reports an existing directory. */
|
|
stat?: (path: string) => Promise<HostFileStat>;
|
|
/** Override mkdir. Default no-op. */
|
|
mkdir?: (path: string) => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Fake fs for WriteTool. All IO methods are `vi.fn()` spies so tests can
|
|
* assert on the readText/writeText/stat/mkdir calls. By default `stat`
|
|
* reports an existing directory (so `ensureParentDirectory` passes without
|
|
* creating anything) and `readText` rejects with ENOENT (so an append to a
|
|
* missing file treats existing content as empty).
|
|
*/
|
|
function createWriteFs(options: WriteFsOptions = {}) {
|
|
const readText = vi.fn(
|
|
options.readText ??
|
|
(async () => {
|
|
throw Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' });
|
|
}),
|
|
);
|
|
const writeText = vi.fn(options.writeText ?? (async () => {}));
|
|
const stat = vi.fn(
|
|
options.stat ?? (async () => ({ isFile: false, isDirectory: true, size: 0 })),
|
|
);
|
|
const mkdir = vi.fn(options.mkdir ?? (async () => {}));
|
|
const fs = { cwd: '/', readText, writeText, stat, mkdir } as unknown as IHostFileSystem;
|
|
return { fs, readText, writeText, stat, mkdir };
|
|
}
|
|
|
|
function makeTool(options: WriteFsOptions = {}, workspace = PERMISSIVE_WORKSPACE) {
|
|
const fakes = createWriteFs(options);
|
|
const tool = new WriteTool(fakes.fs, createTestEnv(), workspace);
|
|
return { tool, ...fakes };
|
|
}
|
|
|
|
function isPromiseLike(value: ToolExecution | Promise<ToolExecution>): value is Promise<ToolExecution> {
|
|
return typeof (value as Promise<ToolExecution>).then === 'function';
|
|
}
|
|
|
|
async function execute(tool: WriteTool, args: WriteInput): Promise<ExecutableToolResult> {
|
|
let execution: ToolExecution;
|
|
try {
|
|
const resolved = tool.resolveExecution(args);
|
|
execution = isPromiseLike(resolved) ? await resolved : resolved;
|
|
} catch (error) {
|
|
const output =
|
|
error instanceof PathSecurityError
|
|
? error.message
|
|
: `Tool "${tool.name}" failed to resolve execution: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`;
|
|
return { isError: true, output };
|
|
}
|
|
if (execution.isError === true) return execution;
|
|
const ctx: ExecutableToolContext = {
|
|
turnId: 0,
|
|
toolCallId: 'call_write',
|
|
signal,
|
|
};
|
|
return execution.execute(ctx);
|
|
}
|
|
|
|
describe('WriteTool', () => {
|
|
it('exposes current metadata and schema', () => {
|
|
const { tool } = makeTool();
|
|
|
|
expect(tool.name).toBe('Write');
|
|
expect(tool.description).toContain('append adds content at EOF without adding a newline');
|
|
expect(tool.description).toContain('\\n stays LF, \\r\\n stays CRLF');
|
|
// The prompt steers the agent toward Edit for partial changes to an
|
|
// existing file. Pin the prohibition so accidental weakening is caught.
|
|
expect(tool.description).toContain('Write is NOT ALLOWED for incremental changes');
|
|
expect(tool.parameters).toMatchObject({
|
|
type: 'object',
|
|
properties: {
|
|
content: {
|
|
type: 'string',
|
|
description: expect.stringContaining('Raw full file content'),
|
|
},
|
|
mode: {
|
|
enum: ['overwrite', 'append'],
|
|
description: expect.stringContaining('Defaults to overwrite'),
|
|
},
|
|
},
|
|
});
|
|
expect(WriteInputSchema.safeParse({ path: '/tmp/out.txt', content: 'hello' }).success).toBe(
|
|
true,
|
|
);
|
|
expect(
|
|
WriteInputSchema.safeParse({ path: '/tmp/out.txt', content: 'hello', mode: 'append' })
|
|
.success,
|
|
).toBe(true);
|
|
expect(
|
|
WriteInputSchema.safeParse({ path: '/tmp/out.txt', content: 'hello', mode: 'bad' }).success,
|
|
).toBe(false);
|
|
expect(WriteInputSchema.safeParse({ path: '/tmp/out.txt' }).success).toBe(false);
|
|
});
|
|
|
|
it('describes the working-directory rule for the path parameter', () => {
|
|
const { tool } = makeTool();
|
|
const params = tool.parameters as {
|
|
properties: { path: { description: string } };
|
|
};
|
|
|
|
expect(params.properties.path.description).toContain('working directory');
|
|
expect(params.properties.path.description).toMatch(/relative/i);
|
|
expect(params.properties.path.description).toMatch(/absolute/i);
|
|
});
|
|
|
|
it('exposes the content on the file_io display so the approval panel can preview it', () => {
|
|
const { tool } = makeTool();
|
|
const execution = tool.resolveExecution({
|
|
path: '/tmp/new.txt',
|
|
content: 'hello\nworld',
|
|
});
|
|
if (execution.isError === true) {
|
|
throw new TypeError('expected runnable execution');
|
|
}
|
|
expect(execution.display).toEqual({
|
|
kind: 'file_io',
|
|
operation: 'write',
|
|
path: '/tmp/new.txt',
|
|
content: 'hello\nworld',
|
|
});
|
|
});
|
|
|
|
it('matches permission args with negated glob path semantics', () => {
|
|
const { tool } = makeTool({}, stubWorkspaceContext('/workspace'));
|
|
const insideSrc = tool.resolveExecution({ path: './src/a.ts', content: 'x' });
|
|
const outsideSrc = tool.resolveExecution({ path: './README.md', content: 'x' });
|
|
if (insideSrc.isError === true || outsideSrc.isError === true) {
|
|
throw new TypeError('expected runnable execution');
|
|
}
|
|
|
|
expect(insideSrc.matchesRule?.('!./src/**')).toBe(false);
|
|
expect(outsideSrc.matchesRule?.('!./src/**')).toBe(true);
|
|
});
|
|
|
|
it('guides batching large content across multiple write calls', () => {
|
|
const { tool } = makeTool();
|
|
|
|
// The guidance must mention that a file too large for one call should be
|
|
// chunked, and spell out the first-overwrite-then-append ordering.
|
|
expect(tool.description).toMatch(/large/i);
|
|
expect(tool.description).toContain('content too large for one call');
|
|
expect(tool.description).toMatch(/overwrite[^.]*first chunk[^.]*then[^.]*append/i);
|
|
});
|
|
|
|
it('writes content through fs and reports bytes written', async () => {
|
|
const { tool, writeText } = makeTool();
|
|
|
|
const result = await execute(tool, { path: '/tmp/new.txt', content: 'hello' });
|
|
|
|
expect(writeText).toHaveBeenCalledWith('/tmp/new.txt', 'hello');
|
|
expect(result.output).toContain('Wrote 5 bytes');
|
|
});
|
|
|
|
it('expands leading tilde paths using the kaos home directory', async () => {
|
|
const fakes = createWriteFs();
|
|
const tool = new WriteTool(fakes.fs, createTestEnv('/home/test'), PERMISSIVE_WORKSPACE);
|
|
|
|
const result = await execute(tool, { path: '~/notes/today.txt', content: 'hello' });
|
|
|
|
expect(fakes.writeText).toHaveBeenCalledWith('/home/test/notes/today.txt', 'hello');
|
|
expect(result.output).toContain('Wrote 5 bytes');
|
|
});
|
|
|
|
it('appends content by reading existing bytes then writing the concatenation', async () => {
|
|
const { tool, readText, writeText } = makeTool({ readText: async () => 'old' });
|
|
|
|
const result = await execute(tool, {
|
|
path: '/tmp/existing.txt',
|
|
content: '\nhello',
|
|
mode: 'append',
|
|
});
|
|
|
|
expect(readText).toHaveBeenCalledWith('/tmp/existing.txt');
|
|
expect(writeText).toHaveBeenCalledWith('/tmp/existing.txt', 'old\nhello');
|
|
expect(result.output).toContain('Appended 6 bytes');
|
|
});
|
|
|
|
it('reports the real UTF-8 byte count for non-ASCII content', async () => {
|
|
// Six Japanese characters: each encodes to 3 UTF-8 bytes → 18 bytes total,
|
|
// even though the JS string length is 6. The reported count must reflect
|
|
// the bytes that land on disk, not the code-unit count.
|
|
const content = 'こんにちは。';
|
|
const expectedBytes = Buffer.byteLength(content, 'utf8');
|
|
expect(expectedBytes).toBe(18);
|
|
|
|
const { tool } = makeTool();
|
|
|
|
const result = await execute(tool, { path: '/tmp/jp.txt', content });
|
|
|
|
expect(result.output).toContain('Wrote 18 bytes');
|
|
expect(result.output).not.toContain('Wrote 6 bytes');
|
|
});
|
|
|
|
it('reports the real UTF-8 byte count for content with surrogate-pair emoji', async () => {
|
|
// 'hi😀': the emoji is a single code point encoded as a UTF-16 surrogate
|
|
// pair, so JS string length is 4 (2 for 'hi' + 2 code units), but the
|
|
// UTF-8 encoding is 6 bytes (2 for 'hi' + 4 for the emoji). The reported
|
|
// count must reflect the bytes on disk, not the code-unit count — this
|
|
// is the sharpest edge of the byte-counting bug.
|
|
const content = 'hi😀';
|
|
expect(content.length).toBe(4);
|
|
const expectedBytes = Buffer.byteLength(content, 'utf8');
|
|
expect(expectedBytes).toBe(6);
|
|
|
|
const { tool } = makeTool();
|
|
|
|
const result = await execute(tool, { path: '/tmp/emoji.txt', content });
|
|
|
|
expect(result.output).toContain('Wrote 6 bytes');
|
|
expect(result.output).not.toContain('Wrote 4 bytes');
|
|
});
|
|
|
|
it('reports the real UTF-8 byte count for non-ASCII append content', async () => {
|
|
const content = 'café';
|
|
const expectedBytes = Buffer.byteLength(content, 'utf8');
|
|
expect(expectedBytes).toBe(5);
|
|
|
|
const { tool, writeText } = makeTool({ readText: async () => 'prefix' });
|
|
|
|
const result = await execute(tool, { path: '/tmp/menu.txt', content, mode: 'append' });
|
|
|
|
expect(writeText).toHaveBeenCalledWith('/tmp/menu.txt', 'prefixcafé');
|
|
expect(result.output).toContain('Appended 5 bytes');
|
|
});
|
|
|
|
it('creates missing parent directories automatically before writing', async () => {
|
|
const enoent = Object.assign(new Error('ENOENT: no such file or directory'), {
|
|
code: 'ENOENT',
|
|
});
|
|
const { tool, mkdir, writeText } = makeTool({ stat: vi.fn().mockRejectedValue(enoent) });
|
|
|
|
const result = await execute(tool, { path: '/tmp/missing-dir/file.txt', content: 'data' });
|
|
|
|
expect(result.isError).toBeFalsy();
|
|
expect(mkdir).toHaveBeenCalledWith('/tmp/missing-dir', { recursive: true });
|
|
expect(writeText).toHaveBeenCalledWith('/tmp/missing-dir/file.txt', 'data');
|
|
});
|
|
|
|
it('surfaces mkdir failures when a missing parent cannot be created', async () => {
|
|
const enoent = Object.assign(new Error('ENOENT: no such file or directory'), {
|
|
code: 'ENOENT',
|
|
});
|
|
const { tool, writeText } = makeTool({
|
|
stat: vi.fn().mockRejectedValue(enoent),
|
|
mkdir: vi.fn().mockRejectedValue(new Error('permission denied')),
|
|
});
|
|
|
|
const result = await execute(tool, { path: '/tmp/missing-dir/file.txt', content: 'data' });
|
|
|
|
expect(result).toMatchObject({ isError: true, output: 'permission denied' });
|
|
expect(writeText).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects writing when the parent path is not a directory', async () => {
|
|
// A regular file standing where a directory is expected.
|
|
const { tool, writeText } = makeTool({
|
|
stat: vi.fn().mockResolvedValue({ isFile: true, isDirectory: false, size: 0 }),
|
|
});
|
|
|
|
const result = await execute(tool, { path: '/tmp/a-file/child.txt', content: 'data' });
|
|
|
|
expect(result).toMatchObject({ isError: true });
|
|
expect(result.output).toMatch(/not a directory/i);
|
|
expect(writeText).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('writes when the parent directory exists', async () => {
|
|
const { tool, writeText } = makeTool({
|
|
stat: vi.fn().mockResolvedValue({ isFile: false, isDirectory: true, size: 0 }),
|
|
});
|
|
|
|
const result = await execute(tool, { path: '/tmp/exists/file.txt', content: 'data' });
|
|
|
|
expect(result.isError).toBeUndefined();
|
|
expect(writeText).toHaveBeenCalledWith('/tmp/exists/file.txt', 'data');
|
|
});
|
|
|
|
it('surfaces fs write failures as tool errors', async () => {
|
|
const { tool } = makeTool({
|
|
writeText: vi.fn().mockRejectedValue(new Error('disk full')),
|
|
});
|
|
|
|
const result = await execute(tool, { path: '/some/file.txt', content: 'data' });
|
|
|
|
expect(result).toMatchObject({ isError: true, output: 'disk full' });
|
|
});
|
|
|
|
it('allows explicit absolute writes outside the workspace', async () => {
|
|
const { tool, writeText } = makeTool({}, stubWorkspaceContext('/workspace'));
|
|
|
|
const result = await execute(tool, { path: '/tmp/pwned.txt', content: 'x' });
|
|
|
|
expect(result.isError).toBeUndefined();
|
|
expect(writeText).toHaveBeenCalledWith('/tmp/pwned.txt', 'x');
|
|
});
|
|
|
|
it('rejects relative traversal writes before fs I/O', async () => {
|
|
const { tool, writeText } = makeTool(
|
|
{},
|
|
stubWorkspaceContext('/workspace/project'),
|
|
);
|
|
|
|
const result = await execute(tool, { path: '../outside.txt', content: 'x' });
|
|
|
|
expect(result).toMatchObject({ isError: true });
|
|
expect(result.output).toContain('absolute path');
|
|
expect(writeText).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('blocks sensitive file writes', async () => {
|
|
const { tool, writeText } = makeTool({}, stubWorkspaceContext('/workspace'));
|
|
|
|
const result = await execute(tool, { path: '/workspace/id_rsa', content: 'key' });
|
|
|
|
expect(result).toMatchObject({ isError: true });
|
|
expect(result.output).toContain('sensitive-file pattern');
|
|
expect(writeText).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('round-trips unicode content (CJK + emoji + accented Latin) through fs.writeText', async () => {
|
|
const { tool, writeText } = makeTool();
|
|
const content = 'Hello 世界 🌍\nUnicode: café, naïve, résumé';
|
|
|
|
const result = await execute(tool, { path: '/tmp/unicode.txt', content });
|
|
|
|
expect(result.isError).toBeFalsy();
|
|
expect(writeText).toHaveBeenCalledWith('/tmp/unicode.txt', content);
|
|
});
|
|
|
|
it('writes empty content as a zero-byte file via fs.writeText("")', async () => {
|
|
const { tool, writeText } = makeTool();
|
|
|
|
const result = await execute(tool, { path: '/tmp/empty.txt', content: '' });
|
|
|
|
expect(result.isError).toBeFalsy();
|
|
expect(writeText).toHaveBeenCalledWith('/tmp/empty.txt', '');
|
|
});
|
|
|
|
it('still reports parent-directory ENOENT surfaced by writeText itself', async () => {
|
|
// When the proactive parent check is inconclusive (e.g. the environment
|
|
// has no `stat`) and the underlying write then fails with ENOENT — for
|
|
// example a parent directory removed between the check and the write —
|
|
// the tool still surfaces a clear "parent directory does not exist"
|
|
// message rather than a raw host error.
|
|
const { tool } = makeTool({
|
|
writeText: vi
|
|
.fn()
|
|
.mockRejectedValue(
|
|
Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }),
|
|
),
|
|
});
|
|
|
|
const result = await execute(tool, { path: '/tmp/missing-dir/file.txt', content: 'data' });
|
|
|
|
expect(result.isError).toBe(true);
|
|
expect(result.output).toContain('parent directory does not exist');
|
|
});
|
|
|
|
it('appending to a nonexistent file creates it with just the appended bytes', async () => {
|
|
// Append mode on a missing path returns success and creates the file:
|
|
// readText rejects with ENOENT, so existing content is treated as empty.
|
|
const { tool, readText, writeText } = makeTool();
|
|
|
|
const result = await execute(tool, {
|
|
path: '/tmp/new-append.txt',
|
|
content: 'New content',
|
|
mode: 'append',
|
|
});
|
|
|
|
expect(result.isError).toBeFalsy();
|
|
expect(toolContentString(result).toLowerCase()).toContain('appended');
|
|
expect(readText).toHaveBeenCalledWith('/tmp/new-append.txt');
|
|
expect(writeText).toHaveBeenCalledWith('/tmp/new-append.txt', 'New content');
|
|
});
|
|
|
|
it('allows absolute writes to a sibling dir that merely shares the work-dir prefix', async () => {
|
|
// Path policy must distinguish "shares a prefix with workspaceDir" from
|
|
// "is inside workspaceDir". /workspace-sneaky/* is outside /workspace.
|
|
const { tool, writeText } = makeTool({}, stubWorkspaceContext('/workspace'));
|
|
|
|
const result = await execute(tool, { path: '/workspace-sneaky/file.txt', content: 'content' });
|
|
|
|
expect(result.isError).toBeFalsy();
|
|
expect(writeText).toHaveBeenCalledWith('/workspace-sneaky/file.txt', 'content');
|
|
});
|
|
});
|