From d8ac76573714627b4807604c380846a952dc9222 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 25 Mar 2026 21:54:08 -0400 Subject: [PATCH 1/4] refactor: move manual trigger handling out of hooks --- lib/commands/manual.ts | 37 +++++++++++++++++++++++++++++++++++++ lib/hooks.ts | 41 ++++++----------------------------------- 2 files changed, 43 insertions(+), 35 deletions(-) diff --git a/lib/commands/manual.ts b/lib/commands/manual.ts index aa8bb1dd..e19a5e09 100644 --- a/lib/commands/manual.ts +++ b/lib/commands/manual.ts @@ -13,6 +13,7 @@ import type { PluginConfig } from "../config" import { sendIgnoredMessage } from "../ui/notification" import { getCurrentParams } from "../strategies/utils" import { buildCompressedBlockGuidance } from "../messages/inject/utils" +import { isIgnoredUserMessage } from "../messages/utils" const MANUAL_MODE_ON = "Manual mode is now ON. Use /dcp compress to trigger context tools manually." @@ -86,3 +87,39 @@ export async function handleManualTriggerCommand( ): Promise { return getTriggerPrompt(tool, ctx.state, ctx.config, userFocus) } + +export function applyPendingManualTrigger( + state: SessionState, + messages: WithParts[], + logger: Logger, +): void { + const pending = state.pendingManualTrigger + if (!pending) { + return + } + + if (!state.sessionId || pending.sessionId !== state.sessionId) { + state.pendingManualTrigger = null + return + } + + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.info.role !== "user" || isIgnoredUserMessage(msg)) { + continue + } + + for (const part of msg.parts) { + if (part.type !== "text" || part.ignored || part.synthetic) { + continue + } + + part.text = pending.prompt + state.pendingManualTrigger = null + logger.debug("Applied manual prompt", { sessionId: pending.sessionId }) + return + } + } + + state.pendingManualTrigger = null +} diff --git a/lib/hooks.ts b/lib/hooks.ts index 8b3d9f1d..f6c3c080 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -14,7 +14,6 @@ import { } from "./messages" import { buildToolIdList, - isIgnoredUserMessage, stripHallucinations, stripHallucinationsFromString, } from "./messages/utils" @@ -24,7 +23,11 @@ import { handleStatsCommand } from "./commands/stats" import { handleContextCommand } from "./commands/context" import { handleHelpCommand } from "./commands/help" import { handleSweepCommand } from "./commands/sweep" -import { handleManualToggleCommand, handleManualTriggerCommand } from "./commands/manual" +import { + applyPendingManualTrigger, + handleManualToggleCommand, + handleManualTriggerCommand, +} from "./commands/manual" import { handleDecompressCommand } from "./commands/decompress" import { handleRecompressCommand } from "./commands/recompress" import { type HostPermissionSnapshot } from "./host-permissions" @@ -39,38 +42,6 @@ const INTERNAL_AGENT_SIGNATURES = [ "Summarize what was done in this conversation", ] -function applyManualPrompt(state: SessionState, messages: WithParts[], logger: Logger): void { - const pending = state.pendingManualTrigger - if (!pending) { - return - } - - if (!state.sessionId || pending.sessionId !== state.sessionId) { - state.pendingManualTrigger = null - return - } - - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i] - if (msg.info.role !== "user" || isIgnoredUserMessage(msg)) { - continue - } - - for (const part of msg.parts) { - if (part.type !== "text" || part.ignored || part.synthetic) { - continue - } - - part.text = pending.prompt - state.pendingManualTrigger = null - logger.debug("Applied manual prompt", { sessionId: pending.sessionId }) - return - } - } - - state.pendingManualTrigger = null -} - export function createSystemPromptHandler( state: SessionState, logger: Logger, @@ -162,7 +133,7 @@ export function createChatMessageTransformHandler( compressionPriorities, ) injectMessageIds(state, config, output.messages, compressionPriorities) - applyManualPrompt(state, output.messages, logger) + applyPendingManualTrigger(state, output.messages, logger) stripStaleMetadata(output.messages) if (state.sessionId) { From bf576d246b8ff63cf1623ca9770108062ac3a6cd Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 26 Mar 2026 10:16:38 -0400 Subject: [PATCH 2/4] refactor: simplify hooks imports --- lib/commands/index.ts | 11 +++++++++++ lib/hooks.ts | 37 ++++++++++++++++--------------------- lib/messages/index.ts | 2 ++ lib/prompts/index.ts | 1 + lib/state/index.ts | 1 + 5 files changed, 31 insertions(+), 21 deletions(-) create mode 100644 lib/commands/index.ts diff --git a/lib/commands/index.ts b/lib/commands/index.ts new file mode 100644 index 00000000..993420bf --- /dev/null +++ b/lib/commands/index.ts @@ -0,0 +1,11 @@ +export { handleContextCommand } from "./context" +export { handleDecompressCommand } from "./decompress" +export { handleHelpCommand } from "./help" +export { + applyPendingManualTrigger, + handleManualToggleCommand, + handleManualTriggerCommand, +} from "./manual" +export { handleRecompressCommand } from "./recompress" +export { handleStatsCommand } from "./stats" +export { handleSweepCommand } from "./sweep" diff --git a/lib/hooks.ts b/lib/hooks.ts index f6c3c080..44bfd7a6 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -2,39 +2,34 @@ import type { SessionState, WithParts } from "./state" import type { Logger } from "./logger" import type { PluginConfig } from "./config" import { assignMessageRefs } from "./message-ids" -import { buildPriorityMap } from "./messages/priority" -import { syncToolCache } from "./state/tool-cache" import { - prune, - syncCompressionBlocks, + buildPriorityMap, + buildToolIdList, injectCompressNudges, - injectMessageIds, injectExtendedSubAgentResults, - stripStaleMetadata, -} from "./messages" -import { - buildToolIdList, + injectMessageIds, + prune, stripHallucinations, stripHallucinationsFromString, -} from "./messages/utils" -import { checkSession } from "./state" -import { renderSystemPrompt } from "./prompts" -import { handleStatsCommand } from "./commands/stats" -import { handleContextCommand } from "./commands/context" -import { handleHelpCommand } from "./commands/help" -import { handleSweepCommand } from "./commands/sweep" + stripStaleMetadata, + syncCompressionBlocks, +} from "./messages" +import { renderSystemPrompt, type PromptStore } from "./prompts" import { applyPendingManualTrigger, + handleContextCommand, + handleDecompressCommand, + handleHelpCommand, handleManualToggleCommand, handleManualTriggerCommand, -} from "./commands/manual" -import { handleDecompressCommand } from "./commands/decompress" -import { handleRecompressCommand } from "./commands/recompress" + handleRecompressCommand, + handleStatsCommand, + handleSweepCommand, +} from "./commands" import { type HostPermissionSnapshot } from "./host-permissions" import { compressPermission, syncCompressPermissionState } from "./shared-utils" -import { ensureSessionInitialized } from "./state/state" +import { checkSession, ensureSessionInitialized, syncToolCache } from "./state" import { cacheSystemPromptTokens } from "./ui/utils" -import type { PromptStore } from "./prompts/store" const INTERNAL_AGENT_SIGNATURES = [ "You are a title generator", diff --git a/lib/messages/index.ts b/lib/messages/index.ts index d9300ed9..4eb50c25 100644 --- a/lib/messages/index.ts +++ b/lib/messages/index.ts @@ -4,3 +4,5 @@ export { injectCompressNudges } from "./inject/inject" export { injectMessageIds } from "./inject/inject" export { injectExtendedSubAgentResults } from "./inject/subagent-results" export { stripStaleMetadata } from "./reasoning-strip" +export { buildPriorityMap } from "./priority" +export { buildToolIdList, stripHallucinations, stripHallucinationsFromString } from "./utils" diff --git a/lib/prompts/index.ts b/lib/prompts/index.ts index d8ff7d33..0b3cd98d 100644 --- a/lib/prompts/index.ts +++ b/lib/prompts/index.ts @@ -1,4 +1,5 @@ import type { RuntimePrompts } from "./store" +export type { PromptStore, RuntimePrompts } from "./store" function stripLegacyInlineComments(content: string): string { return content.replace(/^[ \t]*\/\/.*?\/\/[ \t]*$/gm, "") diff --git a/lib/state/index.ts b/lib/state/index.ts index a665a742..e3a2089c 100644 --- a/lib/state/index.ts +++ b/lib/state/index.ts @@ -1,3 +1,4 @@ export * from "./persistence" export * from "./types" export * from "./state" +export * from "./tool-cache" From 03372387e40a036d36658282a7661aeb8e5772c1 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 26 Mar 2026 15:54:47 -0400 Subject: [PATCH 3/4] fix: skip message ID injection on empty assistant messages (#463) --- lib/messages/inject/inject.ts | 30 ++++++---- tests/message-priority.test.ts | 101 +++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 10 deletions(-) diff --git a/lib/messages/inject/inject.ts b/lib/messages/inject/inject.ts index 4c281b0a..bc55799d 100644 --- a/lib/messages/inject/inject.ts +++ b/lib/messages/inject/inject.ts @@ -186,6 +186,18 @@ export const injectMessageIds = ( continue } + const hasContent = message.parts.some( + (p) => + (p.type === "text" && typeof p.text === "string" && p.text.trim().length > 0) || + (p.type === "tool" && + p.state?.status === "completed" && + typeof p.state.output === "string"), + ) + + if (!hasContent) { + continue + } + let injected = false for (const part of message.parts) { if (part.type === "text") { @@ -195,16 +207,14 @@ export const injectMessageIds = ( } } - if (injected) { - 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) + 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) + } } } } diff --git a/tests/message-priority.test.ts b/tests/message-priority.test.ts index bd8f405e..6b22ed63 100644 --- a/tests/message-priority.test.ts +++ b/tests/message-priority.test.ts @@ -670,3 +670,104 @@ test("hallucination stripping does not affect non-dcp tags", async () => { "
hello
keep", ) }) + +test("injectMessageIds skips empty assistant messages to avoid prefill (issue #463)", () => { + const sessionID = "ses_empty_assistant" + const messages: WithParts[] = [ + buildMessage("msg-user-1", "user", sessionID, "Hello", 1), + { + info: { + id: "msg-assistant-empty", + role: "assistant", + sessionID, + agent: "assistant", + time: { created: 2 }, + } as WithParts["info"], + parts: [], + }, + buildMessage("msg-user-2", "user", sessionID, "continue", 3), + ] + const state = createSessionState() + const config = buildConfig("range") + + assignMessageRefs(state, messages) + injectMessageIds(state, config, messages) + + const emptyAssistant = messages[1]! + assert.equal(emptyAssistant.parts.length, 0, "empty assistant should get no synthetic parts") +}) + +test("injectMessageIds skips assistant with only pending tool parts (issue #463)", () => { + const sessionID = "ses_pending_tool_assistant" + const messages: WithParts[] = [ + buildMessage("msg-user-1", "user", sessionID, "Hello", 1), + { + info: { + id: "msg-assistant-pending", + role: "assistant", + sessionID, + agent: "assistant", + time: { created: 2 }, + } as WithParts["info"], + parts: [ + { + id: "pending-tool-part", + messageID: "msg-assistant-pending", + sessionID, + type: "tool" as const, + tool: "bash", + callID: "call-pending-1", + state: { + status: "pending" as const, + input: { command: "ls" }, + }, + } as any, + ], + }, + buildMessage("msg-user-2", "user", sessionID, "continue", 3), + ] + const state = createSessionState() + const config = buildConfig("range") + + assignMessageRefs(state, messages) + injectMessageIds(state, config, messages) + + const pendingAssistant = messages[1]! + assert.equal( + pendingAssistant.parts.length, + 1, + "assistant with only pending tools should not get a synthetic text part", + ) + assert.equal(pendingAssistant.parts[0]!.type, "tool") +}) + +test("injectMessageIds skips assistant with empty text part (issue #463)", () => { + const sessionID = "ses_empty_text_assistant" + const messages: WithParts[] = [ + buildMessage("msg-user-1", "user", sessionID, "Hello", 1), + { + info: { + id: "msg-assistant-empty-text", + role: "assistant", + sessionID, + agent: "assistant", + time: { created: 2 }, + } as WithParts["info"], + parts: [textPart("msg-assistant-empty-text", sessionID, "empty-text-part", "")], + }, + buildMessage("msg-user-2", "user", sessionID, "continue", 3), + ] + const state = createSessionState() + const config = buildConfig("range") + + assignMessageRefs(state, messages) + injectMessageIds(state, config, messages) + + const emptyTextAssistant = messages[1]! + assert.equal(emptyTextAssistant.parts.length, 1, "should not add a synthetic part") + assert.equal( + (emptyTextAssistant.parts[0] as any).text, + "", + "empty text part should remain untouched", + ) +}) From 474df27c1b859b7fce774e5bc18736fd5caf9405 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 26 Mar 2026 16:01:18 -0400 Subject: [PATCH 4/4] v3.1.3 - Bump version --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0f269765..e8c93514 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tarquinen/opencode-dcp", - "version": "3.1.2", + "version": "3.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tarquinen/opencode-dcp", - "version": "3.1.2", + "version": "3.1.3", "license": "AGPL-3.0-or-later", "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", diff --git a/package.json b/package.json index ef184efa..b7838055 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.2", + "version": "3.1.3", "type": "module", "description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context", "main": "./dist/index.js",