diff --git a/index.ts b/index.ts
index eaff3c7e..8bcd8e34 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,19 +66,7 @@ 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 })
- },
+ "chat.message": createChatMessageHandler(state, logger, config, hostPermissions),
"experimental.text.complete": createTextCompleteHandler(),
"command.execute.before": createCommandExecuteHandler(
ctx.client,
@@ -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..b8e2ed79 100644
--- a/lib/hooks.ts
+++ b/lib/hooks.ts
@@ -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,
@@ -260,3 +263,24 @@ export function createTextCompleteHandler() {
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/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 0cadd0fe..792d5135 100644
--- a/lib/messages/utils.ts
+++ b/lib/messages/utils.ts
@@ -108,6 +108,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/hooks-permission.test.ts b/tests/hooks-permission.test.ts
new file mode 100644
index 00000000..9a13b7ba
--- /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 strips hallucinated tags even 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 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 strips hallucinated metadata tags", async () => {
+ const output = { text: "alpha beta omega" }
+ const handler = createTextCompleteHandler()
+
+ await handler({ sessionID: "session-1", messageID: "message-1", partID: "part-1" }, output)
+
+ assert.equal(output.text, "alpha omega")
+})
diff --git a/tests/message-priority.test.ts b/tests/message-priority.test.ts
index 6b22ed63..499cf3c5 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[] = [