diff --git a/packages/cli/src/acp/acpFileSystemService.test.ts b/packages/cli/src/acp/acpFileSystemService.test.ts index 7ddbb215378..1ae09d0d4cf 100644 --- a/packages/cli/src/acp/acpFileSystemService.test.ts +++ b/packages/cli/src/acp/acpFileSystemService.test.ts @@ -145,6 +145,82 @@ describe('AcpFileSystemService', () => { message: 'Resource not found for document', }); }); + + it.each([ + { + name: 'snake_case "not_found"', + message: 'fs/read_text_file not_found: missing path', + }, + { + name: 'phrase "file not found"', + message: 'agent: file not found at /tmp/x', + }, + ])( + 'should throw normalized ENOENT for $name message variants', + async ({ message }) => { + service = new AcpFileSystemService( + mockConnection, + 'session-1', + { readTextFile: true, writeTextFile: true }, + mockFallback, + '/path/to', + ); + mockConnection.readTextFile.mockRejectedValue(new Error(message)); + + await expect( + service.readTextFile('/path/to/missing'), + ).rejects.toMatchObject({ + code: 'ENOENT', + message, + }); + }, + ); + + it('should throw normalized ENOENT when the ACP error carries a structured `code: "ENOENT"` field', async () => { + service = new AcpFileSystemService( + mockConnection, + 'session-1', + { readTextFile: true, writeTextFile: true }, + mockFallback, + '/path/to', + ); + const structured = Object.assign(new Error('opaque server message'), { + code: 'ENOENT', + }); + mockConnection.readTextFile.mockRejectedValue(structured); + + await expect( + service.readTextFile('/path/to/missing'), + ).rejects.toMatchObject({ + code: 'ENOENT', + message: 'opaque server message', + }); + }); + + it('should preserve the message when a structured ENOENT error is a plain (non-Error) object', async () => { + service = new AcpFileSystemService( + mockConnection, + 'session-1', + { readTextFile: true, writeTextFile: true }, + mockFallback, + '/path/to', + ); + // JSON-RPC clients often surface error responses as plain objects + // (not Error instances). The `message` field must still be preserved + // — without explicit handling, `String({})` collapses to + // '[object Object]' and the real diagnostic is lost. + mockConnection.readTextFile.mockRejectedValue({ + code: 'ENOENT', + message: 'plain object error message', + }); + + await expect( + service.readTextFile('/path/to/missing'), + ).rejects.toMatchObject({ + code: 'ENOENT', + message: 'plain object error message', + }); + }); }); describe('writeTextFile', () => { diff --git a/packages/cli/src/acp/acpFileSystemService.ts b/packages/cli/src/acp/acpFileSystemService.ts index c11dc7f6cfd..36cd50e7493 100644 --- a/packages/cli/src/acp/acpFileSystemService.ts +++ b/packages/cli/src/acp/acpFileSystemService.ts @@ -34,14 +34,41 @@ export class AcpFileSystemService implements FileSystemService { } private normalizeFileSystemError(err: unknown): never { - const errorMessage = err instanceof Error ? err.message : String(err); + // Resolve a useful message for both Error instances and plain objects + // that carry a `message` field (a common shape for JSON-RPC error + // responses). Avoids `String({}) === '[object Object]'` swallowing + // the real message for non-Error rejections. + const errMessage = (() => { + if (err instanceof Error) return err.message; + if (err && typeof err === 'object' && 'message' in err) { + const m = (err as { message?: unknown }).message; + if (typeof m === 'string') return m; + } + return String(err); + })(); + + // Structured signal first: a JSON-RPC error object's `code` field is the + // authoritative not-found indicator when the ACP server emits it. Falls + // back to substring matching below for servers that haven't migrated to + // structured error codes yet. + if (err && typeof err === 'object' && 'code' in err) { + const code = (err as { code?: unknown }).code; + if (code === 'ENOENT') { + const newErr = new Error(errMessage) as NodeJS.ErrnoException; + newErr.code = 'ENOENT'; + throw newErr; + } + } + if ( - errorMessage.includes('Resource not found') || - errorMessage.includes('ENOENT') || - errorMessage.includes('does not exist') || - errorMessage.includes('No such file') + errMessage.includes('Resource not found') || + errMessage.includes('ENOENT') || + errMessage.includes('does not exist') || + errMessage.includes('No such file') || + errMessage.includes('not_found') || + errMessage.includes('file not found') ) { - const newErr = new Error(errorMessage) as NodeJS.ErrnoException; + const newErr = new Error(errMessage) as NodeJS.ErrnoException; newErr.code = 'ENOENT'; throw newErr; }