Skip to content

ACP dynamic nudges prevent OpenAI Responses prefix cache from growing #5

Closed

Description

@Chikage0o0

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 like Context usage: ...
  • injectVisibleIdRange() appends text like [Visible message IDs: ...]
  • injectMessageIds() appends dcp-message-id tags

Relevant code paths:

  • lib/hooks.ts around the message transform pipeline, especially injectCompressNudges() and injectMessageIds()
  • 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

  1. Do not append per-turn dynamic metadata to historical user messages.
  2. 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.
  3. Only inject dynamic nudges when actually needed, e.g. after crossing the configured ACP threshold, not on every transform at low context usage.
  4. 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

  1. ranxianglei commented on May 30, 2026

    @ranxianglei
    Owner

    @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_read metric.

    Bug Manifestation

    With ACP (and the original DCP), cache_read would stop growing after the first few turns:

    Turn 1:  cache_read = 17,728
    Turn 5:  cache_read = 55,424
    Turn 10: cache_read = 55,424  ← stuck, no further growth
    

    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.transform hook:

    Injection Description Changes each turn?
    applyAnchoredNudges Compression prompt nudges (context limit, turn, iteration) ✅ Yes
    injectContextUsage Current token usage info ✅ Yes
    injectVisibleIdRange Visible message ID range ✅ Yes
    injectMessageIds Per-message mNNNNN reference tags ❌ No (stable)

    The 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 — applyAnchoredNudges directly calls injectAnchoredNudge(message, nudgeText) to mutate anchor message content. This pattern existed in DCP from the start.

    ACP added injectContextUsage and injectVisibleIdRange (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:

    1. 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 by assignMessageRefs and invisible to the compress tool.

    2. Dynamic content targets suffix only — applyAnchoredNudges, injectContextUsage, and injectVisibleIdRange now write to the suffix message, leaving all historical messages untouched.

    3. 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:

    Before fix: cache_read stuck at 55,424 — never grows
    After fix:  cache_read grows 17K → 26K → 35K → 53K, continuously increasing
    

    How to Upgrade to 1.1.1

    Note: The auto-update in previous versions had a bug — update.ts hardcoded the old package name @tarquinen/opencode-dcp, preventing auto-update detection. This is also fixed in 1.1.1.

    Manual upgrade (recommended):

    # Option 1: via opencode plugin management
    opencode plugin install opencode-acp
    
    # Option 2: direct npm update
    npm update opencode-acp --prefix ~/.cache/opencode
    
    # Option 3: install latest
    npm install --save opencode-acp@latest --prefix ~/.cache/opencode

    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 在会话初始几轮增长后完全停滞。例如:

    第1轮: cache_read = 17,728
    第5轮: cache_read = 55,424
    第10轮: cache_read = 55,424  ← 停滞,不再增长
    

    每次请求都在"浪费"前 55K token 的重复计算。

    为什么会有这个 Bug

    这是 DCP 的固有设计缺陷,不是 ACP 引入的。

    DCP(以及 fork 后的 ACP)在 chat.messages.transform 钩子中,每轮都会向历史用户消息注入动态内容:

    注入内容 说明 每轮变化?
    applyAnchoredNudges 压缩提示 nudge(上下文限制、轮次、迭代) ✅ 是
    injectContextUsage 当前 token 用量信息 ✅ 是
    injectVisibleIdRange 可见消息 ID 范围 ✅ 是
    injectMessageIds 每条消息的 mNNNNN 引用标签 ❌ 否(固定)

    前三个函数每轮生成不同的文本,注入到会话中间或末尾的历史用户消息中。由于前缀缓存要求请求开头的内容逐字节不变,修改中间某条用户消息会使该消息之后所有内容的缓存失效。

    在 DCP 源码中可以确认这一点——applyAnchoredNudges 直接调用 injectAnchoredNudge(message, nudgeText) 修改锚点消息内容,这个模式在 DCP 就已经存在了。

    ACP 在 fork 后新增了 injectContextUsage 和 injectVisibleIdRange(注入到最后一条用户消息),进一步加剧了这个问题,但根本原因来自 DCP。

    修复方案

    将所有动态注入内容集中到一个新建的 suffix 消息中。

    核心改动:

    1. 新建 createSuffixMessage():创建一条合成用户消息,追加到消息数组末尾。使用固定 seed 生成确定性 ID(msg_dcp_summary_<sha256("acp-dynamic-guidance")>),确保不会被 assignMessageRefs 分配 mNNNNN 引用,对 compress 工具完全透明。

    2. 动态内容全部写入 suffix:applyAnchoredNudges、injectContextUsage、injectVisibleIdRange 改为写入 suffix 消息,不再修改任何历史消息。

    3. 历史消息保持不变:前面所有真实消息的内容逐字节稳定,前缀缓存可以覆盖到最后一条真实消息。

    修复效果(本地实测数据):

    修复前: cache_read 停滞在 55,424,不再增长
    修复后: cache_read 从 17K → 26K → 35K → 53K,持续增长
    

    如何升级到 1.1.1

    注意:之前的自动更新有 bug——update.ts 中硬编码了旧包名 @tarquinen/opencode-dcp,导致自动更新无法正确检测新版本。1.1.1 同时修复了这个问题。

    手动升级(推荐):

    # 方法1:通过 opencode 插件管理
    opencode plugin install opencode-acp
    
    # 方法2:直接 npm 更新
    npm update opencode-acp --prefix ~/.cache/opencode
    
    # 方法3:本地安装
    npm install --save opencode-acp@latest --prefix ~/.cache/opencode

    升级后重启 OpenCode 即可生效。1.1.1 之后的版本自动更新将正常工作。

  2. ranxianglei commented on Jun 27, 2026

    @ranxianglei
    Owner

    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/pruneToolErrors in prune.ts were doing the same in-place mutation on part.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 via filterCompressedRanges + model-initiated compress tool. 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.2 is published on npm.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions