From 78d52872f21f23d2b393781141c8d531674da087 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 17:21:58 +0900 Subject: [PATCH] fix(hooks): dedupe native agent instructions --- .../injector.test.ts | 65 ++++++++ .../directory-agents-injector/injector.ts | 139 +++++++++++++++++- 2 files changed, 201 insertions(+), 3 deletions(-) diff --git a/src/hooks/directory-agents-injector/injector.test.ts b/src/hooks/directory-agents-injector/injector.test.ts index 822381a69..23a75932f 100644 --- a/src/hooks/directory-agents-injector/injector.test.ts +++ b/src/hooks/directory-agents-injector/injector.test.ts @@ -173,6 +173,71 @@ describe("processFilePathForAgentsInjection", () => { expect(output.output.split("[Directory Context:").length - 1).toBe(2) }) + it("dedupes native global Instructions from blocks across reads", async () => { + // given + const { processFilePathForAgentsInjection } = await import("./injector") + const sessionCaches = new Map>() + const filePath = join(testRoot, "file.ts") + const globalAgentsPath = "/Users/example/.config/opencode/AGENTS.md" + const globalAgentsContent = "# GLOBAL AGENTS\nglobal directives" + const nativeOutput = () => ({ + title: "Read result", + output: `base output\n\nAdditional project instructions matched for ${filePath}:\n\nInstructions from: ${globalAgentsPath}\n${globalAgentsContent}`, + metadata: {}, + }) + const firstOutput = nativeOutput() + const secondOutput = nativeOutput() + + // when + await processFilePathForAgentsInjection({ + ctx: { directory: testRoot } as PluginInput, + truncator, + sessionCaches, + filePath, + sessionID: "session-native-global-dedupe", + output: firstOutput, + }) + await processFilePathForAgentsInjection({ + ctx: { directory: testRoot } as PluginInput, + truncator, + sessionCaches, + filePath, + sessionID: "session-native-global-dedupe", + output: secondOutput, + }) + + // then + expect(firstOutput.output).toContain(`Instructions from: ${globalAgentsPath}`) + expect(secondOutput.output).toBe("base output") + }) + + it("does not add Directory Context when native output already included the same AGENTS.md", async () => { + // given + const { processFilePathForAgentsInjection } = await import("./injector") + const filePath = join(srcDirectory, "file.ts") + const srcAgentsPath = join(srcDirectory, "AGENTS.md") + const output = { + title: "Read result", + output: `base output\n\nAdditional project instructions matched for ${filePath}:\n\nInstructions from: ${srcAgentsPath}\n${srcAgentsContent}`, + metadata: {}, + } + + // when + await processFilePathForAgentsInjection({ + ctx: { directory: testRoot } as PluginInput, + truncator, + sessionCaches: new Map(), + filePath, + sessionID: "session-native-local-dedupe", + output, + }) + + // then + expect(output.output).toContain(`Instructions from: ${srcAgentsPath}`) + expect(output.output).not.toContain(`[Directory Context: ${srcAgentsPath}]`) + expect(output.output.split(srcAgentsContent).length - 1).toBe(1) + }) + it("shows truncation notice when content is truncated", async () => { // given const { processFilePathForAgentsInjection } = await import("./injector") diff --git a/src/hooks/directory-agents-injector/injector.ts b/src/hooks/directory-agents-injector/injector.ts index f05dc276f..184aa9e9e 100644 --- a/src/hooks/directory-agents-injector/injector.ts +++ b/src/hooks/directory-agents-injector/injector.ts @@ -8,6 +8,137 @@ import { loadInjectedPaths, saveInjectedPaths } from "./storage"; type DynamicTruncator = ReturnType; +const ADDITIONAL_INSTRUCTIONS_MARKER = "Additional project instructions matched for "; +const DIRECTORY_CONTEXT_MARKER = "[Directory Context: "; +const INSTRUCTIONS_FROM_MARKER = "Instructions from: "; + +interface InstructionBlock { + path: string; + start: number; + end: number; + source: "directory-context" | "instructions-from"; +} + +function lineStartAt(output: string, index: number): number { + const previousNewline = output.lastIndexOf("\n", index - 1); + return previousNewline === -1 ? 0 : previousNewline + 1; +} + +function lineEndAt(output: string, index: number): number { + const nextNewline = output.indexOf("\n", index); + return nextNewline === -1 ? output.length : nextNewline; +} + +function findAdditionalInstructionsBlockStart(output: string, instructionsLineStart: number): number { + const headerStart = output.lastIndexOf(ADDITIONAL_INSTRUCTIONS_MARKER, instructionsLineStart); + if (headerStart === -1) return instructionsLineStart; + + const headerLineEnd = lineEndAt(output, headerStart); + if (headerLineEnd > instructionsLineStart) return instructionsLineStart; + + const gap = output.slice(headerLineEnd, instructionsLineStart); + return gap.trim() === "" ? headerStart : instructionsLineStart; +} + +function findNextInstructionBlockStart(output: string, from: number): number { + const markers = [ + `\n\n${ADDITIONAL_INSTRUCTIONS_MARKER}`, + `\n\n${DIRECTORY_CONTEXT_MARKER}`, + `\n\n${INSTRUCTIONS_FROM_MARKER}`, + ]; + const starts = markers + .map((marker) => output.indexOf(marker, from)) + .filter((index) => index !== -1); + return starts.length > 0 ? Math.min(...starts) : output.length; +} + +function collectInstructionBlocks(output: string): InstructionBlock[] { + const blocks: InstructionBlock[] = []; + + let searchIndex = 0; + while (true) { + const markerIndex = output.indexOf(INSTRUCTIONS_FROM_MARKER, searchIndex); + if (markerIndex === -1) break; + + const lineStart = lineStartAt(output, markerIndex); + const lineEnd = lineEndAt(output, markerIndex); + const instructionPath = output.slice(markerIndex + INSTRUCTIONS_FROM_MARKER.length, lineEnd).trim(); + if (instructionPath) { + blocks.push({ + path: instructionPath, + start: findAdditionalInstructionsBlockStart(output, lineStart), + end: findNextInstructionBlockStart(output, lineEnd), + source: "instructions-from", + }); + } + searchIndex = lineEnd; + } + + searchIndex = 0; + while (true) { + const markerIndex = output.indexOf(DIRECTORY_CONTEXT_MARKER, searchIndex); + if (markerIndex === -1) break; + + const pathStart = markerIndex + DIRECTORY_CONTEXT_MARKER.length; + const pathEnd = output.indexOf("]", pathStart); + if (pathEnd === -1) break; + + const instructionPath = output.slice(pathStart, pathEnd).trim(); + if (instructionPath) { + blocks.push({ + path: instructionPath, + start: lineStartAt(output, markerIndex), + end: findNextInstructionBlockStart(output, pathEnd), + source: "directory-context", + }); + } + searchIndex = pathEnd + 1; + } + + return blocks.sort((a, b) => a.start - b.start); +} + +function removeInstructionBlockRanges( + output: string, + ranges: Array<{ start: number; end: number }>, +): string { + let deduped = output; + for (const range of [...ranges].sort((a, b) => b.start - a.start)) { + deduped = deduped.slice(0, range.start) + deduped.slice(range.end); + } + return deduped.replace(/\n{3,}/g, "\n\n").replace(/\n+$/, ""); +} + +function dedupeExistingInstructionBlocks( + output: string, + cache: Set, +): { output: string; dirty: boolean } { + const blocks = collectInstructionBlocks(output); + if (blocks.length === 0) return { output, dirty: false }; + + const seenInOutput = new Set(); + const rangesToRemove: Array<{ start: number; end: number }> = []; + let dirty = false; + + for (const block of blocks) { + const repeatedNativeInstruction = block.source === "instructions-from" && cache.has(block.path); + if (repeatedNativeInstruction || seenInOutput.has(block.path)) { + rangesToRemove.push({ start: block.start, end: block.end }); + dirty = true; + continue; + } + + cache.add(block.path); + seenInOutput.add(block.path); + dirty = true; + } + + return { + output: rangesToRemove.length > 0 ? removeInstructionBlockRanges(output, rangesToRemove) : output, + dirty, + }; +} + function getSessionCache( sessionCaches: Map>, sessionID: string, @@ -36,15 +167,17 @@ export async function processFilePathForAgentsInjection(input: { const dir = dirname(resolved); const cache = getSessionCache(input.sessionCaches, input.sessionID); const agentsPaths = await findAgentsMdUp({ startDir: dir, rootDir: input.ctx.directory }); + const dedupedExisting = dedupeExistingInstructionBlocks(input.output.output, cache); + input.output.output = dedupedExisting.output; - let dirty = false; + let dirty = dedupedExisting.dirty; for (const agentsPath of agentsPaths) { const agentsDir = dirname(agentsPath); - if (cache.has(agentsDir)) continue; + if (cache.has(agentsPath) || cache.has(agentsDir)) continue; try { const content = await fsPromises.readFile(agentsPath, "utf-8"); - cache.add(agentsDir); + cache.add(agentsPath); const { result, truncated } = await input.truncator.truncate( input.sessionID, content,