From 22a876bb8fbf950573826f6fa68fa2d5e64d9d00 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 18 Mar 2026 23:00:14 +0100 Subject: [PATCH] feat(coding-agent): added automatic deduplication of context files by content - Added automatic deduplication of identical context files by content, keeping the closest (lowest depth) copy when duplicates are discovered. - Implemented dedupeExactContextFiles() function to filter duplicate context entries in both explicit and discovered file lists. - Added 3 test cases covering deduplication of explicit context entries, discovered context entries, and preservation of distinct entries. --- packages/coding-agent/CHANGELOG.md | 3 + .../modes/controllers/selector-controller.ts | 12 +++- packages/coding-agent/src/system-prompt.ts | 20 +++++- .../coding-agent/src/utils/title-generator.ts | 3 +- .../test/system-prompt-dedup.test.ts | 64 ++++++++++++++++++- 5 files changed, 94 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4172d18cc..c7238ebbf 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Added + +- Automatic deduplication of identical context files by content, keeping the closest (lowest depth) copy when duplicates are discovered ## [13.13.0] - 2026-03-18 diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index a1dbeb4c5..b30850872 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -639,6 +639,14 @@ export class SelectorController { this.ctx.pendingTools.clear(); } + #refreshSessionTerminalTitle(): void { + const sessionManager = this.ctx.sessionManager as { + getSessionName?: () => string | undefined; + getCwd: () => string; + }; + setSessionTerminalTitle(sessionManager.getSessionName?.(), sessionManager.getCwd()); + } + async #detachActiveSessionBeforeDeletion(sessionPath: string): Promise { const currentSessionFile = this.ctx.sessionManager.getSessionFile(); if (currentSessionFile !== sessionPath) { @@ -649,7 +657,7 @@ export class SelectorController { if (!detached) { return false; } - setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd()); + this.#refreshSessionTerminalTitle(); this.#clearTransientSessionUi(); this.ctx.statusLine.invalidate(); @@ -666,7 +674,7 @@ export class SelectorController { // Switch session via AgentSession (emits hook and tool session events) await this.ctx.session.switchSession(sessionPath); - setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd()); + this.#refreshSessionTerminalTitle(); // Clear and re-render the chat this.ctx.chatContainer.clear(); diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index 24ca780af..f904816cd 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -260,6 +260,18 @@ export interface LoadContextFilesOptions { cwd?: string; } +function dedupeExactContextFiles( + contextFiles: Array<{ path: string; content: string; depth?: number }>, +): Array<{ path: string; content: string; depth?: number }> { + const lastIndexByContent = new Map(); + for (const [index, file] of contextFiles.entries()) { + // Keep the closest matching context entry when content is byte-for-byte identical. + lastIndexByContent.set(file.content, index); + } + + return contextFiles.filter((file, index) => lastIndexByContent.get(file.content) === index); +} + /** * Load all project context files using the capability API. * Returns {path, content, depth} entries for all discovered context files. @@ -290,7 +302,7 @@ export async function loadProjectContextFiles( return depthB - depthA; }); - return files; + return dedupeExactContextFiles(files); } /** @@ -444,7 +456,9 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): let resolvedCustomPrompt: string | undefined; let resolvedAppendPrompt: string | undefined; let systemPromptCustomization: string | null = null; - let contextFiles: Array<{ path: string; content: string; depth?: number }> = providedContextFiles ?? []; + let contextFiles: Array<{ path: string; content: string; depth?: number }> = dedupeExactContextFiles( + providedContextFiles ?? [], + ); let agentsMdSearch: AgentsMdSearch = { scopePath: ".", limit: AGENTS_MD_LIMIT, @@ -471,7 +485,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): resolvedCustomPrompt = prepResult.value.resolvedCustomPrompt; resolvedAppendPrompt = prepResult.value.resolvedAppendPrompt; systemPromptCustomization = prepResult.value.systemPromptCustomization; - contextFiles = prepResult.value.contextFiles; + contextFiles = dedupeExactContextFiles(prepResult.value.contextFiles); agentsMdSearch = prepResult.value.agentsMdSearch; skills = prepResult.value.skills; } diff --git a/packages/coding-agent/src/utils/title-generator.ts b/packages/coding-agent/src/utils/title-generator.ts index bea3de2dc..5d7d42a61 100644 --- a/packages/coding-agent/src/utils/title-generator.ts +++ b/packages/coding-agent/src/utils/title-generator.ts @@ -16,7 +16,6 @@ import { toReasoningEffort } from "../thinking"; const TITLE_SYSTEM_PROMPT = renderPromptTemplate(titleSystemPrompt); const DEFAULT_TERMINAL_TITLE = "π"; -const TERMINAL_TITLE_SEPARATOR = " – "; const TERMINAL_TITLE_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g; const MAX_INPUT_CHARS = 2000; @@ -161,7 +160,7 @@ function getFallbackTerminalTitle(cwd: string | undefined): string | undefined { export function formatSessionTerminalTitle(sessionName: string | undefined, cwd?: string): string { const label = sanitizeTerminalTitlePart(sessionName) ?? getFallbackTerminalTitle(cwd); - return label ? `${label}${TERMINAL_TITLE_SEPARATOR}${DEFAULT_TERMINAL_TITLE}` : DEFAULT_TERMINAL_TITLE; + return label ? `${DEFAULT_TERMINAL_TITLE}: ${label}` : DEFAULT_TERMINAL_TITLE; } /** diff --git a/packages/coding-agent/test/system-prompt-dedup.test.ts b/packages/coding-agent/test/system-prompt-dedup.test.ts index 1334a074a..3e12ecf79 100644 --- a/packages/coding-agent/test/system-prompt-dedup.test.ts +++ b/packages/coding-agent/test/system-prompt-dedup.test.ts @@ -5,7 +5,11 @@ import * as path from "node:path"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { loadSystemPromptFiles } from "@oh-my-pi/pi-coding-agent/system-prompt"; +import { + buildSystemPrompt, + loadProjectContextFiles, + loadSystemPromptFiles, +} from "@oh-my-pi/pi-coding-agent/system-prompt"; function escapeRegExp(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -77,4 +81,62 @@ describe("SYSTEM.md prompt assembly", () => { await expect(loadSystemPromptFiles({ cwd: projectDir })).resolves.toBe("Project SYSTEM prompt"); }); + it("drops identical explicit context entries even when file names differ", async () => { + const farPath = path.join(tempDir, "far", "AGENTS.md"); + const nearPath = path.join(tempDir, "near", "CLAUDE.md"); + const sharedContent = "Shared context instructions"; + + const prompt = await buildSystemPrompt({ + cwd: tempDir, + customPrompt: "Base prompt", + contextFiles: [ + { path: farPath, content: sharedContent, depth: 2 }, + { path: nearPath, content: sharedContent, depth: 0 }, + ], + skills: [], + rules: [], + toolNames: [], + }); + + const matches = prompt.match(new RegExp(escapeRegExp(sharedContent), "g")) ?? []; + expect(matches).toHaveLength(1); + expect(prompt).not.toContain(``); + expect(prompt).toContain(``); + }); + + it("drops identical discovered context entries and keeps the closest copy", async () => { + const projectDir = path.join(tempDir, "project"); + const appDir = path.join(projectDir, "packages", "app"); + const sharedContent = "Shared context instructions"; + + fs.mkdirSync(appDir, { recursive: true }); + fs.writeFileSync(path.join(projectDir, "AGENTS.md"), sharedContent); + fs.writeFileSync(path.join(appDir, "AGENTS.md"), sharedContent); + + const contextFiles = await loadProjectContextFiles({ cwd: appDir }); + const discoveredFiles = contextFiles.filter(file => file.path.startsWith(projectDir)); + + expect(discoveredFiles).toHaveLength(1); + expect(discoveredFiles[0]?.path).toBe(path.join(appDir, "AGENTS.md")); + }); + + it("keeps distinct context entries when their contents differ", async () => { + const farPath = path.join(tempDir, "far", "AGENTS.md"); + const nearPath = path.join(tempDir, "near", "CLAUDE.md"); + + const prompt = await buildSystemPrompt({ + cwd: tempDir, + customPrompt: "Base prompt", + contextFiles: [ + { path: farPath, content: "Root context instructions", depth: 2 }, + { path: nearPath, content: "Near context instructions", depth: 0 }, + ], + skills: [], + rules: [], + toolNames: [], + }); + + expect(prompt).toContain("Root context instructions"); + expect(prompt).toContain("Near context instructions"); + }); });