test(omo-codex): batch 104 (21 files)

This commit is contained in:
YeonGyu-Kim
2026-05-30 19:12:21 +09:00
parent 89ac00f0e7
commit e9c9142afe
21 changed files with 3653 additions and 0 deletions
@@ -0,0 +1,107 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { configFromEnvironment } from "../src/config.js";
import { SOURCE_PRIORITY } from "../src/rules/constants.js";
import { createEngine, defaultConfig, type EngineDeps } from "../src/rules/engine.js";
import { resolvePluginRulesRoot } from "../src/rules/plugin-root.js";
import type { RuleCandidate } from "../src/rules/types.js";
const projectRoot = "/tmp/codex-rules-bundled-priority";
const bundledPath = join(projectRoot, "bundled-rules", "hephaestus.md");
const homePath = join(projectRoot, "home", ".opencode", "rules", "hephaestus.md");
const bundledBody = "Bundled baseline discipline.";
const homeBody = "Home baseline discipline override.";
const tempDirectories: string[] = [];
afterEach(() => {
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function globalCandidate(source: "plugin-bundled" | "~/.opencode/rules", path: string): RuleCandidate {
return {
path,
realPath: path,
source,
distance: 9999,
isGlobal: true,
isSingleFile: false,
relativePath: source === "plugin-bundled" ? "bundled-rules/hephaestus.md" : ".opencode/rules/hephaestus.md",
};
}
function ruleMarkdown(body: string): string {
return [
"---",
"description: OMO Hephaestus baseline discipline for Codex",
"alwaysApply: true",
"---",
"",
body,
].join("\n");
}
describe("plugin bundled rule priority", () => {
it("#given bundled source explicitly enabled then disabled #when parsing env #then no sources remain enabled", () => {
// given / when
const config = configFromEnvironment({
CODEX_RULES_ENABLED_SOURCES: "plugin-bundled",
CODEX_RULES_DISABLE_BUNDLED: "1",
});
// then
expect(config.enabledSources).toEqual([]);
});
it("#given source priorities #when comparing user-home and bundled rules #then bundled has lower priority", () => {
// given / when / then
expect(SOURCE_PRIORITY.get("~/.opencode/rules")).toBe(101);
expect(SOURCE_PRIORITY.get("plugin-bundled")).toBe(200);
});
it("#given user-home and bundled rules share a description #when formatting static rules #then user-home wins", () => {
// given
const bundledCandidate = globalCandidate("plugin-bundled", bundledPath);
const homeCandidate = globalCandidate("~/.opencode/rules", homePath);
const deps = {
findProjectRoot: () => projectRoot,
findCandidates: () => [bundledCandidate, homeCandidate],
readFile: (path: string) => {
if (path === bundledPath) return ruleMarkdown(bundledBody);
if (path === homePath) return ruleMarkdown(homeBody);
return null;
},
} satisfies EngineDeps;
const engine = createEngine(defaultConfig(), deps);
// when
const loaded = engine.loadStaticRules(projectRoot);
const formatted = engine.formatStatic(loaded.rules);
// then
expect(formatted).toContain(homePath);
expect(formatted).toContain(homeBody);
expect(formatted).not.toContain(bundledPath);
expect(formatted).not.toContain(bundledBody);
});
it("#given aggregate plugin root #when resolving rules root #then components rules directory is selected", () => {
// given
const aggregateRoot = mkdtempSync(join(tmpdir(), "codex-rules-aggregate-plugin-"));
const componentRoot = join(aggregateRoot, "components", "rules");
tempDirectories.push(aggregateRoot);
mkdirSync(join(aggregateRoot, ".codex-plugin"), { recursive: true });
mkdirSync(componentRoot, { recursive: true });
writeFileSync(join(aggregateRoot, ".codex-plugin", "plugin.json"), JSON.stringify({ name: "omo" }));
// when
const resolvedRoot = resolvePluginRulesRoot(aggregateRoot);
// then
expect(resolvedRoot).toBe(componentRoot);
});
});
@@ -0,0 +1,268 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
type CodexPostCompactInput,
type CodexSessionStartInput,
runPostCompactHook,
runSessionStartHook,
runUserPromptSubmitHook,
} from "../src/codex-hook.js";
import { createRuleDiscoveryCache, findRuleCandidates } from "../src/rules/finder.js";
interface FixtureOptions {
readonly writeProjectDuplicate?: boolean;
}
interface Fixture {
readonly root: string;
readonly pluginRoot: string;
readonly pluginData: string;
readonly bundledRulePath: string;
readonly projectRulePath: string;
}
const BUNDLED_ONLY_ENV = {
CODEX_RULES_ENABLED_SOURCES: "plugin-bundled",
};
const PROJECT_AND_BUNDLED_ENV = {
CODEX_RULES_ENABLED_SOURCES: ".omo/rules,plugin-bundled",
};
const DISABLED_BUNDLED_ENV = {
CODEX_RULES_ENABLED_SOURCES: "plugin-bundled",
CODEX_RULES_DISABLE_BUNDLED: "1",
};
const BUNDLED_BODY = "Bundled craftsman baseline.";
const SHARED_BODY = "Always choose the smallest correct change.";
const tempDirectories: string[] = [];
let originalPluginRoot: string | undefined;
beforeEach(() => {
originalPluginRoot = process.env["PLUGIN_ROOT"];
});
afterEach(() => {
restoreEnv("PLUGIN_ROOT", originalPluginRoot);
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function makeFixture(options: FixtureOptions = {}): Fixture {
const root = mkdtempSync(join(tmpdir(), "codex-rules-bundled-project-"));
const pluginRoot = mkdtempSync(join(tmpdir(), "codex-rules-bundled-plugin-"));
const pluginData = mkdtempSync(join(tmpdir(), "codex-rules-bundled-data-"));
tempDirectories.push(root, pluginRoot, pluginData);
writeFileSync(join(root, "package.json"), JSON.stringify({ name: "fixture" }));
mkdirSync(join(root, ".omo", "rules"), { recursive: true });
mkdirSync(join(pluginRoot, "bundled-rules"), { recursive: true });
const bundledRulePath = join(pluginRoot, "bundled-rules", "hephaestus.md");
const bundledBody = options.writeProjectDuplicate === true ? SHARED_BODY : BUNDLED_BODY;
writeFileSync(bundledRulePath, ruleMarkdown(bundledBody));
const projectRulePath = join(root, ".omo", "rules", "hephaestus.md");
if (options.writeProjectDuplicate === true) {
writeFileSync(projectRulePath, ruleMarkdown(SHARED_BODY));
}
process.env["PLUGIN_ROOT"] = pluginRoot;
return { root, pluginRoot, pluginData, bundledRulePath, projectRulePath };
}
function ruleMarkdown(body: string): string {
return ["---", "description: Fixture", "alwaysApply: true", "---", "", body].join("\n");
}
function restoreEnv(name: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[name];
return;
}
process.env[name] = value;
}
function sessionStartInput(root: string): CodexSessionStartInput {
return {
session_id: "session-1",
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-1",
turn_id: "turn-compact",
transcript_path: null,
cwd: root,
hook_event_name: "PostCompact",
model: "gpt-5.5",
trigger: "manual",
};
}
function userPromptSubmitInput(root: string): Parameters<typeof runUserPromptSubmitHook>[0] {
return {
session_id: "session-1",
turn_id: "turn-1",
transcript_path: null,
cwd: root,
hook_event_name: "UserPromptSubmit",
model: "gpt-5.5",
permission_mode: "default",
prompt: "continue",
};
}
function occurrenceCount(value: string, search: string): number {
return value.split(search).length - 1;
}
describe("plugin bundled rules", () => {
it("#given PLUGIN_ROOT with bundled markdown #when finding candidates #then plugin-bundled source is cached", () => {
// given
const { pluginRoot } = makeFixture();
const cache = createRuleDiscoveryCache();
// when
const candidates = findRuleCandidates({ projectRoot: null, targetFile: null, skipUserHome: true, cache });
// then
expect(candidates.map((candidate) => `${candidate.source}:${candidate.relativePath}`)).toEqual([
"plugin-bundled:bundled-rules/hephaestus.md",
]);
expect(cache.scannedRuleFiles.has(join(pluginRoot, "bundled-rules"))).toBe(true);
});
it("#given alwaysApply bundled rule #when SessionStart runs #then static context includes it", async () => {
// given
const { root, pluginData } = makeFixture();
// when
const output = await runSessionStartHook(sessionStartInput(root), {
pluginDataRoot: pluginData,
env: BUNDLED_ONLY_ENV,
});
// then
expect(output).toContain('"hookEventName":"SessionStart"');
expect(output).toContain(BUNDLED_BODY);
});
it("#given same project and bundled body #when SessionStart runs #then project rule wins", async () => {
// given
const { root, pluginData, bundledRulePath, projectRulePath } = makeFixture({ writeProjectDuplicate: true });
// when
const output = await runSessionStartHook(sessionStartInput(root), {
pluginDataRoot: pluginData,
env: PROJECT_AND_BUNDLED_ENV,
});
// then
expect(occurrenceCount(output, SHARED_BODY)).toBe(1);
expect(output).toContain(projectRulePath);
expect(output).not.toContain(bundledRulePath);
});
it("#given bundled rules disabled #when SessionStart runs #then bundled context is suppressed", async () => {
// given
const { root, pluginData } = makeFixture();
// when
const output = await runSessionStartHook(sessionStartInput(root), {
pluginDataRoot: pluginData,
env: DISABLED_BUNDLED_ENV,
});
// then
expect(output).toBe("");
});
it("#given bundled static context already injected #when UserPromptSubmit runs after PostCompact #then it emits no duplicate bundled context", async () => {
// given
const { root, pluginData } = makeFixture();
const firstOutput = await runSessionStartHook(sessionStartInput(root), {
pluginDataRoot: pluginData,
env: BUNDLED_ONLY_ENV,
});
expect(firstOutput).toContain(BUNDLED_BODY);
// when
const compactOutput = await runPostCompactHook(postCompactInput(root), { pluginDataRoot: pluginData });
const output = await runUserPromptSubmitHook(userPromptSubmitInput(root), {
pluginDataRoot: pluginData,
env: BUNDLED_ONLY_ENV,
});
// then
expect(compactOutput).toBe("");
expect(output).toBe("");
});
it("#given bundled rule body exceeds per-rule cap #when SessionStart runs #then bundled body lands in full without truncation", async () => {
// given
const root = mkdtempSync(join(tmpdir(), "codex-rules-bundled-large-project-"));
const pluginRoot = mkdtempSync(join(tmpdir(), "codex-rules-bundled-large-plugin-"));
const pluginData = mkdtempSync(join(tmpdir(), "codex-rules-bundled-large-data-"));
tempDirectories.push(root, pluginRoot, pluginData);
writeFileSync(join(root, "package.json"), JSON.stringify({ name: "fixture" }));
mkdirSync(join(pluginRoot, "bundled-rules"), { recursive: true });
const oversizedBody = "The bundled craftsman discipline is non-negotiable. ".repeat(400);
expect(oversizedBody.length).toBeGreaterThan(12000);
const tailMarker = "BUNDLED_TAIL_SENTINEL_LANDS_IN_FULL";
const bundledBody = `${oversizedBody}\n\n${tailMarker}\n`;
writeFileSync(join(pluginRoot, "bundled-rules", "hephaestus.md"), ruleMarkdown(bundledBody));
process.env["PLUGIN_ROOT"] = pluginRoot;
// when
const output = await runSessionStartHook(sessionStartInput(root), {
pluginDataRoot: pluginData,
env: BUNDLED_ONLY_ENV,
});
// then
expect(output).toContain(tailMarker);
expect(output).not.toContain("[Truncated. Full:");
});
it("#given project rule body exceeds per-rule cap #when SessionStart runs #then project body is truncated", async () => {
// given
const root = mkdtempSync(join(tmpdir(), "codex-rules-project-large-project-"));
const pluginRoot = mkdtempSync(join(tmpdir(), "codex-rules-project-large-plugin-"));
const pluginData = mkdtempSync(join(tmpdir(), "codex-rules-project-large-data-"));
tempDirectories.push(root, pluginRoot, pluginData);
writeFileSync(join(root, "package.json"), JSON.stringify({ name: "fixture" }));
mkdirSync(join(root, ".omo", "rules"), { recursive: true });
mkdirSync(join(pluginRoot, "bundled-rules"), { recursive: true });
const oversizedBody = "The project rule body is intentionally oversized for the cap test. ".repeat(300);
expect(oversizedBody.length).toBeGreaterThan(12000);
const tailMarker = "PROJECT_TAIL_SENTINEL_SHOULD_NOT_LAND";
const projectBody = `${oversizedBody}\n\n${tailMarker}\n`;
writeFileSync(join(root, ".omo", "rules", "oversized.md"), ruleMarkdown(projectBody));
process.env["PLUGIN_ROOT"] = pluginRoot;
// when
const output = await runSessionStartHook(sessionStartInput(root), {
pluginDataRoot: pluginData,
env: { CODEX_RULES_ENABLED_SOURCES: ".omo/rules" },
});
// then
expect(output).toContain("[Truncated. Full: .omo/rules/oversized.md]");
expect(output).not.toContain(tailMarker);
});
});
@@ -0,0 +1,243 @@
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 { runPostToolUseHook, runUserPromptSubmitHook } from "../src/codex-hook.js";
const tempDirectories: string[] = [];
const PROJECT_ONLY_ENV = {
CODEX_RULES_ENABLED_SOURCES: "AGENTS.md,.omo/rules",
};
afterEach(() => {
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
describe("codex rules context-pressure recovery", () => {
it("#given context-pressure recovery prompt and empty static cache #when UserPromptSubmit runs #then it emits no static context", async () => {
// given
const { root, pluginData } = makeTempProject();
// when
const output = await runUserPromptSubmitHook(
{
...userPromptSubmitInput(root),
prompt: [
"Context compacted",
"error context_too_large: Your input exceeds the context window of this model.",
"Please adjust your input and try again.",
].join("\n"),
},
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(output).toBe("");
});
it("#given Codex canonical context-window prompt and empty static cache #when UserPromptSubmit runs #then it emits no static context", async () => {
// given
const { root, pluginData } = makeTempProject();
// when
const output = await runUserPromptSubmitHook(
{
...userPromptSubmitInput(root),
prompt: [
"error context_length_exceeded",
"Codex ran out of room in the model's context window. Start a new thread before retrying.",
].join("\n"),
},
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(output).toBe("");
});
it("#given context-pressure transcript and empty static cache #when UserPromptSubmit runs #then it emits no static context", async () => {
// given
const { root, pluginData } = makeTempProject();
const transcriptPath = writeContextPressureTranscript(root);
// when
const output = await runUserPromptSubmitHook(
{
...userPromptSubmitInput(root),
transcript_path: transcriptPath,
prompt: "continue",
},
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(output).toBe("");
});
it("#given Codex canonical context-window transcript and empty static cache #when UserPromptSubmit runs #then it emits no static context", async () => {
// given
const { root, pluginData } = makeTempProject();
const transcriptPath = writeCodexContextWindowTranscript(root);
// when
const output = await runUserPromptSubmitHook(
{
...userPromptSubmitInput(root),
transcript_path: transcriptPath,
prompt: "continue",
},
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(output).toBe("");
});
it("#given context-pressure transcript and empty dynamic cache #when PostToolUse runs #then it emits no dynamic context", async () => {
// given
const { root, pluginData } = makeTempProject();
const transcriptPath = writeContextPressureTranscript(root);
const filePath = path.join(root, "src", "app.ts");
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, "export const answer = 42;\n");
// when
const output = await runPostToolUseHook(
{
session_id: "session-context-pressure",
turn_id: "turn-1",
transcript_path: transcriptPath,
cwd: root,
hook_event_name: "PostToolUse",
model: "gpt-5.5",
permission_mode: "default",
tool_name: "mcp__filesystem__read_file",
tool_input: { path: filePath },
tool_response: { text: "export const answer = 42;" },
tool_use_id: "call-1",
},
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(output).toBe("");
});
it("#given Codex canonical context-window transcript and empty dynamic cache #when PostToolUse runs #then it emits no dynamic context", async () => {
// given
const { root, pluginData } = makeTempProject();
const transcriptPath = writeCodexContextWindowTranscript(root);
const filePath = path.join(root, "src", "app.ts");
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, "export const answer = 42;\n");
// when
const output = await runPostToolUseHook(
{
session_id: "session-context-pressure",
turn_id: "turn-1",
transcript_path: transcriptPath,
cwd: root,
hook_event_name: "PostToolUse",
model: "gpt-5.5",
permission_mode: "default",
tool_name: "mcp__filesystem__read_file",
tool_input: { path: filePath },
tool_response: { text: "export const answer = 42;" },
tool_use_id: "call-1",
},
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(output).toBe("");
});
});
function makeTempProject(): { readonly root: string; readonly pluginData: string } {
const root = mkdtempSync(path.join(tmpdir(), "codex-rules-context-pressure-project-"));
const pluginData = mkdtempSync(path.join(tmpdir(), "codex-rules-context-pressure-data-"));
tempDirectories.push(root, pluginData);
writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "fixture" }));
writeFileSync(path.join(root, "AGENTS.md"), "Always wear safety goggles when refactoring.");
mkdirSync(path.join(root, ".omo", "rules"), { recursive: true });
writeFileSync(
path.join(root, ".omo", "rules", "typescript.md"),
[
"---",
"description: TypeScript",
'globs: ["**/*.ts", "**/*.tsx"]',
"---",
"",
"Prefer strict TypeScript for all source files.",
].join("\n"),
);
return { root, pluginData };
}
function userPromptSubmitInput(root: string): Parameters<typeof runUserPromptSubmitHook>[0] {
return {
session_id: "session-context-pressure",
turn_id: "turn-1",
transcript_path: null,
cwd: root,
hook_event_name: "UserPromptSubmit",
model: "gpt-5.5",
permission_mode: "default",
prompt: "read src/app.ts",
};
}
function writeContextPressureTranscript(root: string): string {
const transcriptPath = path.join(root, "transcript-context-pressure.jsonl");
writeFileSync(
transcriptPath,
[
JSON.stringify({
type: "message",
payload: {
content: "Context compacted",
},
}),
JSON.stringify({
type: "message",
payload: {
content: "Your input exceeds the context window of this model.",
},
}),
"",
].join("\n"),
);
return transcriptPath;
}
function writeCodexContextWindowTranscript(root: string): string {
const transcriptPath = path.join(root, "transcript-codex-context-window.jsonl");
writeFileSync(
transcriptPath,
[
JSON.stringify({
type: "message",
payload: {
content: {
error: {
code: "context_length_exceeded",
},
},
},
}),
JSON.stringify({
type: "message",
payload: {
content:
"Codex ran out of room in the model's context window. Start a new thread or clear earlier history before retrying.",
},
}),
"",
].join("\n"),
);
return transcriptPath;
}
@@ -0,0 +1,99 @@
import fs from "node:fs";
import { syncBuiltinESMExports } from "node:module";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { CodexPostToolUseInput } from "../src/codex-hook.js";
const tempDirectories: string[] = [];
afterEach(() => {
for (const directory of tempDirectories.splice(0)) {
fs.rmSync(directory, { recursive: true, force: true });
}
});
function makeTempProject(ruleCount: number): { root: string; pluginData: string; targetPath: string } {
const root = fs.mkdtempSync(path.join(tmpdir(), "codex-rules-hook-perf-project-"));
const pluginData = fs.mkdtempSync(path.join(tmpdir(), "codex-rules-hook-perf-data-"));
tempDirectories.push(root, pluginData);
fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "fixture" }));
fs.mkdirSync(path.join(root, ".omo", "rules"), { recursive: true });
fs.mkdirSync(path.join(root, "src"), { recursive: true });
const targetPath = path.join(root, "src", "app.ts");
fs.writeFileSync(targetPath, "export const app = true;\n");
for (let index = 0; index < ruleCount; index += 1) {
fs.writeFileSync(
path.join(root, ".omo", "rules", `rule-${index}.md`),
["---", 'globs: "**/*.ts"', "---", "", `Rule ${index}`].join("\n"),
);
}
return { root, pluginData, targetPath };
}
function postToolUseInput(root: string, filePath: string): CodexPostToolUseInput {
return {
session_id: "session-1",
turn_id: "turn-1",
transcript_path: null,
cwd: root,
hook_event_name: "PostToolUse",
model: "gpt-5.5",
permission_mode: "default",
tool_name: "mcp__filesystem__read_file",
tool_input: { path: filePath },
tool_response: { text: "file contents" },
tool_use_id: "call-1",
};
}
function isProjectRuleRead(filePath: unknown): boolean {
return String(filePath).includes(`${path.sep}.omo${path.sep}rules${path.sep}`);
}
describe("codex rules hook performance", () => {
it("#given unchanged dynamic target #when PostToolUse repeats #then rule files are not reread for fingerprinting", async () => {
// given
const { root, pluginData, targetPath } = makeTempProject(3);
let ruleFileReads = 0;
const originalReadFileSync = fs.readFileSync;
const wrappedReadFileSync = ((...args: Parameters<typeof fs.readFileSync>) => {
if (isProjectRuleRead(args[0])) {
ruleFileReads += 1;
}
return originalReadFileSync(...args);
}) as typeof fs.readFileSync;
fs.readFileSync = wrappedReadFileSync;
syncBuiltinESMExports();
const { runPostToolUseHook } = await import("../src/codex-hook.js");
try {
// when
const firstOutput = await runPostToolUseHook(postToolUseInput(root, targetPath), {
pluginDataRoot: pluginData,
env: { CODEX_RULES_ENABLED_SOURCES: ".omo/rules" },
});
const firstRunRuleFileReads = ruleFileReads;
ruleFileReads = 0;
const secondOutput = await runPostToolUseHook(postToolUseInput(root, targetPath), {
pluginDataRoot: pluginData,
env: { CODEX_RULES_ENABLED_SOURCES: ".omo/rules" },
});
// then
expect(firstOutput).toContain("Rule 0");
expect(firstRunRuleFileReads).toBe(3);
expect(secondOutput).toBe("");
expect(ruleFileReads).toBe(0);
} finally {
fs.readFileSync = originalReadFileSync;
syncBuiltinESMExports();
}
});
});
@@ -0,0 +1,132 @@
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 already injected #when static recovery runs after compaction #then it emits no duplicate budget block", 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
expect(firstContext.length).toBeGreaterThan(20_000);
expect(output).toBe("");
});
});
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<typeof runUserPromptSubmitHook>[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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,156 @@
import { afterEach, describe, expect, it } from "vitest";
import { runPostCompactHook, runSessionStartHook } from "../src/codex-hook.js";
import {
cleanupPostCompactFixtures,
compactSessionStartInput,
EXPANDED_POST_COMPACT_ENV,
makeOversizedProject,
PROJECT_RULES_ENV,
postCompactInput,
readAdditionalContext,
readOptionalAdditionalContext,
writeCompactedTranscript,
writeCompactedWarningTranscript,
writeMalformedContextTooLargeTranscript,
} from "./post-compact-test-fixture.ts";
afterEach(() => {
cleanupPostCompactFixtures();
});
describe("codex rules compacted context recovery", () => {
it("#given compacted session source after PostCompact #when static rules re-inject #then output uses the compact recovery budget", async () => {
// given
const { root, pluginData } = makeOversizedProject("compact-source");
const transcriptPath = writeCompactedTranscript(root, "summary dropped injected rules");
await runPostCompactHook(
{ ...postCompactInput(root), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData },
);
// when
const output = await runSessionStartHook(compactSessionStartInput(root, transcriptPath), {
pluginDataRoot: pluginData,
env: PROJECT_RULES_ENV,
});
// then
const postCompactContext = readAdditionalContext(output);
expect(postCompactContext.length).toBeLessThan(5_000);
expect(postCompactContext).toContain("Instructions from:");
});
it("#given compacted context warning and near-full transcript #when compact source starts twice #then handles compacted context warning once", async () => {
// given
const { root, pluginData } = makeOversizedProject("warning-once");
const transcriptPath = writeCompactedWarningTranscript(root, "C".repeat(760_000));
await runPostCompactHook(
{ ...postCompactInput(root), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData },
);
// when
const firstOutput = await runSessionStartHook(compactSessionStartInput(root, transcriptPath), {
pluginDataRoot: pluginData,
env: EXPANDED_POST_COMPACT_ENV,
});
const secondOutput = await runSessionStartHook(compactSessionStartInput(root, transcriptPath), {
pluginDataRoot: pluginData,
env: EXPANDED_POST_COMPACT_ENV,
});
// then
const firstContext = readOptionalAdditionalContext(firstOutput);
expect(firstContext.length).toBeLessThan(1_000);
expect(firstContext).toContain("[Truncated. Full:");
expect(secondOutput).toBe("");
});
it("#given context-too-large marker with compacted small summary #when compact source starts #then emits emergency-sized context", async () => {
// given
const { root, pluginData } = makeOversizedProject("warning-small");
const transcriptPath = writeCompactedWarningTranscript(root, "small compacted summary");
await runPostCompactHook(
{ ...postCompactInput(root), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData },
);
// when
const output = await runSessionStartHook(compactSessionStartInput(root, transcriptPath), {
pluginDataRoot: pluginData,
env: EXPANDED_POST_COMPACT_ENV,
});
// then
const context = readOptionalAdditionalContext(output);
expect(context.length).toBeLessThan(1_000);
expect(context).toContain("[Truncated. Full:");
});
it("#given compact SessionStart without prior PostCompact state #when context-pressure transcript is present #then emits emergency-sized context", async () => {
// given
const { root, pluginData } = makeOversizedProject("compact-no-state");
const transcriptPath = writeCompactedWarningTranscript(root, "small compacted summary");
// when
const output = await runSessionStartHook(compactSessionStartInput(root, transcriptPath), {
pluginDataRoot: pluginData,
env: EXPANDED_POST_COMPACT_ENV,
});
// then
const context = readOptionalAdditionalContext(output);
expect(context.length).toBeLessThan(1_000);
expect(context).toContain("[Truncated. Full:");
});
it("#given malformed context-too-large transcript and empty session data #when compact source starts #then ignores malformed oversize markers safely", async () => {
// given
const { root, pluginData } = makeOversizedProject("malformed");
const transcriptPath = writeMalformedContextTooLargeTranscript(root, "D".repeat(760_000));
await runPostCompactHook(
{ ...postCompactInput(root), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData },
);
// when
const output = await runSessionStartHook(compactSessionStartInput(root, transcriptPath), {
pluginDataRoot: pluginData,
env: EXPANDED_POST_COMPACT_ENV,
});
// then
const context = readOptionalAdditionalContext(output);
expect(context.length).toBeLessThan(1_000);
expect(context).toContain("[Truncated. Full:");
});
it("#given concurrent compact SessionStart triggers #when both recover context-too-large state #then deduplicates concurrent context-too-large recovery", async () => {
// given
const { root, pluginData } = makeOversizedProject("concurrent");
const transcriptPath = writeCompactedWarningTranscript(root, "E".repeat(760_000));
await runPostCompactHook(
{ ...postCompactInput(root), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData },
);
// when
const outputs = await Promise.all([
runSessionStartHook(compactSessionStartInput(root, transcriptPath), {
pluginDataRoot: pluginData,
env: EXPANDED_POST_COMPACT_ENV,
}),
runSessionStartHook(compactSessionStartInput(root, transcriptPath), {
pluginDataRoot: pluginData,
env: EXPANDED_POST_COMPACT_ENV,
}),
]);
// then
const contexts = outputs.map(readOptionalAdditionalContext);
expect(contexts.filter((context) => context.length > 0)).toHaveLength(1);
expect(contexts.join("").length).toBeLessThan(1_000);
expect(contexts.join("")).toContain("[Truncated. Full:");
});
});
@@ -0,0 +1,299 @@
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 CodexPostToolUseInput,
type CodexSessionStartInput,
runPostCompactHook,
runPostToolUseHook,
runSessionStartHook,
runUserPromptSubmitHook,
} from "../src/codex-hook.js";
const tempDirectories: string[] = [];
const PROJECT_ONLY_ENV = {
CODEX_RULES_ENABLED_SOURCES: "AGENTS.md,.omo/rules",
};
afterEach(() => {
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
describe("codex rules PostCompact deduplication", () => {
it("#given compacted replacement already retained static context #when UserPromptSubmit runs after PostCompact #then it emits no duplicate static context", async () => {
// given
const { root, pluginData } = makeTempProject();
const firstOutput = await runSessionStartHook(sessionStartInput(root), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
const transcriptPath = writeTranscriptWithCompactedReplacement(root, readAdditionalContext(firstOutput));
await runPostCompactHook(
{ ...postCompactInput(root), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData },
);
// when
const output = await runUserPromptSubmitHook(userPromptSubmitInput(root, transcriptPath), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
// then
expect(output).toBe("");
});
it("#given compacted replacement already retained dynamic context #when PostToolUse runs after PostCompact #then it emits no duplicate dynamic context", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
const input = postToolUseInput(root, filePath);
const firstOutput = await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
const transcriptPath = writeTranscriptWithCompactedReplacement(root, readAdditionalContext(firstOutput));
await runPostCompactHook(
{ ...postCompactInput(root), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData },
);
// when
const output = await runPostToolUseHook(
{ ...input, transcript_path: transcriptPath },
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(output).toBe("");
});
it("#given malformed transcript with repeated compactions retaining context #when UserPromptSubmit runs after PostCompact #then it emits no duplicate static context", async () => {
// given
const { root, pluginData } = makeTempProject();
const firstOutput = await runSessionStartHook(sessionStartInput(root), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
const transcriptPath = writeTranscriptWithRepeatedCompactions(root, readAdditionalContext(firstOutput));
await runPostCompactHook(
{ ...postCompactInput(root), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData },
);
// when
const output = await runUserPromptSubmitHook(userPromptSubmitInput(root, transcriptPath), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
// then
expect(output).toBe("");
});
it("#given startup already injected static context #when UserPromptSubmit runs after PostCompact #then it emits no duplicate static context", async () => {
// given
const { root, pluginData } = makeTempProject();
await runSessionStartHook(sessionStartInput(root), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
const transcriptPath = writeTranscriptWithCompactedReplacement(root, "summary without project instructions");
await runPostCompactHook(
{ ...postCompactInput(root), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData },
);
// when
const output = await runUserPromptSubmitHook(userPromptSubmitInput(root, transcriptPath), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
// then
expect(output).toBe("");
});
it("#given startup already injected static context #when compact SessionStart runs after PostCompact #then it emits no duplicate static context", async () => {
// given
const { root, pluginData } = makeTempProject();
await runSessionStartHook(sessionStartInput(root), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
const transcriptPath = writeTranscriptWithCompactedReplacement(root, "summary without project instructions");
await runPostCompactHook(
{ ...postCompactInput(root), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData },
);
// when
const output = await runSessionStartHook(compactSessionStartInput(root, transcriptPath), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
// then
expect(output).toBe("");
});
});
function makeTempProject(): { root: string; pluginData: string } {
const root = mkdtempSync(path.join(tmpdir(), "codex-rules-compact-dedup-project-"));
const pluginData = mkdtempSync(path.join(tmpdir(), "codex-rules-compact-dedup-data-"));
tempDirectories.push(root, pluginData);
writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "fixture" }));
writeFileSync(path.join(root, "AGENTS.md"), "Always wear safety goggles when refactoring.");
mkdirSync(path.join(root, ".omo", "rules"), { recursive: true });
writeFileSync(
path.join(root, ".omo", "rules", "typescript.md"),
[
"---",
"description: TypeScript",
'globs: ["**/*.ts", "**/*.tsx"]',
"---",
"",
"Prefer strict TypeScript for all source files.",
].join("\n"),
);
mkdirSync(path.join(root, "src"), { recursive: true });
writeFileSync(path.join(root, "src", "app.ts"), "export const app = true;\n");
return { root, pluginData };
}
function sessionStartInput(root: string): CodexSessionStartInput {
return {
session_id: "session-compact-dedup",
transcript_path: null,
cwd: root,
hook_event_name: "SessionStart",
model: "gpt-5.5",
permission_mode: "default",
source: "startup",
};
}
function compactSessionStartInput(root: string, transcriptPath: string): CodexSessionStartInput {
return {
...sessionStartInput(root),
transcript_path: transcriptPath,
source: "compact",
};
}
function postCompactInput(root: string): CodexPostCompactInput {
return {
session_id: "session-compact-dedup",
turn_id: "turn-compact",
transcript_path: null,
cwd: root,
hook_event_name: "PostCompact",
model: "gpt-5.5",
trigger: "manual",
};
}
function userPromptSubmitInput(root: string, transcriptPath: string): Parameters<typeof runUserPromptSubmitHook>[0] {
return {
session_id: "session-compact-dedup",
turn_id: "turn-after-compact",
transcript_path: transcriptPath,
cwd: root,
hook_event_name: "UserPromptSubmit",
model: "gpt-5.5",
permission_mode: "default",
prompt: "read src/app.ts",
};
}
function postToolUseInput(root: string, filePath: string): CodexPostToolUseInput {
return {
session_id: "session-compact-dedup",
turn_id: "turn-after-compact",
transcript_path: null,
cwd: root,
hook_event_name: "PostToolUse",
model: "gpt-5.5",
permission_mode: "default",
tool_name: "mcp__filesystem__read_file",
tool_input: { path: filePath },
tool_response: { text: "file contents" },
tool_use_id: "call-1",
};
}
function writeTranscriptWithCompactedReplacement(root: string, ...replacementTexts: string[]): string {
const transcriptPath = path.join(root, "transcript-compacted.jsonl");
const replacementHistory = replacementTexts.map((text) => ({
type: "message",
role: "user",
content: [{ type: "input_text", text }],
}));
writeFileSync(
transcriptPath,
`${JSON.stringify({
type: "compacted",
payload: {
message: "summary",
replacement_history: replacementHistory,
},
})}\n`,
);
return transcriptPath;
}
function writeTranscriptWithRepeatedCompactions(root: string, retainedText: string): string {
const transcriptPath = path.join(root, "transcript-repeated-compacted.jsonl");
writeFileSync(
transcriptPath,
[
"{not json",
JSON.stringify({
type: "compacted",
payload: {
message: "older summary",
replacement_history: [{ type: "message", role: "user", content: "old summary without rules" }],
},
}),
JSON.stringify({
type: "message",
payload: { content: "x".repeat(10_000) },
}),
JSON.stringify({
type: "compacted",
payload: {
message: "latest summary",
replacement_history: [
{
type: "message",
role: "user",
content: [{ type: "input_text", text: retainedText }],
},
],
},
}),
JSON.stringify({
type: "message",
payload: { content: "later prompt after compact" },
}),
"",
].join("\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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,46 @@
import { mkdirSync, rmSync } from "node:fs";
import { afterEach, describe, expect, it } from "vitest";
import { runPostCompactHook, runSessionStartHook } from "../src/codex-hook.js";
import { sessionCachePath } from "../src/persistent-cache.js";
import {
cleanupPostCompactFixtures,
compactSessionStartInput,
EXPANDED_POST_COMPACT_ENV,
makeOversizedProject,
postCompactInput,
writeCompactedWarningTranscript,
} from "./post-compact-test-fixture.ts";
const SESSION_ID = "session-post-compact-lock";
afterEach(() => {
cleanupPostCompactFixtures();
});
describe("codex rules post-compact lock contention", () => {
it("#given compacted session state while cache lock is contended #when compact source starts #then skips fail-open rule injection", async () => {
// given
const { root, pluginData } = makeOversizedProject("lock");
const transcriptPath = writeCompactedWarningTranscript(root, "F".repeat(760_000));
await runPostCompactHook(
{ ...postCompactInput(root, SESSION_ID), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData },
);
const lockPath = `${sessionCachePath(SESSION_ID, pluginData)}.lock`;
mkdirSync(lockPath);
try {
// when
const output = await runSessionStartHook(compactSessionStartInput(root, transcriptPath, SESSION_ID), {
pluginDataRoot: pluginData,
env: EXPANDED_POST_COMPACT_ENV,
});
// then
expect(output).toBe("");
} finally {
rmSync(lockPath, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,83 @@
import { execFileSync, spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { runPostCompactHook } from "../src/codex-hook.js";
import {
cleanupPostCompactFixtures,
compactSessionStartInput,
EXPANDED_POST_COMPACT_ENV,
makeOversizedProject,
postCompactInput,
readOptionalAdditionalContext,
writeCompactedWarningTranscript,
} from "./post-compact-test-fixture.ts";
type CliResult = {
readonly exitCode: number | null;
readonly stdout: string;
readonly stderr: string;
};
const PLUGIN_ROOT = fileURLToPath(new URL("..", import.meta.url));
const CLI_PATH = fileURLToPath(new URL("../dist/cli.js", import.meta.url));
beforeAll(() => {
execFileSync("npm", ["run", "build", "--silent"], { cwd: PLUGIN_ROOT, stdio: "pipe" });
});
afterAll(() => {
cleanupPostCompactFixtures();
});
describe("codex rules post-compact cross-process recovery", () => {
it("#given two compact hook processes share session state #when both start concurrently #then only one emits the budgeted recovery context", async () => {
// given
const { root, pluginData } = makeOversizedProject("process");
const transcriptPath = writeCompactedWarningTranscript(root, "G".repeat(760_000));
await runPostCompactHook(
{ ...postCompactInput(root), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData },
);
const input = `${JSON.stringify(compactSessionStartInput(root, transcriptPath))}\n`;
// when
const [first, second] = await Promise.all([
runHookCli(input, "session-start", { ...EXPANDED_POST_COMPACT_ENV, PLUGIN_DATA: pluginData }),
runHookCli(input, "session-start", { ...EXPANDED_POST_COMPACT_ENV, PLUGIN_DATA: pluginData }),
]);
// then
expect(first.exitCode).toBe(0);
expect(second.exitCode).toBe(0);
expect(first.stderr).toBe("");
expect(second.stderr).toBe("");
const contexts = [first.stdout, second.stdout].map(readOptionalAdditionalContext);
expect(contexts.filter((context) => context.length > 0)).toHaveLength(1);
expect(contexts.join("").length).toBeLessThan(1_000);
});
});
function runHookCli(input: string, subcommand: string, env: NodeJS.ProcessEnv): Promise<CliResult> {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [CLI_PATH, "hook", subcommand], {
env: { ...process.env, ...env },
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.once("error", reject);
child.once("close", (exitCode) => {
resolve({ exitCode, stdout, stderr });
});
child.stdin.end(input);
});
}
@@ -0,0 +1,667 @@
import { spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import {
type CodexPostCompactInput,
type CodexPostToolUseInput,
type CodexSessionStartInput,
runPostCompactHook,
runPostToolUseHook,
runSessionStartHook,
runUserPromptSubmitHook,
} from "../src/codex-hook.js";
type CliResult = {
exitCode: number | null;
stdout: string;
stderr: string;
};
type SessionCache = {
staticDedup?: string[];
dynamicDedup?: Record<string, string[]>;
dynamicTargetFingerprints?: Record<string, string>;
};
const CLI_PATH = fileURLToPath(new URL("../dist/cli.js", import.meta.url));
function runHookCli(input: string, subcommand = "post-tool-use", env: NodeJS.ProcessEnv = {}): Promise<CliResult> {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [CLI_PATH, "hook", subcommand], {
env: { ...process.env, ...env },
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.once("error", reject);
child.once("close", (exitCode) => {
resolve({ exitCode, stdout, stderr });
});
child.stdin.end(input);
});
}
const tempDirectories: string[] = [];
const PROJECT_ONLY_ENV = {
CODEX_RULES_ENABLED_SOURCES: "AGENTS.md,.omo/rules",
};
const RULES_ONLY_ENV = {
CODEX_RULES_ENABLED_SOURCES: ".omo/rules",
};
afterEach(() => {
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function makeTempProject(): { root: string; pluginData: string } {
const root = mkdtempSync(path.join(tmpdir(), "codex-rules-project-"));
const pluginData = mkdtempSync(path.join(tmpdir(), "codex-rules-data-"));
tempDirectories.push(root, pluginData);
writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "fixture" }));
writeFileSync(path.join(root, "AGENTS.md"), "Always wear safety goggles when refactoring.");
mkdirSync(path.join(root, ".omo", "rules"), { recursive: true });
writeFileSync(
path.join(root, ".omo", "rules", "typescript.md"),
[
"---",
"description: TypeScript",
'globs: ["**/*.ts", "**/*.tsx"]',
"---",
"",
"Prefer strict TypeScript for all source files.",
].join("\n"),
);
mkdirSync(path.join(root, "src"), { recursive: true });
writeFileSync(path.join(root, "src", "app.ts"), "export const app = true;\n");
writeFileSync(path.join(root, "src", "other.ts"), "export const other = true;\n");
return { root, pluginData };
}
function sessionStartInput(root: string): CodexSessionStartInput {
return {
session_id: "session-1",
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-1",
turn_id: "turn-compact",
transcript_path: null,
cwd: root,
hook_event_name: "PostCompact",
model: "gpt-5.5",
trigger: "manual",
};
}
function userPromptSubmitInput(
root: string,
transcriptPath: string | null = null,
): Parameters<typeof runUserPromptSubmitHook>[0] {
return {
session_id: "session-1",
turn_id: "turn-1",
transcript_path: transcriptPath,
cwd: root,
hook_event_name: "UserPromptSubmit",
model: "gpt-5.5",
permission_mode: "default",
prompt: "read src/app.ts",
};
}
function postToolUseInput(root: string, filePath: string): CodexPostToolUseInput {
return {
session_id: "session-1",
turn_id: "turn-1",
transcript_path: null,
cwd: root,
hook_event_name: "PostToolUse",
model: "gpt-5.5",
permission_mode: "default",
tool_name: "mcp__filesystem__read_file",
tool_input: { path: filePath },
tool_response: { text: "file contents" },
tool_use_id: "call-1",
};
}
function parseHookOutput(output: string): {
hookSpecificOutput?: {
hookEventName?: string;
additionalContext?: string;
};
} {
expect(output.trim().length).toBeGreaterThan(0);
return JSON.parse(output) as {
hookSpecificOutput?: {
hookEventName?: string;
additionalContext?: string;
};
};
}
function writeTranscriptWithContext(root: string, ...additionalContexts: string[]): string {
const transcriptPath = path.join(root, "transcript.jsonl");
writeFileSync(
transcriptPath,
`${additionalContexts
.map((additionalContext) => JSON.stringify({ hookSpecificOutput: { additionalContext } }))
.join("\n")}\n`,
);
return transcriptPath;
}
function occurrenceCount(value: string, search: string): number {
return value.split(search).length - 1;
}
function sessionCacheFilePath(pluginData: string, sessionId = "session-1"): string {
return path.join(pluginData, "sessions", `${sessionId}.json`);
}
function readSessionCache(pluginData: string): SessionCache {
return JSON.parse(readFileSync(sessionCacheFilePath(pluginData), "utf8")) as SessionCache;
}
function writeTypeScriptRule(root: string, globExpression: string, body: string): void {
writeFileSync(
path.join(root, ".omo", "rules", "typescript.md"),
["---", "description: TypeScript", `globs: ${globExpression}`, "---", "", body].join("\n"),
);
}
describe("codex rules hooks", () => {
it("#given project rules #when SessionStart runs #then emits static additional context", async () => {
// given
const { root, pluginData } = makeTempProject();
// when
const output = await runSessionStartHook(sessionStartInput(root), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
// then
const parsed = parseHookOutput(output);
expect(parsed.hookSpecificOutput?.hookEventName).toBe("SessionStart");
expect(parsed.hookSpecificOutput?.additionalContext).toContain("## Project Instructions");
expect(parsed.hookSpecificOutput?.additionalContext).toContain("Always wear safety goggles");
});
it("#given static context already injected #when UserPromptSubmit runs #then it emits no duplicate context", async () => {
// given
const { root, pluginData } = makeTempProject();
await runSessionStartHook(sessionStartInput(root), { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
// when
const output = await runUserPromptSubmitHook(
{
session_id: "session-1",
turn_id: "turn-1",
transcript_path: null,
cwd: root,
hook_event_name: "UserPromptSubmit",
model: "gpt-5.5",
permission_mode: "default",
prompt: "read src/app.ts",
},
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(output).toBe("");
});
it("#given resumed session #when SessionStart runs #then it preserves the session cache", async () => {
// given
const { root, pluginData } = makeTempProject();
const input = sessionStartInput(root);
await runSessionStartHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
// when
const resumeOutput = await runSessionStartHook(
{ ...input, source: "resume" },
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
const clearOutput = await runSessionStartHook(
{ ...input, source: "clear" },
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(resumeOutput).toBe("");
expect(parseHookOutput(clearOutput).hookSpecificOutput?.additionalContext).toContain(
"Always wear safety goggles",
);
});
it("#given static context remains in transcript but cache is missing #when SessionStart runs #then it emits no duplicate context", async () => {
// given
const { root, pluginData } = makeTempProject();
const firstOutput = await runSessionStartHook(sessionStartInput(root), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
const firstContext = parseHookOutput(firstOutput).hookSpecificOutput?.additionalContext ?? "";
const transcriptPath = writeTranscriptWithContext(root, firstContext);
rmSync(sessionCacheFilePath(pluginData), { force: true });
// when
const output = await runSessionStartHook(
{ ...sessionStartInput(root), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(output).toBe("");
expect(readSessionCache(pluginData).staticDedup).toHaveLength(1);
});
it("#given read-file tool result #when PostToolUse runs #then emits matching dynamic rule context", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
// when
const output = await runPostToolUseHook(postToolUseInput(root, filePath), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
// then
// The literal "src/app.ts" pins POSIX separators and acts as the Windows
// regression line: prior versions emitted "src\\app.ts" on Windows.
const parsed = parseHookOutput(output);
expect(parsed.hookSpecificOutput?.hookEventName).toBe("PostToolUse");
expect(parsed.hookSpecificOutput?.additionalContext).toContain(
"Additional project instructions matched for src/app.ts",
);
expect(parsed.hookSpecificOutput?.additionalContext).toContain("Prefer strict TypeScript");
expect(parsed.hookSpecificOutput?.additionalContext ?? "").not.toContain("src\\app.ts");
expect(output).not.toContain("updatedMCPToolOutput");
expect(output).not.toContain("suppressOutput");
expect(output).not.toContain('"decision"');
});
it("#given multiple target paths matching one rule #when PostToolUse runs #then emits dynamic context once for the first target", async () => {
// given
const { root, pluginData } = makeTempProject();
const firstFilePath = path.join(root, "src", "app.ts");
const secondFilePath = path.join(root, "src", "other.ts");
// when
const output = await runPostToolUseHook(
{
...postToolUseInput(root, firstFilePath),
tool_name: "mcp__filesystem__read_multiple_files",
tool_input: { paths: [firstFilePath, secondFilePath, firstFilePath] },
},
{
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
},
);
// then
const parsed = parseHookOutput(output);
const additionalContext = parsed.hookSpecificOutput?.additionalContext ?? "";
expect(parsed.hookSpecificOutput?.hookEventName).toBe("PostToolUse");
expect(additionalContext).toContain("Additional project instructions matched for src/app.ts");
expect(additionalContext).not.toContain("src\\app.ts");
expect(occurrenceCount(additionalContext, "Prefer strict TypeScript")).toBe(1);
});
it("#given dynamic context already injected #when PostToolUse repeats #then emits no duplicate context", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
const input = postToolUseInput(root, filePath);
await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
const cachedState = readSessionCache(pluginData);
// when
const output = await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
// then
expect(output).toBe("");
expect(Object.keys(cachedState.dynamicTargetFingerprints ?? {})).toHaveLength(1);
expect(readSessionCache(pluginData).dynamicTargetFingerprints).toEqual(cachedState.dynamicTargetFingerprints);
});
it("#given dynamic context remains in transcript but cache is missing #when PostToolUse repeats #then it emits no duplicate context", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
const input = postToolUseInput(root, filePath);
const firstOutput = await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
const firstContext = parseHookOutput(firstOutput).hookSpecificOutput?.additionalContext ?? "";
const transcriptPath = writeTranscriptWithContext(root, firstContext);
rmSync(sessionCacheFilePath(pluginData), { force: true });
// when
const output = await runPostToolUseHook(
{ ...input, transcript_path: transcriptPath },
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
const cachedState = readSessionCache(pluginData);
expect(output).toBe("");
expect(Object.values(cachedState.dynamicDedup ?? {}).flat()).toHaveLength(2);
expect(Object.keys(cachedState.dynamicTargetFingerprints ?? {})).toHaveLength(1);
});
it("#given cached target in one session #when another session reads it #then PostToolUse rechecks independently", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
await runPostToolUseHook(postToolUseInput(root, filePath), { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
// when
const output = await runPostToolUseHook(
{ ...postToolUseInput(root, filePath), session_id: "session-2" },
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(parseHookOutput(output).hookSpecificOutput?.additionalContext).toContain("Prefer strict TypeScript");
});
it("#given cached dynamic target #when rule frontmatter changes #then PostToolUse rechecks the target", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
const input = postToolUseInput(root, filePath);
await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: RULES_ONLY_ENV });
writeTypeScriptRule(root, '"**/*.ts"', "Prefer readonly TypeScript after rule edits.");
// when
const output = await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: RULES_ONLY_ENV });
// then
expect(parseHookOutput(output).hookSpecificOutput?.additionalContext).toContain(
"Prefer readonly TypeScript after rule edits.",
);
});
it("#given cached dynamic context #when PostCompact runs #then PostToolUse emits no duplicate dynamic context", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
const input = postToolUseInput(root, filePath);
const firstOutput = await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
const firstContext = parseHookOutput(firstOutput).hookSpecificOutput?.additionalContext ?? "";
const transcriptPath = writeTranscriptWithContext(root, firstContext);
expect(
await runPostToolUseHook(
{ ...input, transcript_path: transcriptPath },
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
),
).toBe("");
// when
const compactOutput = await runPostCompactHook(
{ ...postCompactInput(root), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData },
);
const output = await runPostToolUseHook(
{ ...input, transcript_path: transcriptPath },
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(compactOutput).toBe("");
expect(output).toBe("");
});
it("#given cached static and dynamic context #when static recovery runs before dynamic #then neither emits duplicate context", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
const staticOutput = await runSessionStartHook(sessionStartInput(root), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
const dynamicOutput = await runPostToolUseHook(postToolUseInput(root, filePath), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
const transcriptPath = writeTranscriptWithContext(
root,
parseHookOutput(staticOutput).hookSpecificOutput?.additionalContext ?? "",
parseHookOutput(dynamicOutput).hookSpecificOutput?.additionalContext ?? "",
);
await runPostCompactHook(
{ ...postCompactInput(root), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData },
);
// when
const staticReinjectOutput = await runUserPromptSubmitHook(userPromptSubmitInput(root, transcriptPath), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
const dynamicReinjectOutput = await runPostToolUseHook(
{ ...postToolUseInput(root, filePath), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(staticReinjectOutput).toBe("");
expect(dynamicReinjectOutput).toBe("");
});
it("#given cached static and dynamic context #when dynamic recovery runs before static #then neither emits duplicate context", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
const staticOutput = await runSessionStartHook(sessionStartInput(root), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
const dynamicOutput = await runPostToolUseHook(postToolUseInput(root, filePath), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
const transcriptPath = writeTranscriptWithContext(
root,
parseHookOutput(staticOutput).hookSpecificOutput?.additionalContext ?? "",
parseHookOutput(dynamicOutput).hookSpecificOutput?.additionalContext ?? "",
);
await runPostCompactHook(
{ ...postCompactInput(root), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData },
);
// when
const dynamicReinjectOutput = await runPostToolUseHook(
{ ...postToolUseInput(root, filePath), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
const staticReinjectOutput = await runUserPromptSubmitHook(userPromptSubmitInput(root, transcriptPath), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
// then
expect(dynamicReinjectOutput).toBe("");
expect(staticReinjectOutput).toBe("");
});
it("#given legacy session cache #when PostToolUse hydrates state #then it accepts the old shape", async () => {
// given
const { root, pluginData } = makeTempProject();
mkdirSync(path.join(pluginData, "sessions"), { recursive: true });
writeFileSync(sessionCacheFilePath(pluginData), `${JSON.stringify({ staticDedup: [], dynamicDedup: {} })}\n`);
// when
const output = await runPostToolUseHook(postToolUseInput(root, path.join(root, "src", "app.ts")), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
// then
expect(parseHookOutput(output).hookSpecificOutput?.additionalContext).toContain("Prefer strict TypeScript");
});
it("#given static-only mode #when PostToolUse runs #then emits no dynamic context", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
// when
const output = await runPostToolUseHook(postToolUseInput(root, filePath), {
pluginDataRoot: pluginData,
env: {
...PROJECT_ONLY_ENV,
CODEX_RULES_MODE: "static",
},
});
// then
expect(output).toBe("");
});
it("#given rules disabled #when PostToolUse runs #then emits no dynamic context", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
// when
const output = await runPostToolUseHook(postToolUseInput(root, filePath), {
pluginDataRoot: pluginData,
env: {
...PROJECT_ONLY_ENV,
CODEX_RULES_DISABLED: "true",
},
});
// then
expect(output).toBe("");
});
it("#given failed tool response #when PostToolUse runs #then emits no dynamic context", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
// when
const output = await runPostToolUseHook(
{
...postToolUseInput(root, filePath),
tool_response: { is_error: true },
},
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(output).toBe("");
});
it("#given tracked tool without path #when PostToolUse runs #then emits no dynamic context", async () => {
// given
const { root, pluginData } = makeTempProject();
// when
const output = await runPostToolUseHook(
{
...postToolUseInput(root, ""),
tool_input: {},
},
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(output).toBe("");
});
it("#given malformed post-tool-use stdin #when hook CLI runs #then it no-ops without stderr", async () => {
// given
const input = "break;\n";
// when
const result = await runHookCli(input);
// then
expect(result).toEqual({
exitCode: 0,
stdout: "",
stderr: "",
});
});
it("#given non-object post-tool-use JSON #when hook CLI runs #then it no-ops without stderr", async () => {
// given
const input = "[]\n";
// when
const result = await runHookCli(input);
// then
expect(result).toEqual({
exitCode: 0,
stdout: "",
stderr: "",
});
});
it("#given debug timing enabled #when PostToolUse hook CLI runs #then phase logs go to stderr only", async () => {
// given
const { root, pluginData } = makeTempProject();
const input = `${JSON.stringify(postToolUseInput(root, path.join(root, "src", "app.ts")))}\n`;
// when
const result = await runHookCli(input, "post-tool-use", {
NODE_DEBUG: "codex-rules",
PLUGIN_DATA: pluginData,
});
// then
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("hookSpecificOutput");
expect(result.stderr).toContain("PostToolUse");
expect(result.stderr).toContain("extract");
expect(result.stderr).toContain("fingerprint");
expect(result.stderr).toContain("load");
expect(result.stderr).toContain("persist");
expect(result.stderr).toContain("ms");
});
it("#given malformed post-compact stdin #when hook CLI runs #then it no-ops without stderr", async () => {
// given
const input = `${JSON.stringify({ hook_event_name: "PostCompact", session_id: "s", turn_id: "t" })}\n`;
// when
const result = await runHookCli(input, "post-compact");
// then
expect(result).toEqual({
exitCode: 0,
stdout: "",
stderr: "",
});
});
});
@@ -0,0 +1,192 @@
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { createEngine, defaultConfig, type EngineDeps } from "../src/rules/engine.js";
import { matchRule as defaultMatchRule } from "../src/rules/matcher.js";
import type { RuleCandidate } from "../src/rules/types.js";
const projectRoot = "/tmp/codex-rules-engine";
function makeCandidate(): RuleCandidate {
return {
path: join(projectRoot, ".omo", "rules", "typescript.md"),
realPath: join(projectRoot, ".omo", "rules", "typescript.md"),
source: ".omo/rules",
distance: 0,
isGlobal: false,
isSingleFile: false,
relativePath: ".omo/rules/typescript.md",
};
}
describe("rule engine dynamic matching", () => {
it("#given duplicate target paths #when loading dynamic rules #then repeated discovery and parsing work is avoided", () => {
// given
const targetPath = join(projectRoot, "src", "app.ts");
const candidate = makeCandidate();
const counters = {
findProjectRoot: 0,
findCandidates: 0,
readFile: 0,
};
const deps = {
findProjectRoot: () => {
counters.findProjectRoot += 1;
return projectRoot;
},
findCandidates: () => {
counters.findCandidates += 1;
return [candidate];
},
readFile: () => {
counters.readFile += 1;
return ["---", "globs: **/*.ts", "---", "", "Prefer strict TypeScript."].join("\n");
},
} satisfies EngineDeps;
const engine = createEngine(defaultConfig(), deps);
// when
const result = engine.loadDynamicRules(projectRoot, [targetPath, targetPath, targetPath]);
// then
expect(result.rules).toHaveLength(1);
expect(counters).toEqual({
findProjectRoot: 1,
findCandidates: 1,
readFile: 1,
});
});
it("#given distinct target files in same directory #when loading dynamic rules #then candidate discovery is reused", () => {
// given
const firstTarget = join(projectRoot, "src", "first.ts");
const secondTarget = join(projectRoot, "src", "second.ts");
const thirdTarget = join(projectRoot, "src", "third.ts");
const candidate = makeCandidate();
let findCandidatesCalls = 0;
const deps = {
findProjectRoot: () => projectRoot,
findCandidates: () => {
findCandidatesCalls += 1;
return [candidate];
},
readFile: () => ["---", "globs: **/*.ts", "---", "", "Prefer strict TypeScript."].join("\n"),
} satisfies EngineDeps;
const engine = createEngine(defaultConfig(), deps);
// when
const result = engine.loadDynamicRules(projectRoot, [firstTarget, secondTarget, thirdTarget]);
// then
expect(result.rules).toHaveLength(1);
expect(findCandidatesCalls).toBe(1);
});
it("#given same rule content and target across loads #when loading dynamic rules repeats #then cached match decision is reused", () => {
// given
const targetPath = join(projectRoot, "src", "app.ts");
const candidate = makeCandidate();
let matchCalls = 0;
const deps = {
findProjectRoot: () => projectRoot,
findCandidates: () => [candidate],
readFile: () => ["---", "globs: **/*.ts", "---", "", "Prefer strict TypeScript."].join("\n"),
matchRule: (input) => {
matchCalls += 1;
return defaultMatchRule(input);
},
} satisfies EngineDeps;
const engine = createEngine(defaultConfig(), deps);
// when
const firstResult = engine.loadDynamicRules(projectRoot, [targetPath]);
const secondResult = engine.loadDynamicRules(projectRoot, [targetPath]);
// then
expect(firstResult.rules).toHaveLength(1);
expect(secondResult.rules).toHaveLength(1);
expect(matchCalls).toBe(1);
});
it("#given same rule path changes body #when loading dynamic rules repeats #then cached match decision invalidates", () => {
// given
const targetPath = join(projectRoot, "src", "app.ts");
const candidate = makeCandidate();
let body = "Prefer strict TypeScript.";
let matchCalls = 0;
const deps = {
findProjectRoot: () => projectRoot,
findCandidates: () => [candidate],
readFile: () => ["---", "globs: **/*.ts", "---", "", body].join("\n"),
matchRule: (input) => {
matchCalls += 1;
return defaultMatchRule(input);
},
} satisfies EngineDeps;
const engine = createEngine(defaultConfig(), deps);
// when
engine.loadDynamicRules(projectRoot, [targetPath]);
body = "Prefer readonly TypeScript.";
engine.loadDynamicRules(projectRoot, [targetPath]);
// then
expect(matchCalls).toBe(2);
});
it("#given same rule path changes frontmatter #when loading dynamic rules repeats #then cached match decision invalidates", () => {
// given
const targetPath = join(projectRoot, "src", "app.ts");
const candidate = makeCandidate();
let globs = "**/*.ts";
let matchCalls = 0;
const deps = {
findProjectRoot: () => projectRoot,
findCandidates: () => [candidate],
readFile: () => ["---", `globs: ${globs}`, "---", "", "Prefer strict TypeScript."].join("\n"),
matchRule: (input) => {
matchCalls += 1;
return defaultMatchRule(input);
},
} satisfies EngineDeps;
const engine = createEngine(defaultConfig(), deps);
// when
const firstResult = engine.loadDynamicRules(projectRoot, [targetPath]);
globs = "**/*.tsx";
const secondResult = engine.loadDynamicRules(projectRoot, [targetPath]);
// then
expect(firstResult.rules).toHaveLength(1);
expect(secondResult.rules).toHaveLength(0);
expect(matchCalls).toBe(2);
});
it("#given same rule and different targets #when loading dynamic rules repeats #then target-specific decisions do not leak", () => {
// given
const sourceTarget = join(projectRoot, "src", "app.ts");
const testTarget = join(projectRoot, "src", "app.test.ts");
const candidate = makeCandidate();
let matchCalls = 0;
const deps = {
findProjectRoot: () => projectRoot,
findCandidates: () => [candidate],
readFile: () =>
["---", 'globs: ["**/*.ts", "!**/*.test.ts"]', "---", "", "Prefer strict TypeScript."].join("\n"),
matchRule: (input) => {
matchCalls += 1;
return defaultMatchRule(input);
},
} satisfies EngineDeps;
const engine = createEngine(defaultConfig(), deps);
// when
const sourceResult = engine.loadDynamicRules(projectRoot, [sourceTarget]);
const testResult = engine.loadDynamicRules(projectRoot, [testTarget]);
// then
expect(sourceResult.rules).toHaveLength(1);
expect(testResult.rules).toHaveLength(0);
expect(matchCalls).toBe(2);
});
});
@@ -0,0 +1,102 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { findRuleCandidates } from "../src/rules/finder.js";
import type { RuleCandidate } from "../src/rules/types.js";
const tempDirectories: string[] = [];
afterEach(() => {
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function makeProject(): { projectRoot: string; homeRoot: string; targetPath: string } {
const projectRoot = mkdtempSync(join(tmpdir(), "codex-rules-finder-project-"));
const homeRoot = mkdtempSync(join(tmpdir(), "codex-rules-finder-home-"));
tempDirectories.push(projectRoot, homeRoot);
mkdirSync(join(projectRoot, "src", ".omo", "rules"), { recursive: true });
mkdirSync(join(projectRoot, ".omo", "rules"), { recursive: true });
mkdirSync(join(homeRoot, ".opencode", "rules"), { recursive: true });
mkdirSync(join(homeRoot, ".config", "opencode"), { recursive: true });
writeFileSync(join(projectRoot, "package.json"), JSON.stringify({ name: "fixture" }));
writeFileSync(join(projectRoot, "AGENTS.md"), "Project rule\n");
writeFileSync(join(projectRoot, "src", ".omo", "rules", "local.md"), "Local rule\n");
writeFileSync(join(projectRoot, ".omo", "rules", "root.md"), "Root rule\n");
writeFileSync(join(homeRoot, ".opencode", "rules", "global.md"), "Global rule\n");
writeFileSync(join(homeRoot, ".config", "opencode", "AGENTS.md"), "Home rule\n");
const targetPath = join(projectRoot, "src", "app.ts");
writeFileSync(targetPath, "export const app = true;\n");
return { projectRoot, homeRoot, targetPath };
}
function candidateSummary(candidate: RuleCandidate): string {
return `${candidate.source}:${candidate.distance}:${candidate.relativePath}`;
}
describe("findRuleCandidates", () => {
it("#given project and user-home rules #when target file is inside project #then candidates keep source distance", () => {
// given
const { projectRoot, homeRoot, targetPath } = makeProject();
// when
const candidates = findRuleCandidates({
projectRoot,
targetFile: targetPath,
homeDir: homeRoot,
disabledSources: new Set(["plugin-bundled"]),
});
// then
expect(candidates.map(candidateSummary)).toEqual([
".omo/rules:0:src/.omo/rules/local.md",
".omo/rules:1:.omo/rules/root.md",
"AGENTS.md:1:AGENTS.md",
"~/.opencode/rules:9999:.opencode/rules/global.md",
"~/.config/opencode/AGENTS.md:9999:.config/opencode/AGENTS.md",
]);
});
it("#given disabled source #when finding candidates #then matching source is omitted", () => {
// given
const { projectRoot, homeRoot, targetPath } = makeProject();
// when
const candidates = findRuleCandidates({
projectRoot,
targetFile: targetPath,
homeDir: homeRoot,
disabledSources: new Set([".omo/rules", "~/.opencode/rules", "plugin-bundled"]),
});
// then
expect(candidates.map(candidateSummary)).toEqual([
"AGENTS.md:1:AGENTS.md",
"~/.config/opencode/AGENTS.md:9999:.config/opencode/AGENTS.md",
]);
});
it("#given skip user home #when finding candidates #then only project rules are returned", () => {
// given
const { projectRoot, homeRoot, targetPath } = makeProject();
// when
const candidates = findRuleCandidates({
projectRoot,
targetFile: targetPath,
homeDir: homeRoot,
skipUserHome: true,
disabledSources: new Set(["plugin-bundled"]),
});
// then
expect(candidates.map(candidateSummary)).toEqual([
".omo/rules:0:src/.omo/rules/local.md",
".omo/rules:1:.omo/rules/root.md",
"AGENTS.md:1:AGENTS.md",
]);
});
});
@@ -0,0 +1,168 @@
import { describe, expect, it } from "vitest";
import { formatDynamicBlock, formatStaticBlock } from "../src/rules/formatter.js";
import type { LoadedRule, MatchReason, RuleSource } from "../src/rules/types.js";
const FORMAT_OPTIONS = {
maxRuleChars: 10_000,
maxResultChars: 10_000,
};
describe("rules formatter hook context", () => {
it("#given multiline dynamic rules #when formatting PostToolUse context #then labels and bodies render on separate lines", () => {
// given
const rule = loadedRule({
path: "/repo/packages/AGENTS.md",
relativePath: "packages/AGENTS.md",
body: ["# packages", "", "## OVERVIEW", "23 sibling packages.", "", "## CONVENTIONS", "Use npm."].join("\n"),
});
// when
const block = formatDynamicBlock(
[rule],
"packages/omo-codex/plugin/components/ulw-loop/src/paths.ts",
FORMAT_OPTIONS,
);
// then
expect(block).toBe(
[
"Additional project instructions matched for packages/omo-codex/plugin/components/ulw-loop/src/paths.ts:",
"",
"Instructions from: /repo/packages/AGENTS.md",
"",
"# packages",
"",
"## OVERVIEW",
"23 sibling packages.",
"",
"## CONVENTIONS",
"Use npm.",
].join("\n"),
);
});
it("#given static rules #when formatting SessionStart context #then it avoids leading blank lines", () => {
// given
const rule = loadedRule({
path: "/repo/AGENTS.md",
relativePath: "AGENTS.md",
body: "Keep generated hook context readable.",
});
// when
const block = formatStaticBlock([rule], FORMAT_OPTIONS);
// then
expect(block).toBe(
[
"## Project Instructions",
"",
"Instructions from: /repo/AGENTS.md",
"",
"Keep generated hook context readable.",
].join("\n"),
);
});
it("#given CRLF and bare CR rule bodies #when formatting context #then it normalizes line endings", () => {
// given
const rule = loadedRule({
body: "First line\r\n indented second line\rThird line",
});
// when
const block = formatDynamicBlock([rule], "src/app.ts", FORMAT_OPTIONS);
// then
expect(block).toContain("First line\n indented second line\nThird line");
expect(block).not.toContain("\r");
});
it("#given duplicate static rules with different line endings #when formatting context #then it renders one copy", () => {
// given
const lfRule = loadedRule({
path: "/repo/AGENTS.md",
relativePath: "AGENTS.md",
body: "Shared rule\nKeep one copy.",
});
const crlfRule = loadedRule({
path: "/repo/packages/AGENTS.md",
relativePath: "packages/AGENTS.md",
body: "Shared rule\r\nKeep one copy.",
});
// when
const block = formatStaticBlock([lfRule, crlfRule], FORMAT_OPTIONS);
// then
expect(occurrenceCount(block, "Shared rule\nKeep one copy.")).toBe(1);
expect(block).not.toContain("/repo/packages/AGENTS.md");
});
it("#given multiple oversized rules #when formatting under a tight result budget #then every rule receives a fair truncated share with a read-full guide", () => {
// given
const rules = [
loadedRule({ path: "/repo/alpha.md", relativePath: "alpha.md", body: `alpha-${"A".repeat(500)}` }),
loadedRule({ path: "/repo/beta.md", relativePath: "beta.md", body: `beta-${"B".repeat(500)}` }),
loadedRule({ path: "/repo/gamma.md", relativePath: "gamma.md", body: `gamma-${"C".repeat(500)}` }),
];
// when
const block = formatDynamicBlock(rules, "src/app.ts", {
maxRuleChars: 10_000,
maxResultChars: 900,
});
// then
expect(block).toContain("Instructions from: /repo/alpha.md");
expect(block).toContain("Instructions from: /repo/beta.md");
expect(block).toContain("Instructions from: /repo/gamma.md");
expect(block).toContain("[Truncated. Full: alpha.md]");
expect(block).toContain("[Truncated. Full: beta.md]");
expect(block).toContain("[Truncated. Full: gamma.md]");
expect(occurrenceCount(block, "[Truncated. Full:")).toBe(3);
});
it("#given no matching rules #when formatting hook context #then it emits no context", () => {
// given
const rules: LoadedRule[] = [];
// when
const dynamicBlock = formatDynamicBlock(rules, "src/app.ts", FORMAT_OPTIONS);
const staticBlock = formatStaticBlock(rules, FORMAT_OPTIONS);
// then
expect(dynamicBlock).toBe("");
expect(staticBlock).toBe("");
});
});
function loadedRule(input: {
readonly body: string;
readonly path?: string;
readonly relativePath?: string;
readonly source?: RuleSource;
readonly matchReason?: MatchReason;
}): LoadedRule {
const path = input.path ?? "/repo/AGENTS.md";
const relativePath = input.relativePath ?? "AGENTS.md";
const source = input.source ?? "AGENTS.md";
return {
path,
realPath: path,
source,
distance: 0,
isGlobal: false,
isSingleFile: true,
relativePath,
frontmatter: {},
body: input.body,
contentHash: "hash",
matchReason: input.matchReason ?? "single-file",
};
}
function occurrenceCount(value: string, search: string): number {
return value.split(search).length - 1;
}
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { formatAdditionalContextOutput } from "../src/hook-output.js";
describe("formatAdditionalContextOutput", () => {
it("#given context with outer whitespace and CRLF #when serializing hook JSON #then additional context is newline-normalized", () => {
// given
const context = "\r\n\r\nFirst line\r\nSecond line\rThird line\r\n";
// when
const output = formatAdditionalContextOutput("PostToolUse", context);
const parsed: unknown = JSON.parse(output);
// then
expect(readAdditionalContext(parsed)).toBe("First line\nSecond line\nThird line");
expect(output.endsWith("\n")).toBe(true);
});
it("#given blank context #when serializing hook JSON #then it emits no hook output", () => {
// given
const context = "\r\n \n";
// when
const output = formatAdditionalContextOutput("SessionStart", context);
// then
expect(output).toBe("");
});
});
function readAdditionalContext(value: unknown): string {
if (!isRecord(value)) throw new TypeError("Expected hook output object");
const hookSpecificOutput = value["hookSpecificOutput"];
if (!isRecord(hookSpecificOutput)) throw new TypeError("Expected hookSpecificOutput object");
const additionalContext = hookSpecificOutput["additionalContext"];
if (typeof additionalContext !== "string") throw new TypeError("Expected additionalContext string");
return additionalContext;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,206 @@
import { describe, expect, it } from "vitest";
import { matchRule, normalizeGlobs } from "../src/rules/matcher.js";
import type { RuleFrontmatter } from "../src/rules/types.js";
function matchFrontmatter(
frontmatter: RuleFrontmatter,
pathBases: {
projectRelative: string;
scopeRelative?: string;
basename?: string;
},
): ReturnType<typeof matchRule> {
const scopeRelative = pathBases.scopeRelative;
const pathBase = {
projectRelative: pathBases.projectRelative,
basename: pathBases.basename ?? pathBases.projectRelative.split("/").at(-1) ?? pathBases.projectRelative,
...(scopeRelative === undefined ? {} : { scopeRelative }),
};
return matchRule({
frontmatter,
isSingleFile: false,
pathBases: pathBase,
});
}
function matchGlobs(globs: string | string[], projectRelative: string): boolean {
return matchFrontmatter({ globs } satisfies RuleFrontmatter, { projectRelative }).matched;
}
describe("matchRule", () => {
it("#given single-file rule #when matching any target #then it always matches", () => {
// given
const frontmatter = {} satisfies RuleFrontmatter;
// when
const result = matchRule({
frontmatter,
isSingleFile: true,
pathBases: { projectRelative: "docs/readme.md", basename: "readme.md" },
});
// then
expect(result).toEqual({ matched: true, reason: "single-file" });
});
it("#given always apply rule #when no glob is configured #then it matches", () => {
// given
const frontmatter = { alwaysApply: true } satisfies RuleFrontmatter;
// when
const result = matchFrontmatter(frontmatter, { projectRelative: "src/app.ts" });
// then
expect(result).toEqual({ matched: true, reason: "alwaysApply" });
});
it("#given rule without patterns #when target is checked #then no match is returned", () => {
// given
const frontmatter = {} satisfies RuleFrontmatter;
// when
const result = matchFrontmatter(frontmatter, { projectRelative: "src/app.ts" });
// then
expect(result).toEqual({ matched: false, reason: { kind: "no-match" } });
});
it("#given recursive glob #when target is nested #then matches without runtime dependencies", () => {
// given
const globs = "**/*.ts";
// when
const matched = matchGlobs(globs, "src/features/app.ts");
// then
expect(matched).toBe(true);
});
it("#given paths alias #when target matches #then glob match is returned", () => {
// given
const frontmatter = { paths: "src/**/*.ts" } satisfies RuleFrontmatter;
// when
const result = matchFrontmatter(frontmatter, { projectRelative: "src/features/app.ts" });
// then
expect(result).toEqual({ matched: true, reason: { kind: "glob", pattern: "src/**/*.ts" } });
});
it("#given applyTo alias #when basename matches #then glob match is returned", () => {
// given
const frontmatter = { applyTo: "*.md" } satisfies RuleFrontmatter;
// when
const result = matchFrontmatter(frontmatter, { projectRelative: "docs/README.md" });
// then
expect(result).toEqual({ matched: true, reason: { kind: "glob", pattern: "*.md" } });
});
it("#given scope-relative target #when scoped path matches #then glob match is returned", () => {
// given
const frontmatter = { globs: "components/**/*.tsx" } satisfies RuleFrontmatter;
// when
const result = matchFrontmatter(frontmatter, {
projectRelative: "packages/ui/components/button.tsx",
scopeRelative: "components/button.tsx",
});
// then
expect(result).toEqual({ matched: true, reason: { kind: "glob", pattern: "components/**/*.tsx" } });
});
it("#given backslash glob and target #when matching #then paths are normalized", () => {
// given
const frontmatter = { globs: "src\\**\\*.ts" } satisfies RuleFrontmatter;
// when
const result = matchFrontmatter(frontmatter, { projectRelative: "src\\features\\app.ts" });
// then
expect(result).toEqual({ matched: true, reason: { kind: "glob", pattern: "src/**/*.ts" } });
});
it("#given multiple positive globs #when later glob matches #then matching pattern is reported", () => {
// given
const frontmatter = { globs: ["docs/**/*.md", "src/**/*.ts"] } satisfies RuleFrontmatter;
// when
const result = matchFrontmatter(frontmatter, { projectRelative: "src/features/app.ts" });
// then
expect(result).toEqual({ matched: true, reason: { kind: "glob", pattern: "src/**/*.ts" } });
});
it("#given negative glob #when target is excluded #then no match is returned", () => {
// given
const globs = ["**/*.ts", "!**/*.test.ts"];
// when
const matched = matchGlobs(globs, "src/features/app.test.ts");
// then
expect(matched).toBe(false);
});
it("#given question-mark glob #when one filename character differs #then target matches", () => {
// given
const globs = "src/app-?.ts";
// when
const matched = matchGlobs(globs, "src/app-a.ts");
// then
expect(matched).toBe(true);
});
it("#given brace glob #when target extension is listed #then matches", () => {
// given
const globs = "src/**/*.{ts,tsx}";
// when
const matched = matchGlobs(globs, "src/features/app.tsx");
// then
expect(matched).toBe(true);
});
it("#given character class glob #when matching listed extension #then target matches", () => {
// given
const globs = "src/**/*.[tj]s";
// when
const matched = matchGlobs(globs, "src/features/app.ts");
// then
expect(matched).toBe(true);
});
it("#given extglob pattern #when matching allowed extension #then target matches", () => {
// given
const globs = "src/**/*.@(ts|tsx)";
// when
const matched = matchGlobs(globs, "src/features/app.tsx");
// then
expect(matched).toBe(true);
});
it("#given duplicate normalized patterns #when normalizing #then first unique pattern order is kept", () => {
// given
const frontmatter = {
globs: ["src\\**\\*.ts", "src/**/*.ts", "!src/**/*.test.ts"],
paths: "!src/**/*.test.ts",
} satisfies RuleFrontmatter;
// when
const patterns = normalizeGlobs(frontmatter);
// then
expect(patterns).toEqual(["src/**/*.ts", "!src/**/*.test.ts"]);
});
});
@@ -0,0 +1,151 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
type PackageJson = {
readonly type: string;
readonly packageManager: string;
readonly bin: Record<string, string>;
readonly files: readonly string[];
readonly dependencies?: Record<string, unknown>;
};
type PluginJson = {
readonly hooks: string;
};
type HookCommand = {
readonly command: string;
};
type HookEntry = {
readonly matcher?: string;
readonly hooks: readonly HookCommand[];
};
type HooksJson = {
readonly hooks: Record<string, readonly HookEntry[]>;
};
function readPackageJson(path: string): PackageJson {
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isPackageJson(parsed)) throw new TypeError(`Invalid package metadata: ${path}`);
return parsed;
}
function readPluginJson(path: string): PluginJson {
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isPluginJson(parsed)) throw new TypeError(`Invalid plugin metadata: ${path}`);
return parsed;
}
function readHooksJson(path: string): HooksJson {
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isHooksJson(parsed)) throw new TypeError(`Invalid hooks metadata: ${path}`);
return parsed;
}
describe("plugin package metadata", () => {
it("#given packaged plugin files #when validating entrypoints #then hook commands use portable plugin root interpolation", () => {
// given
const packageJson = readPackageJson("package.json");
const pluginJson = readPluginJson(".codex-plugin/plugin.json");
const hooksJson = readHooksJson("hooks/hooks.json");
const cliSource = readFileSync("src/cli.ts", "utf8");
// when
const hookConfig = hooksJson.hooks;
const pluginRoot = ["$", "{PLUGIN_ROOT}"].join("");
const commands = [
hookConfig["SessionStart"]?.[0]?.hooks[0]?.command,
hookConfig["UserPromptSubmit"]?.[0]?.hooks[0]?.command,
hookConfig["PostToolUse"]?.[0]?.hooks[0]?.command,
hookConfig["PostCompact"]?.[0]?.hooks[0]?.command,
];
const postToolUseMatcher = hookConfig["PostToolUse"]?.[0]?.matcher ?? "";
// then
expect(packageJson.type).toBe("module");
expect(packageJson.packageManager).toBe("npm@11.12.1");
expect(packageJson.dependencies ?? {}).toEqual({ picomatch: "^4.0.3" });
expect(packageJson.bin["omo-rules"]).toBe("./dist/cli.js");
expect(packageJson.files).toContain("bundled-rules");
expect(pluginJson.hooks).toBe("./hooks/hooks.json");
expect(cliSource.startsWith("#!/usr/bin/env node")).toBe(true);
expect(commands).toEqual([
`node "${pluginRoot}/dist/cli.js" hook session-start`,
`node "${pluginRoot}/dist/cli.js" hook user-prompt-submit`,
`node "${pluginRoot}/dist/cli.js" hook post-tool-use`,
`node "${pluginRoot}/dist/cli.js" hook post-compact`,
]);
expect(postToolUseMatcher).toBe("^apply_patch$");
const postToolUseMatcherRegex = new RegExp(postToolUseMatcher);
expect(postToolUseMatcherRegex.test("apply_patch")).toBe(true);
expect(
[
"read",
"Read",
"read_file",
"mcp__filesystem__read_file",
"mcp__filesystem__read_multiple_files",
"mcp__filesystem__write_file",
"mcp__filesystem__edit_file",
"write",
"Write",
"edit",
"Edit",
"multi_edit",
"MultiEdit",
"multiedit",
"exec_command",
"shell_command",
"bash",
"Bash",
].some((toolName) => postToolUseMatcherRegex.test(toolName)),
).toBe(false);
});
});
function isPackageJson(value: unknown): value is PackageJson {
if (!isRecord(value)) return false;
const dependencies = value["dependencies"];
return (
value["type"] === "module" &&
value["packageManager"] === "npm@11.12.1" &&
isStringRecord(value["bin"]) &&
isStringArray(value["files"]) &&
(dependencies === undefined || isRecord(dependencies))
);
}
function isStringArray(value: unknown): value is readonly string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string");
}
function isPluginJson(value: unknown): value is PluginJson {
return isRecord(value) && typeof value["hooks"] === "string";
}
function isHooksJson(value: unknown): value is HooksJson {
if (!isRecord(value) || !isRecord(value["hooks"])) return false;
return Object.values(value["hooks"]).every(isHookEntries);
}
function isHookEntries(value: unknown): value is readonly HookEntry[] {
return Array.isArray(value) && value.every(isHookEntry);
}
function isHookEntry(value: unknown): value is HookEntry {
return isRecord(value) && Array.isArray(value["hooks"]) && value["hooks"].every(isHookCommand);
}
function isHookCommand(value: unknown): value is HookCommand {
return isRecord(value) && typeof value["command"] === "string";
}
function isStringRecord(value: unknown): value is Record<string, string> {
return isRecord(value) && Object.values(value).every((item) => typeof item === "string");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,63 @@
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
claimPostCompactPending,
isPostCompactPending,
isPostCompactRecoveryInProgress,
markSessionCompacted,
sessionCachePath,
} from "../src/persistent-cache.js";
const tempDirectories: string[] = [];
afterEach(() => {
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
describe("persistent post-compact state", () => {
it("#given post-compact pending state #when static recovery is claimed twice #then only the first caller proceeds", () => {
// given
const pluginData = mkdtempSync(path.join(tmpdir(), "codex-rules-cache-"));
tempDirectories.push(pluginData);
const cachePath = sessionCachePath("session-cache-claim", pluginData);
markSessionCompacted(cachePath);
// when
const firstClaim = claimPostCompactPending(cachePath, "static");
const secondClaim = claimPostCompactPending(cachePath, "static");
// then
expect(firstClaim).toBe("claimed");
expect(secondClaim).toBe("not-pending");
expect(isPostCompactPending(cachePath, "static")).toBe(false);
expect(isPostCompactRecoveryInProgress(cachePath, "static")).toBe(true);
expect(isPostCompactPending(cachePath, "dynamic")).toBe(true);
});
it("#given post-compact pending state and contended lock #when static recovery is claimed #then reports contention without consuming pending state", () => {
// given
const pluginData = mkdtempSync(path.join(tmpdir(), "codex-rules-cache-"));
tempDirectories.push(pluginData);
const cachePath = sessionCachePath("session-cache-contended", pluginData);
markSessionCompacted(cachePath);
const lockPath = `${cachePath}.lock`;
mkdirSync(lockPath);
try {
// when
const claim = claimPostCompactPending(cachePath, "static");
// then
expect(claim).toBe("contended");
expect(isPostCompactPending(cachePath, "static")).toBe(true);
expect(isPostCompactRecoveryInProgress(cachePath, "static")).toBe(false);
} finally {
rmSync(lockPath, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,172 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { withPostCompactBudget } from "../src/post-compact-budget.js";
import type { PiRulesConfig } from "../src/rules/types.js";
const tempDirectories: string[] = [];
const CONFIG: PiRulesConfig = {
disabled: false,
mode: "both",
maxRuleChars: 30_000,
maxResultChars: 50_000,
postCompactMaxRuleChars: 12_000,
postCompactMaxResultChars: 20_000,
enabledSources: "auto",
};
afterEach(() => {
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
describe("post-compact context budget", () => {
it("#given known model near its context window #when resolving post-compact budget #then shrinks projected rule injection", () => {
// given
const transcriptPath = writeCompactedTranscript("A".repeat(760_000));
// when
const budget = withPostCompactBudget(CONFIG, { model: "gpt-5.5", transcriptPath });
// then
expect(budget.maxResultChars).toBeLessThan(1_000);
expect(budget.maxRuleChars).toBeLessThanOrEqual(budget.maxResultChars);
});
it("#given unknown model near its context window #when resolving post-compact budget #then shrinks projected rule injection conservatively", () => {
// given
const transcriptPath = writeCompactedTranscript("A".repeat(760_000));
// when
const budget = withPostCompactBudget(CONFIG, { model: "unknown-model", transcriptPath });
// then
expect(budget.maxResultChars).toBeLessThan(1_000);
expect(budget.maxRuleChars).toBeLessThanOrEqual(budget.maxResultChars);
});
it("#given known roomy model #when resolving post-compact budget #then keeps configured post-compact cap", () => {
// given
const transcriptPath = writeCompactedTranscript("small compacted summary");
// when
const budget = withPostCompactBudget(CONFIG, { model: "openai.gpt-5.5", transcriptPath });
// then
expect(budget.maxRuleChars).toBe(CONFIG.postCompactMaxRuleChars);
expect(budget.maxResultChars).toBe(CONFIG.postCompactMaxResultChars);
});
it("#given context pressure marker after compaction #when resolving post-compact budget #then shrinks projected rule injection", () => {
// given
const transcriptPath = writeCompactedPressureTranscript("small compacted summary");
// when
const budget = withPostCompactBudget(CONFIG, { model: "gpt-5.5", transcriptPath });
// then
expect(budget.maxResultChars).toBeLessThan(1_000);
expect(budget.maxRuleChars).toBeLessThanOrEqual(budget.maxResultChars);
});
it("#given Codex canonical context-window marker after compaction #when resolving post-compact budget #then shrinks projected rule injection", () => {
// given
const transcriptPath = writeCompactedCodexContextWindowTranscript("small compacted summary");
// when
const budget = withPostCompactBudget(CONFIG, { model: "gpt-5.5", transcriptPath });
// then
expect(budget.maxResultChars).toBeLessThan(1_000);
expect(budget.maxRuleChars).toBeLessThanOrEqual(budget.maxResultChars);
});
});
function writeCompactedTranscript(retainedText: string): string {
const root = mkdtempSync(path.join(tmpdir(), "post-compact-budget-"));
tempDirectories.push(root);
const transcriptPath = path.join(root, "transcript.jsonl");
writeFileSync(
transcriptPath,
`${JSON.stringify({
type: "compacted",
payload: {
message: "summary",
replacement_history: [{ type: "message", role: "user", content: retainedText }],
},
})}\n`,
);
return transcriptPath;
}
function writeCompactedPressureTranscript(retainedText: string): string {
const root = mkdtempSync(path.join(tmpdir(), "post-compact-budget-"));
tempDirectories.push(root);
const transcriptPath = path.join(root, "transcript-pressure.jsonl");
writeFileSync(
transcriptPath,
[
JSON.stringify({
type: "compacted",
payload: {
message: "summary",
replacement_history: [{ type: "message", role: "user", content: retainedText }],
},
}),
JSON.stringify({
type: "message",
payload: {
content: {
error: {
code: "context_too_large",
message:
"Your input exceeds the context window of this model. Please adjust your input and try again.",
},
},
},
}),
"",
].join("\n"),
);
return transcriptPath;
}
function writeCompactedCodexContextWindowTranscript(retainedText: string): string {
const root = mkdtempSync(path.join(tmpdir(), "post-compact-budget-"));
tempDirectories.push(root);
const transcriptPath = path.join(root, "transcript-codex-context-window.jsonl");
writeFileSync(
transcriptPath,
[
JSON.stringify({
type: "compacted",
payload: {
message: "summary",
replacement_history: [{ type: "message", role: "user", content: retainedText }],
},
}),
JSON.stringify({
type: "message",
payload: {
content: {
error: {
code: "context_length_exceeded",
},
},
},
}),
JSON.stringify({
type: "message",
payload: {
content:
"Codex ran out of room in the model's context window. Start a new thread or clear earlier history before retrying.",
},
}),
"",
].join("\n"),
);
return transcriptPath;
}
@@ -0,0 +1,196 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type { CodexPostCompactInput, CodexSessionStartInput, CodexUserPromptSubmitInput } from "../src/codex-hook.js";
export const PROJECT_RULES_ENV = {
CODEX_RULES_ENABLED_SOURCES: "AGENTS.md,.omo/rules",
CODEX_RULES_MAX_RESULT_CHARS: "50000",
CODEX_RULES_MAX_RULE_CHARS: "30000",
};
export const EXPANDED_POST_COMPACT_ENV = {
...PROJECT_RULES_ENV,
CODEX_RULES_POST_COMPACT_MAX_RESULT_CHARS: "20000",
CODEX_RULES_POST_COMPACT_MAX_RULE_CHARS: "12000",
};
const tempDirectories: string[] = [];
const DEFAULT_SESSION_ID = "session-post-compact-budget";
export function cleanupPostCompactFixtures(): void {
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
}
export function makeOversizedProject(prefix = "budget"): { root: string; pluginData: string } {
const root = mkdtempSync(path.join(tmpdir(), `codex-rules-post-compact-${prefix}-project-`));
const pluginData = mkdtempSync(path.join(tmpdir(), `codex-rules-post-compact-${prefix}-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 };
}
export function sessionStartInput(root: string, sessionId = DEFAULT_SESSION_ID): CodexSessionStartInput {
return {
session_id: sessionId,
transcript_path: null,
cwd: root,
hook_event_name: "SessionStart",
model: "gpt-5.5",
permission_mode: "default",
source: "startup",
};
}
export function compactSessionStartInput(
root: string,
transcriptPath: string,
sessionId = DEFAULT_SESSION_ID,
): CodexSessionStartInput {
return {
session_id: sessionId,
transcript_path: transcriptPath,
cwd: root,
hook_event_name: "SessionStart",
model: "gpt-5.5",
permission_mode: "default",
source: "compact",
};
}
export function postCompactInput(root: string, sessionId = DEFAULT_SESSION_ID): CodexPostCompactInput {
return {
session_id: sessionId,
turn_id: "turn-compact",
transcript_path: null,
cwd: root,
hook_event_name: "PostCompact",
model: "gpt-5.5",
trigger: "auto",
};
}
export function userPromptSubmitInput(
root: string,
transcriptPath: string,
sessionId = DEFAULT_SESSION_ID,
): CodexUserPromptSubmitInput {
return {
session_id: sessionId,
turn_id: "turn-after-compact",
transcript_path: transcriptPath,
cwd: root,
hook_event_name: "UserPromptSubmit",
model: "gpt-5.5",
permission_mode: "default",
prompt: "continue",
};
}
export 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;
}
export function writeCompactedWarningTranscript(root: string, retainedText: string): string {
const transcriptPath = path.join(root, "transcript-compacted-warning.jsonl");
writeFileSync(
transcriptPath,
[
JSON.stringify({
type: "message",
payload: {
content: "Skill descriptions were shortened to fit the 2% skills context budget. Context compacted.",
},
}),
JSON.stringify({
type: "compacted",
payload: {
message: "summary",
replacement_history: [{ type: "message", role: "user", content: retainedText }],
},
}),
JSON.stringify({
type: "message",
payload: {
content: "Your input exceeds the context window of this model. Please adjust your input and try again.",
},
}),
"",
].join("\n"),
);
return transcriptPath;
}
export function writeMalformedContextTooLargeTranscript(root: string, retainedText = ""): string {
const transcriptPath = path.join(root, "transcript-context-too-large.jsonl");
writeFileSync(
transcriptPath,
[
"{not json",
retainedText,
JSON.stringify({
type: "message",
payload: {
content: "Skill descriptions were shortened to fit the 2% skills context budget. Context compacted.",
},
}),
JSON.stringify({
type: "message",
payload: {
content: {
error: {
code: "context_too_large",
message:
"Your input exceeds the context window of this model. Please adjust your input and try again.",
},
},
},
}),
"",
].join("\n"),
);
return transcriptPath;
}
export function readOptionalAdditionalContext(output: string): string {
if (output.trim().length === 0) {
return "";
}
return readAdditionalContext(output);
}
export function readAdditionalContext(output: string): string {
if (output.trim().length === 0) {
throw new Error("Expected hook output to include additional context.");
}
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,63 @@
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { scanRuleFiles } from "../src/rules/scanner.js";
const tempDirectories: string[] = [];
afterEach(() => {
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
describe("scanRuleFiles", () => {
it("#given more rule files than max #when scanning #then returns only capped files", () => {
// given
const root = mkdtempSync(join(tmpdir(), "codex-rules-scanner-"));
tempDirectories.push(root);
for (let index = 0; index < 5; index += 1) {
writeFileSync(join(root, `rule-${index}.md`), `Rule ${index}\n`);
}
// when
const files = scanRuleFiles({ rootDir: root, maxFiles: 2 });
// then
expect(files).toHaveLength(2);
});
it("#given rule files and an excluded directory #when scanning #then returns sorted non-excluded files", () => {
// given
const root = mkdtempSync(join(tmpdir(), "codex-rules-scanner-"));
tempDirectories.push(root);
mkdirSync(join(root, "dist"), { recursive: true });
writeFileSync(join(root, "beta.md"), "Beta\n");
writeFileSync(join(root, "alpha.md"), "Alpha\n");
writeFileSync(join(root, "dist", "ignored.md"), "Ignored\n");
// when
const files = scanRuleFiles({ rootDir: root });
// then
expect(files.map((file) => file.path)).toEqual([join(root, "alpha.md"), join(root, "beta.md")]);
});
it("#given symlink loop #when scanning #then traversal terminates without duplicate files", () => {
// given
const root = mkdtempSync(join(tmpdir(), "codex-rules-scanner-"));
tempDirectories.push(root);
const nested = join(root, "nested");
mkdirSync(nested, { recursive: true });
writeFileSync(join(root, "root.md"), "Root\n");
symlinkSync(root, join(nested, "loop"));
// when
const files = scanRuleFiles({ rootDir: root });
// then
expect(files.map((file) => file.path)).toEqual([join(root, "root.md")]);
});
});
@@ -0,0 +1,198 @@
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 CodexPostToolUseLike, extractCodexToolPaths } from "../src/tool-paths.js";
const tempDirectories: string[] = [];
afterEach(() => {
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function makeProject(): string {
const root = mkdtempSync(path.join(tmpdir(), "codex-rules-paths-"));
tempDirectories.push(root);
mkdirSync(path.join(root, "src"), { recursive: true });
writeFileSync(path.join(root, "src", "app.ts"), "export const app = true;\n");
return root;
}
function postToolUse(input: { toolName: string; toolInput?: unknown; toolResponse?: unknown }): CodexPostToolUseLike {
return {
tool_name: input.toolName,
tool_input: input.toolInput ?? {},
tool_response: input.toolResponse ?? { text: "ok" },
};
}
describe("extractCodexToolPaths", () => {
it("#given filesystem read payload #when extracting #then returns resolved path", () => {
// given
const root = makeProject();
// when
const paths = extractCodexToolPaths(
postToolUse({
toolName: "mcp__filesystem__read_file",
toolInput: { path: "src/app.ts" },
}),
root,
);
// then
expect(paths).toEqual([path.join(root, "src", "app.ts")]);
});
it("#given apply_patch payload #when extracting #then returns patched file paths", () => {
// given
const root = makeProject();
// when
const paths = extractCodexToolPaths(
postToolUse({
toolName: "apply_patch",
toolInput: {
command: [
"*** Begin Patch",
"*** Update File: src/app.ts",
"@@",
"+export const changed = true;",
"*** End Patch",
].join("\n"),
},
}),
root,
);
// then
expect(paths).toEqual([path.join(root, "src", "app.ts")]);
});
it("#given apply_patch add update and move payload #when extracting #then returns each target once", () => {
// given
const root = makeProject();
// when
const paths = extractCodexToolPaths(
postToolUse({
toolName: "apply_patch",
toolInput: {
command: [
"*** Begin Patch",
"*** Add File: src/new.ts",
"+export const created = true;",
"*** Update File: src/app.ts",
"*** Move to: src/moved.ts",
"@@",
"-export const app = true;",
"+export const moved = true;",
"*** Update File: src/moved.ts",
"@@",
"-export const moved = true;",
"+export const moved = false;",
"*** End Patch",
].join("\n"),
},
}),
root,
);
// then
expect(paths).toEqual([
path.join(root, "src", "new.ts"),
path.join(root, "src", "app.ts"),
path.join(root, "src", "moved.ts"),
]);
});
it("#given mcp write-file payload #when extracting #then returns resolved path", () => {
// given
const root = makeProject();
// when
const paths = extractCodexToolPaths(
postToolUse({
toolName: "mcp__filesystem__write_file",
toolInput: { path: "src/app.ts", content: "export const app = true;\n" },
}),
root,
);
// then
expect(paths).toEqual([path.join(root, "src", "app.ts")]);
});
it("#given mcp edit-file payload #when extracting #then returns resolved path", () => {
// given
const root = makeProject();
// when
const paths = extractCodexToolPaths(
postToolUse({
toolName: "mcp__filesystem__edit_file",
toolInput: { path: "src/app.ts", edits: [] },
}),
root,
);
// then
expect(paths).toEqual([path.join(root, "src", "app.ts")]);
});
it("#given mcp read-multiple-files payload #when extracting #then returns all resolved paths", () => {
// given
const root = makeProject();
writeFileSync(path.join(root, "src", "other.ts"), "export const other = true;\n");
// when
const paths = extractCodexToolPaths(
postToolUse({
toolName: "mcp__filesystem__read_multiple_files",
toolInput: { paths: ["src/app.ts", "src/other.ts"] },
}),
root,
);
// then
expect(paths).toEqual([path.join(root, "src", "app.ts"), path.join(root, "src", "other.ts")]);
});
it("#given shell command payload #when extracting #then returns only existing file tokens", () => {
// given
const root = makeProject();
// when
const paths = extractCodexToolPaths(
postToolUse({
toolName: "exec_command",
toolInput: { cmd: "sed -n '1,80p' src/app.ts src/missing.ts", workdir: root },
}),
"/tmp",
);
// then
expect(paths).toEqual([path.join(root, "src", "app.ts")]);
});
it("#given failed tracked tool payload #when extracting #then returns no paths", () => {
// given
const root = makeProject();
// when
const paths = extractCodexToolPaths(
postToolUse({
toolName: "read",
toolInput: { path: "src/app.ts" },
toolResponse: { is_error: true },
}),
root,
);
// then
expect(paths).toEqual([]);
});
});