Skip to content

Commit 476d670

Browse files
authored
Merge pull request #469 from Opencode-DCP/dev
v3.1.5 - Release
2 parents 32d053a + 516db5b commit 476d670

8 files changed

Lines changed: 85 additions & 85 deletions

File tree

lib/messages/inject/inject.ts

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import { compressPermission, getLastUserMessage, messageHasCompress } from "../.
88
import { saveSessionState } from "../../state/persistence"
99
import {
1010
appendToTextPart,
11-
appendToToolPart,
11+
appendToLastTextPart,
12+
appendToLastToolPart,
1213
createSyntheticTextPart,
1314
hasContent,
1415
isIgnoredUserMessage,
@@ -191,23 +192,20 @@ export const injectMessageIds = (
191192
continue
192193
}
193194

194-
let injected = false
195-
for (const part of message.parts) {
196-
if (part.type === "text") {
197-
injected = appendToTextPart(part, tag) || injected
198-
} else if (part.type === "tool") {
199-
injected = appendToToolPart(part, tag) || injected
200-
}
195+
if (appendToLastToolPart(message, tag)) {
196+
continue
201197
}
202198

203-
if (!injected) {
204-
const syntheticPart = createSyntheticTextPart(message, tag)
205-
const firstToolIndex = message.parts.findIndex((p) => p.type === "tool")
206-
if (firstToolIndex === -1) {
207-
message.parts.push(syntheticPart)
208-
} else {
209-
message.parts.splice(firstToolIndex, 0, syntheticPart)
210-
}
199+
if (appendToLastTextPart(message, tag)) {
200+
continue
201+
}
202+
203+
const syntheticPart = createSyntheticTextPart(message, tag)
204+
const firstToolIndex = message.parts.findIndex((p) => p.type === "tool")
205+
if (firstToolIndex === -1) {
206+
message.parts.push(syntheticPart)
207+
} else {
208+
message.parts.splice(firstToolIndex, 0, syntheticPart)
211209
}
212210
}
213211
}

lib/messages/inject/utils.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,10 @@ function injectAnchoredNudge(message: WithParts, nudgeText: string): void {
255255
return
256256
}
257257

258+
if (!hasContent(message)) {
259+
return
260+
}
261+
258262
for (const part of message.parts) {
259263
if (part.type === "text") {
260264
if (appendToTextPart(part, nudgeText)) {
@@ -263,10 +267,6 @@ function injectAnchoredNudge(message: WithParts, nudgeText: string): void {
263267
}
264268
}
265269

266-
if (!hasContent(message)) {
267-
return
268-
}
269-
270270
const syntheticPart = createSyntheticTextPart(message, nudgeText)
271271
const firstToolIndex = message.parts.findIndex((p) => p.type === "tool")
272272
if (firstToolIndex === -1) {

lib/messages/utils.ts

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,26 @@ type MessagePart = WithParts["parts"][number]
7070
type ToolPart = Extract<MessagePart, { type: "tool" }>
7171
type TextPart = Extract<MessagePart, { type: "text" }>
7272

73+
export const appendToLastTextPart = (message: WithParts, injection: string): boolean => {
74+
const textPart = findLastTextPart(message)
75+
if (!textPart) {
76+
return false
77+
}
78+
79+
return appendToTextPart(textPart, injection)
80+
}
81+
82+
const findLastTextPart = (message: WithParts): TextPart | null => {
83+
for (let i = message.parts.length - 1; i >= 0; i--) {
84+
const part = message.parts[i]
85+
if (part.type === "text") {
86+
return part
87+
}
88+
}
89+
90+
return null
91+
}
92+
7393
export const appendToTextPart = (part: TextPart, injection: string): boolean => {
7494
if (typeof part.text !== "string") {
7595
return false
@@ -88,24 +108,36 @@ export const appendToTextPart = (part: TextPart, injection: string): boolean =>
88108
return true
89109
}
90110

91-
const findLastTextPart = (message: WithParts): TextPart | null => {
111+
export const appendToLastToolPart = (message: WithParts, tag: string): boolean => {
112+
const toolPart = findLastToolPart(message)
113+
if (!toolPart) {
114+
return false
115+
}
116+
117+
return appendToToolPart(toolPart, tag)
118+
}
119+
120+
const findLastToolPart = (message: WithParts): ToolPart | null => {
92121
for (let i = message.parts.length - 1; i >= 0; i--) {
93122
const part = message.parts[i]
94-
if (part.type === "text") {
123+
if (part.type === "tool") {
95124
return part
96125
}
97126
}
98127

99128
return null
100129
}
101130

102-
export const appendToLastTextPart = (message: WithParts, injection: string): boolean => {
103-
const textPart = findLastTextPart(message)
104-
if (!textPart) {
131+
export const appendToToolPart = (part: ToolPart, tag: string): boolean => {
132+
if (part.state?.status !== "completed" || typeof part.state.output !== "string") {
105133
return false
106134
}
135+
if (part.state.output.includes(tag)) {
136+
return true
137+
}
107138

108-
return appendToTextPart(textPart, injection)
139+
part.state.output = `${part.state.output}${tag}`
140+
return true
109141
}
110142

111143
export const hasContent = (message: WithParts): boolean => {
@@ -120,18 +152,6 @@ export const hasContent = (message: WithParts): boolean => {
120152
)
121153
}
122154

123-
export const appendToToolPart = (part: ToolPart, tag: string): boolean => {
124-
if (part.state?.status !== "completed" || typeof part.state.output !== "string") {
125-
return false
126-
}
127-
if (part.state.output.includes(tag)) {
128-
return true
129-
}
130-
131-
part.state.output = `${part.state.output}${tag}`
132-
return true
133-
}
134-
135155
export function buildToolIdList(state: SessionState, messages: WithParts[]): string[] {
136156
const toolIds: string[] = []
137157
for (const msg of messages) {

lib/prompts/compress-message.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ You specify individual raw messages by ID using the injected IDs visible in the
1515
- \`mNNNN\` IDs identify raw messages
1616
1717
Each message has an ID inside XML metadata tags like \`<dcp-message-id priority="high">m0007</dcp-message-id>\`.
18-
The ID tag appears at the end of the message it belongs to — it identifies the message above it, not the one below it.
18+
The ID tag appears at the end of the message it belongs to — each ID covers all the content above it back to the previous ID.
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.
2121
Messages marked as \`<dcp-message-id>BLOCKED</dcp-message-id>\` cannot be compressed.

lib/prompts/compress-range.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ You specify boundaries by ID using the injected IDs visible in the conversation:
4545
- \`bN\` IDs identify previously compressed blocks
4646
4747
Each message has an ID inside XML metadata tags like \`<dcp-message-id>...</dcp-message-id>\`.
48-
The ID tag appears at the end of the message it belongs to — it identifies the message above it, not the one below it.
48+
The ID tag appears at the end of the message it belongs to — each ID covers all the content above it back to the previous ID.
4949
Treat these tags as boundary metadata only, not as tool result content.
5050
5151
Rules:

package-lock.json

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

package.json

Lines changed: 1 addition & 1 deletion
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.4",
4+
"version": "3.1.5",
55
"type": "module",
66
"description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context",
77
"main": "./dist/index.js",

tests/message-priority.test.ts

Lines changed: 24 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ function repeatedWord(word: string, count: number): string {
130130
return Array.from({ length: count }, () => word).join(" ")
131131
}
132132

133-
test("injectMessageIds tags every text part and tool output in message mode", () => {
133+
test("injectMessageIds injects ID once into last tool output for assistant messages", () => {
134134
const sessionID = "ses_message_priority_tags"
135135
const messages: WithParts[] = [
136136
{
@@ -211,30 +211,14 @@ test("injectMessageIds tags every text part and tool output in message mode", ()
211211
assert.equal(assistantToolOne?.type, "tool")
212212
assert.equal(assistantTextTwo?.type, "text")
213213
assert.equal(assistantToolTwo?.type, "tool")
214-
assert.match(
215-
(userTextOne as any).text,
216-
/\n\n<dcp-message-id priority="high">m0001<\/dcp-message-id>/,
217-
)
218-
assert.match(
219-
(userTextTwo as any).text,
220-
/\n\n<dcp-message-id priority="high">m0001<\/dcp-message-id>/,
221-
)
222-
assert.match(
223-
(assistantTextOne as any).text,
224-
/\n\n<dcp-message-id priority="low">m0002<\/dcp-message-id>/,
225-
)
226-
assert.match(
227-
(assistantToolOne as any).state.output,
228-
/<dcp-message-id priority="low">m0002<\/dcp-message-id>/,
229-
)
230-
assert.match(
231-
(assistantTextTwo as any).text,
232-
/\n\n<dcp-message-id priority="low">m0002<\/dcp-message-id>/,
233-
)
234-
assert.match(
235-
(assistantToolTwo as any).state.output,
236-
/<dcp-message-id priority="low">m0002<\/dcp-message-id>/,
237-
)
214+
// User messages: still injected into all text parts
215+
assert.match((userTextOne as any).text, /\n\nm0001<\/dcp-message-id>/)
216+
assert.match((userTextTwo as any).text, /\n\nm0001<\/dcp-message-id>/)
217+
// Assistant messages: ID injected only once into the last tool output
218+
assert.doesNotMatch((assistantTextOne as any).text, /dcp-message-id/)
219+
assert.doesNotMatch((assistantToolOne as any).state.output, /dcp-message-id/)
220+
assert.doesNotMatch((assistantTextTwo as any).text, /dcp-message-id/)
221+
assert.match((assistantToolTwo as any).state.output, /m0002<\/dcp-message-id>/)
238222
})
239223

240224
test("injectMessageIds marks every protected user text part as BLOCKED in message mode", () => {
@@ -290,7 +274,7 @@ test("injectMessageIds marks every protected user text part as BLOCKED in messag
290274
)
291275
})
292276

293-
test("injectMessageIds tags every text part and tool output in range mode", () => {
277+
test("injectMessageIds injects ID once into last tool output in range mode", () => {
294278
const sessionID = "ses_range_message_id_tags"
295279
const messages: WithParts[] = [
296280
buildMessage("msg-user-1", "user", sessionID, repeatedWord("investigate", 6000), 1),
@@ -327,10 +311,11 @@ test("injectMessageIds tags every text part and tool output in range mode", () =
327311
const assistantTextTwo = messages[1]?.parts[2]
328312
const assistantToolTwo = messages[1]?.parts[3]
329313

330-
assert.match((assistantTextOne as any).text, /\n\n<dcp-message-id>m0002<\/dcp-message-id>/)
331-
assert.match((assistantToolOne as any).state.output, /<dcp-message-id>m0002<\/dcp-message-id>/)
332-
assert.match((assistantTextTwo as any).text, /\n\n<dcp-message-id>m0002<\/dcp-message-id>/)
333-
assert.match((assistantToolTwo as any).state.output, /<dcp-message-id>m0002<\/dcp-message-id>/)
314+
// Only the last tool output gets the ID
315+
assert.doesNotMatch((assistantTextOne as any).text, /dcp-message-id/)
316+
assert.doesNotMatch((assistantToolOne as any).state.output, /dcp-message-id/)
317+
assert.doesNotMatch((assistantTextTwo as any).text, /dcp-message-id/)
318+
assert.match((assistantToolTwo as any).state.output, /m0002<\/dcp-message-id>/)
334319
})
335320

336321
test("message mode marks compress tool messages as high priority even when short", () => {
@@ -370,10 +355,9 @@ test("message mode marks compress tool messages as high priority even when short
370355
const assistantText = messages[1]?.parts[0]
371356
const assistantTool = messages[1]?.parts[1]
372357

373-
assert.match(
374-
(assistantText as any).text,
375-
/\n\n<dcp-message-id priority="high">m0002<\/dcp-message-id>/,
376-
)
358+
// ID injected only into the last (only) tool output, not the text part
359+
assert.doesNotMatch((assistantText as any).text, /dcp-message-id/)
360+
assert.match((assistantTool as any).state.output, /m0002<\/dcp-message-id>/)
377361
assert.match(
378362
(assistantTool as any).state.output,
379363
/<dcp-message-id priority="high">m0002<\/dcp-message-id>/,
@@ -628,7 +612,7 @@ test("range-mode nudges skip assistant with only pending tool parts (issue #463)
628612
assert.equal(messages[1]?.parts[0]?.type, "tool")
629613
})
630614

631-
test("range-mode nudges append to an assistant empty text part (issue #463)", () => {
615+
test("range-mode nudges skip assistant messages with only empty text parts (issue #463)", () => {
632616
const sessionID = "ses_range_nudge_empty_text"
633617
const messages: WithParts[] = [
634618
buildMessage("msg-user-1", "user", sessionID, "Hello", 1),
@@ -653,16 +637,14 @@ test("range-mode nudges append to an assistant empty text part (issue #463)", ()
653637
system: "",
654638
compressRange: "",
655639
compressMessage: "",
656-
contextLimitNudge: "<dcp-system-reminder>Base context nudge</dcp-system-reminder>",
657-
turnNudge: "<dcp-system-reminder>Base turn nudge</dcp-system-reminder>",
658-
iterationNudge: "<dcp-system-reminder>Base iteration nudge</dcp-system-reminder>",
640+
contextLimitNudge: "",
641+
turnNudge: "",
642+
iterationNudge: "",
659643
})
660644

645+
// Empty text parts should not receive nudge injection
661646
assert.equal(messages[1]?.parts.length, 1)
662-
assert.match(
663-
(messages[1]?.parts[0] as any).text,
664-
/<dcp-system-reminder>Base context nudge[\s\S]*Compressed block context:/,
665-
)
647+
assert.equal((messages[1]?.parts[0] as any).text, "")
666648
})
667649

668650
test("message-mode rendered compressed summaries mark block IDs as BLOCKED", () => {

0 commit comments

Comments
 (0)