From 48b0cb25fd1ad02bcfa63c6246d2b7924c228493 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:20:15 +0800 Subject: [PATCH] fix(fs-search): respect platform path separators Group paths using node:path.sep so POSIX backslashes remain filename characters while Windows continues to treat them as separators. --- .../acp-agent/tests/fixtures/fs-search-bin/rg | 2 ++ .../snapshots/fs-glob-sampling/session.jsonl | 2 +- packages/fs/tool-fs-search/src/glob.ts | 22 ++++++++++++++----- .../fs/tool-fs-search/tests/tools.spec.ts | 20 ++++++++++++++--- 4 files changed, 36 insertions(+), 10 deletions(-) diff --git a/examples/acp-agent/tests/fixtures/fs-search-bin/rg b/examples/acp-agent/tests/fixtures/fs-search-bin/rg index cd5703bbf7..181ad68837 100755 --- a/examples/acp-agent/tests/fixtures/fs-search-bin/rg +++ b/examples/acp-agent/tests/fixtures/fs-search-bin/rg @@ -3,6 +3,8 @@ printf '%s\n' \ 'archive/a.ts' \ 'archive/b.ts' \ 'archive/c.ts' \ + 'old\one' \ + 'old\two' \ 'src/index.ts' \ 'docs/guide.md' \ 'test/spec.ts' diff --git a/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl b/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl index 85ef5dfe1a..8579543459 100644 --- a/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1785218400010,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1785218400011,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-pro"},"id":"d8c174b5-2f08-49b3-80d5-a69aabefbd7a"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1785218400012,"data":{"turn":1,"step":1,"callId":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\"}"}} -{"type":"tool/result","seq":12,"time":1785218400013,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"glob-sampling-call"},"content":[{"type":"tool-result","toolCallId":"glob-sampling-call","content":[{"type":"text","text":"archive/a.ts\nsrc/index.ts\ndocs/guide.md\ntest/spec.ts\n\n(Showing 4 of 6 paths, sampled across 4 of the 4 top-level entries this pattern matched instead of taken in modification-time order. The complete result could not be saved; narrow pattern or path to see more.)"}],"isError":false}],"role":"user","id":"e9711775-0ea5-4383-a562-76a6b49a4742"}},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1785218400013,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"glob-sampling-call"},"content":[{"type":"tool-result","toolCallId":"glob-sampling-call","content":[{"type":"text","text":"archive/a.ts\nold\\one\nold\\two\nsrc/index.ts\n\n(Showing 4 of 8 paths, sampled across 4 of the 6 top-level entries this pattern matched instead of taken in modification-time order. Narrow path to inspect a specific subtree. The complete result could not be saved; narrow pattern or path to see more.)"}],"isError":false}],"role":"user","id":"e9711775-0ea5-4383-a562-76a6b49a4742"}},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1785218400014,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1785218400015,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1785218400016,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/packages/fs/tool-fs-search/src/glob.ts b/packages/fs/tool-fs-search/src/glob.ts index 97d0d904bb..d934e76d21 100644 --- a/packages/fs/tool-fs-search/src/glob.ts +++ b/packages/fs/tool-fs-search/src/glob.ts @@ -9,6 +9,7 @@ */ import type { Context } from 'cordis' +import { sep } from 'node:path' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView } from '@deepseek-ai/dsh-tools' import type { SpillRef } from '@deepseek-ai/dsh-spill' @@ -112,16 +113,25 @@ export interface GlobSample { /** Remove the displayed search-root prefix before choosing a top-level group. */ function relativeToSearchRoot(path: string, root: string): string { - if (root === '.') return path.replace(/^\.[\\/]/, '') - const trimmedRoot = root.replace(/[\\/]+$/, '') - if (trimmedRoot.length === 0) return path.replace(/^[\\/]+/, '') + if (root === '.') return path.startsWith(`.${sep}`) ? path.slice(2) : path + let rootEnd = root.length + while (rootEnd > 0 && root[rootEnd - 1] === sep) rootEnd -= 1 + const trimmedRoot = root.slice(0, rootEnd) + if (trimmedRoot.length === 0) return stripLeadingSeparators(path) if (path === trimmedRoot) return '' - if (path.startsWith(`${trimmedRoot}/`) || path.startsWith(`${trimmedRoot}\\`)) { + if (path.startsWith(`${trimmedRoot}${sep}`)) { return path.slice(trimmedRoot.length + 1) } return path } +/** Strip only separators recognized by the execution platform. */ +function stripLeadingSeparators(path: string): string { + let start = 0 + while (path[start] === sep) start += 1 + return path.slice(start) +} + /** * The leading path segment of one display path — the top-level entry, relative * to the search root, that the path sits under. A path with no separator is its @@ -131,8 +141,8 @@ function relativeToSearchRoot(path: string, root: string): string { * empty group. */ function topLevelSegment(path: string): string { - const trimmed = path.replace(/^[\\/]+/, '') - const cut = trimmed.search(/[\\/]/) + const trimmed = stripLeadingSeparators(path) + const cut = trimmed.indexOf(sep) return cut === -1 ? trimmed : trimmed.slice(0, cut) } diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index 5055db7c9c..a0d3cdf38c 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -12,7 +12,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { join } from 'node:path' +import { join, sep } from 'node:path' import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecutionToken } from '@deepseek-ai/dsh-tools' @@ -582,14 +582,28 @@ describe('cross-directory sampling', () => { .toEqual({ items: ['./vendor/a.ts', './src/b.ts'], shown: 2, total: 2 }) expect(sampleAcrossTopLevel(['/vendor/a.ts', '/src/b.ts'], 2, '/')) .toEqual({ items: ['/vendor/a.ts', '/src/b.ts'], shown: 2, total: 2 }) - expect(sampleAcrossTopLevel(['C:\\root\\a\\one', 'C:\\root\\b\\two'], 2, 'C:\\root')) - .toEqual({ items: ['C:\\root\\a\\one', 'C:\\root\\b\\two'], shown: 2, total: 2 }) + const rooted = [ + ['root', 'a', 'one'].join(sep), + ['root', 'a', 'two'].join(sep), + ['root', 'b', 'three'].join(sep), + ] + expect(sampleAcrossTopLevel(rooted, 2, 'root')) + .toEqual({ items: [rooted[0], rooted[2]], shown: 2, total: 2 }) expect(sampleAcrossTopLevel(['other/a.ts'], 1, 'src')) .toEqual({ items: ['other/a.ts'], shown: 1, total: 1 }) expect(sampleAcrossTopLevel(['src'], 1, 'src')) .toEqual({ items: ['src'], shown: 1, total: 1 }) }) + it.skipIf(process.platform === 'win32')('treats POSIX backslashes as filename characters', () => { + const paths = ['old\\one', 'old\\two', 'src/a'] + expect(sampleAcrossTopLevel(paths, 2)).toEqual({ + items: ['old\\one', 'old\\two'], + shown: 2, + total: 3, + }) + }) + it('handles more top-level groups than the JavaScript argument limit', () => { const paths = Array.from({ length: 125_000 }, (_, index) => `dir-${index}/file.txt`) expect(sampleAcrossTopLevel(paths, 100)).toMatchObject({ shown: 100, total: 125_000 })