Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/compaction/codex-compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
Expand Down
2 changes: 1 addition & 1 deletion lib/request/compaction-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
24 changes: 20 additions & 4 deletions lib/request/input-filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -38,7 +38,7 @@ export function filterInput(
sanitized = itemWithoutId as InputItem;
}

if (!preserveIds && "metadata" in (sanitized as Record<string, unknown>)) {
if (!preserveIds && !preserveMetadata && "metadata" in (sanitized as Record<string, unknown>)) {
const { metadata: _metadata, ...rest } = sanitized as Record<string, unknown>;
sanitized = rest as InputItem;
}
Expand Down Expand Up @@ -97,6 +97,21 @@ export async function filterOpenCodeSystemPrompts(
/\.opencode\/.*summary/i,
];

const hasCompactionMetadataFlag = (item: InputItem): boolean => {
const rawMeta = (item as Record<string, unknown>)?.metadata ?? (item as Record<string, unknown>)?.meta;
if (!rawMeta || typeof rawMeta !== "object") return false;
const meta = rawMeta as Record<string, unknown>;
const metaAny = meta as Record<string, any>;
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));

Expand Down Expand Up @@ -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);
Expand Down
18 changes: 13 additions & 5 deletions lib/request/request-transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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(
Expand Down
51 changes: 51 additions & 0 deletions spec/compaction-heuristics-22.md
Original file line number Diff line number Diff line change
@@ -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`.
55 changes: 55 additions & 0 deletions test/request-transformer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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[] = [
Expand Down
Loading