diff --git a/lib/messages/inject/inject.ts b/lib/messages/inject/inject.ts index 759b7794..7fa82730 100644 --- a/lib/messages/inject/inject.ts +++ b/lib/messages/inject/inject.ts @@ -8,7 +8,8 @@ import { compressPermission, getLastUserMessage, messageHasCompress } from "../. import { saveSessionState } from "../../state/persistence" import { appendToTextPart, - appendToToolPart, + appendToLastTextPart, + appendToLastToolPart, createSyntheticTextPart, hasContent, isIgnoredUserMessage, @@ -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) } } } diff --git a/lib/messages/inject/utils.ts b/lib/messages/inject/utils.ts index eeb08a82..a216c6b6 100644 --- a/lib/messages/inject/utils.ts +++ b/lib/messages/inject/utils.ts @@ -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)) { @@ -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) { diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts index 792d5135..222ad9f2 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -70,6 +70,26 @@ type MessagePart = WithParts["parts"][number] type ToolPart = Extract type TextPart = Extract +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 @@ -88,10 +108,19 @@ 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 } } @@ -99,13 +128,16 @@ const findLastTextPart = (message: WithParts): TextPart | null => { 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 => { @@ -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) { diff --git a/lib/prompts/compress-message.ts b/lib/prompts/compress-message.ts index 9a5f4911..94d7ff85 100644 --- a/lib/prompts/compress-message.ts +++ b/lib/prompts/compress-message.ts @@ -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 \`m0007\`. -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 \`BLOCKED\` cannot be compressed. diff --git a/lib/prompts/compress-range.ts b/lib/prompts/compress-range.ts index bfc312f8..a730d0a4 100644 --- a/lib/prompts/compress-range.ts +++ b/lib/prompts/compress-range.ts @@ -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 \`...\`. -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: diff --git a/package-lock.json b/package-lock.json index 154f6c75..5601ff81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tarquinen/opencode-dcp", - "version": "3.1.4", + "version": "3.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tarquinen/opencode-dcp", - "version": "3.1.4", + "version": "3.1.5", "license": "AGPL-3.0-or-later", "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", diff --git a/package.json b/package.json index 8117a53d..f273cb86 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/message-priority.test.ts b/tests/message-priority.test.ts index 499cf3c5..ca4ce3a3 100644 --- a/tests/message-priority.test.ts +++ b/tests/message-priority.test.ts @@ -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[] = [ { @@ -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\nm0001<\/dcp-message-id>/, - ) - assert.match( - (userTextTwo as any).text, - /\n\nm0001<\/dcp-message-id>/, - ) - assert.match( - (assistantTextOne as any).text, - /\n\nm0002<\/dcp-message-id>/, - ) - assert.match( - (assistantToolOne as any).state.output, - /m0002<\/dcp-message-id>/, - ) - assert.match( - (assistantTextTwo as any).text, - /\n\nm0002<\/dcp-message-id>/, - ) - assert.match( - (assistantToolTwo as any).state.output, - /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", () => { @@ -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), @@ -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\nm0002<\/dcp-message-id>/) - assert.match((assistantToolOne as any).state.output, /m0002<\/dcp-message-id>/) - assert.match((assistantTextTwo as any).text, /\n\nm0002<\/dcp-message-id>/) - assert.match((assistantToolTwo as any).state.output, /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", () => { @@ -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\nm0002<\/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, /m0002<\/dcp-message-id>/, @@ -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), @@ -653,16 +637,14 @@ test("range-mode nudges append to an assistant empty text part (issue #463)", () system: "", compressRange: "", compressMessage: "", - contextLimitNudge: "Base context nudge", - turnNudge: "Base turn nudge", - iterationNudge: "Base iteration nudge", + 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, - /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", () => {