From 974e76aa1e631b047ba8d7ede001a6be6b0e3b77 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Tue, 8 Sep 2026 22:23:35 +0800 Subject: [PATCH 1/4] fix(glob): allow retrieving matches beyond the default page --- .changeset/glob-pagination.md | 5 + docs/en/reference/tools.md | 4 +- docs/zh/reference/tools.md | 4 +- .../src/agent/tools/os/glob/glob.md | 6 +- .../src/agent/tools/os/glob/glob.ts | 18 ++- .../src/agent/tools/os/glob/globTool.ts | 49 +++++--- .../agent/toolExecutor/toolExecutor.test.ts | 37 +++++- .../os/backends/node-local/tools/glob.test.ts | 113 +++++++++++++++--- 8 files changed, 198 insertions(+), 38 deletions(-) create mode 100644 .changeset/glob-pagination.md diff --git a/.changeset/glob-pagination.md b/.changeset/glob-pagination.md new file mode 100644 index 00000000000..d67f3026a2e --- /dev/null +++ b/.changeset/glob-pagination.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Allow file searches to retrieve matches beyond the first 100 results. diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index 95f74d7b2fc..2270e5c9b2b 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -29,7 +29,9 @@ Tail reads return the newest complete lines in the requested range first. If no **`Grep`** invokes ripgrep to search file contents, supporting regular expressions (`pattern`), a search path (`path`), file type filtering (`type`, e.g., `ts`, `py`), glob filtering (`glob`), and output mode (`output_mode`: `files_with_matches` / `content` / `count_matches`; defaults to `files_with_matches`). `content` mode supports context lines (`-A`, `-B`, `-C`), case-insensitive matching (`-i`), line numbers (`-n`, default true), and multiline matching (`multiline`). All modes support `offset` + `head_limit` pagination; `head_limit` defaults to 250 and `0` means unlimited. Sensitive files such as `.env` files and private keys are automatically filtered out; set `include_ignored=true` to search files ignored by `.gitignore`, though sensitive files remain filtered. -**`Glob`** matches files in a specified directory (`path`; defaults to the working directory) by glob pattern (`pattern`). Results are sorted by modification time in descending order, with a maximum of 100 entries. It respects `.gitignore`, `.ignore`, and `.rgignore` by default; set `include_ignored=true` to include ignored files such as build outputs, while sensitive files remain filtered. Brace patterns such as `*.{ts,tsx}` are supported, and broad wildcard patterns are allowed but usually truncate at the match cap. +**`Glob`** matches files in a specified directory (`path`; defaults to the working directory) by glob pattern (`pattern`). Results are sorted by modification time in descending order, returning 100 entries by default. It respects `.gitignore`, `.ignore`, and `.rgignore` by default; set `include_ignored=true` to include ignored files such as build outputs, while sensitive files remain filtered. Brace patterns such as `*.{ts,tsx}` are supported, and broad wildcard patterns are allowed. + +Use `offset` (default 0) and `head_limit` (default 100) to page through matching paths; the result provides the next offset when more matches are available. Set `head_limit: 0` and omit `offset` to retrieve all collected matches in one search. Outputs exceeding the character budget are saved to a file that the agent can read with `Read`. Each call searches the current filesystem again, so file changes can shift results between pages. Timeouts, unreadable directories, or the output capture limit can still leave the search incomplete; the result warns about these cases, and increasing the offset cannot recover uncollected paths. **`ReadMediaFile`** sends an image or video to the model as multimodal content. It accepts `path`, plus optional image-detail controls such as `region` and `full_resolution`; the file size limit is 100 MB. Default image reads are compressed to the configured model limits. If automatic compression cannot meet those limits safely, the tool returns an error without sending the original image and directs the model to create and read a smaller copy. Availability depends on the current model's vision capabilities (`image_in` / `video_in`). diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index 9e6739e4497..2b60f8a1ac7 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -29,7 +29,9 @@ **`Grep`** 调用 ripgrep 搜索文件内容,支持正则表达式(`pattern`)、搜索路径(`path`)、文件类型过滤(`type`,如 `ts`、`py`)、glob 过滤(`glob`)和输出模式(`output_mode`:`files_with_matches` / `content` / `count_matches`,默认 `files_with_matches`)。`content` 模式支持上下文行(`-A`、`-B`、`-C`)、忽略大小写(`-i`)、行号(`-n`,默认 true)、跨行匹配(`multiline`)。所有模式支持 `offset` + `head_limit` 分页,`head_limit` 默认 250、传 0 表示不限。`.env`、私钥等敏感文件会被自动过滤;`include_ignored=true` 可搜索被 `.gitignore` 忽略的文件,但敏感文件仍保持过滤。 -**`Glob`** 按 glob 模式(`pattern`)在指定目录(`path`,默认工作目录)中匹配文件,结果按修改时间倒序排列,最多返回 100 条。默认尊重 `.gitignore`、`.ignore` 和 `.rgignore`;设置 `include_ignored=true` 可包含构建产物等被忽略的文件,但敏感文件仍会被过滤。支持 `*.{ts,tsx}` 这类花括号模式,也允许宽泛通配符模式,但通常会在匹配上限处截断。 +**`Glob`** 按 glob 模式(`pattern`)在指定目录(`path`,默认工作目录)中匹配文件,结果按修改时间倒序排列,默认返回 100 条。默认尊重 `.gitignore`、`.ignore` 和 `.rgignore`;设置 `include_ignored=true` 可包含构建产物等被忽略的文件,但敏感文件仍会被过滤。支持 `*.{ts,tsx}` 这类花括号模式,也允许宽泛通配符模式。 + +使用 `offset`(默认 0)和 `head_limit`(默认 100)对匹配路径分页;有更多结果时,工具会给出下一页的 offset。设置 `head_limit: 0` 并省略 `offset`,可在一次搜索中取回全部已收集结果。超过字符预算的输出会保存到文件,Agent 可用 `Read` 读取。每次调用都会重新搜索当前文件系统,因此文件变化可能导致跨页结果移动。超时、目录无法读取或输出采集上限仍可能造成搜索不完整;结果会提示这些情况,增加 offset 无法恢复尚未收集的路径。 **`ReadMediaFile`** 将图片或视频以多模态内容发送给模型。它接受 `path`,以及 `region`、`full_resolution` 等可选的图片细节参数;文件大小上限为 100 MB。默认读图会按配置的模型限制压缩;如果自动压缩无法安全满足限制,工具会返回错误且不发送原图,并提示模型先创建更小的副本再读取。是否可用取决于当前模型的视觉能力(`image_in` / `video_in`)。 diff --git a/packages/agent-core-v2/src/agent/tools/os/glob/glob.md b/packages/agent-core-v2/src/agent/tools/os/glob/glob.md index ad299e29afc..a982354ed08 100644 --- a/packages/agent-core-v2/src/agent/tools/os/glob/glob.md +++ b/packages/agent-core-v2/src/agent/tools/os/glob/glob.md @@ -10,7 +10,9 @@ Good patterns: - `*.{ts,tsx}` — brace expansion is supported - `{src,test}/**/*.ts` — cartesian brace expansion is supported too -Results are capped at the first 100 matching paths. If a search would return more, a truncation marker is appended. Refine the pattern (extension, subdirectory) when 100 is not enough, or call again with a narrower anchor. +Results default to 100 matching paths. Use `offset` (default 0) and `head_limit` (default 100) to page through results. When more matches are available, the result gives the next offset; keep the other search arguments unchanged. Set `head_limit=0` and omit `offset` to retrieve all collected matches in one search. Large outputs are saved to a file with a path for Read. + +Each call searches the current filesystem again; pagination is not a snapshot, and file changes can shift results between pages. When you need a complete list from one search, prefer `head_limit=0` and read the saved output if necessary. Search timeouts, traversal errors, and output capture limits can still produce partial results; the result reports these limits, and pagination cannot recover paths that were never collected. Narrow the search and retry when it is incomplete. Large-directory caveat — avoid recursing into dependency / build output even with an anchor, especially when `include_ignored` is set: -- `node_modules/**/*.js`, `.venv/**/*.py`, `__pycache__/**`, `target/**` can produce thousands of results that truncate at the match cap and waste context. Prefer specific subpaths like `node_modules/react/src/**/*.js`. +- `node_modules/**/*.js`, `.venv/**/*.py`, `__pycache__/**`, `target/**` can produce thousands of results and waste search time and context. Prefer specific subpaths like `node_modules/react/src/**/*.js` unless you need a complete listing. diff --git a/packages/agent-core-v2/src/agent/tools/os/glob/glob.ts b/packages/agent-core-v2/src/agent/tools/os/glob/glob.ts index 5043a2e68bf..eebdc60d37d 100644 --- a/packages/agent-core-v2/src/agent/tools/os/glob/glob.ts +++ b/packages/agent-core-v2/src/agent/tools/os/glob/glob.ts @@ -5,6 +5,22 @@ import { type AgentTool } from '#/tool/toolContract'; export const GlobInputSchema = z.object({ pattern: z.string().describe('Glob pattern to match files.'), + head_limit: z + .number() + .int() + .nonnegative() + .optional() + .describe( + 'Maximum number of matching paths to return after offset. Defaults to 100. Pass 0 for all collected matches; large results are saved to a file for Read. Search time and output capture limits still apply.', + ), + offset: z + .number() + .int() + .nonnegative() + .optional() + .describe( + 'Number of matching paths to skip. Defaults to 0. Each call searches the current filesystem again; changes can shift results between pages.', + ), path: z .string() .optional() @@ -27,7 +43,7 @@ export const GlobInputSchema = z.object({ export type GlobInput = z.infer; -export const MAX_MATCHES = 100; +export const DEFAULT_HEAD_LIMIT = 100; export const WINDOWS_PATH_HINT = '\n\nWindows note: the `path` argument accepts both Windows paths ' + diff --git a/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts b/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts index 75110f802bb..23e722dfb72 100644 --- a/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts @@ -37,7 +37,7 @@ import { type GlobInput, GlobInputSchema, IGlobTool, - MAX_MATCHES, + DEFAULT_HEAD_LIMIT, WINDOWS_PATH_HINT, } from './glob'; @@ -238,17 +238,11 @@ export class GlobTool implements IGlobTool { } } - const truncated = kept.length > MAX_MATCHES; - const limited = truncated ? kept.slice(0, MAX_MATCHES) : kept; - - if (limited.length === 0 && !timedOut) { - if (filteredSensitive > 0) { - return { - output: `No non-sensitive matches found (${String(filteredSensitive)} sensitive file(s) filtered).`, - }; - } - return { output: 'No matches found' }; - } + const offset = args.offset ?? 0; + const headLimit = args.head_limit ?? DEFAULT_HEAD_LIMIT; + const limited = headLimit === 0 ? kept.slice(offset) : kept.slice(offset, offset + headLimit); + const truncated = offset + limited.length < kept.length; + const partial = bufferTruncated || timedOut || traversalWarning !== undefined; const pathClass = env.pathClass; const shouldRelativize = isWithinDirectory(searchRoot, workspace.workspaceDir, pathClass); @@ -270,15 +264,38 @@ export class GlobTool implements IGlobTool { if (traversalWarning !== undefined) { lines.push(traversalWarning); } + if (limited.length === 0) { + if (kept.length > 0) { + const resultSet = partial ? 'collected partial result set' : 'current result set'; + lines.push( + `No more matches at offset=${String(offset)} in the ${resultSet} (${String(kept.length)} matches).`, + ); + } else if (partial) { + lines.push('No matches collected; search incomplete.'); + } else if (filteredSensitive > 0) { + lines.push( + `No non-sensitive matches found (${String(filteredSensitive)} sensitive file(s) filtered).`, + ); + } else { + lines.push('No matches found'); + } + } else if (truncated || offset > 0 || partial) { + const total = partial + ? `${String(kept.length)} collected matches (partial result set)` + : String(kept.length); + lines.push(`Showing matches ${String(offset + 1)}–${String(offset + limited.length)} of ${total}.`); + } if (truncated) { - lines.push(`[Truncated at ${String(MAX_MATCHES)} matches — use a more specific pattern]`); - lines.push(`Only the first ${String(MAX_MATCHES)} matches are returned.`); + lines.push( + `Continue with the same search arguments and offset=${String(offset + limited.length)}.`, + ); + lines.push('To retrieve all collected matches in one search, omit offset and use head_limit=0.'); } lines.push(...displayLines); - if (filteredSensitive > 0) { + if (filteredSensitive > 0 && (kept.length > 0 || partial)) { lines.push(`Filtered ${String(filteredSensitive)} sensitive file(s).`); } - if (!truncated && limited.length === MAX_MATCHES) { + if (!truncated && !partial && offset === 0 && headLimit > 0 && limited.length === headLimit) { lines.push(`Found ${String(limited.length)} matches`); } return { output: lines.join('\n') }; diff --git a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts index b0b20e306ee..bff778127e5 100644 --- a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts +++ b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts @@ -37,9 +37,11 @@ import { parseToolCallArguments } from '#/tool/tool-args-parse'; import { IAgentToolResultTruncationService } from '#/agent/toolResultTruncation/toolResultTruncation'; import { ToolResultTruncationService } from '#/agent/toolResultTruncation/toolResultTruncationService'; import { ReadTool } from '#/agent/tools/os/read/readTool'; +import { GlobTool } from '#/agent/tools/os/glob/globTool'; import { ReadInputSchema, type ReadInput } from '#/agent/tools/os/read/read'; import { renderToolResultForModel } from '#/agent/contextMemory/toolResultRender'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { HostProcessService } from '#/os/backends/node-local/hostProcessService'; import { FakeRuntime } from '#/runtime/fakeRuntime'; import type { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; import type { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog'; @@ -54,7 +56,7 @@ import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService'; import { IEventBus } from '#/app/event/eventBus'; import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; -import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemetry'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; @@ -1071,8 +1073,8 @@ describe('truncation pipeline', () => { await readConfig.ready; const runtime = Object.assign(new FakeRuntime( { workspaceId: 'workspace', runtimeId: 'local', generation: 'test' }, - { capabilities: ['fs'] }, - ), { fs: new HostFileSystem() }); + { capabilities: ['fs', 'process'] }, + ), { fs: new HostFileSystem(), process: new HostProcessService() }); const binding: IAgentRuntimeService = { _serviceBrand: undefined, onDidChange: () => ({ dispose: () => {} }), @@ -1087,6 +1089,7 @@ describe('truncation pipeline', () => { truncation, readConfig, )); + registry.register(new GlobTool(binding, stubWorkspaceContext(homeDir), noopTelemetryService)); }); afterEach(async () => { @@ -1129,6 +1132,34 @@ describe('truncation pipeline', () => { expect(readFileSync(outputPath, 'utf8')).toBe(fullOutput); }); + it('recovers every Glob match through spill and Read when the match limit is disabled', async () => { + const expected = Array.from({ length: 500 }, (_, index) => + `file-${String(index).padStart(3, '0')}-${'x'.repeat(100)}.ts`, + ); + await Promise.all(expected.map((name) => writeFile(join(homeDir, name), ''))); + + const [result] = await execute([toolCall('glob_all', 'Glob', { pattern: '*.ts', head_limit: 0 })]); + + expect(result?.isError).not.toBe(true); + expect(result?.truncated).toBe(true); + if (typeof result?.output !== 'string') throw new Error('expected Glob text'); + const path = renderedOutputPath(result.output); + let args: ReadInput | undefined = { path, max_chars: 8000 }; + const recovered: string[] = []; + let pages = 0; + while (args !== undefined && pages < 20) { + const [page] = await execute([toolCall(`read_glob_${String(pages++)}`, 'Read', args)]); + expect(page?.isError).not.toBe(true); + if (typeof page?.output !== 'string') throw new Error('expected Read text'); + recovered.push(...page.output.replaceAll(/^\d+\t/gm, '').split('\n').filter(Boolean)); + const next = /Next Read: (\{[^\n]*\})/.exec(page.note ?? '')?.[1]; + args = next === undefined ? undefined : ReadInputSchema.parse(JSON.parse(next)); + } + expect(args).toBeUndefined(); + expect(pages).toBeGreaterThan(1); + expect(recovered.toSorted()).toEqual(expected); + }); + it('recovers MCP structured records through spill and Read without repeating the MCP call', async () => { const structuredContent = { rows: Array.from({ length: 1200 }, (_, index) => ({ diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/glob.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/glob.test.ts index d52364d311f..01365cccdfd 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/glob.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/glob.test.ts @@ -13,7 +13,7 @@ import { stubWorkspaceContext } from '../../../../session/workspaceContext/stub- import { type GlobInput, GlobInputSchema, - MAX_MATCHES, + DEFAULT_HEAD_LIMIT, WINDOWS_PATH_HINT, } from '#/agent/tools/os/glob/glob'; import { GlobTool, splitCompletePaths } from '#/agent/tools/os/glob/globTool'; @@ -303,9 +303,9 @@ describe('GlobTool', () => { withCwd.toHaveBeenCalledWith('C:/WORKSPACE'); }); - it('walks pure-wildcard patterns, capping at MAX_MATCHES', async () => { + it('walks pure-wildcard patterns and gives a continuation for the default page', async () => { const stdout = - Array.from({ length: MAX_MATCHES + 5 }, (_, i) => `/workspace/${String(i)}.ts`).join('\n') + + Array.from({ length: DEFAULT_HEAD_LIMIT + 5 }, (_, i) => `/workspace/${String(i)}.ts`).join('\n') + '\n'; const exec = execReturning(stdout); const { tool, withCwd } = makeTool(workspace, { exec }); @@ -315,7 +315,7 @@ describe('GlobTool', () => { expect(result.isError).toBeFalsy(); withCwd.toHaveBeenCalledWith('/workspace'); expect(execArgs(exec).at(-1)).toBe('.'); - expect(result.output).toContain(`[Truncated at ${String(MAX_MATCHES)} matches`); + expect(result.output).toContain('Continue with the same search arguments and offset=100.'); }); it('passes a brace pattern through to a single rg --glob', async () => { @@ -387,23 +387,23 @@ describe('GlobTool', () => { expect(execArgs(exec)).not.toContain('--no-ignore'); }); - it('caps returned matches and surfaces the truncation header', async () => { + it('limits the default page and gives the next offset', async () => { const stdout = - Array.from({ length: MAX_MATCHES + 1 }, (_, i) => `/workspace/${String(i)}.ts`).join('\n') + + Array.from({ length: DEFAULT_HEAD_LIMIT + 1 }, (_, i) => `/workspace/${String(i)}.ts`).join('\n') + '\n'; const exec = execReturning(stdout); const { tool } = makeTool(stubWorkspaceContext('/workspace'), { exec }); const result = await execute(tool, { pattern: '*.ts' }); - expect(result.output).toContain(`[Truncated at ${String(MAX_MATCHES)} matches`); + expect(result.output).toContain('Continue with the same search arguments and offset=100.'); expect(result.output).toContain('0.ts'); - expect(result.output).not.toContain(`${String(MAX_MATCHES)}.ts`); + expect(result.output).not.toContain(`${String(DEFAULT_HEAD_LIMIT)}.ts`); }); - it('surfaces a "first N matches" header when matches exceed MAX_MATCHES', async () => { + it('offers an all-matches query when the default page is incomplete', async () => { const stdout = - Array.from({ length: MAX_MATCHES + 50 }, (_, i) => `/workspace/file_${String(i)}.txt`).join( + Array.from({ length: DEFAULT_HEAD_LIMIT + 50 }, (_, i) => `/workspace/file_${String(i)}.txt`).join( '\n', ) + '\n'; const exec = execReturning(stdout); @@ -411,12 +411,12 @@ describe('GlobTool', () => { const result = await execute(tool, { pattern: '*.txt' }); - expect(result.output).toContain(`Only the first ${String(MAX_MATCHES)} matches are returned`); + expect(result.output).toContain('To retrieve all collected matches in one search, omit offset and use head_limit=0.'); }); - it('returns a "Found N matches" footer at exactly MAX_MATCHES without truncation', async () => { + it('returns a "Found N matches" footer at exactly DEFAULT_HEAD_LIMIT without truncation', async () => { const stdout = - Array.from({ length: MAX_MATCHES }, (_, i) => `/workspace/test_${String(i)}.py`).join('\n') + + Array.from({ length: DEFAULT_HEAD_LIMIT }, (_, i) => `/workspace/test_${String(i)}.py`).join('\n') + '\n'; const exec = execReturning(stdout); const { tool } = makeTool(stubWorkspaceContext('/workspace'), { exec }); @@ -424,7 +424,75 @@ describe('GlobTool', () => { const result = await execute(tool, { pattern: '*.py' }); expect(result.output).not.toContain('Only the first'); - expect(result.output).toContain(`Found ${String(MAX_MATCHES)} matches`); + expect(result.output).toContain(`Found ${String(DEFAULT_HEAD_LIMIT)} matches`); + }); + + it.each([2, 10])('distinguishes an exhausted page at offset=%s from a search with no matches', async (offset) => { + const { tool } = makeTool(workspace, { exec: execReturning('/workspace/a.ts\n/workspace/b.ts\n') }); + const result = await execute(tool, GlobInputSchema.parse({ pattern: '*.ts', offset })); + expect(result.output).toBe(`No more matches at offset=${String(offset)} in the current result set (2 matches).`); + }); + + it.each([0, 1, 3])('keeps traversal warnings and partial counts on the page at offset=%s', async (offset) => { + const { tool } = makeTool(workspace, { + exec: execReturning('/workspace/a.ts\n/workspace/b.ts\n/workspace/c.ts\n', 'rg: ./locked: Permission denied', 2), + }); + const result = await execute(tool, GlobInputSchema.parse({ pattern: '*.ts', offset, head_limit: 1 })); + expect(result.output).toContain('Permission denied'); + expect(result.output).toContain('partial result set'); + expect(result.output).not.toContain('No matches found'); + if (offset < 3) expect(result.output).toContain('of 3 collected matches (partial result set).'); + else expect(result.output).toContain('No more matches at offset=3'); + }); + + it.each([ + { head_limit: 1, expected: ['b.ts'] }, + { head_limit: 0, expected: ['b.ts', 'c.ts'] }, + ])('applies head_limit=$head_limit and offset after sensitive filtering', async ({ head_limit, expected }) => { + const { tool } = makeTool(workspace, { + exec: execReturning('/workspace/.env\n/workspace/a.ts\n/workspace/.aws/credentials\n/workspace/b.ts\n/workspace/c.ts\n'), + }); + const result = await execute(tool, GlobInputSchema.parse({ pattern: '**', offset: 1, head_limit })); + const text = toolContentString(result); + expect(text.split('\n').filter((line) => line.endsWith('.ts'))).toEqual(expected); + expect(text).not.toContain('.env'); + expect(text).not.toContain('credentials'); + expect(text).toContain('of 3.'); + }); + + it('does not present a capped capture as a complete search when returning all matches', async () => { + const { tool } = makeTool(workspace, { + exec: execReturning(`/workspace/${'x'.repeat(200)}.ts\n`.repeat(50_000)), + }); + const result = await execute(tool, GlobInputSchema.parse({ pattern: '*.ts', head_limit: 0 })); + expect(result.output).toContain('stdout truncated'); + expect(result.output).toContain('collected matches (partial result set)'); + expect(result.output).not.toContain('Continue with'); + }); + + it('keeps the timeout warning when the requested page has no collected matches', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + try { + let finish!: (code: number) => void; + const completion = new Promise((resolve) => { finish = resolve; }); + const proc = fakeProcess('/workspace/a.ts\n'); + const { tool } = makeTool(workspace, { + exec: vi.fn().mockResolvedValue({ + ...proc, + exitCode: null, + wait: () => completion, + kill: async () => { finish(143); }, + }), + }); + const pending = execute(tool, GlobInputSchema.parse({ pattern: '*.ts', offset: 1 })); + await vi.advanceTimersByTimeAsync(20_000); + const result = await pending; + expect(result.output).toContain('Glob timed out'); + expect(result.output).toContain('No more matches at offset=1 in the collected partial result set'); + expect(result.output).not.toContain('No matches found'); + } finally { + vi.useRealTimers(); + } }); it('filters sensitive files from results', async () => { @@ -867,6 +935,23 @@ describe('GlobTool integration (real ripgrep)', () => { const ws = () => stubWorkspaceContext(tmpDir!); + it('continues through every match beyond the default page without duplicates', async () => { + const expected = Array.from({ length: 347 }, (_, index) => `file-${String(index).padStart(3, '0')}.ts`); + await Promise.all(expected.map((name, index) => touch(name, new Date(1_700_000_000_000 - index * 1000)))); + const tool = new GlobTool(createRuntime(realFs, realEnv, realProcessService), ws(), noopTelemetryService); + const recovered: string[] = []; + for (const offset of [0, 100, 200, 300]) { + const result = await execute(tool, GlobInputSchema.parse({ pattern: '*.ts', offset })); + expect(result.isError).not.toBe(true); + const text = toolContentString(result); + recovered.push(...text.split('\n').filter((line) => /^file-\d+\.ts$/.test(line))); + expect(text).toContain(`Showing matches ${String(offset + 1)}–${String(Math.min(offset + 100, 347))} of 347.`); + if (offset < 300) expect(text).toContain(`offset=${String(offset + 100)}`); + else expect(text).not.toContain('Continue with'); + } + expect(recovered).toEqual(expected); + }); + it('returns files newest-first by modification time (--sortr=modified)', async () => { await touch('old.ts', new Date('2020-01-01T00:00:00Z')); await touch('mid.ts', new Date('2022-01-01T00:00:00Z')); From 3fcdf4a01ccb95ec65e2b35ce0690c5ce3dd1457 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Tue, 8 Sep 2026 22:36:45 +0800 Subject: [PATCH 2/4] test: decouple compaction checks from Glob tool schema size --- .../test/agent/fullCompaction/fullCompaction.test.ts | 7 ++++--- packages/agent-core-v2/test/agent/loop/loop.test.ts | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index b6d09f9eb25..4d8c49bf233 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -673,7 +673,7 @@ describe('FullCompaction', () => { event: 'compaction_finished', properties: expect.objectContaining({ source: 'manual', - tokens_before: 18_510, + tokens_before: expect.any(Number), retry_count: 1, trace_id: 'trace-compact-1', }), @@ -1175,7 +1175,7 @@ describe('FullCompaction', () => { properties: expect.objectContaining({ agent_id: 'main', source: 'manual', - tokens_before: 18_510, + tokens_before: expect.any(Number), duration_ms: expect.any(Number), round: 1, retry_count: 0, @@ -1400,7 +1400,7 @@ describe('FullCompaction', () => { event: 'compaction_failed', properties: expect.objectContaining({ source: 'manual', - tokens_before: 18_510, + tokens_before: expect.any(Number), duration_ms: expect.any(Number), retry_count: 4, error_type: 'APIConnectionError', @@ -1609,6 +1609,7 @@ describe('FullCompaction', () => { const ctx = testAgent(); ctx.configure({ provider: CATALOGUED_PROVIDER, + tools: SNAPSHOT_VISIBLE_TOOLS, modelCapabilities: { ...CATALOGUED_MODEL_CAPABILITIES, max_context_tokens: maxContextTokens, diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index d62565cb8ec..5044b24f05d 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -152,8 +152,8 @@ describe('Agent loop', () => { [wire] context.append_loop_event { "agentId": "main", "event": { "type": "step.begin", "uuid": "", "turnId": "0", "step": 1 }, "time": "