From 5426a29960db1f3c944fc5cd3793e87b3ee13eb1 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sun, 15 Mar 2026 23:16:53 -0400 Subject: [PATCH 1/3] prompts: fix override tag parsing Avoid rejecting copied system prompt overrides that mention dcp-system-reminder in plain text, while still rejecting malformed wrappers and covering the behavior with tests. Closes #442 --- lib/prompts/store.ts | 15 +++--- tests/prompts.test.ts | 103 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 tests/prompts.test.ts diff --git a/lib/prompts/store.ts b/lib/prompts/store.ts index 5216dade..d088f07b 100644 --- a/lib/prompts/store.ts +++ b/lib/prompts/store.ts @@ -183,12 +183,6 @@ function stripConditionalTag(content: string, tagName: string): string { return content.replace(regex, "") } -function hasTagPairMismatch(content: string, tagName: string): boolean { - const openRegex = new RegExp(`<${tagName}\\b[^>]*>`, "i") - const closeRegex = new RegExp(`<\/${tagName}>`, "i") - return openRegex.test(content) !== closeRegex.test(content) -} - function unwrapDcpTagIfWrapped(content: string): string { const trimmed = content.trim() @@ -205,7 +199,14 @@ function unwrapDcpTagIfWrapped(content: string): string { function normalizeReminderPromptContent(content: string): string { const normalized = content.trim() - if (hasTagPairMismatch(normalized, "dcp-system-reminder")) { + if (!normalized) { + return "" + } + + const startsWrapped = /^\s*]*>/i.test(normalized) + const endsWrapped = /<\/dcp-system-reminder>\s*$/i.test(normalized) + + if (startsWrapped !== endsWrapped) { return "" } diff --git a/tests/prompts.test.ts b/tests/prompts.test.ts new file mode 100644 index 00000000..9a3974c5 --- /dev/null +++ b/tests/prompts.test.ts @@ -0,0 +1,103 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { tmpdir } from "node:os" +import { Logger } from "../lib/logger" +import { PromptStore } from "../lib/prompts/store" +import { SYSTEM as SYSTEM_PROMPT } from "../lib/prompts/system" + +function createPromptStoreFixture(overrideContent?: string) { + const rootDir = mkdtempSync(join(tmpdir(), "opencode-dcp-prompts-")) + const configHome = join(rootDir, "config") + const workspaceDir = join(rootDir, "workspace") + + mkdirSync(configHome, { recursive: true }) + mkdirSync(workspaceDir, { recursive: true }) + + const previousConfigHome = process.env.XDG_CONFIG_HOME + const previousOpencodeConfigDir = process.env.OPENCODE_CONFIG_DIR + + process.env.XDG_CONFIG_HOME = configHome + delete process.env.OPENCODE_CONFIG_DIR + + if (overrideContent !== undefined) { + const overrideDir = join(configHome, "opencode", "dcp-prompts", "overrides") + mkdirSync(overrideDir, { recursive: true }) + writeFileSync(join(overrideDir, "system.md"), overrideContent, "utf-8") + } + + const store = new PromptStore(new Logger(false), workspaceDir, true) + + return { + store, + cleanup() { + if (previousConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = previousConfigHome + } + + if (previousOpencodeConfigDir === undefined) { + delete process.env.OPENCODE_CONFIG_DIR + } else { + process.env.OPENCODE_CONFIG_DIR = previousOpencodeConfigDir + } + + rmSync(rootDir, { recursive: true, force: true }) + }, + } +} + +test("system prompt overrides handle reminder tags safely", async (t) => { + await t.test("plain-text mentions do not invalidate copied system prompt overrides", () => { + const fixture = createPromptStoreFixture( + `${SYSTEM_PROMPT.trim()}\n\nExtra override line.\n`, + ) + + try { + const runtimeSystemPrompt = fixture.store.getRuntimePrompts().system + + assert.match(runtimeSystemPrompt, /Extra override line\./) + assert.match(runtimeSystemPrompt, /environment-injected metadata/) + } finally { + fixture.cleanup() + } + }) + + await t.test("fully wrapped overrides are normalized to a single runtime wrapper", () => { + const fixture = createPromptStoreFixture( + `\nWrapped override body\n\n`, + ) + + try { + const runtimeSystemPrompt = fixture.store.getRuntimePrompts().system + const openingTags = runtimeSystemPrompt.match(/]*>/g) ?? [] + const closingTags = runtimeSystemPrompt.match(/<\/dcp-system-reminder>/g) ?? [] + + assert.equal(openingTags.length, 1) + assert.equal(closingTags.length, 1) + assert.match(runtimeSystemPrompt, /Wrapped override body/) + } finally { + fixture.cleanup() + } + }) + + await t.test("malformed boundary wrappers are rejected", () => { + const baselineFixture = createPromptStoreFixture() + const malformedFixture = createPromptStoreFixture( + `\nMalformed override body\n`, + ) + + try { + const baselineSystemPrompt = baselineFixture.store.getRuntimePrompts().system + const malformedSystemPrompt = malformedFixture.store.getRuntimePrompts().system + + assert.equal(malformedSystemPrompt, baselineSystemPrompt) + assert.doesNotMatch(malformedSystemPrompt, /Malformed override body/) + } finally { + malformedFixture.cleanup() + baselineFixture.cleanup() + } + }) +}) From 538b161bedd78d92815259a0b458dc0b5f3ab9b1 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sun, 15 Mar 2026 23:33:57 -0400 Subject: [PATCH 2/3] compress: ignore invalid block placeholders Allow compress to continue when summaries contain extra invalid block placeholders by filtering them before injection, while preserving required block recovery and covering the behavior with tests. Closes #441 --- lib/tools/utils.ts | 61 ++++----------- tests/compress-placeholders.test.ts | 117 ++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 44 deletions(-) create mode 100644 tests/compress-placeholders.test.ts diff --git a/lib/tools/utils.ts b/lib/tools/utils.ts index e7992136..a3c24013 100644 --- a/lib/tools/utils.ts +++ b/lib/tools/utils.ts @@ -409,67 +409,40 @@ export function validateSummaryPlaceholders( endReference: BoundaryReference, summaryByBlockId: Map, ): number[] { - const issues: string[] = [] - const boundaryOptionalIds = new Set() if (startReference.kind === "compressed-block") { if (startReference.blockId === undefined) { - issues.push("Failed to map boundary matches back to raw messages") - } else { - boundaryOptionalIds.add(startReference.blockId) + throw new Error("Failed to map boundary matches back to raw messages") } + boundaryOptionalIds.add(startReference.blockId) } if (endReference.kind === "compressed-block") { if (endReference.blockId === undefined) { - issues.push("Failed to map boundary matches back to raw messages") - } else { - boundaryOptionalIds.add(endReference.blockId) + throw new Error("Failed to map boundary matches back to raw messages") } + boundaryOptionalIds.add(endReference.blockId) } const strictRequiredIds = requiredBlockIds.filter((id) => !boundaryOptionalIds.has(id)) const requiredSet = new Set(requiredBlockIds) - const placeholderIds = placeholders.map((p) => p.blockId) - const placeholderSet = new Set() - const duplicateIds = new Set() - - for (const id of placeholderIds) { - if (placeholderSet.has(id)) { - duplicateIds.add(id) - continue - } - placeholderSet.add(id) - } - - const missing = strictRequiredIds.filter((id) => !placeholderSet.has(id)) - - const unknown = placeholderIds.filter((id) => !summaryByBlockId.has(id)) - if (unknown.length > 0) { - const uniqueUnknown = [...new Set(unknown)] - issues.push( - `Unknown block placeholders: ${uniqueUnknown.map(formatBlockPlaceholder).join(", ")}`, - ) - } + const keptPlaceholderIds = new Set() + const validPlaceholders: ParsedBlockPlaceholder[] = [] - const invalid = placeholderIds.filter((id) => !requiredSet.has(id)) - if (invalid.length > 0) { - const uniqueInvalid = [...new Set(invalid)] - issues.push( - `Invalid block placeholders for selected range: ${uniqueInvalid.map(formatBlockPlaceholder).join(", ")}`, - ) - } + for (const placeholder of placeholders) { + const isKnown = summaryByBlockId.has(placeholder.blockId) + const isRequired = requiredSet.has(placeholder.blockId) + const isDuplicate = keptPlaceholderIds.has(placeholder.blockId) - if (duplicateIds.size > 0) { - issues.push( - `Duplicate block placeholders are not allowed: ${[...duplicateIds].map(formatBlockPlaceholder).join(", ")}`, - ) + if (isKnown && isRequired && !isDuplicate) { + validPlaceholders.push(placeholder) + keptPlaceholderIds.add(placeholder.blockId) + } } - if (issues.length > 0) { - throwCombinedIssues(issues) - } + placeholders.length = 0 + placeholders.push(...validPlaceholders) - return missing + return strictRequiredIds.filter((id) => !keptPlaceholderIds.has(id)) } export function injectBlockPlaceholders( diff --git a/tests/compress-placeholders.test.ts b/tests/compress-placeholders.test.ts new file mode 100644 index 00000000..e7b1c27a --- /dev/null +++ b/tests/compress-placeholders.test.ts @@ -0,0 +1,117 @@ +import assert from "node:assert/strict" +import test from "node:test" +import type { CompressionBlock } from "../lib/state" +import { + appendMissingBlockSummaries, + injectBlockPlaceholders, + parseBlockPlaceholders, + validateSummaryPlaceholders, + wrapCompressedSummary, + type BoundaryReference, +} from "../lib/tools/utils" + +function createBlock(blockId: number, body: string): CompressionBlock { + return { + blockId, + active: true, + deactivatedByUser: false, + compressedTokens: 0, + topic: `Block ${blockId}`, + startId: "m0001", + endId: "m0002", + anchorMessageId: `msg-${blockId}`, + compressMessageId: `compress-${blockId}`, + includedBlockIds: [], + consumedBlockIds: [], + parentBlockIds: [], + directMessageIds: [], + directToolIds: [], + effectiveMessageIds: [`msg-${blockId}`], + effectiveToolIds: [], + createdAt: blockId, + summary: wrapCompressedSummary(blockId, body), + } +} + +function createMessageBoundary(messageId: string, rawIndex: number): BoundaryReference { + return { + kind: "message", + messageId, + rawIndex, + } +} + +test("compress placeholder validation keeps valid placeholders and ignores invalid ones", () => { + const summaryByBlockId = new Map([ + [1, createBlock(1, "First compressed summary")], + [2, createBlock(2, "Second compressed summary")], + ]) + const summary = "Intro (b1) unknown (b9) duplicate (b1) out-of-range (b2) outro" + const parsed = parseBlockPlaceholders(summary) + + const missingBlockIds = validateSummaryPlaceholders( + parsed, + [1], + createMessageBoundary("msg-a", 0), + createMessageBoundary("msg-b", 1), + summaryByBlockId, + ) + + assert.deepEqual( + parsed.map((placeholder) => placeholder.blockId), + [1], + ) + assert.equal(missingBlockIds.length, 0) + + const injected = injectBlockPlaceholders( + summary, + parsed, + summaryByBlockId, + createMessageBoundary("msg-a", 0), + createMessageBoundary("msg-b", 1), + ) + + assert.match(injected.expandedSummary, /First compressed summary/) + assert.doesNotMatch(injected.expandedSummary, /Second compressed summary/) + assert.match(injected.expandedSummary, /\(b9\)/) + assert.match(injected.expandedSummary, /\(b2\)/) + assert.deepEqual(injected.consumedBlockIds, [1]) +}) + +test("compress continues by appending required block summaries the model omitted", () => { + const summaryByBlockId = new Map([[1, createBlock(1, "Recovered compressed summary")]]) + const summary = "The model forgot to include the prior block." + const parsed = parseBlockPlaceholders(summary) + + const missingBlockIds = validateSummaryPlaceholders( + parsed, + [1], + createMessageBoundary("msg-a", 0), + createMessageBoundary("msg-b", 1), + summaryByBlockId, + ) + + assert.deepEqual(missingBlockIds, [1]) + + const injected = injectBlockPlaceholders( + summary, + parsed, + summaryByBlockId, + createMessageBoundary("msg-a", 0), + createMessageBoundary("msg-b", 1), + ) + const finalSummary = appendMissingBlockSummaries( + injected.expandedSummary, + missingBlockIds, + summaryByBlockId, + injected.consumedBlockIds, + ) + + assert.match( + finalSummary.expandedSummary, + /The following previously compressed summaries were also part of this conversation section:/, + ) + assert.match(finalSummary.expandedSummary, /### \(b1\)/) + assert.match(finalSummary.expandedSummary, /Recovered compressed summary/) + assert.deepEqual(finalSummary.consumedBlockIds, [1]) +}) From 2f9b1c0c2ed21d4d8cead5c6a25d651f8082b0ba Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sun, 15 Mar 2026 23:43:54 -0400 Subject: [PATCH 3/3] config: raise default compress thresholds Increase the default compression reminder thresholds so DCP starts nudging later and uses a higher upper bound by default, while keeping the schema, docs, and test fixture aligned. --- README.md | 6 +++--- dcp.schema.json | 4 ++-- lib/config.ts | 4 ++-- tests/compress.test.ts | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 0d2f0f7a..627418a9 100644 --- a/README.md +++ b/README.md @@ -106,11 +106,11 @@ Each level overrides the previous, so project settings take priority over global // Soft upper threshold: above this, DCP keeps injecting strong // compression nudges (based on nudgeFrequency), so compression is // much more likely. Accepts: number or "X%" of model context window. - "maxContextLimit": 100000, + "maxContextLimit": 150000, // Soft lower threshold for reminder nudges: below this, turn/iteration // reminders are off (compression less likely). At/above this, reminders // are on. Accepts: number or "X%" of model context window. - "minContextLimit": 30000, + "minContextLimit": 50000, // Optional per-model override for maxContextLimit by providerID/modelID. // If present, this wins over the global maxContextLimit. // Accepts: number or "X%". @@ -122,7 +122,7 @@ Each level overrides the previous, so project settings take priority over global // Optional per-model override for minContextLimit. // If present, this wins over the global minContextLimit. // "modelMinLimits": { - // "openai/gpt-5.3-codex": 30000, + // "openai/gpt-5.3-codex": 50000, // "anthropic/claude-sonnet-4.6": "25%" // }, // How often the context-limit nudge fires (1 = every fetch, 5 = every 5th) diff --git a/dcp.schema.json b/dcp.schema.json index 9c175312..a927b870 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -141,7 +141,7 @@ }, "maxContextLimit": { "description": "Soft upper threshold. Above this, DCP keeps sending strong compression nudges (based on nudgeFrequency), so the model is pushed to compress. Accepts number or \"X%\" of the model context window.", - "default": 100000, + "default": 150000, "oneOf": [ { "type": "number" @@ -154,7 +154,7 @@ }, "minContextLimit": { "description": "Soft lower threshold for reminder nudges. Below this, turn/iteration reminders are off (compression is less likely). At or above this, reminders are on. Accepts number or \"X%\" of the model context window.", - "default": 30000, + "default": 50000, "oneOf": [ { "type": "number" diff --git a/lib/config.ts b/lib/config.ts index 8b289e29..c0c96547 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -664,8 +664,8 @@ const defaultConfig: PluginConfig = { compress: { permission: "allow", showCompression: false, - maxContextLimit: 100000, - minContextLimit: 30000, + maxContextLimit: 150000, + minContextLimit: 50000, nudgeFrequency: 5, iterationNudgeThreshold: 15, nudgeForce: "soft", diff --git a/tests/compress.test.ts b/tests/compress.test.ts index 17706db7..988603e2 100644 --- a/tests/compress.test.ts +++ b/tests/compress.test.ts @@ -43,8 +43,8 @@ function buildConfig(): PluginConfig { compress: { permission: "allow", showCompression: false, - maxContextLimit: 100000, - minContextLimit: 30000, + maxContextLimit: 150000, + minContextLimit: 50000, nudgeFrequency: 5, iterationNudgeThreshold: 15, nudgeForce: "soft",