@theia/ai-ide 1.70.0-next.21 → 1.70.0-next.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,6 +32,7 @@ import {
32
32
  import { ToolInvocationContext } from '@theia/ai-core';
33
33
  import { Container } from '@theia/core/shared/inversify';
34
34
  import { FileService } from '@theia/filesystem/lib/browser/file-service';
35
+ import { FileOperationError, FileOperationResult } from '@theia/filesystem/lib/common/files';
35
36
  import { URI } from '@theia/core/lib/common/uri';
36
37
  import { WorkspaceService } from '@theia/workspace/lib/browser';
37
38
  import { ProblemManager } from '@theia/markers/lib/browser';
@@ -254,6 +255,420 @@ describe('FileContentFunction.getArgumentsShortLabel', () => {
254
255
  const result = getArgumentsShortLabel(JSON.stringify({ path: 'src/index.ts' }));
255
256
  expect(result).to.be.undefined;
256
257
  });
258
+
259
+ it('returns hasMore true when offset is provided', () => {
260
+ const result = getArgumentsShortLabel(JSON.stringify({ file: 'src/index.ts', offset: 10 }));
261
+ expect(result).to.deep.equal({ label: 'src/index.ts', hasMore: true });
262
+ });
263
+
264
+ it('returns hasMore true when limit is provided', () => {
265
+ const result = getArgumentsShortLabel(JSON.stringify({ file: 'src/index.ts', limit: 50 }));
266
+ expect(result).to.deep.equal({ label: 'src/index.ts', hasMore: true });
267
+ });
268
+
269
+ it('returns hasMore true when both offset and limit are provided', () => {
270
+ const result = getArgumentsShortLabel(JSON.stringify({ file: 'src/index.ts', offset: 10, limit: 50 }));
271
+ expect(result).to.deep.equal({ label: 'src/index.ts', hasMore: true });
272
+ });
273
+ });
274
+
275
+ describe('FileContentFunction handler', () => {
276
+ let container: Container;
277
+ let fileContentFunction: FileContentFunction;
278
+ // Mutable delegates — tests reassign these directly instead of casting the mock object.
279
+ let mockResolve: () => Promise<unknown>;
280
+ let mockRead: () => Promise<unknown>;
281
+ let mockReadStream: () => Promise<unknown>;
282
+ let mockMonacoWorkspace: MonacoWorkspace;
283
+ let mockPreferenceService: { get: <T>(path: string, defaultValue: T) => T };
284
+
285
+ const makeMockStream = (content: string) => {
286
+ const handlers: Record<string, Function> = {};
287
+ // Use setTimeout so the macro-task fires after all pending microtasks
288
+ // (including the await continuation that registers the listeners).
289
+ setTimeout(() => {
290
+ handlers['data']?.(content);
291
+ handlers['end']?.();
292
+ }, 0);
293
+ return {
294
+ on(event: string, cb: Function): void { handlers[event] = cb; },
295
+ pause(): void { },
296
+ resume(): void { },
297
+ destroy(): void { },
298
+ removeListener(): void { }
299
+ };
300
+ };
301
+
302
+ let disableJSDOMInner: () => void;
303
+ before(() => {
304
+ disableJSDOMInner = enableJSDOM();
305
+ });
306
+ after(() => {
307
+ disableJSDOMInner();
308
+ });
309
+
310
+ beforeEach(() => {
311
+ container = new Container();
312
+
313
+ const mockWorkspaceService = {
314
+ roots: [{ resource: new URI('file:///workspace') }]
315
+ } as unknown as WorkspaceService;
316
+
317
+ mockResolve = async () => ({
318
+ isFile: true,
319
+ isDirectory: false,
320
+ size: 1024,
321
+ resource: new URI('file:///workspace/test.txt')
322
+ });
323
+
324
+ mockRead = async () => ({ value: 'line1\nline2\nline3\nline4\nline5' });
325
+
326
+ mockReadStream = async () => ({ value: makeMockStream('line1\nline2\nline3\nline4\nline5') });
327
+
328
+ // The mock object is stable across a test; individual methods delegate to
329
+ // the mutable variables above so tests can substitute behaviour without
330
+ // the fragile `(obj as unknown as {…}).method = …` double-cast pattern.
331
+ const mockFileService = {
332
+ exists: async () => true,
333
+ resolve: () => mockResolve(),
334
+ read: () => mockRead(),
335
+ readStream: () => mockReadStream(),
336
+ } as unknown as FileService;
337
+
338
+ mockPreferenceService = {
339
+ get: <T>(_path: string, defaultValue: T) => defaultValue
340
+ };
341
+
342
+ mockMonacoWorkspace = {
343
+ getTextDocument: () => undefined
344
+ } as unknown as MonacoWorkspace;
345
+
346
+ container.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
347
+ container.bind(FileService).toConstantValue(mockFileService);
348
+ container.bind(PreferenceService).toConstantValue(mockPreferenceService);
349
+ container.bind(MonacoWorkspace).toConstantValue(mockMonacoWorkspace);
350
+ container.bind(WorkspaceFunctionScope).toSelf();
351
+ container.bind(FileContentFunction).toSelf();
352
+
353
+ fileContentFunction = container.get(FileContentFunction);
354
+ });
355
+
356
+ it('returns file content when file is within size limit', async () => {
357
+ const handler = fileContentFunction.getTool().handler;
358
+ const result = await handler(JSON.stringify({ file: 'test.txt' }), undefined);
359
+ expect(result).to.equal('line1\nline2\nline3\nline4\nline5');
360
+ });
361
+
362
+ it('rejects without reading when on-disk size exceeds limit', async () => {
363
+ // Stat reports 512 KB; default limit is 256 KB
364
+ let readCalled = false;
365
+ mockResolve = async () => ({
366
+ isFile: true,
367
+ isDirectory: false,
368
+ size: 512 * 1024,
369
+ resource: new URI('file:///workspace/big.txt')
370
+ });
371
+ mockRead = async () => {
372
+ readCalled = true;
373
+ return { value: 'should not be read' };
374
+ };
375
+
376
+ const handler = fileContentFunction.getTool().handler;
377
+ const result = await handler(JSON.stringify({ file: 'big.txt' }), undefined);
378
+
379
+ const parsed = JSON.parse(result as string);
380
+ expect(parsed.error).to.include('size limit');
381
+ expect(parsed.sizeKB).to.equal(512);
382
+ expect(readCalled).to.be.false;
383
+ });
384
+
385
+ it('returns editor content when file is open in editor and within size limit', async () => {
386
+ let resolveCalled = false;
387
+ mockResolve = async () => {
388
+ resolveCalled = true;
389
+ return { isFile: true, isDirectory: false, size: 1024, resource: new URI('file:///workspace/open.txt') };
390
+ };
391
+ mockMonacoWorkspace.getTextDocument = () => ({
392
+ getText: () => 'editor content'
393
+ } as unknown as ReturnType<MonacoWorkspace['getTextDocument']>);
394
+
395
+ const handler = fileContentFunction.getTool().handler;
396
+ const result = await handler(JSON.stringify({ file: 'open.txt' }), undefined);
397
+
398
+ expect(result).to.equal('editor content');
399
+ expect(resolveCalled).to.be.false;
400
+ });
401
+
402
+ it('rejects editor content when it exceeds the size limit', async () => {
403
+ const bigContent = 'x'.repeat(512 * 1024);
404
+ mockMonacoWorkspace.getTextDocument = () => ({
405
+ getText: () => bigContent
406
+ } as unknown as ReturnType<MonacoWorkspace['getTextDocument']>);
407
+
408
+ const handler = fileContentFunction.getTool().handler;
409
+ const result = await handler(JSON.stringify({ file: 'open.txt' }), undefined);
410
+
411
+ const parsed = JSON.parse(result as string);
412
+ expect(parsed.error).to.include('size limit');
413
+ expect(parsed.sizeKB).to.equal(512);
414
+ });
415
+
416
+ it('returns sliced content with header when offset and limit are provided', async () => {
417
+ const handler = fileContentFunction.getTool().handler;
418
+ const result = await handler(JSON.stringify({ file: 'test.txt', offset: 1, limit: 2 }), undefined);
419
+
420
+ expect(result).to.include('[Lines 2\u20133 of 5 total.');
421
+ expect(result).to.include('line2\nline3');
422
+ });
423
+
424
+ it('returns sliced editor content with header when file is open and offset/limit are provided', async () => {
425
+ mockMonacoWorkspace.getTextDocument = () => ({
426
+ getText: () => 'alpha\nbeta\ngamma\ndelta\nepsilon'
427
+ } as unknown as ReturnType<MonacoWorkspace['getTextDocument']>);
428
+
429
+ const handler = fileContentFunction.getTool().handler;
430
+ const result = await handler(JSON.stringify({ file: 'open.txt', offset: 1, limit: 2 }), undefined);
431
+
432
+ expect(result).to.include('[Lines 2\u20133 of 5 total.');
433
+ expect(result).to.include('beta\ngamma');
434
+ });
435
+
436
+ it('does not call resolve() or read() for paginated disk reads, uses readStream instead', async () => {
437
+ // Stat would report huge file, but the streaming path bypasses both stat and read
438
+ let resolveCalled = false;
439
+ let readCalled = false;
440
+ mockResolve = async () => {
441
+ resolveCalled = true;
442
+ return {
443
+ isFile: true,
444
+ isDirectory: false,
445
+ size: 512 * 1024,
446
+ resource: new URI('file:///workspace/big.txt')
447
+ };
448
+ };
449
+ mockRead = async () => {
450
+ readCalled = true;
451
+ return { value: 'should not be read' };
452
+ };
453
+
454
+ const handler = fileContentFunction.getTool().handler;
455
+ const result = await handler(JSON.stringify({ file: 'big.txt', offset: 0, limit: 3 }), undefined);
456
+
457
+ // resolve and read are NOT called in the streaming path
458
+ expect(resolveCalled).to.be.false;
459
+ expect(readCalled).to.be.false;
460
+ expect(result).to.include('line1\nline2\nline3');
461
+ });
462
+
463
+ it('rejects when the requested slice itself exceeds the size limit', async () => {
464
+ const bigLine = 'x'.repeat(1024);
465
+ const bigContent = Array.from({ length: 300 }, () => bigLine).join('\n');
466
+ mockReadStream = async () => ({ value: makeMockStream(bigContent) });
467
+
468
+ const handler = fileContentFunction.getTool().handler;
469
+ // Reading all 300 lines × 1 KB each = ~300 KB, over the 256 KB default limit
470
+ const result = await handler(JSON.stringify({ file: 'big.txt', offset: 0, limit: 300 }), undefined);
471
+
472
+ const parsed = JSON.parse(result as string);
473
+ expect(parsed.error).to.include('size limit');
474
+ expect(parsed.resultSizeKB).to.be.greaterThan(256);
475
+ });
476
+
477
+ it('returns File not found error when file does not exist', async () => {
478
+ mockResolve = async () => { throw new Error('File not found'); };
479
+ mockRead = async () => { throw new Error('File not found'); };
480
+ mockReadStream = async () => { throw new Error('File not found'); };
481
+
482
+ const handler = fileContentFunction.getTool().handler;
483
+ const result = await handler(JSON.stringify({ file: 'nonexistent.txt' }), undefined);
484
+
485
+ const parsed = JSON.parse(result as string);
486
+ expect(parsed.error).to.equal('File not found');
487
+ });
488
+
489
+ it('rejects negative offset', async () => {
490
+ const handler = fileContentFunction.getTool().handler;
491
+ const result = await handler(JSON.stringify({ file: 'test.txt', offset: -1 }), undefined);
492
+
493
+ const parsed = JSON.parse(result as string);
494
+ expect(parsed.error).to.include('non-negative integer');
495
+ });
496
+
497
+ it('rejects fractional offset', async () => {
498
+ const handler = fileContentFunction.getTool().handler;
499
+ const result = await handler(JSON.stringify({ file: 'test.txt', offset: 1.5 }), undefined);
500
+
501
+ const parsed = JSON.parse(result as string);
502
+ expect(parsed.error).to.include('non-negative integer');
503
+ });
504
+
505
+ it('rejects negative limit', async () => {
506
+ const handler = fileContentFunction.getTool().handler;
507
+ const result = await handler(JSON.stringify({ file: 'test.txt', limit: -1 }), undefined);
508
+
509
+ const parsed = JSON.parse(result as string);
510
+ expect(parsed.error).to.include('positive integer');
511
+ });
512
+
513
+ it('rejects zero limit', async () => {
514
+ const handler = fileContentFunction.getTool().handler;
515
+ const result = await handler(JSON.stringify({ file: 'test.txt', limit: 0 }), undefined);
516
+
517
+ const parsed = JSON.parse(result as string);
518
+ expect(parsed.error).to.include('positive integer');
519
+ });
520
+
521
+ it('returns content from offset to end when only offset is provided', async () => {
522
+ const handler = fileContentFunction.getTool().handler;
523
+ const result = await handler(JSON.stringify({ file: 'test.txt', offset: 2 }), undefined);
524
+
525
+ expect(result).to.include('[Lines 3\u20135 of 5 total.');
526
+ expect(result).to.include('line3\nline4\nline5');
527
+ });
528
+
529
+ it('returns last line when offset is at boundary', async () => {
530
+ const handler = fileContentFunction.getTool().handler;
531
+ const result = await handler(JSON.stringify({ file: 'test.txt', offset: 4, limit: 1 }), undefined);
532
+
533
+ expect(result).to.include('[Lines 5\u20135 of 5 total.');
534
+ expect(result).to.include('line5');
535
+ });
536
+
537
+ it('returns empty content when offset is beyond end of file', async () => {
538
+ const handler = fileContentFunction.getTool().handler;
539
+ const result = await handler(JSON.stringify({ file: 'test.txt', offset: 100, limit: 5 }), undefined);
540
+
541
+ // slice beyond end returns empty array → empty joined string
542
+ expect(result).to.include('[Lines 101\u2013100 of 5 total.');
543
+ });
544
+
545
+ it('uses custom preference value for size limit', async () => {
546
+ // Set a very small limit of 1 KB
547
+ mockPreferenceService.get = <T>(_path: string, _defaultValue: T) => 1 as unknown as T;
548
+
549
+ const content = 'x'.repeat(2 * 1024); // 2 KB
550
+ mockRead = async () => ({ value: content });
551
+ mockResolve = async () => ({
552
+ isFile: true,
553
+ isDirectory: false,
554
+ size: 2 * 1024,
555
+ resource: new URI('file:///workspace/test.txt')
556
+ });
557
+
558
+ const handler = fileContentFunction.getTool().handler;
559
+ const result = await handler(JSON.stringify({ file: 'test.txt' }), undefined);
560
+
561
+ const parsed = JSON.parse(result as string);
562
+ expect(parsed.error).to.include('size limit');
563
+ expect(parsed.maxSizeKB).to.equal(1);
564
+ });
565
+
566
+ it('falls back to streaming when stat.size is undefined and file is within limit', async () => {
567
+ // stat does not include a size — the code must not treat this as "0 KB"
568
+ // but instead stream the file and succeed when content is small.
569
+ let readCalled = false;
570
+ mockResolve = async () => ({
571
+ isFile: true,
572
+ isDirectory: false,
573
+ size: undefined,
574
+ resource: new URI('file:///workspace/test.txt')
575
+ });
576
+ mockRead = async () => {
577
+ readCalled = true;
578
+ return { value: 'should not be used' };
579
+ };
580
+ // mockReadStream already returns the small 5-line fixture from beforeEach
581
+
582
+ const handler = fileContentFunction.getTool().handler;
583
+ const result = await handler(JSON.stringify({ file: 'test.txt' }), undefined);
584
+
585
+ expect(readCalled).to.be.false;
586
+ expect(result).to.include('line1');
587
+ // Full-file streaming fallback must NOT include the [Lines...] header
588
+ expect(result).to.not.include('[Lines');
589
+ });
590
+
591
+ it('returns size-limit error (not "File not found") when stat.size is undefined and streamed content exceeds limit', async () => {
592
+ // stat does not include a size; the streamed content is larger than the limit.
593
+ mockResolve = async () => ({
594
+ isFile: true,
595
+ isDirectory: false,
596
+ size: undefined,
597
+ resource: new URI('file:///workspace/big.txt')
598
+ });
599
+ const bigLine = 'x'.repeat(1024);
600
+ const bigContent = Array.from({ length: 300 }, () => bigLine).join('\n'); // ~300 KB
601
+ mockReadStream = async () => ({ value: makeMockStream(bigContent) });
602
+
603
+ const handler = fileContentFunction.getTool().handler;
604
+ const result = await handler(JSON.stringify({ file: 'big.txt' }), undefined);
605
+
606
+ const parsed = JSON.parse(result as string);
607
+ expect(parsed.error).to.include('size limit');
608
+ expect(parsed.error).to.include('offset');
609
+ expect(parsed.error).not.to.equal('File not found');
610
+ });
611
+
612
+ it('returns size-limit error (not "File not found") when fileService.read throws FILE_TOO_LARGE', async () => {
613
+ // Simulate a file system provider that enforces its own hard size limit below maxSizeKB.
614
+ // stat.size is present and within our configured limit, but read() still throws.
615
+ mockResolve = async () => ({
616
+ isFile: true,
617
+ isDirectory: false,
618
+ size: 100 * 1024, // 100 KB — under the 256 KB default limit
619
+ resource: new URI('file:///workspace/test.txt')
620
+ });
621
+ mockRead = async () => {
622
+ throw new FileOperationError('File too large', FileOperationResult.FILE_TOO_LARGE);
623
+ };
624
+
625
+ const handler = fileContentFunction.getTool().handler;
626
+ const result = await handler(JSON.stringify({ file: 'test.txt' }), undefined);
627
+
628
+ const parsed = JSON.parse(result as string);
629
+ expect(parsed.error).to.include('size limit');
630
+ expect(parsed.error).to.include('offset');
631
+ expect(parsed.maxSizeKB).to.equal(256);
632
+ });
633
+
634
+ it('returns size-limit error (not "File not found") when fileService.read throws FILE_EXCEEDS_MEMORY_LIMIT', async () => {
635
+ mockResolve = async () => ({
636
+ isFile: true,
637
+ isDirectory: false,
638
+ size: 100 * 1024,
639
+ resource: new URI('file:///workspace/test.txt')
640
+ });
641
+ mockRead = async () => {
642
+ throw new FileOperationError('Exceeds memory limit', FileOperationResult.FILE_EXCEEDS_MEMORY_LIMIT);
643
+ };
644
+
645
+ const handler = fileContentFunction.getTool().handler;
646
+ const result = await handler(JSON.stringify({ file: 'test.txt' }), undefined);
647
+
648
+ const parsed = JSON.parse(result as string);
649
+ expect(parsed.error).to.include('size limit');
650
+ expect(parsed.error).to.include('offset');
651
+ expect(parsed.maxSizeKB).to.equal(256);
652
+ });
653
+
654
+ it('returns size-limit error (not "File not found") when readStream throws FILE_TOO_LARGE for paginated read', async () => {
655
+ // This is the key scenario: files.maxFileSizeMB is lower than the file size,
656
+ // but the caller is trying to read a chunk with offset/limit.
657
+ // Before the fix, readStream would throw FILE_TOO_LARGE and the catch block
658
+ // would return "File not found".
659
+ mockReadStream = async () => {
660
+ throw new FileOperationError('File too large', FileOperationResult.FILE_TOO_LARGE);
661
+ };
662
+
663
+ const handler = fileContentFunction.getTool().handler;
664
+ const result = await handler(JSON.stringify({ file: 'big.txt', offset: 0, limit: 50 }), undefined);
665
+
666
+ const parsed = JSON.parse(result as string);
667
+ expect(parsed.error).to.include('size limit');
668
+ expect(parsed.error).to.include('offset');
669
+ expect(parsed.error).not.to.equal('File not found');
670
+ expect(parsed.maxSizeKB).to.equal(256);
671
+ });
257
672
  });
258
673
 
259
674
  describe('FindFilesByPattern.getArgumentsShortLabel', () => {