Skip to content

Commit 59f110e

Browse files
authored
Merge branch 'dev' into patch-1
2 parents 18c3393 + 5fd2335 commit 59f110e

10 files changed

Lines changed: 409 additions & 218 deletions

lib/compress/pipeline.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,15 @@ export async function finalizeSession(
8585
entries: NotificationEntry[],
8686
batchTopic: string | undefined,
8787
): Promise<void> {
88-
ctx.state.manualMode = ctx.state.manualMode ? "active" : false
88+
if (ctx.state.manualMode === "compress-pending") {
89+
ctx.state.manualMode = false
90+
await refreshManualMode(
91+
ctx.state,
92+
toolCtx.sessionID,
93+
ctx.logger,
94+
ctx.config.manualMode.enabled,
95+
)
96+
}
8997
applyPendingCompressionDurations(ctx.state)
9098
await saveSessionState(ctx.state, ctx.logger)
9199

lib/hooks.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ const INTERNAL_AGENT_SIGNATURES = [
4646
"Summarize what was done in this conversation",
4747
]
4848

49+
function isInternalAgentCall(systemPrompts: string[]): boolean {
50+
const primaryPrompt = systemPrompts[0]
51+
if (typeof primaryPrompt !== "string" || primaryPrompt.length === 0) {
52+
return false
53+
}
54+
55+
return INTERNAL_AGENT_SIGNATURES.some((signature) => primaryPrompt.includes(signature))
56+
}
57+
4958
export function createSystemPromptHandler(
5059
state: SessionState,
5160
logger: Logger,
@@ -65,8 +74,7 @@ export function createSystemPromptHandler(
6574
return
6675
}
6776

68-
const systemText = output.system.join("\n")
69-
if (INTERNAL_AGENT_SIGNATURES.some((sig) => systemText.includes(sig))) {
77+
if (isInternalAgentCall(output.system)) {
7078
logger.info("Skipping DCP system prompt injection for internal agent")
7179
return
7280
}

lib/messages/utils.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ const SUMMARY_ID_HASH_LENGTH = 16
77
const DCP_BLOCK_ID_TAG_REGEX = /(<dcp-message-id(?=[\s>])[^>]*>)b\d+(<\/dcp-message-id>)/g
88
const DCP_PAIRED_TAG_REGEX = /<dcp[^>]*>[\s\S]*?<\/dcp[^>]*>/gi
99
const DCP_UNPAIRED_TAG_REGEX = /<\/?dcp[^>]*>/gi
10+
const INJECTED_MESSAGE_ID_SUFFIX_REGEX = /(?<=\n)<dcp-message-id[^>]*>m\d+<\/dcp-message-id>\s*$/
11+
const HALLUCINATED_PARAMETER_SUFFIX_REGEX = /(?<=\n)m\d+<\/parameter>\s*$/
1012

1113
const generateStableId = (prefix: string, seed: string): string => {
1214
const hash = createHash("sha256").update(seed).digest("hex").slice(0, SUMMARY_ID_HASH_LENGTH)
@@ -163,7 +165,12 @@ export const replaceBlockIdsWithBlocked = (text: string): string => {
163165
}
164166

165167
export const stripHallucinationsFromString = (text: string): string => {
166-
return text.replace(DCP_PAIRED_TAG_REGEX, "").replace(DCP_UNPAIRED_TAG_REGEX, "")
168+
const withoutKnownSuffixes = text
169+
.replace(INJECTED_MESSAGE_ID_SUFFIX_REGEX, "")
170+
.replace(HALLUCINATED_PARAMETER_SUFFIX_REGEX, "")
171+
return withoutKnownSuffixes
172+
.replace(DCP_PAIRED_TAG_REGEX, "")
173+
.replace(DCP_UNPAIRED_TAG_REGEX, "")
167174
}
168175

169176
export const stripHallucinations = (messages: WithParts[]): void => {

lib/protected-patterns.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
function normalizePath(input: string): string {
2-
return input.replaceAll("\\\\", "/")
2+
// A single backslash. In source, "\\" is the one-character string; the
3+
// previous "\\\\" was a *two*-character string, so it only ever matched a
4+
// doubled separator -- which a real Windows path does not contain. The
5+
// normalisation was therefore a no-op on the only platform that needs it.
6+
return input.replaceAll("\\", "/")
37
}
48

59
function escapeRegExpChar(ch: string): string {

package-lock.json

Lines changed: 53 additions & 204 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://json.schemastore.org/package.json",
33
"name": "@tarquinen/opencode-dcp",
4-
"version": "3.1.14",
4+
"version": "3.1.15",
55
"type": "module",
66
"description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context",
77
"main": "./dist/index.js",
@@ -53,23 +53,20 @@
5353
"author": "tarquinen",
5454
"license": "AGPL-3.0-or-later",
5555
"peerDependencies": {
56-
"@opencode-ai/plugin": ">=1.4.3",
57-
"@opentui/core": "^0.4.2",
58-
"@opentui/solid": "^0.4.2",
59-
"solid-js": "^1.9.12"
56+
"@opencode-ai/plugin": ">=1.4.3"
6057
},
6158
"dependencies": {
6259
"@anthropic-ai/tokenizer": "^0.0.4",
6360
"@opencode-ai/sdk": "^1.4.3",
64-
"jsonc-parser": "^3.3.1"
61+
"@opentui/core": "^0.4.5",
62+
"@opentui/solid": "^0.4.5",
63+
"jsonc-parser": "^3.3.1",
64+
"solid-js": "^1.9.12"
6565
},
6666
"devDependencies": {
6767
"@opencode-ai/plugin": "^1.4.3",
68-
"@opentui/core": "^0.4.2",
69-
"@opentui/solid": "^0.4.2",
7068
"@types/node": "^25.5.0",
7169
"prettier": "^3.8.1",
72-
"solid-js": "^1.9.12",
7370
"tsup": "^8.5.1",
7471
"tsx": "^4.21.0",
7572
"typescript": "^6.0.2"

tests/finalize-session.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import assert from "node:assert/strict"
2+
import test from "node:test"
3+
import { finalizeSession } from "../lib/compress/pipeline"
4+
import type { PluginConfig } from "../lib/config"
5+
import { Logger } from "../lib/logger"
6+
import {
7+
createSessionState,
8+
loadManualModeSetting,
9+
saveManualModeSetting,
10+
type WithParts,
11+
} from "../lib/state"
12+
13+
function buildConfig(manualMode = false): PluginConfig {
14+
return {
15+
enabled: true,
16+
debug: false,
17+
pruneNotification: "off",
18+
pruneNotificationType: "chat",
19+
commands: { enabled: true, protectedTools: [] },
20+
manualMode: { enabled: manualMode, automaticStrategies: true },
21+
turnProtection: { enabled: false, turns: 4 },
22+
experimental: { allowSubAgents: false, customPrompts: false },
23+
protectedFilePatterns: [],
24+
compress: {
25+
mode: "message",
26+
permission: "allow",
27+
showCompression: false,
28+
maxContextLimit: 150000,
29+
minContextLimit: 50000,
30+
nudgeFrequency: 5,
31+
iterationNudgeThreshold: 15,
32+
nudgeForce: "soft",
33+
protectedTools: ["task"],
34+
protectTags: false,
35+
protectUserMessages: false,
36+
},
37+
strategies: {
38+
deduplication: { enabled: true, protectedTools: [] },
39+
purgeErrors: { enabled: true, turns: 4, protectedTools: [] },
40+
},
41+
} as PluginConfig
42+
}
43+
44+
function buildToolContext(state: ReturnType<typeof createSessionState>, manualMode = false) {
45+
return {
46+
client: { session: { get: async () => ({}) } },
47+
state,
48+
logger: new Logger(false),
49+
config: buildConfig(manualMode),
50+
prompts: {
51+
reload() {},
52+
getRuntimePrompts() {
53+
return {} as any
54+
},
55+
},
56+
}
57+
}
58+
59+
test("finalizeSession resets compress-pending to auto mode", async () => {
60+
const sessionId = `finalize-compress-pending-${Date.now()}`
61+
const state = createSessionState()
62+
state.sessionId = sessionId
63+
state.manualMode = "compress-pending"
64+
65+
await finalizeSession(
66+
buildToolContext(state) as any,
67+
{ sessionID: sessionId, metadata: () => {}, ask: async () => {} },
68+
[] as WithParts[],
69+
[],
70+
undefined,
71+
)
72+
73+
assert.equal(state.manualMode, false)
74+
75+
const persisted = await loadManualModeSetting(sessionId, new Logger(false))
76+
assert.equal(persisted, false)
77+
})
78+
79+
test("finalizeSession restores persisted manual mode after compression", async () => {
80+
const sessionId = `finalize-persisted-manual-${Date.now()}`
81+
const logger = new Logger(false)
82+
await saveManualModeSetting(sessionId, true, logger)
83+
84+
const state = createSessionState()
85+
state.sessionId = sessionId
86+
state.manualMode = "compress-pending"
87+
88+
await finalizeSession(
89+
buildToolContext(state) as any,
90+
{ sessionID: sessionId, metadata: () => {}, ask: async () => {} },
91+
[] as WithParts[],
92+
[],
93+
undefined,
94+
)
95+
96+
assert.equal(state.manualMode, "active")
97+
98+
const persisted = await loadManualModeSetting(sessionId, logger)
99+
assert.equal(persisted, true)
100+
})
101+
102+
test("finalizeSession restores configured manual mode after compression", async () => {
103+
const sessionId = `finalize-configured-manual-${Date.now()}`
104+
const state = createSessionState()
105+
state.sessionId = sessionId
106+
state.manualMode = "compress-pending"
107+
108+
await finalizeSession(
109+
buildToolContext(state, true) as any,
110+
{ sessionID: sessionId, metadata: () => {}, ask: async () => {} },
111+
[] as WithParts[],
112+
[],
113+
undefined,
114+
)
115+
116+
assert.equal(state.manualMode, "active")
117+
118+
const persisted = await loadManualModeSetting(sessionId, new Logger(false))
119+
assert.equal(persisted, true)
120+
})

tests/hooks-permission.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,69 @@ test("system prompt handler caches full model context for percentage thresholds"
114114
assert.equal(state.modelContextLimit, 200000)
115115
})
116116

117+
function buildPromptStore() {
118+
return {
119+
reload() {},
120+
getRuntimePrompts() {
121+
return {
122+
system: "DCP-RUNTIME-PROMPT",
123+
manualExtension: "",
124+
subagentExtension: "",
125+
}
126+
},
127+
} as any
128+
}
129+
130+
test("system prompt handler injects nudges for main session with bundled internal prompts", async () => {
131+
const state = createSessionState()
132+
const handler = createSystemPromptHandler(
133+
state,
134+
new Logger(false),
135+
buildConfig("allow"),
136+
buildPromptStore(),
137+
)
138+
const output = {
139+
system: [
140+
"You are the primary coding assistant for this repository.",
141+
"You are a title generator for short session names.",
142+
],
143+
}
144+
145+
await handler(
146+
{
147+
sessionID: "session-1",
148+
model: { limit: { context: 200000 } },
149+
} as any,
150+
output,
151+
)
152+
153+
assert.match(output.system[output.system.length - 1], /DCP-RUNTIME-PROMPT/)
154+
})
155+
156+
test("system prompt handler skips injection for internal agent calls", async () => {
157+
const state = createSessionState()
158+
const handler = createSystemPromptHandler(
159+
state,
160+
new Logger(false),
161+
buildConfig("allow"),
162+
buildPromptStore(),
163+
)
164+
const output = {
165+
system: ["You are a title generator. Return only a short title."],
166+
}
167+
168+
await handler(
169+
{
170+
sessionID: "session-1",
171+
model: { limit: { context: 200000 } },
172+
} as any,
173+
output,
174+
)
175+
176+
assert.equal(output.system.length, 1)
177+
assert.doesNotMatch(output.system[0], /DCP-RUNTIME-PROMPT/)
178+
})
179+
117180
test("chat message transform strips hallucinated tags even when compress is denied", async () => {
118181
const state = createSessionState()
119182
const logger = new Logger(false)

tests/message-priority.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -814,6 +814,37 @@ test("hallucination stripping does not affect non-dcp tags", async () => {
814814
)
815815
})
816816

817+
test("hallucination stripping preserves content when dcp-message-id is mentioned in text (issue #556)", () => {
818+
const input =
819+
"The tag called `<dcp-message-id>` is used to track messages. " +
820+
"This text should survive.\n\n" +
821+
"<dcp-message-id>m0369</dcp-message-id>"
822+
823+
assert.equal(
824+
stripHallucinationsFromString(input),
825+
"The tag called `` is used to track messages. This text should survive.\n\n",
826+
)
827+
})
828+
829+
test("hallucination stripping handles priority on injected message-id suffixes", () => {
830+
const input =
831+
"The tag called `<dcp-message-id>` is used to track messages. " +
832+
"This text should survive.\n\n" +
833+
'<dcp-message-id priority="low">m0370</dcp-message-id>'
834+
835+
assert.equal(
836+
stripHallucinationsFromString(input),
837+
"The tag called `` is used to track messages. This text should survive.\n\n",
838+
)
839+
})
840+
841+
test("hallucination stripping removes trailing mXXXX</parameter> artifact (issue #555)", () => {
842+
assert.equal(
843+
stripHallucinationsFromString("Total: maybe 20 lines changed.\n\nm0340</parameter>\n\n"),
844+
"Total: maybe 20 lines changed.\n\n",
845+
)
846+
})
847+
817848
test("injectMessageIds skips empty assistant messages to avoid prefill (issue #463)", () => {
818849
const sessionID = "ses_empty_assistant"
819850
const messages: WithParts[] = [

0 commit comments

Comments
 (0)