fix(codex-rules): dedupe compacted context
This commit is contained in:
@@ -20,6 +20,8 @@ import { sortCandidates } from "./rules/ordering.js";
|
|||||||
import { findProjectRoot } from "./rules/project-root.js";
|
import { findProjectRoot } from "./rules/project-root.js";
|
||||||
import type { LoadedRule, PiRulesConfig, RuleCandidate } from "./rules/types.js";
|
import type { LoadedRule, PiRulesConfig, RuleCandidate } from "./rules/types.js";
|
||||||
import { extractCodexToolPaths } from "./tool-paths.js";
|
import { extractCodexToolPaths } from "./tool-paths.js";
|
||||||
|
import type { TranscriptSearchOptions } from "./transcript-search.js";
|
||||||
|
import { readTranscriptSearchText } from "./transcript-search.js";
|
||||||
|
|
||||||
type ContextInjectionHookEventName = "SessionStart" | "UserPromptSubmit" | "PostToolUse";
|
type ContextInjectionHookEventName = "SessionStart" | "UserPromptSubmit" | "PostToolUse";
|
||||||
|
|
||||||
@@ -90,7 +92,7 @@ export async function runSessionStartHook(
|
|||||||
clearSessionState(cachePath);
|
clearSessionState(cachePath);
|
||||||
}
|
}
|
||||||
const postCompactPending = input.source !== "clear" && isPostCompactPending(cachePath, "static");
|
const postCompactPending = input.source !== "clear" && isPostCompactPending(cachePath, "static");
|
||||||
const transcriptPath = input.source === "clear" || postCompactPending ? null : input.transcript_path;
|
const transcriptPath = input.source === "clear" ? null : input.transcript_path;
|
||||||
return runStaticInjection(
|
return runStaticInjection(
|
||||||
input.cwd,
|
input.cwd,
|
||||||
transcriptPath,
|
transcriptPath,
|
||||||
@@ -98,6 +100,7 @@ export async function runSessionStartHook(
|
|||||||
cachePath,
|
cachePath,
|
||||||
options,
|
options,
|
||||||
postCompactPending ? "static" : undefined,
|
postCompactPending ? "static" : undefined,
|
||||||
|
{ latestCompactedReplacementOnly: postCompactPending },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,14 +118,14 @@ export async function runUserPromptSubmitHook(
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot);
|
const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot);
|
||||||
const postCompactPending = isPostCompactPending(cachePath, "static");
|
const postCompactPending = isPostCompactPending(cachePath, "static");
|
||||||
const transcriptPath = postCompactPending ? null : input.transcript_path;
|
|
||||||
return runStaticInjection(
|
return runStaticInjection(
|
||||||
input.cwd,
|
input.cwd,
|
||||||
transcriptPath,
|
input.transcript_path,
|
||||||
"UserPromptSubmit",
|
"UserPromptSubmit",
|
||||||
cachePath,
|
cachePath,
|
||||||
options,
|
options,
|
||||||
postCompactPending ? "static" : undefined,
|
postCompactPending ? "static" : undefined,
|
||||||
|
{ latestCompactedReplacementOnly: postCompactPending },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +155,6 @@ export async function runPostToolUseHook(
|
|||||||
|
|
||||||
const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot);
|
const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot);
|
||||||
const postCompactPending = isPostCompactPending(cachePath, "dynamic");
|
const postCompactPending = isPostCompactPending(cachePath, "dynamic");
|
||||||
const transcriptPath = postCompactPending ? null : input.transcript_path;
|
|
||||||
const engine = createRulesEngine(options);
|
const engine = createRulesEngine(options);
|
||||||
hydrateEngineState(engine, cachePath);
|
hydrateEngineState(engine, cachePath);
|
||||||
debugTimer.lap("hydrate", {
|
debugTimer.lap("hydrate", {
|
||||||
@@ -180,10 +182,11 @@ export async function runPostToolUseHook(
|
|||||||
debugTimer.lap("load", { diagnostics: loaded.diagnostics.length, loadedRules: loaded.rules.length });
|
debugTimer.lap("load", { diagnostics: loaded.diagnostics.length, loadedRules: loaded.rules.length });
|
||||||
const rules = filterRulesAlreadyInTranscript(
|
const rules = filterRulesAlreadyInTranscript(
|
||||||
loaded.rules.filter((rule) => !engine.isStaticInjected(rule) && !engine.isDynamicInjected(rule)),
|
loaded.rules.filter((rule) => !engine.isStaticInjected(rule) && !engine.isDynamicInjected(rule)),
|
||||||
transcriptPath,
|
input.transcript_path,
|
||||||
(rule) => {
|
(rule) => {
|
||||||
engine.markDynamicInjected(rule);
|
engine.markDynamicInjected(rule);
|
||||||
},
|
},
|
||||||
|
{ latestCompactedReplacementOnly: postCompactPending },
|
||||||
);
|
);
|
||||||
debugTimer.lap("filter", { rules: rules.length });
|
debugTimer.lap("filter", { rules: rules.length });
|
||||||
for (const target of pendingTargetFingerprints) {
|
for (const target of pendingTargetFingerprints) {
|
||||||
@@ -216,6 +219,7 @@ function runStaticInjection(
|
|||||||
cachePath: string,
|
cachePath: string,
|
||||||
options: CodexRulesHookOptions,
|
options: CodexRulesHookOptions,
|
||||||
completedPostCompactChannel?: "static",
|
completedPostCompactChannel?: "static",
|
||||||
|
transcriptSearchOptions: TranscriptSearchOptions = {},
|
||||||
): string {
|
): string {
|
||||||
const config = configFromEnvironment(options.env);
|
const config = configFromEnvironment(options.env);
|
||||||
if (config.disabled || config.mode === "off" || config.mode === "dynamic") {
|
if (config.disabled || config.mode === "off" || config.mode === "dynamic") {
|
||||||
@@ -233,6 +237,7 @@ function runStaticInjection(
|
|||||||
(rule) => {
|
(rule) => {
|
||||||
engine.markStaticInjected(rule);
|
engine.markStaticInjected(rule);
|
||||||
},
|
},
|
||||||
|
transcriptSearchOptions,
|
||||||
);
|
);
|
||||||
if (rules.length === 0) {
|
if (rules.length === 0) {
|
||||||
persistEngineState(engine, cachePath, completedPostCompactChannel);
|
persistEngineState(engine, cachePath, completedPostCompactChannel);
|
||||||
@@ -251,12 +256,13 @@ function filterRulesAlreadyInTranscript(
|
|||||||
rules: ReadonlyArray<LoadedRule>,
|
rules: ReadonlyArray<LoadedRule>,
|
||||||
transcriptPath: string | null,
|
transcriptPath: string | null,
|
||||||
markInjected: (rule: LoadedRule) => void,
|
markInjected: (rule: LoadedRule) => void,
|
||||||
|
options: TranscriptSearchOptions = {},
|
||||||
): LoadedRule[] {
|
): LoadedRule[] {
|
||||||
if (rules.length === 0 || transcriptPath === null) {
|
if (rules.length === 0 || transcriptPath === null) {
|
||||||
return [...rules];
|
return [...rules];
|
||||||
}
|
}
|
||||||
|
|
||||||
const transcriptText = readTranscriptSearchText(transcriptPath);
|
const transcriptText = readTranscriptSearchText(transcriptPath, options);
|
||||||
if (transcriptText === null) {
|
if (transcriptText === null) {
|
||||||
return [...rules];
|
return [...rules];
|
||||||
}
|
}
|
||||||
@@ -287,54 +293,6 @@ function isRuleAlreadyInTranscript(rule: LoadedRule, transcriptText: string): bo
|
|||||||
return markers.some((marker) => transcriptText.includes(marker));
|
return markers.some((marker) => transcriptText.includes(marker));
|
||||||
}
|
}
|
||||||
|
|
||||||
function readTranscriptSearchText(transcriptPath: string): string | null {
|
|
||||||
try {
|
|
||||||
const rawTranscript = readFileSync(transcriptPath, "utf8");
|
|
||||||
return [rawTranscript, ...collectJsonLineStrings(rawTranscript)].join("\n");
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectJsonLineStrings(rawTranscript: string): string[] {
|
|
||||||
const values: string[] = [];
|
|
||||||
for (const line of rawTranscript.split(/\r?\n/)) {
|
|
||||||
if (line.trim().length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed: unknown = JSON.parse(line);
|
|
||||||
collectStrings(parsed, values);
|
|
||||||
} catch {
|
|
||||||
// Non-JSON transcript lines are still covered by the raw transcript text.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectStrings(value: unknown, output: string[]): void {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
output.push(value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
for (const item of value) {
|
|
||||||
collectStrings(item, output);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value !== "object" || value === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of Object.values(value)) {
|
|
||||||
collectStrings(item, output);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createRulesEngine(options: CodexRulesHookOptions) {
|
function createRulesEngine(options: CodexRulesHookOptions) {
|
||||||
const config = configFromEnvironment(options.env);
|
const config = configFromEnvironment(options.env);
|
||||||
return createEngine(config, {
|
return createEngine(config, {
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
export interface TranscriptSearchOptions {
|
||||||
|
readonly latestCompactedReplacementOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readTranscriptSearchText(transcriptPath: string, options: TranscriptSearchOptions = {}): string | null {
|
||||||
|
try {
|
||||||
|
const rawTranscript = readFileSync(transcriptPath, "utf8");
|
||||||
|
if (options.latestCompactedReplacementOnly === true) {
|
||||||
|
return latestCompactedReplacementSearchText(rawTranscript);
|
||||||
|
}
|
||||||
|
return [rawTranscript, ...collectJsonLineStrings(rawTranscript)].join("\n");
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestCompactedReplacementSearchText(rawTranscript: string): string | null {
|
||||||
|
const lines = rawTranscript.split(/\r?\n/);
|
||||||
|
let latestCompactedLineIndex = -1;
|
||||||
|
let replacementHistory: unknown[] | null = null;
|
||||||
|
for (const [index, line] of lines.entries()) {
|
||||||
|
const parsed = parseJsonLine(line);
|
||||||
|
if (!isRecord(parsed) || parsed["type"] !== "compacted") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parsed["payload"];
|
||||||
|
if (!isRecord(payload)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateReplacementHistory = payload["replacement_history"];
|
||||||
|
if (!Array.isArray(candidateReplacementHistory)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
latestCompactedLineIndex = index;
|
||||||
|
replacementHistory = candidateReplacementHistory;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replacementHistory === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values: string[] = [];
|
||||||
|
collectStrings(replacementHistory, values);
|
||||||
|
const laterTranscript = lines.slice(latestCompactedLineIndex + 1).join("\n");
|
||||||
|
values.push(laterTranscript, ...collectJsonLineStrings(laterTranscript));
|
||||||
|
return values.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectJsonLineStrings(rawTranscript: string): string[] {
|
||||||
|
const values: string[] = [];
|
||||||
|
for (const line of rawTranscript.split(/\r?\n/)) {
|
||||||
|
const parsed = parseJsonLine(line);
|
||||||
|
if (parsed !== null) {
|
||||||
|
collectStrings(parsed, values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonLine(line: string): unknown | null {
|
||||||
|
if (line.trim().length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(line);
|
||||||
|
return parsed;
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectStrings(value: unknown, output: string[]): void {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
output.push(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) {
|
||||||
|
collectStrings(item, output);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of Object.values(value)) {
|
||||||
|
collectStrings(item, output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
+268
@@ -0,0 +1,268 @@
|
|||||||
|
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 compacted replacement dropped static context #when UserPromptSubmit runs after PostCompact #then it re-injects 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(readAdditionalContext(output)).toContain("Always wear safety goggles");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 postCompactInput(root: string): CodexPostCompactInput {
|
||||||
|
return {
|
||||||
|
session_id: "session-compact-dedup",
|
||||||
|
turn_id: "turn-compact",
|
||||||
|
transcript_path: null,
|
||||||
|
cwd: root,
|
||||||
|
hook_event_name: "PostCompact",
|
||||||
|
model: "gpt-5.5",
|
||||||
|
trigger: "manual",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function userPromptSubmitInput(root: string, transcriptPath: string): Parameters<typeof runUserPromptSubmitHook>[0] {
|
||||||
|
return {
|
||||||
|
session_id: "session-compact-dedup",
|
||||||
|
turn_id: "turn-after-compact",
|
||||||
|
transcript_path: transcriptPath,
|
||||||
|
cwd: root,
|
||||||
|
hook_event_name: "UserPromptSubmit",
|
||||||
|
model: "gpt-5.5",
|
||||||
|
permission_mode: "default",
|
||||||
|
prompt: "read src/app.ts",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function postToolUseInput(root: string, filePath: string): CodexPostToolUseInput {
|
||||||
|
return {
|
||||||
|
session_id: "session-compact-dedup",
|
||||||
|
turn_id: "turn-after-compact",
|
||||||
|
transcript_path: null,
|
||||||
|
cwd: root,
|
||||||
|
hook_event_name: "PostToolUse",
|
||||||
|
model: "gpt-5.5",
|
||||||
|
permission_mode: "default",
|
||||||
|
tool_name: "mcp__filesystem__read_file",
|
||||||
|
tool_input: { path: filePath },
|
||||||
|
tool_response: { text: "file contents" },
|
||||||
|
tool_use_id: "call-1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTranscriptWithCompactedReplacement(root: string, ...replacementTexts: string[]): string {
|
||||||
|
const transcriptPath = path.join(root, "transcript-compacted.jsonl");
|
||||||
|
const replacementHistory = replacementTexts.map((text) => ({
|
||||||
|
type: "message",
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "input_text", text }],
|
||||||
|
}));
|
||||||
|
writeFileSync(
|
||||||
|
transcriptPath,
|
||||||
|
`${JSON.stringify({
|
||||||
|
type: "compacted",
|
||||||
|
payload: {
|
||||||
|
message: "summary",
|
||||||
|
replacement_history: replacementHistory,
|
||||||
|
},
|
||||||
|
})}\n`,
|
||||||
|
);
|
||||||
|
return transcriptPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTranscriptWithRepeatedCompactions(root: string, retainedText: string): string {
|
||||||
|
const transcriptPath = path.join(root, "transcript-repeated-compacted.jsonl");
|
||||||
|
writeFileSync(
|
||||||
|
transcriptPath,
|
||||||
|
[
|
||||||
|
"{not json",
|
||||||
|
JSON.stringify({
|
||||||
|
type: "compacted",
|
||||||
|
payload: {
|
||||||
|
message: "older summary",
|
||||||
|
replacement_history: [{ type: "message", role: "user", content: "old summary without rules" }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
type: "message",
|
||||||
|
payload: { content: "x".repeat(10_000) },
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
type: "compacted",
|
||||||
|
payload: {
|
||||||
|
message: "latest summary",
|
||||||
|
replacement_history: [
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "input_text", text: retainedText }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
type: "message",
|
||||||
|
payload: { content: "later prompt after compact" },
|
||||||
|
}),
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
return transcriptPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAdditionalContext(output: string): string {
|
||||||
|
expect(output.trim().length).toBeGreaterThan(0);
|
||||||
|
const parsed: unknown = JSON.parse(output);
|
||||||
|
if (!isRecord(parsed)) return "";
|
||||||
|
const hookSpecificOutput = parsed["hookSpecificOutput"];
|
||||||
|
if (!isRecord(hookSpecificOutput)) return "";
|
||||||
|
const additionalContext = hookSpecificOutput["additionalContext"];
|
||||||
|
return typeof additionalContext === "string" ? additionalContext : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user