diff --git a/packages/core/src/instruction-discovery.ts b/packages/core/src/instruction-discovery.ts index 357cb580f59..f5147543e09 100644 --- a/packages/core/src/instruction-discovery.ts +++ b/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 { makeLocationNode } from "@opencode-ai/util/effect/app-node" +import { createPatch } from "diff" import { Bus } from "./bus.js" import { Instructions } from "./instructions/index.js" import { AbsolutePath } from "./schema.js" @@ -81,8 +82,7 @@ export const layer = (options?: Options) => read: Effect.succeed(value), 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.", }, }) @@ -120,3 +120,27 @@ export const node = configured() function render(files: ReadonlyArray) { return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n") } + +function renderUpdate(previous: ReadonlyArray, current: ReadonlyArray) { + 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") +} diff --git a/packages/core/test/instruction-discovery.test.ts b/packages/core/test/instruction-discovery.test.ts index de5005a38c1..44b932abebe 100644 --- a/packages/core/test/instruction-discovery.test.ts +++ b/packages/core/test/instruction-discovery.test.ts @@ -111,6 +111,50 @@ describe("InstructionDiscovery", () => { ).toBe(false) }).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", () => { @@ -168,20 +212,15 @@ describe("ConfigInstructionPlugin.Plugin", () => { yield* Effect.promise(() => fs.writeFile(packageFile, "changed")) 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* 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* emitAndWait({ type: "delete", path: globalFile })