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

This commit is contained in:
Kit Langton
2026-08-13 14:26:44 -04:00
committed by GitHub
parent 9fff6e2b4f
commit f2408060b7
2 changed files with 76 additions and 13 deletions
+26 -2
View File
@@ -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<File>) {
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")
}
@@ -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 })