Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/acp-range-reads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Read a line range over ACP without fetching the whole file; those reads no longer report the file's total line count.
5 changes: 5 additions & 0 deletions .changeset/large-file-reads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Read large text files in bounded memory and read tail lines without scanning whole files.
72 changes: 63 additions & 9 deletions packages/acp-adapter/src/kaos-acp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,16 @@ export class AcpKaos implements Kaos {
* Return a small UTF-8 header derived from the same ACP text source as
* `readText` / `readLines`, used only by text-read callers for sniffing.
* Keep `readBytes` local so binary callers such as ReadMediaFile stay safe.
*
* Bounded to the first line via ACP `line` / `limit` so a sniff never
* transfers the whole file: `detectFileType` only needs the leading magic
* bytes (≤16) plus a NUL scan, both of which line 1 covers, and line 1 is
* the smallest unit `fs/readTextFile` can return. A non-compliant client
* that ignores `limit` is still capped to line 1 by `readLineRange`.
*/
async readTextPreview(path: string, n: number): Promise<Buffer> {
const text = await this.readText(path);
const first = await this.readLineRange(path, { startLine: 1, maxLines: 1 }).next();
const text = first.value ?? '';
return Buffer.from(text.slice(0, n), 'utf8');
}

Expand All @@ -168,15 +175,40 @@ export class AcpKaos implements Kaos {
options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' },
): AsyncGenerator<string> {
const text = await this.readText(path, options);
if (text.length === 0) return;
let start = 0;
for (let i = 0; i < text.length; i++) {
if (text.charCodeAt(i) === 0x0a /* \n */) {
yield text.slice(start, i + 1);
start = i + 1;
}
yield* splitLinesKeepTerminator(text);
}

/**
* Yield a line range from the file via ACP's native `line` / `limit`
* parameters on `fs/readTextFile`, so a compliant client can return
* only the requested window instead of the whole file. `startLine` is
* 1-based (ACP convention).
*
* `encoding` / `errors` are accepted for interface compatibility but
* ignored — the ACP response is already a decoded string. If the
* client does not honor `line` / `limit` and returns more than
* `maxLines` lines, the output is truncated to `maxLines` defensively
* so a non-compliant client cannot turn a range read into a full-file
* read.
*/
async *readLineRange(
path: string,
options: { startLine: number; maxLines: number; errors?: 'strict' | 'replace' | 'ignore' },
): AsyncGenerator<string> {
const rpcPath = this.toClientPath(path);
let text: string;
try {
const resp = await this.conn.readTextFile({
sessionId: this.sessionId,
path: rpcPath,
line: options.startLine,
limit: options.maxLines,
});
text = resp.content;
} catch (error) {
throw wrapKaosError(`acp: readTextFile failed for ${rpcPath}`, error);
}
if (start < text.length) yield text.slice(start);
yield* splitLinesKeepTerminator(text, options.maxLines);
}

// ── writes: route through ACP `fs/writeTextFile` ───────────────────
Expand Down Expand Up @@ -250,6 +282,28 @@ export class AcpKaos implements Kaos {
}
}

/**
* Split a decoded string into lines, each terminated by its `\n` (the
* final line has no terminator if the string did not end with `\n`).
* When `maxLines` is given, stop after yielding that many lines — a
* defensive cap for ACP clients that ignore the `limit` parameter.
*/
function* splitLinesKeepTerminator(text: string, maxLines?: number): Generator<string> {
if (text.length === 0) return;
let start = 0;
let yielded = 0;
for (let i = 0; i < text.length; i++) {
if (text.charCodeAt(i) !== 0x0a /* \n */) continue;
yield text.slice(start, i + 1);
yielded += 1;
if (maxLines !== undefined && yielded >= maxLines) return;
start = i + 1;
}
if (start < text.length && (maxLines === undefined || yielded < maxLines)) {
yield text.slice(start);
}
}

/**
* Build a `KaosError` wrapping a raw RPC failure. We can't use the
* `Error(message, { cause })` overload here because {@link KaosError}'s
Expand Down
95 changes: 95 additions & 0 deletions packages/acp-adapter/test/kaos-acp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,101 @@ describe('AcpKaos', () => {
});
});

describe('readLineRange', () => {
async function collect(gen: AsyncGenerator<string>): Promise<string[]> {
const out: string[] = [];
for await (const line of gen) out.push(line);
return out;
}

it('forwards line/limit to conn.readTextFile and yields the window lines', async () => {
const conn = makeMockConn({ readHandler: async () => ({ content: 'b\nc\n' }) });
const kaos = new AcpKaos(conn.asConn(), 's1', makeMockInner());
expect(await collect(kaos.readLineRange('/a.ts', { startLine: 2, maxLines: 2 }))).toEqual([
'b\n',
'c\n',
]);
expect(conn.readCalls).toEqual([{ sessionId: 's1', path: '/a.ts', line: 2, limit: 2 }]);
});

it('truncates to maxLines when the client returns more than the window', async () => {
// Simulates a client that ignores `limit` and returns the whole file.
const conn = makeMockConn({ readHandler: async () => ({ content: 'a\nb\nc\nd\n' }) });
const kaos = new AcpKaos(conn.asConn(), 's1', makeMockInner());
expect(await collect(kaos.readLineRange('/a.ts', { startLine: 1, maxLines: 2 }))).toEqual([
'a\n',
'b\n',
]);
});

it('yields nothing for an empty response', async () => {
const conn = makeMockConn({ readHandler: async () => ({ content: '' }) });
const kaos = new AcpKaos(conn.asConn(), 's1', makeMockInner());
expect(await collect(kaos.readLineRange('/a.ts', { startLine: 1, maxLines: 10 }))).toEqual(
[],
);
});

it('wraps RPC errors in KaosError with cause set', async () => {
const rpcErr = new Error('rpc died');
const conn = makeMockConn({
readHandler: async () => {
throw rpcErr;
},
});
const kaos = new AcpKaos(conn.asConn(), 's1', makeMockInner());
await expect(
collect(kaos.readLineRange('/x.ts', { startLine: 1, maxLines: 1 })),
).rejects.toBeInstanceOf(KaosError);
});
});

describe('readTextPreview', () => {
it('reads only line 1 via line/limit and returns the first n chars', async () => {
const conn = makeMockConn({
readHandler: async () => ({ content: 'first line here\nsecond line\n' }),
});
const kaos = new AcpKaos(conn.asConn(), 's1', makeMockInner());

const buf = await kaos.readTextPreview('/a.ts', 5);

expect(buf.toString('utf8')).toBe('first');
// Crucially: the sniff is bounded to line 1 — never a whole-file read.
expect(conn.readCalls).toEqual([{ sessionId: 's1', path: '/a.ts', line: 1, limit: 1 }]);
});

it('stays bounded to line 1 even when the client ignores limit', async () => {
// Simulates a non-compliant client that returns the whole file despite
// `limit: 1`; readLineRange's defensive cap keeps the preview to line 1.
const conn = makeMockConn({ readHandler: async () => ({ content: 'a\nb\nc\nd\n' }) });
const kaos = new AcpKaos(conn.asConn(), 's1', makeMockInner());

const buf = await kaos.readTextPreview('/a.ts', 100);

expect(buf.toString('utf8')).toBe('a\n');
});

it('returns an empty buffer for an empty response', async () => {
const conn = makeMockConn({ readHandler: async () => ({ content: '' }) });
const kaos = new AcpKaos(conn.asConn(), 's1', makeMockInner());

const buf = await kaos.readTextPreview('/a.ts', 512);

expect(buf.byteLength).toBe(0);
});

it('wraps RPC errors in KaosError', async () => {
const conn = makeMockConn({
readHandler: async () => {
throw new Error('rpc died');
},
});
const kaos = new AcpKaos(conn.asConn(), 's1', makeMockInner());

await expect(kaos.readTextPreview('/x.ts', 512)).rejects.toBeInstanceOf(KaosError);
});
});

describe('writeText', () => {
it('forwards content to conn.writeTextFile and returns char count', async () => {
const conn = makeMockConn({});
Expand Down
Loading