|
1 | 1 | import assert from "node:assert/strict"; |
2 | 2 | import { execFileSync } from "node:child_process"; |
3 | 3 | import { existsSync } from "node:fs"; |
4 | | -import { chmod, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; |
| 4 | +import { appendFile, chmod, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; |
5 | 5 | import { tmpdir } from "node:os"; |
6 | 6 | import { dirname, join } from "node:path"; |
7 | 7 | import { pathToFileURL } from "node:url"; |
@@ -58,6 +58,20 @@ async function writeJson(path: string, value: unknown): Promise<void> { |
58 | 58 | await writeFile(path, JSON.stringify(value, null, 2)); |
59 | 59 | } |
60 | 60 |
|
| 61 | +function hooksLogPath(cwd: string): string { |
| 62 | + const day = new Date().toISOString().slice(0, 10); |
| 63 | + return join(cwd, ".omx", "logs", `hooks-${day}.jsonl`); |
| 64 | +} |
| 65 | + |
| 66 | +async function readHooksLogEntries(cwd: string): Promise<Array<Record<string, unknown>>> { |
| 67 | + const content = await readFile(hooksLogPath(cwd), "utf-8"); |
| 68 | + return content |
| 69 | + .split("\n") |
| 70 | + .map((line) => line.trim()) |
| 71 | + .filter(Boolean) |
| 72 | + .map((line) => JSON.parse(line) as Record<string, unknown>); |
| 73 | +} |
| 74 | + |
61 | 75 | async function writeActiveAutopilotSession(cwd: string, sessionId: string): Promise<void> { |
62 | 76 | await writeJson(join(cwd, ".omx", "state", "session.json"), { |
63 | 77 | session_id: sessionId, |
@@ -211,9 +225,8 @@ describe("codex native hook config", () => { |
211 | 225 | }; |
212 | 226 | assert.equal( |
213 | 227 | postToolUse.matcher, |
214 | | - "Edit|Write|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|Patch|mcp__.*|omx_.*|state_.*|project_memory_.*|notepad_.*|trace_.*", |
| 228 | + "Bash|Edit|MultiEdit|Write|NotebookEdit|ApplyPatch|apply_patch|Patch|mcp__.*|omx_.*|state_.*|project_memory_.*|notepad_.*|trace_.*", |
215 | 229 | ); |
216 | | - assert.doesNotMatch(postToolUse.matcher ?? "", /\bBash\b/); |
217 | 230 | assert.match( |
218 | 231 | String(postToolUse.hooks?.[0]?.command || ""), |
219 | 232 | /codex-native-hook\.js"?$/, |
@@ -1109,6 +1122,112 @@ export async function onHookEvent(event) { |
1109 | 1122 | } |
1110 | 1123 | }); |
1111 | 1124 |
|
| 1125 | + it("emits hook dispatch pre_tool_use before mutation and post_tool_use after mutation", async () => { |
| 1126 | + const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-tool-order-")); |
| 1127 | + try { |
| 1128 | + execFileSync("git", ["init"], { cwd, stdio: "ignore" }); |
| 1129 | + execFileSync("git", ["checkout", "-b", "tool-events"], { cwd, stdio: "ignore" }); |
| 1130 | + const targetPath = join(cwd, "src", "output.txt"); |
| 1131 | + const toolInput = { file_path: "src/output.txt", content: "next\n" }; |
| 1132 | + |
| 1133 | + await dispatchCodexNativeHook( |
| 1134 | + { |
| 1135 | + hook_event_name: "PreToolUse", |
| 1136 | + cwd, |
| 1137 | + session_id: "sess-tool-events", |
| 1138 | + agent: "agent5", |
| 1139 | + tool_name: "Write", |
| 1140 | + tool_use_id: "trace-write-1", |
| 1141 | + tool_input: toolInput, |
| 1142 | + }, |
| 1143 | + { cwd }, |
| 1144 | + ); |
| 1145 | + |
| 1146 | + await mkdir(dirname(targetPath), { recursive: true }); |
| 1147 | + await writeFile(targetPath, "next\n", "utf-8"); |
| 1148 | + await appendFile( |
| 1149 | + hooksLogPath(cwd), |
| 1150 | + `${JSON.stringify({ type: "mutation_marker", path: targetPath })}\n`, |
| 1151 | + ); |
| 1152 | + |
| 1153 | + await dispatchCodexNativeHook( |
| 1154 | + { |
| 1155 | + hook_event_name: "PostToolUse", |
| 1156 | + cwd, |
| 1157 | + session_id: "sess-tool-events", |
| 1158 | + agent: "agent5", |
| 1159 | + tool_name: "Write", |
| 1160 | + tool_use_id: "trace-write-1", |
| 1161 | + tool_input: toolInput, |
| 1162 | + tool_response: { ok: true }, |
| 1163 | + }, |
| 1164 | + { cwd }, |
| 1165 | + ); |
| 1166 | + |
| 1167 | + const entries = await readHooksLogEntries(cwd); |
| 1168 | + const preIndex = entries.findIndex((entry) => entry.type === "hook_dispatch" && entry.event === "pre-tool-use"); |
| 1169 | + const mutationIndex = entries.findIndex((entry) => entry.type === "mutation_marker"); |
| 1170 | + const postIndex = entries.findIndex((entry) => entry.type === "hook_dispatch" && entry.event === "post-tool-use"); |
| 1171 | + assert.equal(preIndex >= 0, true); |
| 1172 | + assert.equal(mutationIndex >= 0, true); |
| 1173 | + assert.equal(postIndex >= 0, true); |
| 1174 | + assert.equal(preIndex < mutationIndex, true); |
| 1175 | + assert.equal(mutationIndex < postIndex, true); |
| 1176 | + |
| 1177 | + const preContext = entries[preIndex]?.context as Record<string, unknown>; |
| 1178 | + const postContext = entries[postIndex]?.context as Record<string, unknown>; |
| 1179 | + assert.equal(preContext.tool_lifecycle_event, "pre_tool_use"); |
| 1180 | + assert.equal(postContext.tool_lifecycle_event, "post_tool_use"); |
| 1181 | + assert.equal(preContext.trace_id, "omx:sess-tool-events:trace-write-1"); |
| 1182 | + assert.equal(postContext.trace_id, "omx:sess-tool-events:trace-write-1"); |
| 1183 | + assert.equal(preContext.session_id, "sess-tool-events"); |
| 1184 | + assert.equal(preContext.agent, "agent5"); |
| 1185 | + assert.equal(preContext.cwd, cwd); |
| 1186 | + assert.equal(preContext.repo_root, cwd); |
| 1187 | + assert.equal(preContext.branch, "tool-events"); |
| 1188 | + assert.deepEqual(preContext.tool_input, { |
| 1189 | + operation: "write", |
| 1190 | + file_path: "src/output.txt", |
| 1191 | + extracted_paths: ["src/output.txt"], |
| 1192 | + paths: [{ path: "src/output.txt", role: "target", kind: "file" }], |
| 1193 | + file_count: 1, |
| 1194 | + }); |
| 1195 | + assert.deepEqual(preContext.extracted_paths, ["src/output.txt"]); |
| 1196 | + assert.equal("missing_pre_tool_use" in postContext, false); |
| 1197 | + } finally { |
| 1198 | + await rm(cwd, { recursive: true, force: true }); |
| 1199 | + } |
| 1200 | + }); |
| 1201 | + |
| 1202 | + it("marks mutating post_tool_use when no matching pre_tool_use trace exists", async () => { |
| 1203 | + const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-tool-missing-pre-")); |
| 1204 | + try { |
| 1205 | + const result = await dispatchCodexNativeHook( |
| 1206 | + { |
| 1207 | + hook_event_name: "PostToolUse", |
| 1208 | + cwd, |
| 1209 | + session_id: "sess-missing-pre", |
| 1210 | + agent: "agent5", |
| 1211 | + tool_name: "Write", |
| 1212 | + tool_use_id: "trace-orphan", |
| 1213 | + tool_input: { file_path: "src/orphan.txt", content: "orphan\n" }, |
| 1214 | + tool_response: { ok: true }, |
| 1215 | + }, |
| 1216 | + { cwd }, |
| 1217 | + ); |
| 1218 | + |
| 1219 | + assert.equal(result.omxEventName, "post-tool-use"); |
| 1220 | + const entries = await readHooksLogEntries(cwd); |
| 1221 | + const postEntry = entries.find((entry) => entry.type === "hook_dispatch" && entry.event === "post-tool-use"); |
| 1222 | + const postContext = postEntry?.context as Record<string, unknown>; |
| 1223 | + assert.equal(postContext.tool_lifecycle_event, "post_tool_use"); |
| 1224 | + assert.equal(postContext.trace_id, "omx:sess-missing-pre:trace-orphan"); |
| 1225 | + assert.equal(postContext.missing_pre_tool_use, true); |
| 1226 | + } finally { |
| 1227 | + await rm(cwd, { recursive: true, force: true }); |
| 1228 | + } |
| 1229 | + }); |
| 1230 | + |
1112 | 1231 | it("writes SessionStart state against the long-lived session owner pid and injects environment context", async () => { |
1113 | 1232 | const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-session-start-")); |
1114 | 1233 | try { |
|
0 commit comments