test(omo-codex): batch 104 (21 files)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
+132
@@ -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);
|
||||
}
|
||||
+156
@@ -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:");
|
||||
});
|
||||
});
|
||||
+299
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
+83
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user