diff --git a/lib/compaction/codex-compaction.ts b/lib/compaction/codex-compaction.ts index 9a0c884..21682e7 100644 --- a/lib/compaction/codex-compaction.ts +++ b/lib/compaction/codex-compaction.ts @@ -85,15 +85,18 @@ export function serializeConversation( } export function buildCompactionPromptItems(transcript: string): InputItem[] { + const compactionMetadata = { source: "opencode-compaction", opencodeCompaction: true }; const developer: InputItem = { type: "message", role: "developer", content: CODEX_COMPACTION_PROMPT, + metadata: compactionMetadata, }; const user: InputItem = { type: "message", role: "user", content: transcript || "(conversation is empty)", + metadata: compactionMetadata, }; return [developer, user]; } diff --git a/lib/request/compaction-helpers.ts b/lib/request/compaction-helpers.ts index 0fb181f..f1c9810 100644 --- a/lib/request/compaction-helpers.ts +++ b/lib/request/compaction-helpers.ts @@ -98,7 +98,7 @@ export function applyCompactionIfNeeded( } const preserveIds = compactionOptions.preserveIds ?? false; - body.input = filterInput(compactionBuild.items, { preserveIds }); + body.input = filterInput(compactionBuild.items, { preserveIds, preserveMetadata: true }); delete (body as any).tools; delete (body as any).tool_choice; delete (body as any).parallel_tool_calls; diff --git a/lib/request/input-filters.ts b/lib/request/input-filters.ts index f3720d7..96a800f 100644 --- a/lib/request/input-filters.ts +++ b/lib/request/input-filters.ts @@ -17,11 +17,11 @@ const TOOL_REMAP_MESSAGE_HASH = generateContentHash(TOOL_REMAP_MESSAGE); export function filterInput( input: InputItem[] | undefined, - options: { preserveIds?: boolean } = {}, + options: { preserveIds?: boolean; preserveMetadata?: boolean } = {}, ): InputItem[] | undefined { if (!Array.isArray(input)) return input; - const { preserveIds = false } = options; + const { preserveIds = false, preserveMetadata = false } = options; return input .filter((item) => { @@ -38,7 +38,7 @@ export function filterInput( sanitized = itemWithoutId as InputItem; } - if (!preserveIds && "metadata" in (sanitized as Record)) { + if (!preserveIds && !preserveMetadata && "metadata" in (sanitized as Record)) { const { metadata: _metadata, ...rest } = sanitized as Record; sanitized = rest as InputItem; } @@ -97,6 +97,21 @@ export async function filterOpenCodeSystemPrompts( /\.opencode\/.*summary/i, ]; + const hasCompactionMetadataFlag = (item: InputItem): boolean => { + const rawMeta = (item as Record)?.metadata ?? (item as Record)?.meta; + if (!rawMeta || typeof rawMeta !== "object") return false; + const meta = rawMeta as Record; + const metaAny = meta as Record; + const source = metaAny.source as unknown; + if (typeof source === "string" && source.toLowerCase() === "opencode-compaction") { + return true; + } + if (metaAny.opencodeCompaction === true || metaAny.opencode_compaction === true) { + return true; + } + return false; + }; + const matchesCompactionInstruction = (value: string): boolean => compactionInstructionPatterns.some((pattern) => pattern.test(value)); @@ -151,7 +166,8 @@ export async function filterOpenCodeSystemPrompts( continue; } - if (isOpenCodeCompactionPrompt(item)) { + const compactionMetadataFlagged = hasCompactionMetadataFlag(item); + if (compactionMetadataFlagged || isOpenCodeCompactionPrompt(item)) { const sanitized = sanitizeOpenCodeCompactionPrompt(item); if (sanitized) { filteredInput.push(sanitized); diff --git a/lib/request/request-transformer.ts b/lib/request/request-transformer.ts index 6a2a6be..b75e01f 100644 --- a/lib/request/request-transformer.ts +++ b/lib/request/request-transformer.ts @@ -52,10 +52,10 @@ async function transformInputForCodex( logDebug(`Filtering ${originalIds.length} message IDs from input:`, originalIds); } - body.input = filterInput(body.input, { preserveIds }); + let workingInput = filterInput(body.input, { preserveIds, preserveMetadata: true }); if (!preserveIds) { - const remainingIds = (body.input || []).filter((item) => item.id).map((item) => item.id); + const remainingIds = (workingInput || []).filter((item) => item.id).map((item) => item.id); if (remainingIds.length > 0) { logWarn(`WARNING: ${remainingIds.length} IDs still present after filtering:`, remainingIds); } else if (originalIds.length > 0) { @@ -66,12 +66,20 @@ async function transformInputForCodex( } if (codexMode) { - body.input = await filterOpenCodeSystemPrompts(body.input); - body.input = addCodexBridgeMessage(body.input, hasNormalizedTools, sessionContext); + workingInput = await filterOpenCodeSystemPrompts(workingInput); + if (!preserveIds) { + workingInput = filterInput(workingInput, { preserveIds }); + } + workingInput = addCodexBridgeMessage(workingInput, hasNormalizedTools, sessionContext); + body.input = workingInput; return; } - body.input = addToolRemapMessage(body.input, hasNormalizedTools); + if (!preserveIds) { + workingInput = filterInput(workingInput, { preserveIds }); + } + + body.input = addToolRemapMessage(workingInput, hasNormalizedTools); } export async function transformRequestBody( diff --git a/spec/compaction-heuristics-22.md b/spec/compaction-heuristics-22.md new file mode 100644 index 0000000..638a46e --- /dev/null +++ b/spec/compaction-heuristics-22.md @@ -0,0 +1,51 @@ +# Issue 22 – Compaction heuristics metadata flag + +**Issue**: https://github.com/open-hax/codex/issues/22 (follow-up to PR #20 review comment r2532755818) + +## Context & Current Behavior + +- Compaction prompt sanitization lives in `lib/request/input-filters.ts:72-165` (`filterOpenCodeSystemPrompts`). It relies on regex heuristics over content to strip OpenCode auto-compaction summary-file instructions. +- Core filtering pipeline in `lib/request/request-transformer.ts:38-75` runs `filterInput` **before** `filterOpenCodeSystemPrompts`; `filterInput` currently strips `metadata` when `preserveIds` is false, so any upstream metadata markers are lost before heuristic detection. +- Compaction prompts produced by this plugin are built in `lib/compaction/codex-compaction.ts:88-99` via `buildCompactionPromptItems`, but no metadata flags are attached to identify them as OpenCode compaction artifacts. +- Tests for the filtering behavior live in `test/request-transformer.test.ts:539-618` and currently cover regex-only heuristics (no metadata awareness). + +## Problem + +Heuristic-only detection risks false positives/negatives. Review feedback requested an explicit metadata flag on OpenCode compaction prompts (e.g., `metadata.source === "opencode-compaction"`) and to prefer that flag over regex checks, falling back to heuristics when metadata is absent. + +## Solution Strategy + +### Phase 1: Metadata flag plumbing + +- Tag plugin-generated compaction prompt items (developer + user) with a clear metadata flag, e.g., `metadata: { source: "opencode-compaction" }` or boolean `opencodeCompaction`. Ensure the flag survives filtering. +- Adjust the filtering pipeline to preserve metadata long enough for detection (e.g., allow metadata passthrough pre-sanitization or re-order detection vs. stripping) while still removing other metadata before sending to Codex backend unless IDs are preserved. + +### Phase 2: Metadata-aware filtering + +- Update `filterOpenCodeSystemPrompts` to first check metadata flags for compaction/system prompts and sanitize/remove based on that before running regex heuristics. Heuristics remain as fallback when metadata is missing. +- Ensure system prompt detection (`isOpenCodeSystemPrompt`) remains unchanged. + +### Phase 3: Tests + +- Expand `test/request-transformer.test.ts` to cover: + - Metadata-tagged compaction prompts being sanitized/removed (preferred path). + - Fallback to heuristics when metadata flag is absent. + - Metadata preserved just long enough for detection but not leaked when `preserveIds` is false. + +## Definition of Done / Requirements + +- [x] Incoming OpenCode compaction prompts marked with metadata are detected and sanitized/removed without relying on text heuristics. +- [x] Heuristic detection remains functional when metadata is absent. +- [x] Metadata needed for detection is not stripped before filtering; final output still omits metadata unless explicitly preserved. +- [x] Tests updated/added to cover metadata flag path and fallback behavior. + +## Files to Modify + +- `lib/compaction/codex-compaction.ts` – attach metadata flag to compaction prompt items built by the plugin. +- `lib/request/input-filters.ts` – prefer metadata-aware detection and keep heuristics as fallback. +- `lib/request/request-transformer.ts` – ensure metadata survives into filter stage (ordering/options tweak) but is removed thereafter when appropriate. +- `test/request-transformer.test.ts` – add coverage for metadata-flagged compaction prompts and fallback behavior. + +## Change Log + +- 2025-11-20: Implemented metadata flag detection/preservation pipeline, tagged compaction prompt builders, added metadata-focused tests, and ran `npm test -- request-transformer.test.ts`. diff --git a/test/request-transformer.test.ts b/test/request-transformer.test.ts index 1340bb8..e35f5eb 100644 --- a/test/request-transformer.test.ts +++ b/test/request-transformer.test.ts @@ -238,6 +238,23 @@ describe("filterInput", () => { expect(result![0]).toHaveProperty("metadata"); }); + it("preserves metadata when explicitly requested without preserving IDs", async () => { + const input: InputItem[] = [ + { + id: "msg_456", + type: "message", + role: "developer", + content: "Summary saved to ~/.opencode/summary.md", + metadata: { source: "opencode-compaction" }, + }, + ]; + const result = filterInput(input, { preserveMetadata: true }); + + expect(result).toHaveLength(1); + expect(result![0]).not.toHaveProperty("id"); + expect(result![0]).toHaveProperty("metadata"); + }); + it("should handle mixed items with and without IDs", async () => { const input: InputItem[] = [ { type: "message", role: "user", content: "1" }, @@ -613,6 +630,21 @@ describe("filterOpenCodeSystemPrompts", () => { expect(result![1].role).toBe("user"); }); + it("should use metadata flag to detect compaction prompts", async () => { + const input: InputItem[] = [ + { + type: "message", + role: "developer", + content: "Summary saved to ~/.opencode/summary.md for inspection", + metadata: { source: "opencode-compaction" }, + }, + { type: "message", role: "user", content: "continue" }, + ]; + const result = await filterOpenCodeSystemPrompts(input); + expect(result).toHaveLength(1); + expect(result![0].role).toBe("user"); + }); + it("should return undefined for undefined input", async () => { expect(await filterOpenCodeSystemPrompts(undefined)).toBeUndefined(); }); @@ -743,6 +775,29 @@ describe("transformRequestBody", () => { expect(result2.prompt_cache_key).toBe("cache_meta-conv-789-fork-fork-x"); }); + it("filters metadata-tagged compaction prompts and strips metadata when IDs are not preserved", async () => { + const body: RequestBody = { + model: "gpt-5", + input: [ + { + type: "message", + role: "developer", + content: "Summary saved to ~/.opencode/summary.md for inspection", + metadata: { source: "opencode-compaction" }, + }, + { type: "message", role: "user", content: "continue" }, + ], + }; + + const transformedBody = await transformRequestBody(body, codexInstructions); + expect(transformedBody).toBeDefined(); + const messages = transformedBody.input ?? []; + + expect(messages.some((item) => (item as any).metadata)).toBe(false); + expect(JSON.stringify(messages)).not.toContain(".opencode/summary"); + expect(messages.some((item) => item.role === "user" && (item as any).content === "continue")).toBe(true); + }); + it("keeps bridge prompt across turns so prompt_cache_key stays stable", async () => { const sessionManager = new SessionManager({ enabled: true }); const baseInput: InputItem[] = [