Skip to content

Commit 41f699e

Browse files
authored
Merge pull request #465 from Opencode-DCP/dev
merge dev into master
2 parents 30256bb + dc16a0d commit 41f699e

7 files changed

Lines changed: 382 additions & 38 deletions

File tree

index.ts

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Logger } from "./lib/logger"
1010
import { createSessionState } from "./lib/state"
1111
import { PromptStore } from "./lib/prompts/store"
1212
import {
13+
createChatMessageHandler,
1314
createChatMessageTransformHandler,
1415
createCommandExecuteHandler,
1516
createSystemPromptHandler,
@@ -65,19 +66,7 @@ const plugin: Plugin = (async (ctx) => {
6566
prompts,
6667
hostPermissions,
6768
) as any,
68-
"chat.message": async (
69-
input: {
70-
sessionID: string
71-
agent?: string
72-
model?: { providerID: string; modelID: string }
73-
messageID?: string
74-
variant?: string
75-
},
76-
_output: any,
77-
) => {
78-
state.variant = input.variant
79-
logger.debug("Cached variant from chat.message hook", { variant: input.variant })
80-
},
69+
"chat.message": createChatMessageHandler(state, logger, config, hostPermissions),
8170
"experimental.text.complete": createTextCompleteHandler(),
8271
"command.execute.before": createCommandExecuteHandler(
8372
ctx.client,
@@ -96,21 +85,21 @@ const plugin: Plugin = (async (ctx) => {
9685
}),
9786
},
9887
config: async (opencodeConfig) => {
99-
if (config.commands.enabled) {
100-
opencodeConfig.command ??= {}
101-
opencodeConfig.command["dcp"] = {
102-
template: "",
103-
description: "Show available DCP commands",
104-
}
105-
}
106-
10788
if (
10889
config.compress.permission !== "deny" &&
10990
compressDisabledByOpencode(opencodeConfig.permission)
11091
) {
11192
config.compress.permission = "deny"
11293
}
11394

95+
if (config.commands.enabled && config.compress.permission !== "deny") {
96+
opencodeConfig.command ??= {}
97+
opencodeConfig.command["dcp"] = {
98+
template: "",
99+
description: "Show available DCP commands",
100+
}
101+
}
102+
114103
const toolsToAdd: string[] = []
115104
if (config.compress.permission !== "deny" && !config.experimental.allowSubAgents) {
116105
toolsToAdd.push("compress")

lib/hooks.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,9 @@ export function createCommandExecuteHandler(
171171
syncCompressPermissionState(state, config, hostPermissions, messages)
172172

173173
const effectivePermission = compressPermission(state, config)
174+
if (effectivePermission === "deny") {
175+
return
176+
}
174177

175178
const args = (input.arguments || "").trim().split(/\s+/).filter(Boolean)
176179
const subcommand = args[0]?.toLowerCase() || ""
@@ -209,7 +212,7 @@ export function createCommandExecuteHandler(
209212
throw new Error("__DCP_MANUAL_HANDLED__")
210213
}
211214

212-
if (subcommand === "compress" && effectivePermission !== "deny") {
215+
if (subcommand === "compress") {
213216
const userFocus = subArgs.join(" ").trim()
214217
const prompt = await handleManualTriggerCommand(commandCtx, "compress", userFocus)
215218
if (!prompt) {
@@ -230,15 +233,15 @@ export function createCommandExecuteHandler(
230233
return
231234
}
232235

233-
if (subcommand === "decompress" && effectivePermission !== "deny") {
236+
if (subcommand === "decompress") {
234237
await handleDecompressCommand({
235238
...commandCtx,
236239
args: subArgs,
237240
})
238241
throw new Error("__DCP_DECOMPRESS_HANDLED__")
239242
}
240243

241-
if (subcommand === "recompress" && effectivePermission !== "deny") {
244+
if (subcommand === "recompress") {
242245
await handleRecompressCommand({
243246
...commandCtx,
244247
args: subArgs,
@@ -260,3 +263,24 @@ export function createTextCompleteHandler() {
260263
output.text = stripHallucinationsFromString(output.text)
261264
}
262265
}
266+
267+
export function createChatMessageHandler(
268+
state: SessionState,
269+
logger: Logger,
270+
_config: PluginConfig,
271+
_hostPermissions: HostPermissionSnapshot,
272+
) {
273+
return async (
274+
input: {
275+
sessionID: string
276+
agent?: string
277+
model?: { providerID: string; modelID: string }
278+
messageID?: string
279+
variant?: string
280+
},
281+
_output: any,
282+
) => {
283+
state.variant = input.variant
284+
logger.debug("Cached variant from chat.message hook", { variant: input.variant })
285+
}
286+
}

lib/messages/inject/inject.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
appendToTextPart,
1111
appendToToolPart,
1212
createSyntheticTextPart,
13+
hasContent,
1314
isIgnoredUserMessage,
1415
isProtectedUserMessage,
1516
} from "../utils"
@@ -186,15 +187,7 @@ export const injectMessageIds = (
186187
continue
187188
}
188189

189-
const hasContent = message.parts.some(
190-
(p) =>
191-
(p.type === "text" && typeof p.text === "string" && p.text.trim().length > 0) ||
192-
(p.type === "tool" &&
193-
p.state?.status === "completed" &&
194-
typeof p.state.output === "string"),
195-
)
196-
197-
if (!hasContent) {
190+
if (!hasContent(message)) {
198191
continue
199192
}
200193

lib/messages/inject/utils.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@ import {
88
type MessagePriority,
99
listPriorityRefsBeforeIndex,
1010
} from "../priority"
11-
import { appendToLastTextPart, createSyntheticTextPart, isIgnoredUserMessage } from "../utils"
11+
import {
12+
appendToTextPart,
13+
appendToLastTextPart,
14+
createSyntheticTextPart,
15+
hasContent,
16+
isIgnoredUserMessage,
17+
} from "../utils"
1218
import { getLastUserMessage } from "../../shared-utils"
1319
import { getCurrentTokenUsage } from "../../strategies/utils"
1420
import { getActiveSummaryTokenUsage } from "../../state/utils"
@@ -236,11 +242,11 @@ function injectAnchoredNudge(message: WithParts, nudgeText: string): void {
236242
return
237243
}
238244

239-
if (appendToLastTextPart(message, nudgeText)) {
240-
return
241-
}
242-
243245
if (message.info.role === "user") {
246+
if (appendToLastTextPart(message, nudgeText)) {
247+
return
248+
}
249+
244250
message.parts.push(createSyntheticTextPart(message, nudgeText))
245251
return
246252
}
@@ -249,6 +255,18 @@ function injectAnchoredNudge(message: WithParts, nudgeText: string): void {
249255
return
250256
}
251257

258+
for (const part of message.parts) {
259+
if (part.type === "text") {
260+
if (appendToTextPart(part, nudgeText)) {
261+
return
262+
}
263+
}
264+
}
265+
266+
if (!hasContent(message)) {
267+
return
268+
}
269+
252270
const syntheticPart = createSyntheticTextPart(message, nudgeText)
253271
const firstToolIndex = message.parts.findIndex((p) => p.type === "tool")
254272
if (firstToolIndex === -1) {

lib/messages/utils.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,18 @@ export const appendToLastTextPart = (message: WithParts, injection: string): boo
108108
return appendToTextPart(textPart, injection)
109109
}
110110

111+
export const hasContent = (message: WithParts): boolean => {
112+
return message.parts.some(
113+
(part) =>
114+
(part.type === "text" &&
115+
typeof part.text === "string" &&
116+
part.text.trim().length > 0) ||
117+
(part.type === "tool" &&
118+
part.state?.status === "completed" &&
119+
typeof part.state.output === "string"),
120+
)
121+
}
122+
111123
export const appendToToolPart = (part: ToolPart, tag: string): boolean => {
112124
if (part.state?.status !== "completed" || typeof part.state.output !== "string") {
113125
return false

tests/hooks-permission.test.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import assert from "node:assert/strict"
2+
import test from "node:test"
3+
import type { PluginConfig } from "../lib/config"
4+
import {
5+
createChatMessageHandler,
6+
createChatMessageTransformHandler,
7+
createCommandExecuteHandler,
8+
createTextCompleteHandler,
9+
} from "../lib/hooks"
10+
import { Logger } from "../lib/logger"
11+
import { createSessionState, type WithParts } from "../lib/state"
12+
13+
function buildConfig(permission: "allow" | "ask" | "deny" = "allow"): PluginConfig {
14+
return {
15+
enabled: true,
16+
debug: false,
17+
pruneNotification: "off",
18+
pruneNotificationType: "chat",
19+
commands: {
20+
enabled: true,
21+
protectedTools: [],
22+
},
23+
manualMode: {
24+
enabled: false,
25+
automaticStrategies: true,
26+
},
27+
turnProtection: {
28+
enabled: false,
29+
turns: 4,
30+
},
31+
experimental: {
32+
allowSubAgents: false,
33+
customPrompts: false,
34+
},
35+
protectedFilePatterns: [],
36+
compress: {
37+
mode: "message",
38+
permission,
39+
showCompression: false,
40+
maxContextLimit: 150000,
41+
minContextLimit: 50000,
42+
nudgeFrequency: 5,
43+
iterationNudgeThreshold: 15,
44+
nudgeForce: "soft",
45+
protectedTools: ["task"],
46+
protectUserMessages: false,
47+
},
48+
strategies: {
49+
deduplication: {
50+
enabled: true,
51+
protectedTools: [],
52+
},
53+
purgeErrors: {
54+
enabled: true,
55+
turns: 4,
56+
protectedTools: [],
57+
},
58+
},
59+
}
60+
}
61+
62+
function buildMessage(id: string, role: "user" | "assistant", text: string): WithParts {
63+
return {
64+
info: {
65+
id,
66+
role,
67+
sessionID: "session-1",
68+
agent: "assistant",
69+
time: { created: 1 },
70+
} as WithParts["info"],
71+
parts: [
72+
{
73+
id: `${id}-part`,
74+
messageID: id,
75+
sessionID: "session-1",
76+
type: "text",
77+
text,
78+
},
79+
],
80+
}
81+
}
82+
83+
test("chat message transform strips hallucinated tags even when compress is denied", async () => {
84+
const state = createSessionState()
85+
const logger = new Logger(false)
86+
const config = buildConfig("deny")
87+
const handler = createChatMessageTransformHandler(
88+
{ session: { get: async () => ({}) } } as any,
89+
state,
90+
logger,
91+
config,
92+
{
93+
reload() {},
94+
getRuntimePrompts() {
95+
return {} as any
96+
},
97+
} as any,
98+
{ global: undefined, agents: {} },
99+
)
100+
const output = {
101+
messages: [buildMessage("assistant-1", "assistant", "alpha <dcp>beta</dcp> omega")],
102+
}
103+
104+
await handler({}, output)
105+
106+
assert.equal(output.messages[0]?.parts[0]?.type, "text")
107+
assert.equal((output.messages[0]?.parts[0] as any).text, "alpha omega")
108+
})
109+
110+
test("command execute exits after effective permission resolves to deny", async () => {
111+
let sessionMessagesCalls = 0
112+
const output = { parts: [] as any[] }
113+
const handler = createCommandExecuteHandler(
114+
{
115+
session: {
116+
messages: async () => {
117+
sessionMessagesCalls += 1
118+
return { data: [] }
119+
},
120+
},
121+
} as any,
122+
createSessionState(),
123+
new Logger(false),
124+
buildConfig("deny"),
125+
"/tmp",
126+
{ global: undefined, agents: {} },
127+
)
128+
129+
await handler({ command: "dcp", sessionID: "session-1", arguments: "context" }, output)
130+
131+
assert.equal(sessionMessagesCalls, 1)
132+
assert.deepEqual(output.parts, [])
133+
})
134+
135+
test("chat message hook caches variant even when effective permission is denied", async () => {
136+
const state = createSessionState()
137+
const handler = createChatMessageHandler(state, new Logger(false), buildConfig("allow"), {
138+
global: { "*": "deny" },
139+
agents: {},
140+
})
141+
142+
await handler({ sessionID: "session-1", variant: "danger", agent: "assistant" }, {})
143+
144+
assert.equal(state.variant, "danger")
145+
})
146+
147+
test("text complete strips hallucinated metadata tags", async () => {
148+
const output = { text: "alpha <dcp>beta</dcp> omega" }
149+
const handler = createTextCompleteHandler()
150+
151+
await handler({ sessionID: "session-1", messageID: "message-1", partID: "part-1" }, output)
152+
153+
assert.equal(output.text, "alpha omega")
154+
})

0 commit comments

Comments
 (0)