fix(core): handle ENOENT from ACP readTextFileWithInfo gracefully

- Add readTextFileWithInfo implementation in AcpFileSystemService
- Convert RESOURCE_NOT_FOUND errors to ENOENT for consistent handling
- Strip UTF-8 BOM from ACP responses
- Treat ENOENT errors in write-file tool as new file creation

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
mingholy.lmh 2026-03-11 22:19:35 +08:00
parent bddce08750
commit 2cbd092835
5 changed files with 213 additions and 41 deletions

View file

@ -179,4 +179,83 @@ describe('AcpFileSystemService', () => {
expect(client.readTextFile).not.toHaveBeenCalled();
});
});
describe('readTextFileWithInfo', () => {
it('reads through ACP and strips UTF-8 BOM', async () => {
const client = {
readTextFile: vi.fn().mockResolvedValue({ content: '\ufeffhello' }),
} as unknown as AgentSideConnection;
const svc = new AcpFileSystemService(
client,
'session-info-1',
{ readTextFile: true, writeTextFile: true },
createFallback(),
);
const result = await svc.readTextFileWithInfo('/some/file.txt');
expect(result).toEqual({
content: 'hello',
encoding: 'utf-8',
bom: true,
});
expect(client.readTextFile).toHaveBeenCalledWith({
path: '/some/file.txt',
sessionId: 'session-info-1',
});
});
it('converts RESOURCE_NOT_FOUND error to ENOENT', async () => {
const resourceNotFoundError = {
code: RESOURCE_NOT_FOUND_CODE,
message: 'File not found',
};
const client = {
readTextFile: vi.fn().mockRejectedValue(resourceNotFoundError),
} as unknown as AgentSideConnection;
const svc = new AcpFileSystemService(
client,
'session-info-2',
{ readTextFile: true, writeTextFile: true },
createFallback(),
);
await expect(
svc.readTextFileWithInfo('/some/missing.txt'),
).rejects.toMatchObject({
code: 'ENOENT',
errno: -2,
path: '/some/missing.txt',
});
});
it('uses fallback when readTextFile capability is disabled', async () => {
const client = {
readTextFile: vi.fn(),
} as unknown as AgentSideConnection;
const fallback = createFallback();
(
fallback.readTextFileWithInfo as ReturnType<typeof vi.fn>
).mockResolvedValue({ content: 'fallback', encoding: 'gbk', bom: false });
const svc = new AcpFileSystemService(
client,
'session-info-3',
{ readTextFile: false, writeTextFile: true },
fallback,
);
const result = await svc.readTextFileWithInfo('/some/file.txt');
expect(result).toEqual({
content: 'fallback',
encoding: 'gbk',
bom: false,
});
expect(fallback.readTextFileWithInfo).toHaveBeenCalledWith(
'/some/file.txt',
);
expect(client.readTextFile).not.toHaveBeenCalled();
});
});
});

View file

@ -16,6 +16,26 @@ import type {
const RESOURCE_NOT_FOUND_CODE = -32002;
function getErrorCode(error: unknown): unknown {
if (error instanceof RequestError) {
return error.code;
}
if (typeof error === 'object' && error !== null && 'code' in error) {
return (error as { code?: unknown }).code;
}
return undefined;
}
function createEnoentError(filePath: string): NodeJS.ErrnoException {
const err = new Error(`File not found: ${filePath}`) as NodeJS.ErrnoException;
err.code = 'ENOENT';
err.errno = -2;
err.path = filePath;
return err;
}
export class AcpFileSystemService implements FileSystemService {
constructor(
private readonly connection: AgentSideConnection,
@ -36,21 +56,10 @@ export class AcpFileSystemService implements FileSystemService {
sessionId: this.sessionId,
});
} catch (error) {
const errorCode =
error instanceof RequestError
? error.code
: typeof error === 'object' && error !== null && 'code' in error
? (error as { code?: unknown }).code
: undefined;
const errorCode = getErrorCode(error);
if (errorCode === RESOURCE_NOT_FOUND_CODE) {
const err = new Error(
`File not found: ${filePath}`,
) as NodeJS.ErrnoException;
err.code = 'ENOENT';
err.errno = -2;
err.path = filePath;
throw err;
throw createEnoentError(filePath);
}
throw error;
@ -60,9 +69,33 @@ export class AcpFileSystemService implements FileSystemService {
}
async readTextFileWithInfo(filePath: string): Promise<FileReadResult> {
// ACP protocol does not expose encoding metadata; delegate to the local
// fallback which performs a single-pass read with encoding detection.
return this.fallback.readTextFileWithInfo(filePath);
if (!this.capabilities.readTextFile) {
return this.fallback.readTextFileWithInfo(filePath);
}
let response: { content: string };
try {
response = await this.connection.readTextFile({
path: filePath,
sessionId: this.sessionId,
});
} catch (error) {
const errorCode = getErrorCode(error);
if (errorCode === RESOURCE_NOT_FOUND_CODE) {
throw createEnoentError(filePath);
}
throw error;
}
const hasUtf8Bom =
response.content.length > 0 && response.content.codePointAt(0) === 0xfeff;
return {
content: hasUtf8Bom ? response.content.slice(1) : response.content,
// ACP protocol currently returns text only and does not expose source encoding.
encoding: 'utf-8',
bom: hasUtf8Bom,
};
}
async writeTextFile(