Bladeren bron

fix(core): render granular instruction updates (#42383)

Kit Langton 3 dagen geleden
bovenliggende
commit
f2408060b7
2 gewijzigde bestanden met toevoegingen van 76 en 13 verwijderingen
  1. 26 2
      packages/core/src/instruction-discovery.ts
  2. 50 11
      packages/core/test/instruction-discovery.test.ts

+ 26 - 2
packages/core/src/instruction-discovery.ts

@@ -2,6 +2,7 @@ export * as InstructionDiscovery from "./instruction-discovery.js"
 
 
 import { Context, Effect, Layer, Schema, Types } from "effect"
 import { Context, Effect, Layer, Schema, Types } from "effect"
 import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
 import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
+import { createPatch } from "diff"
 import { Bus } from "./bus.js"
 import { Bus } from "./bus.js"
 import { Instructions } from "./instructions/index.js"
 import { Instructions } from "./instructions/index.js"
 import { AbsolutePath } from "./schema.js"
 import { AbsolutePath } from "./schema.js"
@@ -81,8 +82,7 @@ export const layer = (options?: Options) =>
           read: Effect.succeed(value),
           read: Effect.succeed(value),
           render: {
           render: {
             initial: render,
             initial: render,
-            changed: (_previous, current) =>
-              `These instructions replace all previously loaded ambient instructions.\n\n${render(current)}`,
+            changed: renderUpdate,
             removed: () => "Previously loaded instructions no longer apply.",
             removed: () => "Previously loaded instructions no longer apply.",
           },
           },
         })
         })
@@ -120,3 +120,27 @@ export const node = configured()
 function render(files: ReadonlyArray<File>) {
 function render(files: ReadonlyArray<File>) {
   return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
   return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
 }
 }
+
+function renderUpdate(previous: ReadonlyArray<File>, current: ReadonlyArray<File>) {
+  const changes = Instructions.diffByKey(
+    previous,
+    current,
+    (file) => file.path,
+    (before, after) => before.content !== after.content,
+  )
+  return [
+    ...changes.removed.map((file) => `The instructions from ${file.path} no longer apply.`),
+    ...changes.added.map((file) => `New instructions apply from:\n${render([file])}`),
+    ...changes.changed.map(({ previous: before, current: after }) => {
+      const patch = createPatch(after.path, before.content, after.content, "", "", { context: 3 })
+      const diff = [
+        `The instructions from ${after.path} changed. Here's the diff:`,
+        "```diff",
+        patch.slice(patch.indexOf("@@")).trimEnd(),
+        "```",
+      ].join("\n")
+      const replacement = `The instructions changed:\n${render([after])}`
+      return diff.length < replacement.length ? diff : replacement
+    }),
+  ].join("\n\n")
+}

+ 50 - 11
packages/core/test/instruction-discovery.test.ts

@@ -111,6 +111,50 @@ describe("InstructionDiscovery", () => {
       ).toBe(false)
       ).toBe(false)
     }).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
     }).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
   )
   )
+
+  it.effect("renders granular instruction updates", () =>
+    Effect.gen(function* () {
+      const discovery = yield* InstructionDiscovery.Service
+      yield* discovery.transform((draft) => {
+        draft.add(file("/global/AGENTS.md", "global"))
+        draft.add(
+          file("/repo/AGENTS.md", ["old", ...Array.from({ length: 20 }, (_, index) => `keep ${index}`)].join("\n")),
+        )
+      })
+      const initial = yield* readInitial(yield* discovery.load())
+
+      yield* discovery.transform((draft) => {
+        draft.update("/repo/AGENTS.md", (current) => {
+          current.content = ["new", ...Array.from({ length: 20 }, (_, index) => `keep ${index}`)].join("\n")
+        })
+      })
+      const modified = (yield* readUpdate(yield* discovery.load(), initial)).text
+      expect(modified).toContain("The instructions from /repo/AGENTS.md changed. Here's the diff:")
+      expect(modified).toContain("-old\n+new")
+      expect(modified).not.toContain("global")
+
+      const rewritten = state({
+        "core/instructions": [{ path: "/repo/AGENTS.md", content: "old one\nold two\nold three\nold four" }],
+      })
+      yield* discovery.transform((draft) => {
+        draft.remove("/global/AGENTS.md")
+        draft.update("/repo/AGENTS.md", (current) => {
+          current.content = "new"
+        })
+      })
+      expect((yield* readUpdate(yield* discovery.load(), rewritten)).text).toBe(
+        "The instructions changed:\nInstructions from: /repo/AGENTS.md\nnew",
+      )
+
+      yield* discovery.transform((draft) => {
+        draft.add(file("/repo/packages/AGENTS.md", "package"))
+      })
+      const structural = (yield* readUpdate(yield* discovery.load(), initial)).text
+      expect(structural).toContain("The instructions from /global/AGENTS.md no longer apply.")
+      expect(structural).toContain("New instructions apply from:\nInstructions from: /repo/packages/AGENTS.md\npackage")
+      expect(structural).not.toContain("Instructions from: /global/AGENTS.md\nglobal")
+    }).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
+  )
 })
 })
 
 
 describe("ConfigInstructionPlugin.Plugin", () => {
 describe("ConfigInstructionPlugin.Plugin", () => {
@@ -168,20 +212,15 @@ describe("ConfigInstructionPlugin.Plugin", () => {
 
 
           yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
           yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
           yield* emitAndWait({ type: "update", path: packageFile })
           yield* emitAndWait({ type: "update", path: packageFile })
-          expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toContain(
-            `Instructions from: ${packageFile}\nchanged`,
-          )
+          const changed = (yield* readUpdate(yield* discovery.load(), initialized)).text
+          expect(changed).toContain(`The instructions changed:\nInstructions from: ${packageFile}\nchanged`)
+          expect(changed).not.toContain(`Instructions from: ${globalFile}\nglobal`)
 
 
           yield* Effect.promise(() => fs.rm(packageFile))
           yield* Effect.promise(() => fs.rm(packageFile))
           yield* emitAndWait({ type: "delete", path: packageFile })
           yield* emitAndWait({ type: "delete", path: packageFile })
-          expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
-            [
-              "These instructions replace all previously loaded ambient instructions.",
-              `Instructions from: ${globalFile}\nglobal`,
-              `Instructions from: ${projectFile}\nproject`,
-              `Instructions from: ${sharedFile}\nshared`,
-            ].join("\n\n"),
-          )
+          const removed = (yield* readUpdate(yield* discovery.load(), initialized)).text
+          expect(removed).toContain(`The instructions from ${packageFile} no longer apply.`)
+          expect(removed).not.toContain(`Instructions from: ${globalFile}\nglobal`)
 
 
           yield* Effect.promise(() => fs.rm(globalFile))
           yield* Effect.promise(() => fs.rm(globalFile))
           yield* emitAndWait({ type: "delete", path: globalFile })
           yield* emitAndWait({ type: "delete", path: globalFile })