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
30 changes: 14 additions & 16 deletions lib/messages/inject/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { compressPermission, getLastUserMessage, messageHasCompress } from "../.
import { saveSessionState } from "../../state/persistence"
import {
appendToTextPart,
appendToToolPart,
appendToLastTextPart,
appendToLastToolPart,
createSyntheticTextPart,
hasContent,
isIgnoredUserMessage,
Expand Down Expand Up @@ -191,23 +192,20 @@ export const injectMessageIds = (
continue
}

let injected = false
for (const part of message.parts) {
if (part.type === "text") {
injected = appendToTextPart(part, tag) || injected
} else if (part.type === "tool") {
injected = appendToToolPart(part, tag) || injected
}
if (appendToLastToolPart(message, tag)) {
continue
}

if (!injected) {
const syntheticPart = createSyntheticTextPart(message, tag)
const firstToolIndex = message.parts.findIndex((p) => p.type === "tool")
if (firstToolIndex === -1) {
message.parts.push(syntheticPart)
} else {
message.parts.splice(firstToolIndex, 0, syntheticPart)
}
if (appendToLastTextPart(message, tag)) {
continue
}

const syntheticPart = createSyntheticTextPart(message, tag)
const firstToolIndex = message.parts.findIndex((p) => p.type === "tool")
if (firstToolIndex === -1) {
message.parts.push(syntheticPart)
} else {
message.parts.splice(firstToolIndex, 0, syntheticPart)
}
}
}
8 changes: 4 additions & 4 deletions lib/messages/inject/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,10 @@ function injectAnchoredNudge(message: WithParts, nudgeText: string): void {
return
}

if (!hasContent(message)) {
return
}

for (const part of message.parts) {
if (part.type === "text") {
if (appendToTextPart(part, nudgeText)) {
Expand All @@ -263,10 +267,6 @@ function injectAnchoredNudge(message: WithParts, nudgeText: string): void {
}
}

if (!hasContent(message)) {
return
}

const syntheticPart = createSyntheticTextPart(message, nudgeText)
const firstToolIndex = message.parts.findIndex((p) => p.type === "tool")
if (firstToolIndex === -1) {
Expand Down
56 changes: 38 additions & 18 deletions lib/messages/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,26 @@ type MessagePart = WithParts["parts"][number]
type ToolPart = Extract<MessagePart, { type: "tool" }>
type TextPart = Extract<MessagePart, { type: "text" }>

export const appendToLastTextPart = (message: WithParts, injection: string): boolean => {
const textPart = findLastTextPart(message)
if (!textPart) {
return false
}

return appendToTextPart(textPart, injection)
}

const findLastTextPart = (message: WithParts): TextPart | null => {
for (let i = message.parts.length - 1; i >= 0; i--) {
const part = message.parts[i]
if (part.type === "text") {
return part
}
}

return null
}

export const appendToTextPart = (part: TextPart, injection: string): boolean => {
if (typeof part.text !== "string") {
return false
Expand All @@ -88,24 +108,36 @@ export const appendToTextPart = (part: TextPart, injection: string): boolean =>
return true
}

const findLastTextPart = (message: WithParts): TextPart | null => {
export const appendToLastToolPart = (message: WithParts, tag: string): boolean => {
const toolPart = findLastToolPart(message)
if (!toolPart) {
return false
}

return appendToToolPart(toolPart, tag)
}

const findLastToolPart = (message: WithParts): ToolPart | null => {
for (let i = message.parts.length - 1; i >= 0; i--) {
const part = message.parts[i]
if (part.type === "text") {
if (part.type === "tool") {
return part
}
}

return null
}

export const appendToLastTextPart = (message: WithParts, injection: string): boolean => {
const textPart = findLastTextPart(message)
if (!textPart) {
export const appendToToolPart = (part: ToolPart, tag: string): boolean => {
if (part.state?.status !== "completed" || typeof part.state.output !== "string") {
return false
}
if (part.state.output.includes(tag)) {
return true
}

return appendToTextPart(textPart, injection)
part.state.output = `${part.state.output}${tag}`
return true
}

export const hasContent = (message: WithParts): boolean => {
Expand All @@ -120,18 +152,6 @@ export const hasContent = (message: WithParts): boolean => {
)
}

export const appendToToolPart = (part: ToolPart, tag: string): boolean => {
if (part.state?.status !== "completed" || typeof part.state.output !== "string") {
return false
}
if (part.state.output.includes(tag)) {
return true
}

part.state.output = `${part.state.output}${tag}`
return true
}

export function buildToolIdList(state: SessionState, messages: WithParts[]): string[] {
const toolIds: string[] = []
for (const msg of messages) {
Expand Down
2 changes: 1 addition & 1 deletion lib/prompts/compress-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ You specify individual raw messages by ID using the injected IDs visible in the
- \`mNNNN\` IDs identify raw messages
Each message has an ID inside XML metadata tags like \`<dcp-message-id priority="high">m0007</dcp-message-id>\`.
The ID tag appears at the end of the message it belongs to — it identifies the message above it, not the one below it.
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.
Treat these tags as message metadata only, not as content to summarize. Use only the inner \`mNNNN\` value as the \`messageId\`.
The \`priority\` attribute indicates relative context cost. Prefer higher-priority closed messages before lower-priority ones.
Messages marked as \`<dcp-message-id>BLOCKED</dcp-message-id>\` cannot be compressed.
Expand Down
2 changes: 1 addition & 1 deletion lib/prompts/compress-range.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ You specify boundaries by ID using the injected IDs visible in the conversation:
- \`bN\` IDs identify previously compressed blocks
Each message has an ID inside XML metadata tags like \`<dcp-message-id>...</dcp-message-id>\`.
The ID tag appears at the end of the message it belongs to — it identifies the message above it, not the one below it.
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.
Treat these tags as boundary metadata only, not as tool result content.
Rules:
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@tarquinen/opencode-dcp",
"version": "3.1.4",
"version": "3.1.5",
"type": "module",
"description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context",
"main": "./dist/index.js",
Expand Down
66 changes: 24 additions & 42 deletions tests/message-priority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ function repeatedWord(word: string, count: number): string {
return Array.from({ length: count }, () => word).join(" ")
}

test("injectMessageIds tags every text part and tool output in message mode", () => {
test("injectMessageIds injects ID once into last tool output for assistant messages", () => {
const sessionID = "ses_message_priority_tags"
const messages: WithParts[] = [
{
Expand Down Expand Up @@ -211,30 +211,14 @@ test("injectMessageIds tags every text part and tool output in message mode", ()
assert.equal(assistantToolOne?.type, "tool")
assert.equal(assistantTextTwo?.type, "text")
assert.equal(assistantToolTwo?.type, "tool")
assert.match(
(userTextOne as any).text,
/\n\n<dcp-message-id priority="high">m0001<\/dcp-message-id>/,
)
assert.match(
(userTextTwo as any).text,
/\n\n<dcp-message-id priority="high">m0001<\/dcp-message-id>/,
)
assert.match(
(assistantTextOne as any).text,
/\n\n<dcp-message-id priority="low">m0002<\/dcp-message-id>/,
)
assert.match(
(assistantToolOne as any).state.output,
/<dcp-message-id priority="low">m0002<\/dcp-message-id>/,
)
assert.match(
(assistantTextTwo as any).text,
/\n\n<dcp-message-id priority="low">m0002<\/dcp-message-id>/,
)
assert.match(
(assistantToolTwo as any).state.output,
/<dcp-message-id priority="low">m0002<\/dcp-message-id>/,
)
// User messages: still injected into all text parts
assert.match((userTextOne as any).text, /\n\nm0001<\/dcp-message-id>/)
assert.match((userTextTwo as any).text, /\n\nm0001<\/dcp-message-id>/)
// Assistant messages: ID injected only once into the last tool output
assert.doesNotMatch((assistantTextOne as any).text, /dcp-message-id/)
assert.doesNotMatch((assistantToolOne as any).state.output, /dcp-message-id/)
assert.doesNotMatch((assistantTextTwo as any).text, /dcp-message-id/)
assert.match((assistantToolTwo as any).state.output, /m0002<\/dcp-message-id>/)
})

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

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

assert.match((assistantTextOne as any).text, /\n\n<dcp-message-id>m0002<\/dcp-message-id>/)
assert.match((assistantToolOne as any).state.output, /<dcp-message-id>m0002<\/dcp-message-id>/)
assert.match((assistantTextTwo as any).text, /\n\n<dcp-message-id>m0002<\/dcp-message-id>/)
assert.match((assistantToolTwo as any).state.output, /<dcp-message-id>m0002<\/dcp-message-id>/)
// Only the last tool output gets the ID
assert.doesNotMatch((assistantTextOne as any).text, /dcp-message-id/)
assert.doesNotMatch((assistantToolOne as any).state.output, /dcp-message-id/)
assert.doesNotMatch((assistantTextTwo as any).text, /dcp-message-id/)
assert.match((assistantToolTwo as any).state.output, /m0002<\/dcp-message-id>/)
})

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

assert.match(
(assistantText as any).text,
/\n\n<dcp-message-id priority="high">m0002<\/dcp-message-id>/,
)
// ID injected only into the last (only) tool output, not the text part
assert.doesNotMatch((assistantText as any).text, /dcp-message-id/)
assert.match((assistantTool as any).state.output, /m0002<\/dcp-message-id>/)
assert.match(
(assistantTool as any).state.output,
/<dcp-message-id priority="high">m0002<\/dcp-message-id>/,
Expand Down Expand Up @@ -628,7 +612,7 @@ test("range-mode nudges skip assistant with only pending tool parts (issue #463)
assert.equal(messages[1]?.parts[0]?.type, "tool")
})

test("range-mode nudges append to an assistant empty text part (issue #463)", () => {
test("range-mode nudges skip assistant messages with only empty text parts (issue #463)", () => {
const sessionID = "ses_range_nudge_empty_text"
const messages: WithParts[] = [
buildMessage("msg-user-1", "user", sessionID, "Hello", 1),
Expand All @@ -653,16 +637,14 @@ test("range-mode nudges append to an assistant empty text part (issue #463)", ()
system: "",
compressRange: "",
compressMessage: "",
contextLimitNudge: "<dcp-system-reminder>Base context nudge</dcp-system-reminder>",
turnNudge: "<dcp-system-reminder>Base turn nudge</dcp-system-reminder>",
iterationNudge: "<dcp-system-reminder>Base iteration nudge</dcp-system-reminder>",
contextLimitNudge: "",
turnNudge: "",
iterationNudge: "",
})

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

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