fix(hooks): dedupe native agent instructions
This commit is contained in:
@@ -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<string, Set<string>>()
|
||||
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")
|
||||
|
||||
@@ -8,6 +8,137 @@ import { loadInjectedPaths, saveInjectedPaths } from "./storage";
|
||||
|
||||
type DynamicTruncator = ReturnType<typeof createDynamicTruncator>;
|
||||
|
||||
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<string>,
|
||||
): { output: string; dirty: boolean } {
|
||||
const blocks = collectInstructionBlocks(output);
|
||||
if (blocks.length === 0) return { output, dirty: false };
|
||||
|
||||
const seenInOutput = new Set<string>();
|
||||
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<string, Set<string>>,
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user