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
11 changes: 11 additions & 0 deletions lib/commands/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export { handleContextCommand } from "./context"
export { handleDecompressCommand } from "./decompress"
export { handleHelpCommand } from "./help"
export {
applyPendingManualTrigger,
handleManualToggleCommand,
handleManualTriggerCommand,
} from "./manual"
export { handleRecompressCommand } from "./recompress"
export { handleStatsCommand } from "./stats"
export { handleSweepCommand } from "./sweep"
37 changes: 37 additions & 0 deletions lib/commands/manual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { PluginConfig } from "../config"
import { sendIgnoredMessage } from "../ui/notification"
import { getCurrentParams } from "../strategies/utils"
import { buildCompressedBlockGuidance } from "../messages/inject/utils"
import { isIgnoredUserMessage } from "../messages/utils"

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

Expand Down Expand Up @@ -86,3 +87,39 @@ export async function handleManualTriggerCommand(
): Promise<string | null> {
return getTriggerPrompt(tool, ctx.state, ctx.config, userFocus)
}

export function applyPendingManualTrigger(
state: SessionState,
messages: WithParts[],
logger: Logger,
): void {
const pending = state.pendingManualTrigger
if (!pending) {
return
}

if (!state.sessionId || pending.sessionId !== state.sessionId) {
state.pendingManualTrigger = null
return
}

for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.info.role !== "user" || isIgnoredUserMessage(msg)) {
continue
}

for (const part of msg.parts) {
if (part.type !== "text" || part.ignored || part.synthetic) {
continue
}

part.text = pending.prompt
state.pendingManualTrigger = null
logger.debug("Applied manual prompt", { sessionId: pending.sessionId })
return
}
}

state.pendingManualTrigger = null
}
74 changes: 20 additions & 54 deletions lib/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,75 +2,41 @@ import type { SessionState, WithParts } from "./state"
import type { Logger } from "./logger"
import type { PluginConfig } from "./config"
import { assignMessageRefs } from "./message-ids"
import { buildPriorityMap } from "./messages/priority"
import { syncToolCache } from "./state/tool-cache"
import {
prune,
syncCompressionBlocks,
buildPriorityMap,
buildToolIdList,
injectCompressNudges,
injectMessageIds,
injectExtendedSubAgentResults,
injectMessageIds,
prune,
stripHallucinations,
stripHallucinationsFromString,
stripStaleMetadata,
syncCompressionBlocks,
} from "./messages"
import { renderSystemPrompt, type PromptStore } from "./prompts"
import {
buildToolIdList,
isIgnoredUserMessage,
stripHallucinations,
stripHallucinationsFromString,
} from "./messages/utils"
import { checkSession } from "./state"
import { renderSystemPrompt } from "./prompts"
import { handleStatsCommand } from "./commands/stats"
import { handleContextCommand } from "./commands/context"
import { handleHelpCommand } from "./commands/help"
import { handleSweepCommand } from "./commands/sweep"
import { handleManualToggleCommand, handleManualTriggerCommand } from "./commands/manual"
import { handleDecompressCommand } from "./commands/decompress"
import { handleRecompressCommand } from "./commands/recompress"
applyPendingManualTrigger,
handleContextCommand,
handleDecompressCommand,
handleHelpCommand,
handleManualToggleCommand,
handleManualTriggerCommand,
handleRecompressCommand,
handleStatsCommand,
handleSweepCommand,
} from "./commands"
import { type HostPermissionSnapshot } from "./host-permissions"
import { compressPermission, syncCompressPermissionState } from "./shared-utils"
import { ensureSessionInitialized } from "./state/state"
import { checkSession, ensureSessionInitialized, syncToolCache } from "./state"
import { cacheSystemPromptTokens } from "./ui/utils"
import type { PromptStore } from "./prompts/store"

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

function applyManualPrompt(state: SessionState, messages: WithParts[], logger: Logger): void {
const pending = state.pendingManualTrigger
if (!pending) {
return
}

if (!state.sessionId || pending.sessionId !== state.sessionId) {
state.pendingManualTrigger = null
return
}

for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.info.role !== "user" || isIgnoredUserMessage(msg)) {
continue
}

for (const part of msg.parts) {
if (part.type !== "text" || part.ignored || part.synthetic) {
continue
}

part.text = pending.prompt
state.pendingManualTrigger = null
logger.debug("Applied manual prompt", { sessionId: pending.sessionId })
return
}
}

state.pendingManualTrigger = null
}

export function createSystemPromptHandler(
state: SessionState,
logger: Logger,
Expand Down Expand Up @@ -162,7 +128,7 @@ export function createChatMessageTransformHandler(
compressionPriorities,
)
injectMessageIds(state, config, output.messages, compressionPriorities)
applyManualPrompt(state, output.messages, logger)
applyPendingManualTrigger(state, output.messages, logger)
stripStaleMetadata(output.messages)

if (state.sessionId) {
Expand Down
2 changes: 2 additions & 0 deletions lib/messages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ export { injectCompressNudges } from "./inject/inject"
export { injectMessageIds } from "./inject/inject"
export { injectExtendedSubAgentResults } from "./inject/subagent-results"
export { stripStaleMetadata } from "./reasoning-strip"
export { buildPriorityMap } from "./priority"
export { buildToolIdList, stripHallucinations, stripHallucinationsFromString } from "./utils"
30 changes: 20 additions & 10 deletions lib/messages/inject/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,18 @@ 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) {
continue
}

let injected = false
for (const part of message.parts) {
if (part.type === "text") {
Expand All @@ -195,16 +207,14 @@ export const injectMessageIds = (
}
}

if (injected) {
continue
}

const syntheticPart = createSyntheticTextPart(message, tag)
const firstToolIndex = message.parts.findIndex((p) => p.type === "tool")
if (firstToolIndex === -1) {
message.parts.push(syntheticPart)
} else {
message.parts.splice(firstToolIndex, 0, syntheticPart)
if (!injected) {
const syntheticPart = createSyntheticTextPart(message, tag)
const firstToolIndex = message.parts.findIndex((p) => p.type === "tool")
if (firstToolIndex === -1) {
message.parts.push(syntheticPart)
} else {
message.parts.splice(firstToolIndex, 0, syntheticPart)
}
}
}
}
1 change: 1 addition & 0 deletions lib/prompts/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { RuntimePrompts } from "./store"
export type { PromptStore, RuntimePrompts } from "./store"

function stripLegacyInlineComments(content: string): string {
return content.replace(/^[ \t]*\/\/.*?\/\/[ \t]*$/gm, "")
Expand Down
1 change: 1 addition & 0 deletions lib/state/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./persistence"
export * from "./types"
export * from "./state"
export * from "./tool-cache"
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@tarquinen/opencode-dcp",
"version": "3.1.2",
"version": "3.1.3",
"type": "module",
"description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context",
"main": "./dist/index.js",
Expand Down
101 changes: 101 additions & 0 deletions tests/message-priority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,3 +670,104 @@ test("hallucination stripping does not affect non-dcp tags", async () => {
"<div>hello</div> <system-reminder>keep</system-reminder>",
)
})

test("injectMessageIds skips empty assistant messages to avoid prefill (issue #463)", () => {
const sessionID = "ses_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: [],
},
buildMessage("msg-user-2", "user", sessionID, "continue", 3),
]
const state = createSessionState()
const config = buildConfig("range")

assignMessageRefs(state, messages)
injectMessageIds(state, config, messages)

const emptyAssistant = messages[1]!
assert.equal(emptyAssistant.parts.length, 0, "empty assistant should get no synthetic parts")
})

test("injectMessageIds skips assistant with only pending tool parts (issue #463)", () => {
const sessionID = "ses_pending_tool_assistant"
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,
],
},
buildMessage("msg-user-2", "user", sessionID, "continue", 3),
]
const state = createSessionState()
const config = buildConfig("range")

assignMessageRefs(state, messages)
injectMessageIds(state, config, messages)

const pendingAssistant = messages[1]!
assert.equal(
pendingAssistant.parts.length,
1,
"assistant with only pending tools should not get a synthetic text part",
)
assert.equal(pendingAssistant.parts[0]!.type, "tool")
})

test("injectMessageIds skips assistant with empty text part (issue #463)", () => {
const sessionID = "ses_empty_text_assistant"
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", "")],
},
buildMessage("msg-user-2", "user", sessionID, "continue", 3),
]
const state = createSessionState()
const config = buildConfig("range")

assignMessageRefs(state, messages)
injectMessageIds(state, config, messages)

const emptyTextAssistant = messages[1]!
assert.equal(emptyTextAssistant.parts.length, 1, "should not add a synthetic part")
assert.equal(
(emptyTextAssistant.parts[0] as any).text,
"",
"empty text part should remain untouched",
)
})
Loading