Bug Description
Prompt cache hit rate collapses in long sessions due to duplicate DCP message-id tag injection into mid-conversation messages.
In a single long session (~490K tokens, opencode 1.18.14, @tarquinen/opencode-dcp 3.1.15, dcp.jsonc with maxContextLimit: 800000, minContextLimit: 500000), with no provider/model switching during the affected period, prompt cache hit rate (DeepSeek provider) degraded from normal 95–99% down to ~55% (and at one point 37.9%), then immediately recovered to 96.8%+ after a manual session compact.
Per-step telemetry showed a persistent ~221K-token "never hits cache" segment (cache read stayed pinned near ~272K per call while fresh input stayed ~221K per call), which cannot be explained by normal prefix growth.
Investigation
createChatMessageTransformHandler (hooks.ts) runs on every request before sending to the provider:
stripHallucinations → assignMessageRefs → syncCompressionBlocks → buildToolIdList
→ prune → injectCompressNudges → injectMessageIds → stripStaleMetadata
injectMessageIds (messages/inject/inject.ts) iterates every user/assistant message and appends a \n<dcp-message-id>mNNNN</dcp-message-id> tag to its last text/tool part. Idempotency relies on appendToTextPart/appendToToolPart doing an includes(tag) check before injecting.
Idempotency breaks in two cases:
-
Streaming / incomplete tool parts: while a tool output is still streaming (part not yet completed, part.state.output absent/partial), hasContent() returns false and/or appendToToolPart skips (requires state.status === "completed"). The message is then handled via a different branch, and on a later request (once the part completes) the same message is injected again.
-
Compression block rebuild: syncCompressionBlocks rebuilds message parts. The old injected tag string is not recognized by includes() on the reconstructed part text, so the tag is appended a second time to the same part.
Evidence
Querying the opencode SQLite DB (message/part tables) for session ses_faac2189fffePFTK40ZUh9E71g, 4 parts contain 2 duplicate <dcp-message-id> tags (verified via regex count), all concentrated right before the cache-hit-rate degradation began:
prt_0553e0ac1001JsA5pzr60cfoc8 (msg msg_0553e0aab002mA7RcBIgp6YvFB)
prt_05a8d9737001CHpI8eFScL50lF (msg msg_05a8d77fb001SPcwxt0hsozSlC)
prt_05a91a376001ROS2pgC3yVkBdb (msg msg_05a917cbc001ckDmoJxCjxfRS5)
prt_05a96edae001fwbOBPLZlSjKbO (msg msg_05a96c3af0012HBsvcON9o44RA)
Impact
Providers (esp. DeepSeek-style) cache on byte-exact prefix match. A duplicated tag appended to any mid-conversation message invalidates every cached segment after that message, permanently, until the next compression. Result: ~221K fresh tokens every request → severe token-billing blowup (user observed 25% of daily quota consumed in ~20 minutes).
Expected Behavior
injectMessageIds should be idempotent even for:
- tool parts still streaming (incomplete/
running status) — skip injection until completed, then inject exactly once;
- parts rebuilt by
syncCompressionBlocks/compaction — dedupe tags per part so a message never receives two dcp-message-id tags.
Suggestion: make the idempotency check structural (e.g. track injected refs per message in session state keyed by raw part id), not a raw includes() on part text; and guard the injection path so incomplete tool parts don't produce synthetic tags that later get doubled.
Debug Context Logs
DCP has been disabled on the user machine, so fresh debug logs could not be regenerated. The reproducible evidence above comes directly from the opencode DB (message/part tables) via SQL, plus DCP lib/messages/inject/inject.ts + lib/messages/utils.ts source inspection (v3.1.15). Two of the four double-tagged parts' stored text ends with two adjacent <dcp-message-id>...</dcp-message-id> tags, confirming duplicate injection, not a single tag split.
Tool Call Details
Not a compress-tool failure; the issue is the implicit per-request message-id injection (no user-facing tool call involved).
DCP Version
3.1.15
Opencode Version
1.18.14
Model
Other (deepseek-v4-flash via scnet & sensenova-token providers)
Additional Context
- Reproducible pattern: cache hit rate pinned ~55%, "fresh" segment constant ~221K regardless of session growth → not prefix growth; only compression rescues it.
- After removing DCP from the plugin list, the same session class no longer shows the degradation (assessment in progress).
- DCP README already acknowledges ~5% cache-hit cost; this report is about a much larger, non-recoverable hit-rate collapse from duplicated injection, e.g. 85%→55% in our case.
Bug Description
Prompt cache hit rate collapses in long sessions due to duplicate DCP message-id tag injection into mid-conversation messages.
In a single long session (~490K tokens, opencode 1.18.14,
@tarquinen/opencode-dcp3.1.15,dcp.jsoncwithmaxContextLimit: 800000, minContextLimit: 500000), with no provider/model switching during the affected period, prompt cache hit rate (DeepSeek provider) degraded from normal 95–99% down to ~55% (and at one point 37.9%), then immediately recovered to 96.8%+ after a manual session compact.Per-step telemetry showed a persistent ~221K-token "never hits cache" segment (cache read stayed pinned near ~272K per call while fresh input stayed ~221K per call), which cannot be explained by normal prefix growth.
Investigation
createChatMessageTransformHandler(hooks.ts) runs on every request before sending to the provider:injectMessageIds(messages/inject/inject.ts) iterates every user/assistant message and appends a\n<dcp-message-id>mNNNN</dcp-message-id>tag to its last text/tool part. Idempotency relies onappendToTextPart/appendToToolPartdoing anincludes(tag)check before injecting.Idempotency breaks in two cases:
Streaming / incomplete tool parts: while a tool output is still streaming (part not yet
completed,part.state.outputabsent/partial),hasContent()returns false and/orappendToToolPartskips (requiresstate.status === "completed"). The message is then handled via a different branch, and on a later request (once the part completes) the same message is injected again.Compression block rebuild:
syncCompressionBlocksrebuilds message parts. The old injected tag string is not recognized byincludes()on the reconstructed part text, so the tag is appended a second time to the same part.Evidence
Querying the opencode SQLite DB (
message/parttables) for sessionses_faac2189fffePFTK40ZUh9E71g, 4 parts contain 2 duplicate<dcp-message-id>tags (verified via regex count), all concentrated right before the cache-hit-rate degradation began:prt_0553e0ac1001JsA5pzr60cfoc8(msgmsg_0553e0aab002mA7RcBIgp6YvFB)prt_05a8d9737001CHpI8eFScL50lF(msgmsg_05a8d77fb001SPcwxt0hsozSlC)prt_05a91a376001ROS2pgC3yVkBdb(msgmsg_05a917cbc001ckDmoJxCjxfRS5)prt_05a96edae001fwbOBPLZlSjKbO(msgmsg_05a96c3af0012HBsvcON9o44RA)Impact
Providers (esp. DeepSeek-style) cache on byte-exact prefix match. A duplicated tag appended to any mid-conversation message invalidates every cached segment after that message, permanently, until the next compression. Result: ~221K fresh tokens every request → severe token-billing blowup (user observed 25% of daily quota consumed in ~20 minutes).
Expected Behavior
injectMessageIdsshould be idempotent even for:runningstatus) — skip injection until completed, then inject exactly once;syncCompressionBlocks/compaction — dedupe tags per part so a message never receives twodcp-message-idtags.Suggestion: make the idempotency check structural (e.g. track injected refs per message in session state keyed by raw part id), not a raw
includes()on part text; and guard the injection path so incomplete tool parts don't produce synthetic tags that later get doubled.Debug Context Logs
DCP has been disabled on the user machine, so fresh debug logs could not be regenerated. The reproducible evidence above comes directly from the opencode DB (
message/parttables) via SQL, plus DCPlib/messages/inject/inject.ts+lib/messages/utils.tssource inspection (v3.1.15). Two of the four double-tagged parts' stored text ends with two adjacent<dcp-message-id>...</dcp-message-id>tags, confirming duplicate injection, not a single tag split.Tool Call Details
Not a compress-tool failure; the issue is the implicit per-request message-id injection (no user-facing tool call involved).
DCP Version
3.1.15
Opencode Version
1.18.14
Model
Other (deepseek-v4-flash via scnet & sensenova-token providers)
Additional Context