Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 10 additions & 21 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -96,21 +85,21 @@ 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)
) {
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")
Expand Down
30 changes: 27 additions & 3 deletions lib/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() || ""
Expand Down Expand Up @@ -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) {
Expand All @@ -230,15 +233,15 @@ export function createCommandExecuteHandler(
return
}

if (subcommand === "decompress" && effectivePermission !== "deny") {
if (subcommand === "decompress") {
await handleDecompressCommand({
...commandCtx,
args: subArgs,
})
throw new Error("__DCP_DECOMPRESS_HANDLED__")
}

if (subcommand === "recompress" && effectivePermission !== "deny") {
if (subcommand === "recompress") {
await handleRecompressCommand({
...commandCtx,
args: subArgs,
Expand All @@ -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 })
}
}
11 changes: 2 additions & 9 deletions lib/messages/inject/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
appendToTextPart,
appendToToolPart,
createSyntheticTextPart,
hasContent,
isIgnoredUserMessage,
isProtectedUserMessage,
} from "../utils"
Expand Down Expand Up @@ -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
}

Expand Down
28 changes: 23 additions & 5 deletions lib/messages/inject/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand All @@ -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) {
Expand Down
12 changes: 12 additions & 0 deletions lib/messages/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
154 changes: 154 additions & 0 deletions tests/hooks-permission.test.ts
Original file line number Diff line number Diff line change
@@ -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 <dcp>beta</dcp> 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 <dcp>beta</dcp> omega" }
const handler = createTextCompleteHandler()

await handler({ sessionID: "session-1", messageID: "message-1", partID: "part-1" }, output)

assert.equal(output.text, "alpha omega")
})
Loading
Loading