Repository navigation
ACP dynamic nudges prevent OpenAI Responses prefix cache from growing #5
Description
Summary
When opencode-acp is enabled with OpenAI Responses models, the API reports cached_tokens initially, but the prompt cache stops growing after an early prefix. In one observed session, cache read tokens plateaued at about 25.6K while total prompt tokens kept growing from 38K to 83K.
The likely cause is that ACP injects dynamic metadata into an earlier user message on every chat.messages.transform call. Because OpenAI Responses uses prefix caching, changing that earlier message invalidates the stable prefix for all subsequent assistant/tool content, so later large tool outputs cannot become part of the reusable cached prefix.
Evidence
Observed from OpenCode SQLite token counters for a session using openai/gpt-5.4 via POST /v1/responses:
| Turn time | Non-cached input | Cache read | Prompt total | Cache-read delta |
|---|---|---|---|---|
| 00:53:10 | 7,041 | 18,944 | 25,985 | +18,944 |
| 00:53:19 | 12,415 | 25,600 | 38,015 | +6,656 |
| 00:53:27 | 26,766 | 25,600 | 52,366 | 0 |
| 00:53:37 | 43,056 | 25,600 | 68,656 | 0 |
| 00:53:45 | 46,656 | 25,600 | 72,256 | 0 |
| 00:53:50 | 52,409 | 25,600 | 78,009 | 0 |
| 00:53:56 | 57,709 | 25,600 | 83,309 | 0 |
This matches the request dashboard symptom: C 25.6K remains fixed while input grows.
The raw persisted part rows did not contain ACP tags such as dcp-message-id, Context usage, or Visible message IDs, so the issue appears to be request-time mutation by the ACP transform hook rather than SQLite persistence corruption.
Suspected cause
OpenAI Responses prompt cache is prefix-based: the token prefix must remain byte/token-stable between requests. ACP currently appends dynamic request-time text to the last user message:
injectContextUsage()appends text likeContext usage: ...injectVisibleIdRange()appends text like[Visible message IDs: ...]injectMessageIds()appendsdcp-message-idtags
Relevant code paths:
lib/hooks.tsaround the message transform pipeline, especiallyinjectCompressNudges()andinjectMessageIds()lib/messages/inject/inject.ts:injectContextUsage()injectVisibleIdRange()injectMessageIds()
In the observed session, the last user message occurred before many subsequent read / grep tool outputs. ACP changed that earlier user message each turn because context usage and visible ID range changed. This means all content after that user message no longer had a stable prefix, so cache could only hit the earlier ~25.6K tokens and could not grow with later large tool outputs.
Expected behavior
ACP should preserve OpenAI Responses prefix cache growth when no compression has occurred. Dynamic ACP guidance should not rewrite earlier messages in a way that prevents later stable conversation/tool content from being cached.
Actual behavior
cached_tokens plateaus after an early prefix, while later repeated prompt content is billed/sent as non-cached input on every turn.
Possible fixes
- Do not append per-turn dynamic metadata to historical user messages.
- Move dynamic ACP guidance (
Context usage,Visible message IDs, aging warnings, etc.) to a request suffix / latest synthetic message after existing conversation content, so it does not invalidate the cacheable historical prefix. - Only inject dynamic nudges when actually needed, e.g. after crossing the configured ACP threshold, not on every transform at low context usage.
- Keep stable IDs stable, but avoid rewriting already-stable historical parts if possible.
Notes
For OpenAI Responses, cache.write = 0 in OpenCode SQLite is expected. The relevant signal is tokens.cache.read, populated from usage.input_tokens_details.cached_tokens.
This issue is specifically about preserving prefix stability for OpenAI Responses prompt caching.
Activity
ranxianglei commentedon May 30, 2026
@Chikage0o0
Prefix Cache Invalidation — Root Cause & Fix
Background
The OpenAI Responses API supports prefix caching — if consecutive requests share identical leading content, the API reuses cached token computations and reports cache hits via the
cache_readmetric.Bug Manifestation
With ACP (and the original DCP),
cache_readwould stop growing after the first few turns:Every subsequent request was wasting the opportunity to cache ~55K tokens of repeated computation.
Root Cause: A DCP Design Flaw (Not Introduced by ACP)
DCP (and ACP after forking) injects dynamic per-turn content into historical user messages during the
chat.messages.transformhook:applyAnchoredNudgesinjectContextUsageinjectVisibleIdRangeinjectMessageIdsThe first three functions generate different text each turn and inject it into historical user messages in the middle of the conversation. Since prefix caching requires the leading content to remain byte-identical across requests, modifying any message invalidates the cache for all content after that message.
This can be confirmed in the DCP source code —
applyAnchoredNudgesdirectly callsinjectAnchoredNudge(message, nudgeText)to mutate anchor message content. This pattern existed in DCP from the start.ACP added
injectContextUsageandinjectVisibleIdRange(targeting the last user message) after forking, which exacerbated the issue. But the root cause traces back to DCP's design.Fix: Suffix Message Pattern
All dynamic injections are now consolidated into a single synthetic suffix message.
Key changes:
New
createSuffixMessage()— Creates a synthetic user message appended to the end of the message array. Uses a fixed seed (acp-dynamic-guidance) to generate a deterministic ID (msg_dcp_summary_<sha256-hash>), ensuring it is skipped byassignMessageRefsand invisible to the compress tool.Dynamic content targets suffix only —
applyAnchoredNudges,injectContextUsage, andinjectVisibleIdRangenow write to the suffix message, leaving all historical messages untouched.Historical messages stay byte-identical — All real messages before the suffix remain stable across turns, allowing prefix cache to cover up to the last real message.
Measured results:
How to Upgrade to 1.1.1
Note: The auto-update in previous versions had a bug —
update.tshardcoded the old package name@tarquinen/opencode-dcp, preventing auto-update detection. This is also fixed in 1.1.1.Manual upgrade (recommended):
Restart OpenCode after upgrading. Auto-update will work correctly from 1.1.1 onward.
前缀缓存失效问题说明
背景
OpenAI Responses API 支持前缀缓存(Prefix Cache)——如果连续请求的前面部分内容相同,API 会复用已缓存的 token 计算结果,跳过重复计算,返回
cache_read指标反映命中量。Bug 表现
在 ACP(以及原版 DCP)中,
cache_read在会话初始几轮增长后完全停滞。例如:每次请求都在"浪费"前 55K token 的重复计算。
为什么会有这个 Bug
这是 DCP 的固有设计缺陷,不是 ACP 引入的。
DCP(以及 fork 后的 ACP)在
chat.messages.transform钩子中,每轮都会向历史用户消息注入动态内容:applyAnchoredNudgesinjectContextUsageinjectVisibleIdRangeinjectMessageIds前三个函数每轮生成不同的文本,注入到会话中间或末尾的历史用户消息中。由于前缀缓存要求请求开头的内容逐字节不变,修改中间某条用户消息会使该消息之后所有内容的缓存失效。
在 DCP 源码中可以确认这一点——
applyAnchoredNudges直接调用injectAnchoredNudge(message, nudgeText)修改锚点消息内容,这个模式在 DCP 就已经存在了。ACP 在 fork 后新增了
injectContextUsage和injectVisibleIdRange(注入到最后一条用户消息),进一步加剧了这个问题,但根本原因来自 DCP。修复方案
将所有动态注入内容集中到一个新建的 suffix 消息中。
核心改动:
新建
createSuffixMessage():创建一条合成用户消息,追加到消息数组末尾。使用固定 seed 生成确定性 ID(msg_dcp_summary_<sha256("acp-dynamic-guidance")>),确保不会被 assignMessageRefs 分配 mNNNNN 引用,对 compress 工具完全透明。动态内容全部写入 suffix:
applyAnchoredNudges、injectContextUsage、injectVisibleIdRange改为写入 suffix 消息,不再修改任何历史消息。历史消息保持不变:前面所有真实消息的内容逐字节稳定,前缀缓存可以覆盖到最后一条真实消息。
修复效果(本地实测数据):
如何升级到 1.1.1
注意:之前的自动更新有 bug——
update.ts中硬编码了旧包名@tarquinen/opencode-dcp,导致自动更新无法正确检测新版本。1.1.1 同时修复了这个问题。手动升级(推荐):
升级后重启 OpenCode 即可生效。1.1.1 之后的版本自动更新将正常工作。
ranxianglei commentedon Jun 27, 2026
Second part of the prefix-cache fix:
pruneToolOutputs(PR #26)This issue fixed the nudge-injection half of the "in-place message mutation breaks prefix cache" bug class. PR #26 (merged, published as v1.4.2) fixes the other half —
pruneToolOutputs/pruneToolInputs/pruneToolErrorsinprune.tswere doing the same in-place mutation onpart.state.output, causing periodic cache invalidation every time tool-output pruning fired.Evidence: Session with 828 messages had 176 cache misses over 2 days — 89% of all fresh input tokens (50.8M) wasted on re-sending context that should have been cached.
Fix: Disabled the three in-place mutating calls in
prune(). Compression still works viafilterCompressedRanges+ model-initiatedcompresstool. The functions are kept as dead code for a future suffix-message approach (same pattern used for nudges in this issue's fix).Upgrade:
opencode-acp@1.4.2is published on npm.perf: avoid transform work that scales with compression history (#384)
perf: avoid transform work that scales with compression history (#384)