From 4f35959138b08f37e7804b9d8dd12848957f17f5 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 26 Mar 2026 21:41:18 -0400 Subject: [PATCH 1/3] refactor: tighten deny handling --- index.ts | 33 +++---- lib/hooks.ts | 38 ++++++-- lib/messages/utils.ts | 11 ++- tests/hooks-permission.test.ts | 154 +++++++++++++++++++++++++++++++++ tests/message-priority.test.ts | 2 +- 5 files changed, 209 insertions(+), 29 deletions(-) create mode 100644 tests/hooks-permission.test.ts diff --git a/index.ts b/index.ts index eaff3c7e..b708795e 100644 --- a/index.ts +++ b/index.ts @@ -10,6 +10,7 @@ import { Logger } from "./lib/logger" import { createSessionState } from "./lib/state" import { PromptStore } from "./lib/prompts/store" import { + createChatMessageHandler, createChatMessageTransformHandler, createCommandExecuteHandler, createSystemPromptHandler, @@ -65,20 +66,8 @@ const plugin: Plugin = (async (ctx) => { prompts, hostPermissions, ) as any, - "chat.message": async ( - input: { - sessionID: string - agent?: string - model?: { providerID: string; modelID: string } - messageID?: string - variant?: string - }, - _output: any, - ) => { - state.variant = input.variant - logger.debug("Cached variant from chat.message hook", { variant: input.variant }) - }, - "experimental.text.complete": createTextCompleteHandler(), + "chat.message": createChatMessageHandler(state, logger, config, hostPermissions), + "experimental.text.complete": createTextCompleteHandler(state, config), "command.execute.before": createCommandExecuteHandler( ctx.client, state, @@ -96,14 +85,6 @@ const plugin: Plugin = (async (ctx) => { }), }, config: async (opencodeConfig) => { - if (config.commands.enabled) { - opencodeConfig.command ??= {} - opencodeConfig.command["dcp"] = { - template: "", - description: "Show available DCP commands", - } - } - if ( config.compress.permission !== "deny" && compressDisabledByOpencode(opencodeConfig.permission) @@ -111,6 +92,14 @@ const plugin: Plugin = (async (ctx) => { config.compress.permission = "deny" } + if (config.commands.enabled && config.compress.permission !== "deny") { + opencodeConfig.command ??= {} + opencodeConfig.command["dcp"] = { + template: "", + description: "Show available DCP commands", + } + } + const toolsToAdd: string[] = [] if (config.compress.permission !== "deny" && !config.experimental.allowSubAgents) { toolsToAdd.push("compress") diff --git a/lib/hooks.ts b/lib/hooks.ts index 44bfd7a6..93ea0fd0 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -103,7 +103,7 @@ export function createChatMessageTransformHandler( return } - stripHallucinations(output.messages) + stripHallucinations(output.messages, state, config) cacheSystemPromptTokens(state, output.messages) assignMessageRefs(state, output.messages) syncCompressionBlocks(state, logger, output.messages) @@ -171,6 +171,9 @@ export function createCommandExecuteHandler( syncCompressPermissionState(state, config, hostPermissions, messages) const effectivePermission = compressPermission(state, config) + if (effectivePermission === "deny") { + return + } const args = (input.arguments || "").trim().split(/\s+/).filter(Boolean) const subcommand = args[0]?.toLowerCase() || "" @@ -209,7 +212,7 @@ export function createCommandExecuteHandler( throw new Error("__DCP_MANUAL_HANDLED__") } - if (subcommand === "compress" && effectivePermission !== "deny") { + if (subcommand === "compress") { const userFocus = subArgs.join(" ").trim() const prompt = await handleManualTriggerCommand(commandCtx, "compress", userFocus) if (!prompt) { @@ -230,7 +233,7 @@ export function createCommandExecuteHandler( return } - if (subcommand === "decompress" && effectivePermission !== "deny") { + if (subcommand === "decompress") { await handleDecompressCommand({ ...commandCtx, args: subArgs, @@ -238,7 +241,7 @@ export function createCommandExecuteHandler( throw new Error("__DCP_DECOMPRESS_HANDLED__") } - if (subcommand === "recompress" && effectivePermission !== "deny") { + if (subcommand === "recompress") { await handleRecompressCommand({ ...commandCtx, args: subArgs, @@ -252,11 +255,36 @@ export function createCommandExecuteHandler( } } -export function createTextCompleteHandler() { +export function createTextCompleteHandler(state: SessionState, config: PluginConfig) { return async ( _input: { sessionID: string; messageID: string; partID: string }, output: { text: string }, ) => { + if (compressPermission(state, config) === "deny") { + return + } + output.text = stripHallucinationsFromString(output.text) } } + +export function createChatMessageHandler( + state: SessionState, + logger: Logger, + _config: PluginConfig, + _hostPermissions: HostPermissionSnapshot, +) { + return async ( + input: { + sessionID: string + agent?: string + model?: { providerID: string; modelID: string } + messageID?: string + variant?: string + }, + _output: any, + ) => { + state.variant = input.variant + logger.debug("Cached variant from chat.message hook", { variant: input.variant }) + } +} diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts index 0cadd0fe..e3b0ec49 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto" import type { PluginConfig } from "../config" +import { compressPermission } from "../shared-utils" import { isMessageCompacted } from "../shared-utils" import type { SessionState, WithParts } from "../state" import type { UserMessage } from "@opencode-ai/sdk/v2" @@ -175,7 +176,15 @@ export const stripHallucinationsFromString = (text: string): string => { return text.replace(DCP_PAIRED_TAG_REGEX, "").replace(DCP_UNPAIRED_TAG_REGEX, "") } -export const stripHallucinations = (messages: WithParts[]): void => { +export const stripHallucinations = ( + messages: WithParts[], + state: SessionState, + config: PluginConfig, +): void => { + if (compressPermission(state, config) === "deny") { + return + } + for (const message of messages) { for (const part of message.parts) { if (part.type === "text" && typeof part.text === "string") { diff --git a/tests/hooks-permission.test.ts b/tests/hooks-permission.test.ts new file mode 100644 index 00000000..041d0ce8 --- /dev/null +++ b/tests/hooks-permission.test.ts @@ -0,0 +1,154 @@ +import assert from "node:assert/strict" +import test from "node:test" +import type { PluginConfig } from "../lib/config" +import { + createChatMessageHandler, + createChatMessageTransformHandler, + createCommandExecuteHandler, + createTextCompleteHandler, +} from "../lib/hooks" +import { Logger } from "../lib/logger" +import { createSessionState, type WithParts } from "../lib/state" + +function buildConfig(permission: "allow" | "ask" | "deny" = "allow"): PluginConfig { + return { + enabled: true, + debug: false, + pruneNotification: "off", + pruneNotificationType: "chat", + commands: { + enabled: true, + protectedTools: [], + }, + manualMode: { + enabled: false, + automaticStrategies: true, + }, + turnProtection: { + enabled: false, + turns: 4, + }, + experimental: { + allowSubAgents: false, + customPrompts: false, + }, + protectedFilePatterns: [], + compress: { + mode: "message", + permission, + showCompression: false, + maxContextLimit: 150000, + minContextLimit: 50000, + nudgeFrequency: 5, + iterationNudgeThreshold: 15, + nudgeForce: "soft", + protectedTools: ["task"], + protectUserMessages: false, + }, + strategies: { + deduplication: { + enabled: true, + protectedTools: [], + }, + purgeErrors: { + enabled: true, + turns: 4, + protectedTools: [], + }, + }, + } +} + +function buildMessage(id: string, role: "user" | "assistant", text: string): WithParts { + return { + info: { + id, + role, + sessionID: "session-1", + agent: "assistant", + time: { created: 1 }, + } as WithParts["info"], + parts: [ + { + id: `${id}-part`, + messageID: id, + sessionID: "session-1", + type: "text", + text, + }, + ], + } +} + +test("chat message transform leaves messages untouched when compress is denied", async () => { + const state = createSessionState() + const logger = new Logger(false) + const config = buildConfig("deny") + const handler = createChatMessageTransformHandler( + { session: { get: async () => ({}) } } as any, + state, + logger, + config, + { + reload() {}, + getRuntimePrompts() { + return {} as any + }, + } as any, + { global: undefined, agents: {} }, + ) + const output = { + messages: [buildMessage("assistant-1", "assistant", "alpha beta omega")], + } + + await handler({}, output) + + assert.equal(output.messages[0]?.parts[0]?.type, "text") + assert.equal((output.messages[0]?.parts[0] as any).text, "alpha beta omega") +}) + +test("command execute exits after effective permission resolves to deny", async () => { + let sessionMessagesCalls = 0 + const output = { parts: [] as any[] } + const handler = createCommandExecuteHandler( + { + session: { + messages: async () => { + sessionMessagesCalls += 1 + return { data: [] } + }, + }, + } as any, + createSessionState(), + new Logger(false), + buildConfig("deny"), + "/tmp", + { global: undefined, agents: {} }, + ) + + await handler({ command: "dcp", sessionID: "session-1", arguments: "context" }, output) + + assert.equal(sessionMessagesCalls, 1) + assert.deepEqual(output.parts, []) +}) + +test("chat message hook caches variant even when effective permission is denied", async () => { + const state = createSessionState() + const handler = createChatMessageHandler(state, new Logger(false), buildConfig("allow"), { + global: { "*": "deny" }, + agents: {}, + }) + + await handler({ sessionID: "session-1", variant: "danger", agent: "assistant" }, {}) + + assert.equal(state.variant, "danger") +}) + +test("text complete leaves output untouched when compress is denied", async () => { + const output = { text: "alpha beta omega" } + const handler = createTextCompleteHandler(createSessionState(), buildConfig("deny")) + + await handler({ sessionID: "session-1", messageID: "message-1", partID: "part-1" }, output) + + assert.equal(output.text, "alpha beta omega") +}) diff --git a/tests/message-priority.test.ts b/tests/message-priority.test.ts index 6b22ed63..dd800800 100644 --- a/tests/message-priority.test.ts +++ b/tests/message-priority.test.ts @@ -622,7 +622,7 @@ test("hallucination stripping removes all dcp-prefixed XML tags including varian assert.equal(stripHallucinationsFromString(text), "alphaomega") - const handler = createTextCompleteHandler() + const handler = createTextCompleteHandler(createSessionState(), buildConfig()) const output = { text } await handler({ sessionID: "session", messageID: "message", partID: "part" }, output) assert.equal(output.text, "alphaomega") From 5767be1de84a36db31f33e7037a8a38e0fa5a8c2 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Fri, 27 Mar 2026 13:48:22 -0400 Subject: [PATCH 2/3] fix: tighten assistant injection guards --- lib/messages/inject/inject.ts | 11 +-- lib/messages/inject/utils.ts | 28 ++++-- lib/messages/utils.ts | 12 +++ tests/message-priority.test.ts | 154 +++++++++++++++++++++++++++++++++ 4 files changed, 191 insertions(+), 14 deletions(-) diff --git a/lib/messages/inject/inject.ts b/lib/messages/inject/inject.ts index bc55799d..759b7794 100644 --- a/lib/messages/inject/inject.ts +++ b/lib/messages/inject/inject.ts @@ -10,6 +10,7 @@ import { appendToTextPart, appendToToolPart, createSyntheticTextPart, + hasContent, isIgnoredUserMessage, isProtectedUserMessage, } from "../utils" @@ -186,15 +187,7 @@ 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) { + if (!hasContent(message)) { continue } diff --git a/lib/messages/inject/utils.ts b/lib/messages/inject/utils.ts index a19618e9..eeb08a82 100644 --- a/lib/messages/inject/utils.ts +++ b/lib/messages/inject/utils.ts @@ -8,7 +8,13 @@ import { type MessagePriority, listPriorityRefsBeforeIndex, } from "../priority" -import { appendToLastTextPart, createSyntheticTextPart, isIgnoredUserMessage } from "../utils" +import { + appendToTextPart, + appendToLastTextPart, + createSyntheticTextPart, + hasContent, + isIgnoredUserMessage, +} from "../utils" import { getLastUserMessage } from "../../shared-utils" import { getCurrentTokenUsage } from "../../strategies/utils" import { getActiveSummaryTokenUsage } from "../../state/utils" @@ -236,11 +242,11 @@ function injectAnchoredNudge(message: WithParts, nudgeText: string): void { return } - if (appendToLastTextPart(message, nudgeText)) { - return - } - if (message.info.role === "user") { + if (appendToLastTextPart(message, nudgeText)) { + return + } + message.parts.push(createSyntheticTextPart(message, nudgeText)) return } @@ -249,6 +255,18 @@ function injectAnchoredNudge(message: WithParts, nudgeText: string): void { return } + for (const part of message.parts) { + if (part.type === "text") { + if (appendToTextPart(part, nudgeText)) { + return + } + } + } + + 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 e3b0ec49..f9f9890d 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -109,6 +109,18 @@ export const appendToLastTextPart = (message: WithParts, injection: string): boo return appendToTextPart(textPart, injection) } +export const hasContent = (message: WithParts): boolean => { + return message.parts.some( + (part) => + (part.type === "text" && + typeof part.text === "string" && + part.text.trim().length > 0) || + (part.type === "tool" && + part.state?.status === "completed" && + typeof part.state.output === "string"), + ) +} + export const appendToToolPart = (part: ToolPart, tag: string): boolean => { if (part.state?.status !== "completed" || typeof part.state.output !== "string") { return false diff --git a/tests/message-priority.test.ts b/tests/message-priority.test.ts index dd800800..fd26be87 100644 --- a/tests/message-priority.test.ts +++ b/tests/message-priority.test.ts @@ -511,6 +511,160 @@ test("range-mode nudges append to existing text parts before tool outputs", () = assert.equal((toolOutput as any).state.output, "task output body") }) +test("range-mode nudges inject only once for assistant messages with multiple text parts", () => { + const sessionID = "ses_range_nudge_multi_text" + const messages: WithParts[] = [ + buildMessage("msg-user-1", "user", sessionID, "Hello", 1), + { + info: { + id: "msg-assistant-1", + role: "assistant", + sessionID, + agent: "assistant", + time: { created: 2 }, + } as WithParts["info"], + parts: [ + textPart("msg-assistant-1", sessionID, "assistant-text-1", "First chunk."), + textPart("msg-assistant-1", sessionID, "assistant-text-2", "Second chunk."), + ], + }, + ] + const state = createSessionState() + const config = buildConfig("range") + + assignMessageRefs(state, messages) + state.nudges.contextLimitAnchors.add("msg-assistant-1") + + applyAnchoredNudges(state, config, messages, { + system: "", + compressRange: "", + compressMessage: "", + contextLimitNudge: "Base context nudge", + turnNudge: "Base turn nudge", + iterationNudge: "Base iteration nudge", + }) + + assert.match((messages[1]?.parts[0] as any).text, /Base context nudge/) + assert.doesNotMatch((messages[1]?.parts[1] as any).text, /Base context nudge/) +}) + +test("range-mode nudges skip empty assistant messages to avoid prefill (issue #463)", () => { + const sessionID = "ses_range_nudge_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: [], + }, + ] + const state = createSessionState() + const config = buildConfig("range") + + assignMessageRefs(state, messages) + state.nudges.contextLimitAnchors.add("msg-assistant-empty") + + applyAnchoredNudges(state, config, messages, { + system: "", + compressRange: "", + compressMessage: "", + contextLimitNudge: "Base context nudge", + turnNudge: "Base turn nudge", + iterationNudge: "Base iteration nudge", + }) + + assert.equal(messages[1]?.parts.length, 0) +}) + +test("range-mode nudges skip assistant with only pending tool parts (issue #463)", () => { + const sessionID = "ses_range_nudge_pending_tool" + 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, + ], + }, + ] + const state = createSessionState() + const config = buildConfig("range") + + assignMessageRefs(state, messages) + state.nudges.contextLimitAnchors.add("msg-assistant-pending") + + applyAnchoredNudges(state, config, messages, { + system: "", + compressRange: "", + compressMessage: "", + contextLimitNudge: "Base context nudge", + turnNudge: "Base turn nudge", + iterationNudge: "Base iteration nudge", + }) + + assert.equal(messages[1]?.parts.length, 1) + assert.equal(messages[1]?.parts[0]?.type, "tool") +}) + +test("range-mode nudges append to an assistant empty text part (issue #463)", () => { + const sessionID = "ses_range_nudge_empty_text" + 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", "")], + }, + ] + const state = createSessionState() + const config = buildConfig("range") + + assignMessageRefs(state, messages) + state.nudges.contextLimitAnchors.add("msg-assistant-empty-text") + + applyAnchoredNudges(state, config, messages, { + system: "", + compressRange: "", + compressMessage: "", + contextLimitNudge: "Base context nudge", + turnNudge: "Base turn nudge", + iterationNudge: "Base iteration nudge", + }) + + assert.equal(messages[1]?.parts.length, 1) + assert.match( + (messages[1]?.parts[0] as any).text, + /Base context nudge[\s\S]*Compressed block context:/, + ) +}) + test("message-mode rendered compressed summaries mark block IDs as BLOCKED", () => { const sessionID = "ses_message_blocked_blocks" const messages: WithParts[] = [ From dc16a0d7bffb861cb2cbd9671c8f15b18f03b2e8 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Fri, 27 Mar 2026 13:56:41 -0400 Subject: [PATCH 3/3] refactor: simplify hallucination stripping --- index.ts | 2 +- lib/hooks.ts | 8 ++------ lib/messages/utils.ts | 11 +---------- tests/hooks-permission.test.ts | 10 +++++----- tests/message-priority.test.ts | 2 +- 5 files changed, 10 insertions(+), 23 deletions(-) diff --git a/index.ts b/index.ts index b708795e..8bcd8e34 100644 --- a/index.ts +++ b/index.ts @@ -67,7 +67,7 @@ const plugin: Plugin = (async (ctx) => { hostPermissions, ) as any, "chat.message": createChatMessageHandler(state, logger, config, hostPermissions), - "experimental.text.complete": createTextCompleteHandler(state, config), + "experimental.text.complete": createTextCompleteHandler(), "command.execute.before": createCommandExecuteHandler( ctx.client, state, diff --git a/lib/hooks.ts b/lib/hooks.ts index 93ea0fd0..b8e2ed79 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -103,7 +103,7 @@ export function createChatMessageTransformHandler( return } - stripHallucinations(output.messages, state, config) + stripHallucinations(output.messages) cacheSystemPromptTokens(state, output.messages) assignMessageRefs(state, output.messages) syncCompressionBlocks(state, logger, output.messages) @@ -255,15 +255,11 @@ export function createCommandExecuteHandler( } } -export function createTextCompleteHandler(state: SessionState, config: PluginConfig) { +export function createTextCompleteHandler() { return async ( _input: { sessionID: string; messageID: string; partID: string }, output: { text: string }, ) => { - if (compressPermission(state, config) === "deny") { - return - } - output.text = stripHallucinationsFromString(output.text) } } diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts index f9f9890d..792d5135 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -1,6 +1,5 @@ import { createHash } from "node:crypto" import type { PluginConfig } from "../config" -import { compressPermission } from "../shared-utils" import { isMessageCompacted } from "../shared-utils" import type { SessionState, WithParts } from "../state" import type { UserMessage } from "@opencode-ai/sdk/v2" @@ -188,15 +187,7 @@ export const stripHallucinationsFromString = (text: string): string => { return text.replace(DCP_PAIRED_TAG_REGEX, "").replace(DCP_UNPAIRED_TAG_REGEX, "") } -export const stripHallucinations = ( - messages: WithParts[], - state: SessionState, - config: PluginConfig, -): void => { - if (compressPermission(state, config) === "deny") { - return - } - +export const stripHallucinations = (messages: WithParts[]): void => { for (const message of messages) { for (const part of message.parts) { if (part.type === "text" && typeof part.text === "string") { diff --git a/tests/hooks-permission.test.ts b/tests/hooks-permission.test.ts index 041d0ce8..9a13b7ba 100644 --- a/tests/hooks-permission.test.ts +++ b/tests/hooks-permission.test.ts @@ -80,7 +80,7 @@ function buildMessage(id: string, role: "user" | "assistant", text: string): Wit } } -test("chat message transform leaves messages untouched when compress is denied", async () => { +test("chat message transform strips hallucinated tags even when compress is denied", async () => { const state = createSessionState() const logger = new Logger(false) const config = buildConfig("deny") @@ -104,7 +104,7 @@ test("chat message transform leaves messages untouched when compress is denied", await handler({}, output) assert.equal(output.messages[0]?.parts[0]?.type, "text") - assert.equal((output.messages[0]?.parts[0] as any).text, "alpha beta omega") + assert.equal((output.messages[0]?.parts[0] as any).text, "alpha omega") }) test("command execute exits after effective permission resolves to deny", async () => { @@ -144,11 +144,11 @@ test("chat message hook caches variant even when effective permission is denied" assert.equal(state.variant, "danger") }) -test("text complete leaves output untouched when compress is denied", async () => { +test("text complete strips hallucinated metadata tags", async () => { const output = { text: "alpha beta omega" } - const handler = createTextCompleteHandler(createSessionState(), buildConfig("deny")) + const handler = createTextCompleteHandler() await handler({ sessionID: "session-1", messageID: "message-1", partID: "part-1" }, output) - assert.equal(output.text, "alpha beta omega") + assert.equal(output.text, "alpha omega") }) diff --git a/tests/message-priority.test.ts b/tests/message-priority.test.ts index fd26be87..499cf3c5 100644 --- a/tests/message-priority.test.ts +++ b/tests/message-priority.test.ts @@ -776,7 +776,7 @@ test("hallucination stripping removes all dcp-prefixed XML tags including varian assert.equal(stripHallucinationsFromString(text), "alphaomega") - const handler = createTextCompleteHandler(createSessionState(), buildConfig()) + const handler = createTextCompleteHandler() const output = { text } await handler({ sessionID: "session", messageID: "message", partID: "part" }, output) assert.equal(output.text, "alphaomega")