From e9c9142afe794f0a9ece768196350aec2e0af12d Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 30 May 2026 19:12:21 +0900 Subject: [PATCH] test(omo-codex): batch 104 (21 files) --- .../rules/test/bundled-rules-priority.test.ts | 107 +++ .../rules/test/bundled-rules.test.ts | 268 +++++++ .../test/codex-hook-context-pressure.test.ts | 243 +++++++ .../rules/test/codex-hook-performance.test.ts | 99 +++ .../codex-hook-post-compact-budget.test.ts | 132 ++++ .../codex-hook-post-compact-context.test.ts | 156 ++++ .../codex-hook-post-compact-dedup.test.ts | 299 ++++++++ .../test/codex-hook-post-compact-lock.test.ts | 46 ++ .../codex-hook-post-compact-process.test.ts | 83 +++ .../components/rules/test/codex-hook.test.ts | 667 ++++++++++++++++++ .../components/rules/test/engine.test.ts | 192 +++++ .../components/rules/test/finder.test.ts | 102 +++ .../components/rules/test/formatter.test.ts | 168 +++++ .../components/rules/test/hook-output.test.ts | 42 ++ .../components/rules/test/matcher.test.ts | 206 ++++++ .../rules/test/package-smoke.test.ts | 151 ++++ .../rules/test/persistent-cache.test.ts | 63 ++ .../rules/test/post-compact-budget.test.ts | 172 +++++ .../rules/test/post-compact-test-fixture.ts | 196 +++++ .../components/rules/test/scanner.test.ts | 63 ++ .../components/rules/test/tool-paths.test.ts | 198 ++++++ 21 files changed, 3653 insertions(+) create mode 100644 packages/omo-codex/plugin/components/rules/test/bundled-rules-priority.test.ts create mode 100644 packages/omo-codex/plugin/components/rules/test/bundled-rules.test.ts create mode 100644 packages/omo-codex/plugin/components/rules/test/codex-hook-context-pressure.test.ts create mode 100644 packages/omo-codex/plugin/components/rules/test/codex-hook-performance.test.ts create mode 100644 packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-budget.test.ts create mode 100644 packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-context.test.ts create mode 100644 packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-dedup.test.ts create mode 100644 packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-lock.test.ts create mode 100644 packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-process.test.ts create mode 100644 packages/omo-codex/plugin/components/rules/test/codex-hook.test.ts create mode 100644 packages/omo-codex/plugin/components/rules/test/engine.test.ts create mode 100644 packages/omo-codex/plugin/components/rules/test/finder.test.ts create mode 100644 packages/omo-codex/plugin/components/rules/test/formatter.test.ts create mode 100644 packages/omo-codex/plugin/components/rules/test/hook-output.test.ts create mode 100644 packages/omo-codex/plugin/components/rules/test/matcher.test.ts create mode 100644 packages/omo-codex/plugin/components/rules/test/package-smoke.test.ts create mode 100644 packages/omo-codex/plugin/components/rules/test/persistent-cache.test.ts create mode 100644 packages/omo-codex/plugin/components/rules/test/post-compact-budget.test.ts create mode 100644 packages/omo-codex/plugin/components/rules/test/post-compact-test-fixture.ts create mode 100644 packages/omo-codex/plugin/components/rules/test/scanner.test.ts create mode 100644 packages/omo-codex/plugin/components/rules/test/tool-paths.test.ts diff --git a/packages/omo-codex/plugin/components/rules/test/bundled-rules-priority.test.ts b/packages/omo-codex/plugin/components/rules/test/bundled-rules-priority.test.ts new file mode 100644 index 000000000..ee749f9ad --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/bundled-rules-priority.test.ts @@ -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); + }); +}); diff --git a/packages/omo-codex/plugin/components/rules/test/bundled-rules.test.ts b/packages/omo-codex/plugin/components/rules/test/bundled-rules.test.ts new file mode 100644 index 000000000..80f60bd4f --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/bundled-rules.test.ts @@ -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[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); + }); +}); diff --git a/packages/omo-codex/plugin/components/rules/test/codex-hook-context-pressure.test.ts b/packages/omo-codex/plugin/components/rules/test/codex-hook-context-pressure.test.ts new file mode 100644 index 000000000..6e7f81ddd --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/codex-hook-context-pressure.test.ts @@ -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[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; +} diff --git a/packages/omo-codex/plugin/components/rules/test/codex-hook-performance.test.ts b/packages/omo-codex/plugin/components/rules/test/codex-hook-performance.test.ts new file mode 100644 index 000000000..6cb812bd8 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/codex-hook-performance.test.ts @@ -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) => { + 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(); + } + }); +}); diff --git a/packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-budget.test.ts b/packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-budget.test.ts new file mode 100644 index 000000000..a674fc334 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-budget.test.ts @@ -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[0] { + return { + session_id: "session-post-compact-budget", + turn_id: "turn-after-compact", + transcript_path: transcriptPath, + cwd: root, + hook_event_name: "UserPromptSubmit", + model: "gpt-5.5", + permission_mode: "default", + prompt: "continue", + }; +} + +function writeCompactedTranscript(root: string, retainedText: string): string { + const transcriptPath = path.join(root, "transcript-compacted.jsonl"); + writeFileSync( + transcriptPath, + `${JSON.stringify({ + type: "compacted", + payload: { + message: "summary", + replacement_history: [{ type: "message", role: "user", content: retainedText }], + }, + })}\n`, + ); + return transcriptPath; +} + +function readAdditionalContext(output: string): string { + expect(output.trim().length).toBeGreaterThan(0); + const parsed: unknown = JSON.parse(output); + if (!isRecord(parsed)) return ""; + const hookSpecificOutput = parsed["hookSpecificOutput"]; + if (!isRecord(hookSpecificOutput)) return ""; + const additionalContext = hookSpecificOutput["additionalContext"]; + return typeof additionalContext === "string" ? additionalContext : ""; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-context.test.ts b/packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-context.test.ts new file mode 100644 index 000000000..dd20c24ab --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-context.test.ts @@ -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:"); + }); +}); diff --git a/packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-dedup.test.ts b/packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-dedup.test.ts new file mode 100644 index 000000000..87d76327d --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-dedup.test.ts @@ -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[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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-lock.test.ts b/packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-lock.test.ts new file mode 100644 index 000000000..d621fb71e --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-lock.test.ts @@ -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 }); + } + }); +}); diff --git a/packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-process.test.ts b/packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-process.test.ts new file mode 100644 index 000000000..e8e199e7a --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-process.test.ts @@ -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 { + 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); + }); +} diff --git a/packages/omo-codex/plugin/components/rules/test/codex-hook.test.ts b/packages/omo-codex/plugin/components/rules/test/codex-hook.test.ts new file mode 100644 index 000000000..5659e1f54 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/codex-hook.test.ts @@ -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; + dynamicTargetFingerprints?: Record; +}; + +const CLI_PATH = fileURLToPath(new URL("../dist/cli.js", import.meta.url)); + +function runHookCli(input: string, subcommand = "post-tool-use", env: NodeJS.ProcessEnv = {}): Promise { + 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[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: "", + }); + }); +}); diff --git a/packages/omo-codex/plugin/components/rules/test/engine.test.ts b/packages/omo-codex/plugin/components/rules/test/engine.test.ts new file mode 100644 index 000000000..4b9363530 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/engine.test.ts @@ -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); + }); +}); diff --git a/packages/omo-codex/plugin/components/rules/test/finder.test.ts b/packages/omo-codex/plugin/components/rules/test/finder.test.ts new file mode 100644 index 000000000..3f10ca7f7 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/finder.test.ts @@ -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", + ]); + }); +}); diff --git a/packages/omo-codex/plugin/components/rules/test/formatter.test.ts b/packages/omo-codex/plugin/components/rules/test/formatter.test.ts new file mode 100644 index 000000000..903974c0b --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/formatter.test.ts @@ -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; +} diff --git a/packages/omo-codex/plugin/components/rules/test/hook-output.test.ts b/packages/omo-codex/plugin/components/rules/test/hook-output.test.ts new file mode 100644 index 000000000..aa022863a --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/hook-output.test.ts @@ -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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/omo-codex/plugin/components/rules/test/matcher.test.ts b/packages/omo-codex/plugin/components/rules/test/matcher.test.ts new file mode 100644 index 000000000..5a4b5c223 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/matcher.test.ts @@ -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 { + 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"]); + }); +}); diff --git a/packages/omo-codex/plugin/components/rules/test/package-smoke.test.ts b/packages/omo-codex/plugin/components/rules/test/package-smoke.test.ts new file mode 100644 index 000000000..bd0b96351 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/package-smoke.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; + readonly files: readonly string[]; + readonly dependencies?: Record; +}; + +type PluginJson = { + readonly hooks: string; +}; + +type HookCommand = { + readonly command: string; +}; + +type HookEntry = { + readonly matcher?: string; + readonly hooks: readonly HookCommand[]; +}; + +type HooksJson = { + readonly hooks: Record; +}; + +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 { + return isRecord(value) && Object.values(value).every((item) => typeof item === "string"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/omo-codex/plugin/components/rules/test/persistent-cache.test.ts b/packages/omo-codex/plugin/components/rules/test/persistent-cache.test.ts new file mode 100644 index 000000000..4581a1e13 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/persistent-cache.test.ts @@ -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 }); + } + }); +}); diff --git a/packages/omo-codex/plugin/components/rules/test/post-compact-budget.test.ts b/packages/omo-codex/plugin/components/rules/test/post-compact-budget.test.ts new file mode 100644 index 000000000..0c462b278 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/post-compact-budget.test.ts @@ -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; +} diff --git a/packages/omo-codex/plugin/components/rules/test/post-compact-test-fixture.ts b/packages/omo-codex/plugin/components/rules/test/post-compact-test-fixture.ts new file mode 100644 index 000000000..c34d54b00 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/post-compact-test-fixture.ts @@ -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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/omo-codex/plugin/components/rules/test/scanner.test.ts b/packages/omo-codex/plugin/components/rules/test/scanner.test.ts new file mode 100644 index 000000000..fb178639f --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/scanner.test.ts @@ -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")]); + }); +}); diff --git a/packages/omo-codex/plugin/components/rules/test/tool-paths.test.ts b/packages/omo-codex/plugin/components/rules/test/tool-paths.test.ts new file mode 100644 index 000000000..17be09442 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/tool-paths.test.ts @@ -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([]); + }); +});