Skip to content

Commit 30256bb

Browse files
authored
Merge pull request #464 from Opencode-DCP/dev
v3.1.3 - Version bump
2 parents f537689 + 474df27 commit 30256bb

File tree

10 files changed

+196
-67
lines changed

10 files changed

+196
-67
lines changed

lib/commands/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export { handleContextCommand } from "./context"
2+
export { handleDecompressCommand } from "./decompress"
3+
export { handleHelpCommand } from "./help"
4+
export {
5+
applyPendingManualTrigger,
6+
handleManualToggleCommand,
7+
handleManualTriggerCommand,
8+
} from "./manual"
9+
export { handleRecompressCommand } from "./recompress"
10+
export { handleStatsCommand } from "./stats"
11+
export { handleSweepCommand } from "./sweep"

lib/commands/manual.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { PluginConfig } from "../config"
1313
import { sendIgnoredMessage } from "../ui/notification"
1414
import { getCurrentParams } from "../strategies/utils"
1515
import { buildCompressedBlockGuidance } from "../messages/inject/utils"
16+
import { isIgnoredUserMessage } from "../messages/utils"
1617

1718
const MANUAL_MODE_ON = "Manual mode is now ON. Use /dcp compress to trigger context tools manually."
1819

@@ -86,3 +87,39 @@ export async function handleManualTriggerCommand(
8687
): Promise<string | null> {
8788
return getTriggerPrompt(tool, ctx.state, ctx.config, userFocus)
8889
}
90+
91+
export function applyPendingManualTrigger(
92+
state: SessionState,
93+
messages: WithParts[],
94+
logger: Logger,
95+
): void {
96+
const pending = state.pendingManualTrigger
97+
if (!pending) {
98+
return
99+
}
100+
101+
if (!state.sessionId || pending.sessionId !== state.sessionId) {
102+
state.pendingManualTrigger = null
103+
return
104+
}
105+
106+
for (let i = messages.length - 1; i >= 0; i--) {
107+
const msg = messages[i]
108+
if (msg.info.role !== "user" || isIgnoredUserMessage(msg)) {
109+
continue
110+
}
111+
112+
for (const part of msg.parts) {
113+
if (part.type !== "text" || part.ignored || part.synthetic) {
114+
continue
115+
}
116+
117+
part.text = pending.prompt
118+
state.pendingManualTrigger = null
119+
logger.debug("Applied manual prompt", { sessionId: pending.sessionId })
120+
return
121+
}
122+
}
123+
124+
state.pendingManualTrigger = null
125+
}

lib/hooks.ts

Lines changed: 20 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -2,75 +2,41 @@ import type { SessionState, WithParts } from "./state"
22
import type { Logger } from "./logger"
33
import type { PluginConfig } from "./config"
44
import { assignMessageRefs } from "./message-ids"
5-
import { buildPriorityMap } from "./messages/priority"
6-
import { syncToolCache } from "./state/tool-cache"
75
import {
8-
prune,
9-
syncCompressionBlocks,
6+
buildPriorityMap,
7+
buildToolIdList,
108
injectCompressNudges,
11-
injectMessageIds,
129
injectExtendedSubAgentResults,
10+
injectMessageIds,
11+
prune,
12+
stripHallucinations,
13+
stripHallucinationsFromString,
1314
stripStaleMetadata,
15+
syncCompressionBlocks,
1416
} from "./messages"
17+
import { renderSystemPrompt, type PromptStore } from "./prompts"
1518
import {
16-
buildToolIdList,
17-
isIgnoredUserMessage,
18-
stripHallucinations,
19-
stripHallucinationsFromString,
20-
} from "./messages/utils"
21-
import { checkSession } from "./state"
22-
import { renderSystemPrompt } from "./prompts"
23-
import { handleStatsCommand } from "./commands/stats"
24-
import { handleContextCommand } from "./commands/context"
25-
import { handleHelpCommand } from "./commands/help"
26-
import { handleSweepCommand } from "./commands/sweep"
27-
import { handleManualToggleCommand, handleManualTriggerCommand } from "./commands/manual"
28-
import { handleDecompressCommand } from "./commands/decompress"
29-
import { handleRecompressCommand } from "./commands/recompress"
19+
applyPendingManualTrigger,
20+
handleContextCommand,
21+
handleDecompressCommand,
22+
handleHelpCommand,
23+
handleManualToggleCommand,
24+
handleManualTriggerCommand,
25+
handleRecompressCommand,
26+
handleStatsCommand,
27+
handleSweepCommand,
28+
} from "./commands"
3029
import { type HostPermissionSnapshot } from "./host-permissions"
3130
import { compressPermission, syncCompressPermissionState } from "./shared-utils"
32-
import { ensureSessionInitialized } from "./state/state"
31+
import { checkSession, ensureSessionInitialized, syncToolCache } from "./state"
3332
import { cacheSystemPromptTokens } from "./ui/utils"
34-
import type { PromptStore } from "./prompts/store"
3533

3634
const INTERNAL_AGENT_SIGNATURES = [
3735
"You are a title generator",
3836
"You are a helpful AI assistant tasked with summarizing conversations",
3937
"Summarize what was done in this conversation",
4038
]
4139

42-
function applyManualPrompt(state: SessionState, messages: WithParts[], logger: Logger): void {
43-
const pending = state.pendingManualTrigger
44-
if (!pending) {
45-
return
46-
}
47-
48-
if (!state.sessionId || pending.sessionId !== state.sessionId) {
49-
state.pendingManualTrigger = null
50-
return
51-
}
52-
53-
for (let i = messages.length - 1; i >= 0; i--) {
54-
const msg = messages[i]
55-
if (msg.info.role !== "user" || isIgnoredUserMessage(msg)) {
56-
continue
57-
}
58-
59-
for (const part of msg.parts) {
60-
if (part.type !== "text" || part.ignored || part.synthetic) {
61-
continue
62-
}
63-
64-
part.text = pending.prompt
65-
state.pendingManualTrigger = null
66-
logger.debug("Applied manual prompt", { sessionId: pending.sessionId })
67-
return
68-
}
69-
}
70-
71-
state.pendingManualTrigger = null
72-
}
73-
7440
export function createSystemPromptHandler(
7541
state: SessionState,
7642
logger: Logger,
@@ -162,7 +128,7 @@ export function createChatMessageTransformHandler(
162128
compressionPriorities,
163129
)
164130
injectMessageIds(state, config, output.messages, compressionPriorities)
165-
applyManualPrompt(state, output.messages, logger)
131+
applyPendingManualTrigger(state, output.messages, logger)
166132
stripStaleMetadata(output.messages)
167133

168134
if (state.sessionId) {

lib/messages/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ export { injectCompressNudges } from "./inject/inject"
44
export { injectMessageIds } from "./inject/inject"
55
export { injectExtendedSubAgentResults } from "./inject/subagent-results"
66
export { stripStaleMetadata } from "./reasoning-strip"
7+
export { buildPriorityMap } from "./priority"
8+
export { buildToolIdList, stripHallucinations, stripHallucinationsFromString } from "./utils"

lib/messages/inject/inject.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,18 @@ export const injectMessageIds = (
186186
continue
187187
}
188188

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) {
198+
continue
199+
}
200+
189201
let injected = false
190202
for (const part of message.parts) {
191203
if (part.type === "text") {
@@ -195,16 +207,14 @@ export const injectMessageIds = (
195207
}
196208
}
197209

198-
if (injected) {
199-
continue
200-
}
201-
202-
const syntheticPart = createSyntheticTextPart(message, tag)
203-
const firstToolIndex = message.parts.findIndex((p) => p.type === "tool")
204-
if (firstToolIndex === -1) {
205-
message.parts.push(syntheticPart)
206-
} else {
207-
message.parts.splice(firstToolIndex, 0, syntheticPart)
210+
if (!injected) {
211+
const syntheticPart = createSyntheticTextPart(message, tag)
212+
const firstToolIndex = message.parts.findIndex((p) => p.type === "tool")
213+
if (firstToolIndex === -1) {
214+
message.parts.push(syntheticPart)
215+
} else {
216+
message.parts.splice(firstToolIndex, 0, syntheticPart)
217+
}
208218
}
209219
}
210220
}

lib/prompts/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { RuntimePrompts } from "./store"
2+
export type { PromptStore, RuntimePrompts } from "./store"
23

34
function stripLegacyInlineComments(content: string): string {
45
return content.replace(/^[ \t]*\/\/.*?\/\/[ \t]*$/gm, "")

lib/state/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from "./persistence"
22
export * from "./types"
33
export * from "./state"
4+
export * from "./tool-cache"

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://json.schemastore.org/package.json",
33
"name": "@tarquinen/opencode-dcp",
4-
"version": "3.1.2",
4+
"version": "3.1.3",
55
"type": "module",
66
"description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context",
77
"main": "./dist/index.js",

tests/message-priority.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -670,3 +670,104 @@ test("hallucination stripping does not affect non-dcp tags", async () => {
670670
"<div>hello</div> <system-reminder>keep</system-reminder>",
671671
)
672672
})
673+
674+
test("injectMessageIds skips empty assistant messages to avoid prefill (issue #463)", () => {
675+
const sessionID = "ses_empty_assistant"
676+
const messages: WithParts[] = [
677+
buildMessage("msg-user-1", "user", sessionID, "Hello", 1),
678+
{
679+
info: {
680+
id: "msg-assistant-empty",
681+
role: "assistant",
682+
sessionID,
683+
agent: "assistant",
684+
time: { created: 2 },
685+
} as WithParts["info"],
686+
parts: [],
687+
},
688+
buildMessage("msg-user-2", "user", sessionID, "continue", 3),
689+
]
690+
const state = createSessionState()
691+
const config = buildConfig("range")
692+
693+
assignMessageRefs(state, messages)
694+
injectMessageIds(state, config, messages)
695+
696+
const emptyAssistant = messages[1]!
697+
assert.equal(emptyAssistant.parts.length, 0, "empty assistant should get no synthetic parts")
698+
})
699+
700+
test("injectMessageIds skips assistant with only pending tool parts (issue #463)", () => {
701+
const sessionID = "ses_pending_tool_assistant"
702+
const messages: WithParts[] = [
703+
buildMessage("msg-user-1", "user", sessionID, "Hello", 1),
704+
{
705+
info: {
706+
id: "msg-assistant-pending",
707+
role: "assistant",
708+
sessionID,
709+
agent: "assistant",
710+
time: { created: 2 },
711+
} as WithParts["info"],
712+
parts: [
713+
{
714+
id: "pending-tool-part",
715+
messageID: "msg-assistant-pending",
716+
sessionID,
717+
type: "tool" as const,
718+
tool: "bash",
719+
callID: "call-pending-1",
720+
state: {
721+
status: "pending" as const,
722+
input: { command: "ls" },
723+
},
724+
} as any,
725+
],
726+
},
727+
buildMessage("msg-user-2", "user", sessionID, "continue", 3),
728+
]
729+
const state = createSessionState()
730+
const config = buildConfig("range")
731+
732+
assignMessageRefs(state, messages)
733+
injectMessageIds(state, config, messages)
734+
735+
const pendingAssistant = messages[1]!
736+
assert.equal(
737+
pendingAssistant.parts.length,
738+
1,
739+
"assistant with only pending tools should not get a synthetic text part",
740+
)
741+
assert.equal(pendingAssistant.parts[0]!.type, "tool")
742+
})
743+
744+
test("injectMessageIds skips assistant with empty text part (issue #463)", () => {
745+
const sessionID = "ses_empty_text_assistant"
746+
const messages: WithParts[] = [
747+
buildMessage("msg-user-1", "user", sessionID, "Hello", 1),
748+
{
749+
info: {
750+
id: "msg-assistant-empty-text",
751+
role: "assistant",
752+
sessionID,
753+
agent: "assistant",
754+
time: { created: 2 },
755+
} as WithParts["info"],
756+
parts: [textPart("msg-assistant-empty-text", sessionID, "empty-text-part", "")],
757+
},
758+
buildMessage("msg-user-2", "user", sessionID, "continue", 3),
759+
]
760+
const state = createSessionState()
761+
const config = buildConfig("range")
762+
763+
assignMessageRefs(state, messages)
764+
injectMessageIds(state, config, messages)
765+
766+
const emptyTextAssistant = messages[1]!
767+
assert.equal(emptyTextAssistant.parts.length, 1, "should not add a synthetic part")
768+
assert.equal(
769+
(emptyTextAssistant.parts[0] as any).text,
770+
"",
771+
"empty text part should remain untouched",
772+
)
773+
})

0 commit comments

Comments
 (0)