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[] = [