Bladeren bron

chore: rm fuzzy search on references (#30931)

Shoubhit Dash 2 maanden geleden
bovenliggende
commit
a136caa1b3

+ 26 - 95
packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx

@@ -271,47 +271,6 @@ export function Autocomplete(props: {
     }
   }
 
-  function createReferenceFilePart(input: {
-    alias: string
-    root: string
-    item: string
-    lineRange?: { startLine: number; endLine?: number }
-  }) {
-    const filename = `${input.alias}/${
-      input.lineRange && !input.item.endsWith("/")
-        ? `${input.item}#${input.lineRange.startLine}${input.lineRange.endLine ? `-${input.lineRange.endLine}` : ""}`
-        : input.item
-    }`
-    const urlObj = pathToFileURL(path.join(input.root, input.item))
-
-    if (input.lineRange && !input.item.endsWith("/")) {
-      urlObj.searchParams.set("start", String(input.lineRange.startLine))
-      if (input.lineRange.endLine !== undefined) {
-        urlObj.searchParams.set("end", String(input.lineRange.endLine))
-      }
-    }
-
-    return {
-      filename,
-      url: urlObj.href,
-      part: {
-        type: "file" as const,
-        mime: input.item.endsWith("/") ? "application/x-directory" : "text/plain",
-        filename,
-        url: urlObj.href,
-        source: {
-          type: "file" as const,
-          text: {
-            start: 0,
-            end: 0,
-            value: "",
-          },
-          path: filename,
-        },
-      },
-    }
-  }
-
   function referencePromptText(reference: Reference.Resolved) {
     const problem = reference.kind === "invalid" ? reference.message : undefined
     return [
@@ -336,18 +295,12 @@ export function Autocomplete(props: {
     }),
   )
 
-  const referenceSearch = createMemo(() => {
+  const referenceMatch = createMemo(() => {
     if (!store.visible || store.visible === "/") return
-    const { lineRange, baseQuery } = extractLineRange(search())
+    const { baseQuery } = extractLineRange(search())
     const slash = baseQuery.indexOf("/")
-    if (slash === -1) return
-    const reference = references().find((item) => item.name === baseQuery.slice(0, slash))
-    if (!reference || reference.kind === "invalid") return
-    return {
-      reference,
-      query: baseQuery.slice(slash + 1),
-      lineRange,
-    }
+    const alias = slash === -1 ? baseQuery : baseQuery.slice(0, slash)
+    return references().find((item) => item.name === alias)
   })
 
   function normalizeMentionPath(filePath: string) {
@@ -380,7 +333,7 @@ export function Autocomplete(props: {
     () => search(),
     async (query) => {
       if (!store.visible || store.visible === "/") return []
-      if (referenceSearch()) return []
+      if (referenceMatch()) return []
 
       const { lineRange, baseQuery } = extractLineRange(query ?? "")
 
@@ -430,43 +383,6 @@ export function Autocomplete(props: {
     },
   )
 
-  const [referenceFiles] = createResource(
-    () => referenceSearch(),
-    async (match) => {
-      if (!match) return []
-
-      const result = await sdk.client.find.files({
-        directory: match.reference.path,
-        query: match.query,
-        limit: 50,
-      })
-
-      if (result.error || !result.data) return []
-
-      const width = props.anchor().width - 4
-      return result.data.map((item): AutocompleteOption => {
-        const { filename, part } = createReferenceFilePart({
-          alias: match.reference.name,
-          root: match.reference.path,
-          item,
-          lineRange: match.lineRange,
-        })
-        return {
-          display: Locale.truncateMiddle(filename, width),
-          value: filename,
-          isDirectory: item.endsWith("/"),
-          path: filename,
-          onSelect: () => {
-            insertPart(filename, part)
-          },
-        }
-      })
-    },
-    {
-      initialValue: [],
-    },
-  )
-
   const mcpResources = createMemo(() => {
     if (!store.visible || store.visible === "/") return []
 
@@ -529,8 +445,22 @@ export function Autocomplete(props: {
     references().map(
       (reference): AutocompleteOption => ({
         display: "@" + reference.name,
-        description: reference.kind === "invalid" ? reference.message : " configured reference",
+        description: reference.kind === "invalid" ? reference.message : " dir",
         onSelect: () => {
+          if (reference.kind !== "invalid") {
+            insertPart(reference.name, {
+              type: "file",
+              mime: "application/x-directory",
+              filename: reference.name,
+              url: pathToFileURL(reference.path).href,
+              source: {
+                type: "file",
+                text: { start: 0, end: 0, value: "" },
+                path: reference.name,
+              },
+            })
+            return
+          }
           insertPart(reference.name, {
             type: "text",
             text: referencePromptText(reference),
@@ -572,16 +502,15 @@ export function Autocomplete(props: {
 
   const options = createMemo((prev: AutocompleteOption[] | undefined) => {
     const filesValue = files()
-    const referenceFilesValue = referenceFiles()
-    const referenceSearchValue = referenceSearch()
+    const referenceMatchValue = referenceMatch()
     const agentsValue = agents()
     const referenceAliasesValue = referenceAliases()
     const commandsValue = commands()
 
     const mixed: AutocompleteOption[] =
       store.visible === "@"
-        ? referenceSearchValue
-          ? referenceFilesValue || []
+        ? referenceMatchValue
+          ? referenceAliasesValue.filter((item) => item.display === `@${referenceMatchValue.name}`)
           : [...referenceAliasesValue, ...agentsValue, ...(filesValue || []), ...mcpResources()]
         : [...commandsValue]
 
@@ -591,10 +520,12 @@ export function Autocomplete(props: {
       return mixed
     }
 
-    if ((files.loading || referenceFiles.loading) && prev && prev.length > 0) {
+    if (files.loading && prev && prev.length > 0) {
       return prev
     }
 
+    if (referenceMatchValue) return mixed
+
     const result = fuzzysort.go(removeLineRange(searchValue), mixed, {
       keys: [
         (obj) => removeLineRange((obj.value ?? obj.display).trimEnd()),

+ 56 - 91
packages/opencode/src/session/prompt.ts

@@ -145,9 +145,47 @@ export const layer = Layer.effect(
       yield* state.cancel(sessionID)
     })
 
+    const resolveReferenceParts = Effect.fnUntraced(function* (template: string) {
+      const parts: Types.DeepMutable<PromptInput["parts"]> = []
+      const seen = new Set<string>()
+      yield* Effect.forEach(
+        ConfigMarkdown.files(template),
+        Effect.fnUntraced(function* (match) {
+          const name = match[1]
+          if (!name) return
+          const alias = name.split("/")[0]
+          if (!alias || seen.has(alias)) return
+          const reference = yield* references.get(alias)
+          if (!reference) return
+          seen.add(alias)
+
+          const start = match.index ?? 0
+          const source = { value: match[0], start, end: start + match[0].length }
+          if (reference.kind === "invalid") {
+            parts.push(referenceTextPart({ reference, source }))
+            return
+          }
+
+          yield* references.ensure(reference.path)
+          parts.push({
+            type: "file",
+            url: pathToFileURL(reference.path).href,
+            filename: alias,
+            mime: "application/x-directory",
+            source: { type: "file", text: source, path: alias },
+          })
+        }),
+        { concurrency: 1, discard: true },
+      )
+      return parts
+    })
+
     const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) {
       const ctx = yield* InstanceState.context
-      const parts: Types.DeepMutable<PromptInput["parts"]> = [{ type: "text", text: template }]
+      const parts: Types.DeepMutable<PromptInput["parts"]> = [
+        { type: "text", text: template },
+        ...(yield* resolveReferenceParts(template)),
+      ]
       const files = ConfigMarkdown.files(template)
       const seen = new Set<string>()
       yield* Effect.forEach(
@@ -160,60 +198,7 @@ export const layer = Layer.effect(
 
           const slash = name.indexOf("/")
           const alias = slash === -1 ? name : name.slice(0, slash)
-          const reference = yield* references.get(alias)
-          if (reference) {
-            const start = match.index ?? 0
-            const source = { value: match[0], start, end: start + match[0].length }
-            if (reference.kind === "invalid") {
-              parts.push(
-                referenceTextPart({ reference, source, target: slash === -1 ? undefined : name.slice(slash + 1) }),
-              )
-              return
-            }
-
-            yield* references.ensure(reference.path)
-            if (slash === -1) {
-              parts.push(referenceTextPart({ reference, source }))
-              return
-            }
-
-            const target = name.slice(slash + 1)
-            const targetPath = path.resolve(reference.path, target)
-            if (!FSUtil.contains(reference.path, targetPath)) {
-              parts.push(
-                referenceTextPart({
-                  reference,
-                  source,
-                  target,
-                  targetPath,
-                  problem: `Path escapes configured reference @${alias}: ${target}`,
-                }),
-              )
-              return
-            }
-
-            const info = yield* fsys.stat(targetPath).pipe(Effect.option)
-            if (Option.isNone(info)) {
-              parts.push(
-                referenceTextPart({
-                  reference,
-                  source,
-                  target,
-                  targetPath,
-                  problem: `Path does not exist inside configured reference @${alias}: ${target}`,
-                }),
-              )
-              return
-            }
-
-            parts.push({
-              type: "file",
-              url: pathToFileURL(targetPath).href,
-              filename: name,
-              mime: info.value.type === "Directory" ? "application/x-directory" : "text/plain",
-            })
-            return
-          }
+          if (yield* references.get(alias)) return
 
           const filepath = name.startsWith("~/")
             ? path.join(os.homedir(), name.slice(2))
@@ -770,30 +755,6 @@ export const layer = Layer.effect(
         id: part.id ? PartID.make(part.id) : PartID.ascending(),
       })
 
-      const referenceContextFromFilePart = Effect.fnUntraced(function* (
-        part: Extract<PromptInput["parts"][number], { type: "file" }>,
-        filepath: string,
-      ) {
-        const name = part.filename?.replace(/#\d+(?:-\d*)?$/, "")
-        if (!name) return
-        const slash = name.indexOf("/")
-        if (slash === -1) return
-
-        const reference = yield* references.get(name.slice(0, slash))
-        if (!reference || reference.kind === "invalid") return
-        if (!FSUtil.contains(reference.path, filepath)) return
-
-        const target = path.relative(reference.path, filepath).split(path.sep).join("/")
-        if (!target || target.startsWith("../") || target === "..") return
-
-        return referenceTextPart({
-          reference,
-          source: part.source?.text ?? { value: `@${name}`, start: 0, end: name.length + 1 },
-          target,
-          targetPath: filepath,
-        })
-      })
-
       const resolvePart: (part: PromptInput["parts"][number]) => Effect.Effect<Draft<SessionV1.Part>[]> = Effect.fn(
         "SessionPrompt.resolveUserPart",
       )(function* (part) {
@@ -876,7 +837,6 @@ export const layer = Layer.effect(
             case "file:": {
               log.info("file", { mime: part.mime })
               const filepath = fileURLToPath(part.url)
-              const referenceContext = yield* referenceContextFromFilePart(part, filepath)
               const mime = (yield* fsys.isDir(filepath)) ? "application/x-directory" : part.mime
 
               const { read } = yield* registry.named()
@@ -922,9 +882,6 @@ export const layer = Layer.effect(
                 }
                 const args = { filePath: filepath, offset, limit }
                 const pieces: Draft<SessionV1.Part>[] = [
-                  ...(referenceContext
-                    ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }]
-                    : []),
                   {
                     messageID: info.id,
                     sessionID: input.sessionID,
@@ -990,9 +947,6 @@ export const layer = Layer.effect(
                     error: new NamedError.Unknown({ message }).toObject(),
                   })
                   return [
-                    ...(referenceContext
-                      ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }]
-                      : []),
                     {
                       messageID: info.id,
                       sessionID: input.sessionID,
@@ -1003,9 +957,6 @@ export const layer = Layer.effect(
                   ]
                 }
                 return [
-                  ...(referenceContext
-                    ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }]
-                    : []),
                   {
                     messageID: info.id,
                     sessionID: input.sessionID,
@@ -1025,7 +976,6 @@ export const layer = Layer.effect(
               }
 
               return [
-                ...(referenceContext ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }] : []),
                 {
                   messageID: info.id,
                   sessionID: input.sessionID,
@@ -1071,7 +1021,22 @@ export const layer = Layer.effect(
         return [{ ...part, messageID: info.id, sessionID: input.sessionID }]
       })
 
-      const resolvedParts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe(
+      const submittedParts: Types.DeepMutable<PromptInput["parts"]> = [...input.parts]
+      const attachedReferences = new Set(
+        input.parts.flatMap((part) =>
+          part.type === "file" && part.mime === "application/x-directory" ? [part.url] : [],
+        ),
+      )
+      for (const part of input.parts) {
+        if (part.type !== "text" || part.synthetic) continue
+        for (const reference of yield* resolveReferenceParts(part.text)) {
+          if (reference.type === "file" && attachedReferences.has(reference.url)) continue
+          if (reference.type === "file") attachedReferences.add(reference.url)
+          submittedParts.push(reference)
+        }
+      }
+
+      const resolvedParts = yield* Effect.forEach(submittedParts, resolvePart, { concurrency: "unbounded" }).pipe(
         Effect.map((x) => x.flat().map(assign)),
       )
 

+ 23 - 96
packages/opencode/test/session/prompt.test.ts

@@ -1969,7 +1969,7 @@ noLLMServer.instance(
 )
 
 noLLMServer.instance(
-  "resolves configured reference mentions before workspace paths and agents",
+  "resolves configured reference mentions to one root directory attachment",
   () =>
     Effect.gen(function* () {
       const { directory: dir } = yield* TestInstance
@@ -1984,33 +1984,18 @@ noLLMServer.instance(
       const parts = yield* prompt.resolvePromptParts(
         "Use @docs and @docs/README.md and @docs/guide and @docs/missing.md and @docs/README.md and @build",
       )
-      const references = parts.filter(
-        (part): part is SessionV1.TextPartInput =>
-          part.type === "text" && part.synthetic === true && part.text.startsWith("Referenced configured reference "),
-      )
       const files = parts.filter((part): part is SessionV1.FilePartInput => part.type === "file")
       const agents = parts.filter((part): part is SessionV1.AgentPartInput => part.type === "agent")
-      const bare = references.find((part) => part.text.includes("@docs."))
-      const missing = references.find((part) => part.text.includes("@docs/missing.md"))
-      const guide = files.find((part) => part.filename === "docs/guide")
-
-      expect(references.length).toBe(2)
-      expect(bare?.metadata?.reference).toMatchObject({
-        name: "docs",
-        kind: "local",
-        path: docs,
-      })
-      expect(missing?.text).toContain("Path does not exist inside configured reference @docs")
-      expect(missing?.metadata?.reference).toMatchObject({
-        target: "missing.md",
-        targetPath: path.join(docs, "missing.md"),
+      const text = parts.find((part): part is SessionV1.TextPartInput => part.type === "text" && !part.synthetic)
+
+      expect(text?.text).toContain("@docs")
+      expect(files).toHaveLength(1)
+      expect(files[0]).toMatchObject({
+        filename: "docs",
+        mime: "application/x-directory",
+        source: { type: "file", path: "docs", text: { value: "@docs" } },
       })
-
-      expect(files.length).toBe(2)
-      expect(files.map((file) => fileURLToPath(file.url)).sort()).toEqual(
-        [path.join(docs, "README.md"), path.join(docs, "guide")].sort(),
-      )
-      expect(guide?.mime).toBe("application/x-directory")
+      expect(fileURLToPath(files[0].url)).toBe(docs)
       expect(agents.map((agent) => agent.name)).toEqual(["build"])
     }),
   {
@@ -2024,96 +2009,38 @@ noLLMServer.instance(
 )
 
 noLLMServer.instance(
-  "injects metadata for bare configured reference mentions",
-  () =>
-    Effect.gen(function* () {
-      const { directory: dir } = yield* TestInstance
-      const docs = path.join(dir, "external-docs")
-      yield* ensureDir(docs)
-
-      const prompt = yield* SessionPrompt.Service
-      const sessions = yield* Session.Service
-      const session = yield* sessions.create({})
-      const message = yield* prompt.prompt({
-        sessionID: session.id,
-        noReply: true,
-        parts: yield* prompt.resolvePromptParts("Use @docs for context"),
-      })
-
-      const stored = yield* MessageV2.get({ sessionID: session.id, messageID: message.info.id })
-      const synthetic = stored.parts.filter(
-        (part): part is SessionV1.TextPart => part.type === "text" && part.synthetic === true,
-      )
-      const reference = synthetic.find((part) => part.text.startsWith("Referenced configured reference @docs."))
-
-      expect(reference?.metadata?.reference).toMatchObject({ name: "docs", kind: "local", path: docs })
-      expect(synthetic.some((part) => part.text.includes(`Reference root: ${docs}`))).toBe(true)
-      expect(synthetic.some((part) => part.text.includes("Inspect the configured reference"))).toBe(true)
-
-      yield* sessions.remove(session.id)
-    }),
-  {
-    config: {
-      ...cfg,
-      reference: {
-        docs: "./external-docs",
-      },
-    },
-  },
-)
-
-noLLMServer.instance(
-  "injects metadata for configured reference file attachments",
+  "stores raw reference mentions alongside directory attachments",
   () =>
     Effect.gen(function* () {
       const { directory: dir } = yield* TestInstance
       const docs = path.join(dir, "external-docs")
-      const readme = path.join(docs, "README.md")
       yield* ensureDir(docs)
-      yield* writeText(readme, "reference readme")
 
       const prompt = yield* SessionPrompt.Service
       const sessions = yield* Session.Service
       const session = yield* sessions.create({})
       const message = yield* prompt.prompt({
         sessionID: session.id,
-        agent: "build",
         noReply: true,
-        parts: [
-          { type: "text", text: "Read @docs/README.md" },
-          {
-            type: "file",
-            mime: "text/plain",
-            filename: "docs/README.md",
-            url: pathToFileURL(readme).href,
-            source: {
-              type: "file",
-              path: "docs/README.md",
-              text: { value: "@docs/README.md", start: 5, end: 20 },
-            },
-          },
-        ],
+        parts: [{ type: "text", text: "Use @docs for context" }],
       })
 
       const stored = yield* MessageV2.get({ sessionID: session.id, messageID: message.info.id })
       const synthetic = stored.parts.filter(
         (part): part is SessionV1.TextPart => part.type === "text" && part.synthetic === true,
       )
-      const reference = synthetic.find((part) =>
-        part.text.startsWith("Referenced configured reference @docs/README.md."),
-      )
-
-      expect(reference?.metadata?.reference).toMatchObject({
-        name: "docs",
-        kind: "local",
-        path: docs,
-        target: "README.md",
-        targetPath: readme,
-        source: { value: "@docs/README.md", start: 5, end: 20 },
+      const files = stored.parts.filter((part): part is SessionV1.FilePart => part.type === "file")
+      const text = stored.parts.find((part): part is SessionV1.TextPart => part.type === "text" && !part.synthetic)
+
+      expect(text?.text).toBe("Use @docs for context")
+      expect(synthetic.some((part) => part.text.includes(JSON.stringify({ filePath: docs })))).toBe(true)
+      expect(files).toHaveLength(1)
+      expect(files[0]).toMatchObject({
+        filename: "docs",
+        mime: "application/x-directory",
+        source: { type: "file", path: "docs", text: { value: "@docs", start: 4, end: 9 } },
       })
-      expect(synthetic.findIndex((part) => part === reference)).toBeLessThan(
-        synthetic.findIndex((part) => part.text.startsWith("Called the Read tool with the following input:")),
-      )
+      expect(fileURLToPath(files[0].url)).toBe(docs)
 
       yield* sessions.remove(session.id)
     }),