From bb8ef30bbe71bf4b439fc1b3b8579ebe122191a4 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 29 May 2026 11:17:24 +0900 Subject: [PATCH] feat(omo-codex): add post-compact rule budget to codex hook Trim rule and result char budgets after compaction via new postCompactMaxRuleChars/postCompactMaxResultChars config, applied through withPostCompactBudget. Extract codex-hook helpers into dynamic-target-fingerprints, hook-output, path-utils, rules-engine-factory, and transcript-rule-filter modules. --- .../plugin/components/rules/src/codex-hook.ts | 210 +----------------- .../plugin/components/rules/src/config.ts | 8 + .../rules/src/dynamic-target-fingerprints.ts | 98 ++++++++ .../components/rules/src/hook-output.ts | 14 ++ .../plugin/components/rules/src/path-utils.ts | 29 +++ .../rules/src/post-compact-budget.ts | 9 + .../rules/src/rules-engine-factory.ts | 24 ++ .../components/rules/src/rules/constants.ts | 4 + .../components/rules/src/rules/engine.ts | 4 + .../components/rules/src/rules/types.ts | 2 + .../rules/src/transcript-rule-filter.ts | 44 ++++ .../codex-hook-post-compact-budget.test.ts | 136 ++++++++++++ 12 files changed, 381 insertions(+), 201 deletions(-) create mode 100644 packages/omo-codex/plugin/components/rules/src/dynamic-target-fingerprints.ts create mode 100644 packages/omo-codex/plugin/components/rules/src/hook-output.ts create mode 100644 packages/omo-codex/plugin/components/rules/src/path-utils.ts create mode 100644 packages/omo-codex/plugin/components/rules/src/post-compact-budget.ts create mode 100644 packages/omo-codex/plugin/components/rules/src/rules-engine-factory.ts create mode 100644 packages/omo-codex/plugin/components/rules/src/transcript-rule-filter.ts create mode 100644 packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-budget.test.ts diff --git a/packages/omo-codex/plugin/components/rules/src/codex-hook.ts b/packages/omo-codex/plugin/components/rules/src/codex-hook.ts index 07646add9..eba4249d0 100644 --- a/packages/omo-codex/plugin/components/rules/src/codex-hook.ts +++ b/packages/omo-codex/plugin/components/rules/src/codex-hook.ts @@ -1,8 +1,8 @@ -import { readFileSync, statSync } from "node:fs"; -import { isAbsolute, relative, resolve } from "node:path"; - import { configFromEnvironment } from "./config.js"; import { createHookDebugTimer } from "./debug-log.js"; +import { fingerprintDynamicTargets } from "./dynamic-target-fingerprints.js"; +import { formatAdditionalContextOutput } from "./hook-output.js"; +import { displayPath, uniqueStrings } from "./path-utils.js"; import { clearSessionState, hasPostCompactPending, @@ -12,18 +12,11 @@ import { persistEngineState, sessionCachePath, } from "./persistent-cache.js"; -import { SOURCE_PRIORITY } from "./rules/constants.js"; -import { createEngine } from "./rules/engine.js"; -import { createRuleDiscoveryCache, findRuleCandidates } from "./rules/finder.js"; -import { hashContent } from "./rules/matcher.js"; -import { sortCandidates } from "./rules/ordering.js"; -import { findProjectRoot } from "./rules/project-root.js"; -import type { LoadedRule, PiRulesConfig, RuleCandidate } from "./rules/types.js"; +import { withPostCompactBudget } from "./post-compact-budget.js"; +import { createRulesEngine } from "./rules-engine-factory.js"; import { extractCodexToolPaths } from "./tool-paths.js"; +import { filterRulesAlreadyInTranscript } from "./transcript-rule-filter.js"; import type { TranscriptSearchOptions } from "./transcript-search.js"; -import { readTranscriptSearchText } from "./transcript-search.js"; - -type ContextInjectionHookEventName = "SessionStart" | "UserPromptSubmit" | "PostToolUse"; export type CodexSessionStartInput = { session_id: string; @@ -75,12 +68,6 @@ export interface CodexRulesHookOptions { pluginDataRoot?: string; } -interface DynamicTargetFingerprint { - targetPath: string; - cacheKey: string; - fingerprint: string; -} - export async function runSessionStartHook( input: CodexSessionStartInput, options: CodexRulesHookOptions = {}, @@ -155,7 +142,7 @@ export async function runPostToolUseHook( const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot); const postCompactPending = isPostCompactPending(cachePath, "dynamic"); - const engine = createRulesEngine(options); + const engine = createRulesEngine(options, postCompactPending ? withPostCompactBudget(config) : config); hydrateEngineState(engine, cachePath); debugTimer.lap("hydrate", { dynamicDedupScopes: engine.state.dynamicDedup.size, @@ -226,7 +213,8 @@ function runStaticInjection( return ""; } - const engine = createRulesEngine(options); + const effectiveConfig = completedPostCompactChannel === undefined ? config : withPostCompactBudget(config); + const engine = createRulesEngine(options, effectiveConfig); hydrateEngineState(engine, cachePath); engine.state.cwd = cwd; @@ -251,183 +239,3 @@ function runStaticInjection( persistEngineState(engine, cachePath, completedPostCompactChannel); return formatAdditionalContextOutput(eventName, block); } - -function filterRulesAlreadyInTranscript( - rules: ReadonlyArray, - transcriptPath: string | null, - markInjected: (rule: LoadedRule) => void, - options: TranscriptSearchOptions = {}, -): LoadedRule[] { - if (rules.length === 0 || transcriptPath === null) { - return [...rules]; - } - - const transcriptText = readTranscriptSearchText(transcriptPath, options); - if (transcriptText === null) { - return [...rules]; - } - - const pendingRules: LoadedRule[] = []; - for (const rule of rules) { - if (isRuleAlreadyInTranscript(rule, transcriptText)) { - markInjected(rule); - continue; - } - - pendingRules.push(rule); - } - return pendingRules; -} - -function isRuleAlreadyInTranscript(rule: LoadedRule, transcriptText: string): boolean { - const bodyNeedle = rule.body.trim().slice(0, 2_000); - if (bodyNeedle.length === 0 || !transcriptText.includes(bodyNeedle)) { - return false; - } - - const markers = [ - `Instructions from: ${rule.path}`, - `Instructions from: ${rule.realPath}`, - rule.relativePath.length === 0 ? null : rule.relativePath, - ].filter((marker): marker is string => marker !== null); - return markers.some((marker) => transcriptText.includes(marker)); -} - -function createRulesEngine(options: CodexRulesHookOptions) { - const config = configFromEnvironment(options.env); - return createEngine(config, { - findCandidates: findRuleCandidates, - findProjectRoot, - readFile: (path) => { - try { - return readFileSync(path, "utf8"); - } catch { - return null; - } - }, - }); -} - -function fingerprintDynamicTargets( - cwd: string, - targetPaths: ReadonlyArray, - config: PiRulesConfig, -): DynamicTargetFingerprint[] { - const disabledSources = disabledSourcesFor(config); - const discoveryCache = createRuleDiscoveryCache(); - const cwdProjectRoot = findProjectRoot(cwd); - const fingerprints: DynamicTargetFingerprint[] = []; - - for (const targetPath of uniqueStrings(targetPaths)) { - const projectRoot = - cwdProjectRoot !== null && isSameOrChildPath(targetPath, cwdProjectRoot) - ? cwdProjectRoot - : findProjectRoot(targetPath); - const findOptions: { - projectRoot: string | null; - targetFile: string; - disabledSources?: ReadonlySet; - cache: ReturnType; - } = { - projectRoot, - targetFile: targetPath, - cache: discoveryCache, - }; - if (disabledSources !== undefined) { - findOptions.disabledSources = disabledSources; - } - const candidates = findRuleCandidates(findOptions); - const candidateFingerprint = sortCandidates(candidates).map(fingerprintCandidate).join("\u0001"); - const cacheKey = dynamicTargetCacheKey(targetPath); - fingerprints.push({ - targetPath, - cacheKey, - fingerprint: hashContent( - [ - "v1", - config.enabledSources === "auto" ? "auto" : config.enabledSources.join(","), - projectRoot ?? "", - cacheKey, - candidateFingerprint, - ].join("\u0000"), - ), - }); - } - - return fingerprints; -} - -function fingerprintCandidate(candidate: RuleCandidate): string { - return [ - candidate.realPath, - candidate.relativePath, - candidate.source, - candidate.isGlobal ? "global" : "project", - candidate.isSingleFile ? "single" : "multi", - String(candidate.distance), - fileFingerprint(candidate.path), - ].join("\u0000"); -} - -function fileFingerprint(filePath: string): string { - try { - const stats = statSync(filePath, { bigint: true }); - return `${stats.mtimeNs}:${stats.ctimeNs}:${stats.size}`; - } catch { - return "missing"; - } -} - -function disabledSourcesFor(config: PiRulesConfig): ReadonlySet | undefined { - if (config.enabledSources === "auto") { - return undefined; - } - - const enabledSources = new Set(config.enabledSources); - return new Set([...SOURCE_PRIORITY.keys()].filter((source) => !enabledSources.has(source))); -} - -function dynamicTargetCacheKey(targetPath: string): string { - return toPosixPath(resolve(targetPath)); -} - -function isSameOrChildPath(childPath: string, parentPath: string): boolean { - const childRelativePath = relative(parentPath, resolve(childPath)); - return childRelativePath === "" || (!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath)); -} - -function uniqueStrings(values: ReadonlyArray): string[] { - const uniqueValues: string[] = []; - const seenValues = new Set(); - for (const value of values) { - if (seenValues.has(value)) { - continue; - } - - seenValues.add(value); - uniqueValues.push(value); - } - return uniqueValues; -} - -function formatAdditionalContextOutput(eventName: ContextInjectionHookEventName, additionalContext: string): string { - if (additionalContext.trim().length === 0) return ""; - return `${JSON.stringify({ - hookSpecificOutput: { - hookEventName: eventName, - additionalContext, - }, - })}\n`; -} - -function displayPath(cwd: string, filePath: string): string { - const rel = isAbsolute(filePath) ? relative(cwd, filePath) : filePath; - // Normalize to POSIX separators so injected rule context renders the same - // path string on Linux/macOS and Windows (Codex feeds this verbatim into - // the model prompt, and the existing engine already emits POSIX paths). - return toPosixPath(rel); -} - -function toPosixPath(path: string): string { - return path.replaceAll("\\", "/"); -} diff --git a/packages/omo-codex/plugin/components/rules/src/config.ts b/packages/omo-codex/plugin/components/rules/src/config.ts index 146ba964c..8db63e7ba 100644 --- a/packages/omo-codex/plugin/components/rules/src/config.ts +++ b/packages/omo-codex/plugin/components/rules/src/config.ts @@ -13,6 +13,14 @@ export function configFromEnvironment(env: NodeJS.ProcessEnv = process.env): PiR config.maxResultChars = parsePositiveInteger(firstEnv(env, "CODEX_RULES_MAX_RESULT_CHARS", "PI_RULES_MAX_RESULT_CHARS")) ?? config.maxResultChars; + config.postCompactMaxRuleChars = + parsePositiveInteger( + firstEnv(env, "CODEX_RULES_POST_COMPACT_MAX_RULE_CHARS", "PI_RULES_POST_COMPACT_MAX_RULE_CHARS"), + ) ?? config.postCompactMaxRuleChars; + config.postCompactMaxResultChars = + parsePositiveInteger( + firstEnv(env, "CODEX_RULES_POST_COMPACT_MAX_RESULT_CHARS", "PI_RULES_POST_COMPACT_MAX_RESULT_CHARS"), + ) ?? config.postCompactMaxResultChars; config.enabledSources = parseEnabledSources( firstEnv(env, "CODEX_RULES_ENABLED_SOURCES", "PI_RULES_ENABLED_SOURCES"), disableBundledRules, diff --git a/packages/omo-codex/plugin/components/rules/src/dynamic-target-fingerprints.ts b/packages/omo-codex/plugin/components/rules/src/dynamic-target-fingerprints.ts new file mode 100644 index 000000000..32a8cb6c0 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/dynamic-target-fingerprints.ts @@ -0,0 +1,98 @@ +import { statSync } from "node:fs"; +import { resolve } from "node:path"; +import { isSameOrChildPath, toPosixPath, uniqueStrings } from "./path-utils.js"; +import { SOURCE_PRIORITY } from "./rules/constants.js"; +import { createRuleDiscoveryCache, findRuleCandidates } from "./rules/finder.js"; +import { hashContent } from "./rules/matcher.js"; +import { sortCandidates } from "./rules/ordering.js"; +import { findProjectRoot } from "./rules/project-root.js"; +import type { PiRulesConfig, RuleCandidate } from "./rules/types.js"; + +export interface DynamicTargetFingerprint { + targetPath: string; + cacheKey: string; + fingerprint: string; +} + +export function fingerprintDynamicTargets( + cwd: string, + targetPaths: ReadonlyArray, + config: PiRulesConfig, +): DynamicTargetFingerprint[] { + const disabledSources = disabledSourcesFor(config); + const discoveryCache = createRuleDiscoveryCache(); + const cwdProjectRoot = findProjectRoot(cwd); + const fingerprints: DynamicTargetFingerprint[] = []; + + for (const targetPath of uniqueStrings(targetPaths)) { + const projectRoot = + cwdProjectRoot !== null && isSameOrChildPath(targetPath, cwdProjectRoot) + ? cwdProjectRoot + : findProjectRoot(targetPath); + const findOptions: { + projectRoot: string | null; + targetFile: string; + disabledSources?: ReadonlySet; + cache: ReturnType; + } = { + projectRoot, + targetFile: targetPath, + cache: discoveryCache, + }; + if (disabledSources !== undefined) { + findOptions.disabledSources = disabledSources; + } + const candidates = findRuleCandidates(findOptions); + const candidateFingerprint = sortCandidates(candidates).map(fingerprintCandidate).join("\u0001"); + const cacheKey = dynamicTargetCacheKey(targetPath); + fingerprints.push({ + targetPath, + cacheKey, + fingerprint: hashContent( + [ + "v1", + config.enabledSources === "auto" ? "auto" : config.enabledSources.join(","), + projectRoot ?? "", + cacheKey, + candidateFingerprint, + ].join("\u0000"), + ), + }); + } + + return fingerprints; +} + +function fingerprintCandidate(candidate: RuleCandidate): string { + return [ + candidate.realPath, + candidate.relativePath, + candidate.source, + candidate.isGlobal ? "global" : "project", + candidate.isSingleFile ? "single" : "multi", + String(candidate.distance), + fileFingerprint(candidate.path), + ].join("\u0000"); +} + +function fileFingerprint(filePath: string): string { + try { + const stats = statSync(filePath, { bigint: true }); + return `${stats.mtimeNs}:${stats.ctimeNs}:${stats.size}`; + } catch { + return "missing"; + } +} + +function disabledSourcesFor(config: PiRulesConfig): ReadonlySet | undefined { + if (config.enabledSources === "auto") { + return undefined; + } + + const enabledSources = new Set(config.enabledSources); + return new Set([...SOURCE_PRIORITY.keys()].filter((source) => !enabledSources.has(source))); +} + +function dynamicTargetCacheKey(targetPath: string): string { + return toPosixPath(resolve(targetPath)); +} diff --git a/packages/omo-codex/plugin/components/rules/src/hook-output.ts b/packages/omo-codex/plugin/components/rules/src/hook-output.ts new file mode 100644 index 000000000..c2f430741 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/hook-output.ts @@ -0,0 +1,14 @@ +export type ContextInjectionHookEventName = "SessionStart" | "UserPromptSubmit" | "PostToolUse"; + +export function formatAdditionalContextOutput( + eventName: ContextInjectionHookEventName, + additionalContext: string, +): string { + if (additionalContext.trim().length === 0) return ""; + return `${JSON.stringify({ + hookSpecificOutput: { + hookEventName: eventName, + additionalContext, + }, + })}\n`; +} diff --git a/packages/omo-codex/plugin/components/rules/src/path-utils.ts b/packages/omo-codex/plugin/components/rules/src/path-utils.ts new file mode 100644 index 000000000..8f60449c3 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/path-utils.ts @@ -0,0 +1,29 @@ +import { isAbsolute, relative, resolve } from "node:path"; + +export function displayPath(cwd: string, filePath: string): string { + const rel = isAbsolute(filePath) ? relative(cwd, filePath) : filePath; + return toPosixPath(rel); +} + +export function isSameOrChildPath(childPath: string, parentPath: string): boolean { + const childRelativePath = relative(parentPath, resolve(childPath)); + return childRelativePath === "" || (!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath)); +} + +export function toPosixPath(path: string): string { + return path.replaceAll("\\", "/"); +} + +export function uniqueStrings(values: ReadonlyArray): string[] { + const uniqueValues: string[] = []; + const seenValues = new Set(); + for (const value of values) { + if (seenValues.has(value)) { + continue; + } + + seenValues.add(value); + uniqueValues.push(value); + } + return uniqueValues; +} diff --git a/packages/omo-codex/plugin/components/rules/src/post-compact-budget.ts b/packages/omo-codex/plugin/components/rules/src/post-compact-budget.ts new file mode 100644 index 000000000..6e573030b --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/post-compact-budget.ts @@ -0,0 +1,9 @@ +import type { PiRulesConfig } from "./rules/types.js"; + +export function withPostCompactBudget(config: PiRulesConfig): PiRulesConfig { + return { + ...config, + maxRuleChars: Math.min(config.maxRuleChars, config.postCompactMaxRuleChars), + maxResultChars: Math.min(config.maxResultChars, config.postCompactMaxResultChars), + }; +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules-engine-factory.ts b/packages/omo-codex/plugin/components/rules/src/rules-engine-factory.ts new file mode 100644 index 000000000..484957fe2 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules-engine-factory.ts @@ -0,0 +1,24 @@ +import { readFileSync } from "node:fs"; + +import { configFromEnvironment } from "./config.js"; +import { createEngine } from "./rules/engine.js"; +import { findRuleCandidates } from "./rules/finder.js"; +import { findProjectRoot } from "./rules/project-root.js"; + +interface RulesEngineFactoryOptions { + env?: NodeJS.ProcessEnv; +} + +export function createRulesEngine(options: RulesEngineFactoryOptions, config = configFromEnvironment(options.env)) { + return createEngine(config, { + findCandidates: findRuleCandidates, + findProjectRoot, + readFile: (path) => { + try { + return readFileSync(path, "utf8"); + } catch { + return null; + } + }, + }); +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/constants.ts b/packages/omo-codex/plugin/components/rules/src/rules/constants.ts index 38baef529..b586aef66 100644 --- a/packages/omo-codex/plugin/components/rules/src/rules/constants.ts +++ b/packages/omo-codex/plugin/components/rules/src/rules/constants.ts @@ -92,6 +92,10 @@ export const DEFAULT_MAX_SCAN_FILES = 1000; */ export const DEFAULT_MAX_RESULT_CHARS = 40000; +export const DEFAULT_POST_COMPACT_MAX_RULE_CHARS = 6000; + +export const DEFAULT_POST_COMPACT_MAX_RESULT_CHARS = 12000; + /** * Truncation marker template. `{path}` is replaced with the relative path. */ diff --git a/packages/omo-codex/plugin/components/rules/src/rules/engine.ts b/packages/omo-codex/plugin/components/rules/src/rules/engine.ts index 84ad3471c..8634e7293 100644 --- a/packages/omo-codex/plugin/components/rules/src/rules/engine.ts +++ b/packages/omo-codex/plugin/components/rules/src/rules/engine.ts @@ -12,6 +12,8 @@ import { import { DEFAULT_MAX_RESULT_CHARS, DEFAULT_MAX_RULE_CHARS, + DEFAULT_POST_COMPACT_MAX_RESULT_CHARS, + DEFAULT_POST_COMPACT_MAX_RULE_CHARS, PROJECT_SINGLE_FILES, SOURCE_PRIORITY, } from "./constants.js"; @@ -74,6 +76,8 @@ export function defaultConfig(): PiRulesConfig { mode: "both", maxRuleChars: DEFAULT_MAX_RULE_CHARS, maxResultChars: DEFAULT_MAX_RESULT_CHARS, + postCompactMaxRuleChars: DEFAULT_POST_COMPACT_MAX_RULE_CHARS, + postCompactMaxResultChars: DEFAULT_POST_COMPACT_MAX_RESULT_CHARS, enabledSources: "auto", }; } diff --git a/packages/omo-codex/plugin/components/rules/src/rules/types.ts b/packages/omo-codex/plugin/components/rules/src/rules/types.ts index f24f046d9..bb914bb1e 100644 --- a/packages/omo-codex/plugin/components/rules/src/rules/types.ts +++ b/packages/omo-codex/plugin/components/rules/src/rules/types.ts @@ -114,6 +114,8 @@ export interface PiRulesConfig { mode: "static" | "dynamic" | "both" | "off"; maxRuleChars: number; maxResultChars: number; + postCompactMaxRuleChars: number; + postCompactMaxResultChars: number; enabledSources: RuleSource[] | "auto"; } diff --git a/packages/omo-codex/plugin/components/rules/src/transcript-rule-filter.ts b/packages/omo-codex/plugin/components/rules/src/transcript-rule-filter.ts new file mode 100644 index 000000000..c657f6e12 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/transcript-rule-filter.ts @@ -0,0 +1,44 @@ +import type { LoadedRule } from "./rules/types.js"; +import type { TranscriptSearchOptions } from "./transcript-search.js"; +import { readTranscriptSearchText } from "./transcript-search.js"; + +export function filterRulesAlreadyInTranscript( + rules: ReadonlyArray, + transcriptPath: string | null, + markInjected: (rule: LoadedRule) => void, + options: TranscriptSearchOptions = {}, +): LoadedRule[] { + if (rules.length === 0 || transcriptPath === null) { + return [...rules]; + } + + const transcriptText = readTranscriptSearchText(transcriptPath, options); + if (transcriptText === null) { + return [...rules]; + } + + const pendingRules: LoadedRule[] = []; + for (const rule of rules) { + if (isRuleAlreadyInTranscript(rule, transcriptText)) { + markInjected(rule); + continue; + } + + pendingRules.push(rule); + } + return pendingRules; +} + +function isRuleAlreadyInTranscript(rule: LoadedRule, transcriptText: string): boolean { + const bodyNeedle = rule.body.trim().slice(0, 2_000); + if (bodyNeedle.length === 0 || !transcriptText.includes(bodyNeedle)) { + return false; + } + + const markers = [ + `Instructions from: ${rule.path}`, + `Instructions from: ${rule.realPath}`, + rule.relativePath.length === 0 ? null : rule.relativePath, + ].filter((marker): marker is string => marker !== null); + return markers.some((marker) => transcriptText.includes(marker)); +} diff --git a/packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-budget.test.ts b/packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-budget.test.ts new file mode 100644 index 000000000..b72c60876 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-budget.test.ts @@ -0,0 +1,136 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + type CodexPostCompactInput, + type CodexSessionStartInput, + runPostCompactHook, + runSessionStartHook, + runUserPromptSubmitHook, +} from "../src/codex-hook.js"; + +const tempDirectories: string[] = []; +const PROJECT_RULES_ENV = { + CODEX_RULES_ENABLED_SOURCES: "AGENTS.md,.omo/rules", + CODEX_RULES_MAX_RESULT_CHARS: "50000", + CODEX_RULES_MAX_RULE_CHARS: "30000", +}; + +afterEach(() => { + for (const directory of tempDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("codex rules post-compaction context budget", () => { + it("#given oversized project rules after compaction #when static rules re-inject #then output uses the post-compact budget", async () => { + // given + const { root, pluginData } = makeOversizedProject(); + const firstOutput = await runSessionStartHook(sessionStartInput(root), { + pluginDataRoot: pluginData, + env: PROJECT_RULES_ENV, + }); + const firstContext = readAdditionalContext(firstOutput); + const transcriptPath = writeCompactedTranscript(root, "summary dropped injected rules"); + await runPostCompactHook( + { ...postCompactInput(root), transcript_path: transcriptPath }, + { pluginDataRoot: pluginData }, + ); + + // when + const output = await runUserPromptSubmitHook(userPromptSubmitInput(root, transcriptPath), { + pluginDataRoot: pluginData, + env: PROJECT_RULES_ENV, + }); + + // then + const postCompactContext = readAdditionalContext(output); + expect(firstContext.length).toBeGreaterThan(20_000); + expect(postCompactContext.length).toBeLessThan(firstContext.length); + expect(postCompactContext.length).toBeLessThan(14_000); + expect(postCompactContext).toContain("[Rule truncated. Read full rule:"); + expect(postCompactContext).toContain("Instructions from:"); + }); +}); + +function makeOversizedProject(): { root: string; pluginData: string } { + const root = mkdtempSync(path.join(tmpdir(), "codex-rules-post-compact-budget-project-")); + const pluginData = mkdtempSync(path.join(tmpdir(), "codex-rules-post-compact-budget-data-")); + tempDirectories.push(root, pluginData); + writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "fixture" })); + writeFileSync(path.join(root, "AGENTS.md"), `Project rule\n${"A".repeat(30_000)}`); + mkdirSync(path.join(root, ".omo", "rules"), { recursive: true }); + writeFileSync( + path.join(root, ".omo", "rules", "typescript.md"), + ["---", 'globs: "**/*.ts"', "---", "", `TypeScript rule\n${"B".repeat(30_000)}`].join("\n"), + ); + return { root, pluginData }; +} + +function sessionStartInput(root: string): CodexSessionStartInput { + return { + session_id: "session-post-compact-budget", + transcript_path: null, + cwd: root, + hook_event_name: "SessionStart", + model: "gpt-5.5", + permission_mode: "default", + source: "startup", + }; +} + +function postCompactInput(root: string): CodexPostCompactInput { + return { + session_id: "session-post-compact-budget", + turn_id: "turn-compact", + transcript_path: null, + cwd: root, + hook_event_name: "PostCompact", + model: "gpt-5.5", + trigger: "auto", + }; +} + +function userPromptSubmitInput(root: string, transcriptPath: string): Parameters[0] { + return { + session_id: "session-post-compact-budget", + turn_id: "turn-after-compact", + transcript_path: transcriptPath, + cwd: root, + hook_event_name: "UserPromptSubmit", + model: "gpt-5.5", + permission_mode: "default", + prompt: "continue", + }; +} + +function writeCompactedTranscript(root: string, retainedText: string): string { + const transcriptPath = path.join(root, "transcript-compacted.jsonl"); + writeFileSync( + transcriptPath, + `${JSON.stringify({ + type: "compacted", + payload: { + message: "summary", + replacement_history: [{ type: "message", role: "user", content: retainedText }], + }, + })}\n`, + ); + return transcriptPath; +} + +function readAdditionalContext(output: string): string { + expect(output.trim().length).toBeGreaterThan(0); + const parsed: unknown = JSON.parse(output); + if (!isRecord(parsed)) return ""; + const hookSpecificOutput = parsed["hookSpecificOutput"]; + if (!isRecord(hookSpecificOutput)) return ""; + const additionalContext = hookSpecificOutput["additionalContext"]; + return typeof additionalContext === "string" ? additionalContext : ""; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +}