Skip to content

fix(protocol): keep mid-conversation system turns on cli-hop - #87

Open
imutum wants to merge 1 commit into
dofastted:mainfrom
imutum:fix/cli-hop-mid-conversation-system
Open

imutum wants to merge 1 commit into
dofastted:mainfrom
imutum:fix/cli-hop-mid-conversation-system

Conversation

@imutum

@imutum imutum commented Sep 22, 2026

Copy link
Copy Markdown

Summary

  • cli-hop stops lifting trailing role=system turns into system[] on non-Haiku models. They stay at the tail of messages, which is the shape official Claude Code sends.
  • system[] is processed before every message breakpoint. Moving per-turn text (not only <total_tokens>) there invalidates the whole cached prefix, so the live block belongs behind the read boundary: replayed history stays byte-stable and each turn reads the previous prefix.
  • Haiku has no mid-conversation-system beta and 400s on role=system, so it keeps the existing liftTrailingSystemMessages + pin path automatically.
  • KIN_CLI_HOP_SYSTEM_MODE=lift restores the old behavior; KIN_CLI_HOP_SYSTEM_MODE=fold keeps the deterministic fold fallback.
  • Only messages shape changes. Breakpoints, TTLs, betas and identity handling are untouched.

Official Claude Code 2.1.278 behavior

  • Captured opus-5 traffic (request_log_debug, 392 messages) cycles user, system, assistant and ends on role=system carrying the live <total_tokens>N tokens left</total_tokens>. Historical system turns replay their old values (14971564 / 14950836 / … / 14909247).
  • messages[].role=system is accepted with the mid-conversation-system-2026-04-07 beta. vm2api already carries this constant (anthropic-policy.mjs) and uses the same mechanism on the unofficial persona path (insertMidConversationSystem).
  • Haiku does not support it: HAIKU_BETA_HEADER omits the beta and Anthropic returns 400 role 'system' is not supported on this model.
  • Cache blocks are processed tools → system → messages (see [Bug] 入站带 ttl=1h 时 cli-hop 多轮请求 400(订阅客户端按入口默认 1h / API key 开 ENABLE_PROMPT_CACHING_1H),且 cli-hop 无法使用 1h 缓存 #46), so the volatile counter must sit after the read boundary instead of in system[].

Results

A/B on sonnet-5, replaying the official shape with a per-turn text tail:

mode turn 2 (replay)
lift (current) cache_creation=38231, cache_read=0 (full rewrite)
official cache_creation=14841, cache_read=23391 (reads the previous turn)

Live replays of the same shape: <total_tokens> tail cache_read 23389 → 38228 → 53067; ordinary live-text tail 23391 → 38232; Haiku lift+pin path 8259 → 15874 → 23489.

Test plan

  • node --test test/unit/cli-hop-body.test.mjs — 28/28 (5 new cases; two existing lift assertions now pin KIN_CLI_HOP_SYSTEM_MODE=lift, assertions unchanged)
  • node scripts/run-isolated-tests.mjs test/unit .test.mjs
  • Replay the CC shape against a live slot with KIN_CLI_HOP_SYSTEM_MODE=lift and official; confirm cache_read moves from 0 to the previous turn's full prefix

Related: #46.

…l CC shape)

Official Claude Code ends requests on role=system turns (the live
<total_tokens> counter) and replays historical system turns unchanged.
liftTrailingSystemMessages moved that live block into system[], which sits
before every message breakpoint, so any volatile text there invalidated the
whole cached prefix. Keep the official shape on models that support
mid-conversation-system, fall back to lift+pin on Haiku, and keep
KIN_CLI_HOP_SYSTEM_MODE=lift|fold as an escape hatch.
@imutum

imutum commented Sep 23, 2026

Copy link
Copy Markdown
Author
image

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant