Skip to content

Commit aa4f76a

Browse files
committed
mark blocked refs in message mode
1 parent 3da14ff commit aa4f76a

File tree

10 files changed

+318
-12
lines changed

10 files changed

+318
-12
lines changed

lib/compress/message-utils.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import type { PluginConfig } from "../config"
12
import type { SessionState } from "../state"
23
import { parseBoundaryId } from "../message-ids"
3-
import { isIgnoredUserMessage } from "../messages/utils"
4+
import { isIgnoredUserMessage, isProtectedUserMessage } from "../messages/utils"
45
import { resolveAnchorMessageId, resolveBoundaryIds, resolveSelection } from "./search"
56
import { COMPRESSED_BLOCK_HEADER } from "./state"
67
import type {
@@ -66,6 +67,7 @@ export function resolveMessages(
6667
args: CompressMessageToolArgs,
6768
searchContext: SearchContext,
6869
state: SessionState,
70+
config: PluginConfig,
6971
): ResolvedMessageCompressionsResult {
7072
const issues: string[] = []
7173
const plans: ResolvedMessageCompression[] = []
@@ -88,6 +90,7 @@ export function resolveMessages(
8890
},
8991
searchContext,
9092
state,
93+
config,
9194
)
9295
seenMessageIds.add(plan.entry.messageId)
9396
plans.push(plan)
@@ -111,7 +114,14 @@ function resolveMessage(
111114
entry: CompressMessageEntry,
112115
searchContext: SearchContext,
113116
state: SessionState,
117+
config: PluginConfig,
114118
): ResolvedMessageCompression {
119+
if (entry.messageId.toUpperCase() === "BLOCKED") {
120+
throw new SoftIssue(
121+
"messageId BLOCKED refers to a protected message and cannot be compressed.",
122+
)
123+
}
124+
115125
const parsed = parseBoundaryId(entry.messageId)
116126

117127
if (!parsed) {
@@ -122,7 +132,7 @@ function resolveMessage(
122132

123133
if (parsed.kind === "compressed-block") {
124134
throw new SoftIssue(
125-
`messageId ${entry.messageId} is invalid in message mode. Block IDs like bN are not allowed; use an mNNNN message ID instead.`,
135+
`messageId ${entry.messageId} is invalid here. Block IDs like bN are not allowed; use an mNNNN message ID instead.`,
126136
)
127137
}
128138

@@ -157,6 +167,12 @@ function resolveMessage(
157167
throw new Error(`messageId ${parsed.ref} is not available in the current conversation.`)
158168
}
159169

170+
if (isProtectedUserMessage(config, message)) {
171+
throw new SoftIssue(
172+
`messageId ${parsed.ref} refers to a protected message and cannot be compressed.`,
173+
)
174+
}
175+
160176
const pruneEntry = state.prune.messages.byMessageId.get(rawMessageId)
161177
if (pruneEntry && pruneEntry.activeBlockIds.length > 0) {
162178
throw new Error(`messageId ${parsed.ref} is already part of an active compression.`)

lib/compress/message.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,12 @@ export function createCompressMessageTool(ctx: ToolContext): ReturnType<typeof t
5454
toolCtx,
5555
`Compress Message: ${input.topic}`,
5656
)
57-
const { plans, skippedIssues } = resolveMessages(input, searchContext, ctx.state)
57+
const { plans, skippedIssues } = resolveMessages(
58+
input,
59+
searchContext,
60+
ctx.state,
61+
ctx.config,
62+
)
5863

5964
if (plans.length === 0 && skippedIssues.length > 0) {
6065
throw new Error(formatIssues(skippedIssues))

lib/messages/inject/inject.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
createSyntheticTextPart,
1313
findLastToolPart,
1414
isIgnoredUserMessage,
15+
isProtectedUserMessage,
1516
} from "../utils"
1617
import {
1718
addAnchor,
@@ -157,11 +158,15 @@ export const injectMessageIds = (
157158
continue
158159
}
159160

161+
const isBlockedMessage = isProtectedUserMessage(config, message)
160162
const priority =
161-
config.compress.mode === "message"
163+
config.compress.mode === "message" && !isBlockedMessage
162164
? compressionPriorities?.get(message.info.id)?.priority
163165
: undefined
164-
const tag = formatMessageIdTag(messageRef, priority ? { priority } : undefined)
166+
const tag = formatMessageIdTag(
167+
isBlockedMessage ? "BLOCKED" : messageRef,
168+
priority ? { priority } : undefined,
169+
)
165170

166171
if (appendToTextPart(message, tag)) {
167172
continue

lib/messages/priority.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { PluginConfig } from "../config"
22
import { countAllMessageTokens } from "../strategies/utils"
33
import { isMessageCompacted } from "../shared-utils"
44
import type { SessionState, WithParts } from "../state"
5-
import { isIgnoredUserMessage } from "./utils"
5+
import { isIgnoredUserMessage, isProtectedUserMessage } from "./utils"
66

77
const MEDIUM_PRIORITY_MIN_TOKENS = 500
88
const HIGH_PRIORITY_MIN_TOKENS = 5000
@@ -32,6 +32,10 @@ export function buildPriorityMap(
3232
continue
3333
}
3434

35+
if (isProtectedUserMessage(config, message)) {
36+
continue
37+
}
38+
3539
if (isMessageCompacted(state, message)) {
3640
continue
3741
}

lib/messages/prune.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { SessionState, WithParts } from "../state"
22
import type { Logger } from "../logger"
33
import type { PluginConfig } from "../config"
44
import { isMessageCompacted, getLastUserMessage } from "../shared-utils"
5-
import { createSyntheticUserMessage } from "./utils"
5+
import { createSyntheticUserMessage, replaceBlockIdsWithBlocked } from "./utils"
66
import type { UserMessage } from "@opencode-ai/sdk/v2"
77

88
const PRUNED_TOOL_OUTPUT_REPLACEMENT =
@@ -16,7 +16,7 @@ export const prune = (
1616
config: PluginConfig,
1717
messages: WithParts[],
1818
): void => {
19-
filterCompressedRanges(state, logger, messages)
19+
filterCompressedRanges(state, logger, config, messages)
2020
// pruneFullTool(state, logger, messages)
2121
pruneToolOutputs(state, logger, messages)
2222
pruneToolInputs(state, logger, messages)
@@ -158,6 +158,7 @@ const pruneToolErrors = (state: SessionState, logger: Logger, messages: WithPart
158158
const filterCompressedRanges = (
159159
state: SessionState,
160160
logger: Logger,
161+
config: PluginConfig,
161162
messages: WithParts[],
162163
): void => {
163164
if (
@@ -194,7 +195,10 @@ const filterCompressedRanges = (
194195

195196
if (userMessage) {
196197
const userInfo = userMessage.info as UserMessage
197-
const summaryContent = rawSummaryContent
198+
const summaryContent =
199+
config.compress.mode === "message"
200+
? replaceBlockIdsWithBlocked(rawSummaryContent)
201+
: rawSummaryContent
198202
const summarySeed = `${summary.blockId}:${summary.anchorMessageId}`
199203
result.push(
200204
createSyntheticUserMessage(

lib/messages/utils.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { createHash } from "node:crypto"
2+
import type { PluginConfig } from "../config"
23
import { isMessageCompacted } from "../shared-utils"
34
import type { SessionState, WithParts } from "../state"
45
import type { UserMessage } from "@opencode-ai/sdk/v2"
56

67
const SUMMARY_ID_HASH_LENGTH = 16
7-
const DCP_MESSAGE_ID_TAG_REGEX = /<dcp-message-id(?=[\s>])[^>]*>(?:m\d+|b\d+)<\/dcp-message-id>/g
8+
const DCP_MESSAGE_ID_TAG_REGEX =
9+
/<dcp-message-id(?=[\s>])[^>]*>(?:m\d+|b\d+|BLOCKED)<\/dcp-message-id>/g
10+
const DCP_BLOCK_ID_TAG_REGEX = /(<dcp-message-id(?=[\s>])[^>]*>)b\d+(<\/dcp-message-id>)/g
811
const DCP_SYSTEM_REMINDER_REGEX =
912
/<dcp-system-reminder(?=[\s>])[^>]*>[\s\S]*?<\/dcp-system-reminder>/g
1013

@@ -157,6 +160,19 @@ export const isIgnoredUserMessage = (message: WithParts): boolean => {
157160
return true
158161
}
159162

163+
export function isProtectedUserMessage(config: PluginConfig, message: WithParts): boolean {
164+
return (
165+
config.compress.mode === "message" &&
166+
config.compress.protectUserMessages &&
167+
message.info.role === "user" &&
168+
!isIgnoredUserMessage(message)
169+
)
170+
}
171+
172+
export const replaceBlockIdsWithBlocked = (text: string): string => {
173+
return text.replace(DCP_BLOCK_ID_TAG_REGEX, "$1BLOCKED$2")
174+
}
175+
160176
export const stripHallucinationsFromString = (text: string): string => {
161177
return text.replace(DCP_SYSTEM_REMINDER_REGEX, "").replace(DCP_MESSAGE_ID_TAG_REGEX, "")
162178
}

lib/prompts/compress-message.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Each message has an ID inside XML metadata tags like \`<dcp-message-id priority=
1818
The ID tag appears at the end of the message it belongs to — it identifies the message above it, not the one below it.
1919
Treat these tags as message metadata only, not as content to summarize. Use only the inner \`mNNNN\` value as the \`messageId\`.
2020
The \`priority\` attribute indicates relative context cost. Prefer higher-priority closed messages before lower-priority ones.
21+
Messages marked as \`<dcp-message-id>BLOCKED</dcp-message-id>\` cannot be compressed.
2122
2223
Rules:
2324

tests/compress-message.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,68 @@ test("compress message mode rejects compressed block ids", async () => {
332332
)
333333
})
334334

335+
test("compress message mode skips protected user message references", async () => {
336+
const sessionID = `ses_message_compress_protected_user_${Date.now()}`
337+
const rawMessages = buildMessages(sessionID)
338+
const state = createSessionState()
339+
const logger = new Logger(false)
340+
const config = buildConfig()
341+
config.compress.protectUserMessages = true
342+
343+
const tool = createCompressMessageTool({
344+
client: {
345+
session: {
346+
messages: async () => ({ data: rawMessages }),
347+
get: async () => ({ data: { parentID: null } }),
348+
},
349+
},
350+
state,
351+
logger,
352+
config,
353+
prompts: {
354+
reload() {},
355+
getRuntimePrompts() {
356+
return { compressMessage: "", compressRange: "" }
357+
},
358+
},
359+
} as any)
360+
361+
const result = await tool.execute(
362+
{
363+
topic: "Protected user entries",
364+
content: [
365+
{
366+
messageId: "BLOCKED",
367+
topic: "Protected marker",
368+
summary: "Should be skipped.",
369+
},
370+
{
371+
messageId: "m0001",
372+
topic: "Hidden protected ref",
373+
summary: "Should also be skipped.",
374+
},
375+
{
376+
messageId: "m0002",
377+
topic: "Valid note",
378+
summary: "Captured the assistant's code-path findings.",
379+
},
380+
],
381+
},
382+
{
383+
ask: async () => {},
384+
metadata: () => {},
385+
sessionID,
386+
messageID: "msg-compress-message-protected-user",
387+
},
388+
)
389+
390+
assert.equal(state.prune.messages.blocksById.size, 1)
391+
assert.match(result, /^Compressed 1 message into \[Compressed conversation section\]\./)
392+
assert.match(result, /Skipped 2 issues:/)
393+
assert.match(result, /messageId BLOCKED refers to a protected message/)
394+
assert.match(result, /messageId m0001 refers to a protected message/)
395+
})
396+
335397
test("compress message mode allows messages containing compress tool parts", async () => {
336398
const sessionID = `ses_message_compress_tool_${Date.now()}`
337399
const rawMessages = buildMessages(sessionID)

0 commit comments

Comments
 (0)