Skip to content

V2: provider-checkpoint range claim swallows a correlated tool message, losing the tool result and disabling ACP for the session #456

Description

@akrhin

Scope

V2 port of opencode-acp on OpenCode 2.0.18. providerContext is emitted only by native compaction, so the live repro is a captured/synthetic replay of the observed lowered shape rather than an interactive session.

Related but distinct: #425 covers public-context vs runner-history boundaries across native checkpoints. This issue is about the range claim stealing a correlated tool message and the resulting loss of the tool result.

Root cause

claimCheckpointRanges in lib/v2/projection/normalize.ts:488-510 expands each provider checkpoint across the whole index range between its neighbouring checkpoints and claims every index not yet claimed — including indices that carry a tool call a source-bound assistant message already correlates, or the tool result answering that call.

The resulting origin has no exact lowered input/output match, so exact-correlation validation rejects it: invalid-source — "Tool origin source:1:part:0 has no exact lowered input/output match".

Consequence

The tool result disappears from the model output entirely, and because the rejection is fail-closed, ACP turns itself off for the rest of the session. Without providerContext the identical scenario passes:

without providerContext: valid=true   aidx=[1,2] result={2,0} origLen=2 accepted=true
with    providerContext: valid=false  aidx=[1]   result=undefined origLen=1 ckptIdx=[2]
                        invalid-source "Tool origin source:1:part:0 has no exact lowered input/output match"

Repro

Synthetic B2 shape replay — user / assistant+tool-call / role:"tool"+tool-result / compaction — through normalizeV2ProjectedHistory. Adding the providerContext field is the only difference.

Proposed fix

In lib/v2/projection/normalize.ts:

  1. Pass the set of source-reserved indices into claimCheckpointRanges and skip them. Reserved indices carry a tool call that a source-bound assistant already correlates, or the tool result answering it; letting the checkpoint swallow them strips the result of its call and makes exact-correlation reject the whole session.
  2. A checkpoint whose entire range was reserved to correlated sources decodes nothing. Emitting an empty normalized message would put a contentless assistant message into the algorithm projection, so set draft.normalized = undefined instead.

Guard: the checkpoint's own outgoingMessageIndices must keep the tool-free remainder, and a checkpoint that is not wholly reserved must behave exactly as before.

Tests

tests/v2-message-projection.test.ts: T2.x — unit for normalizeV2ProjectedHistory asserting result = {messageIndex: 2, contentIndex: 0} and originalContent.length === 2 under providerContext, plus the wholly-reserved checkpoint emitting no normalized message.

Activity

  1. ranxianglei commented on Sep 29, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    [bot] 🏷 Triage complete — bug confirmed by repro, root cause matches your analysis exactly.

    Verification (evidence): I replayed your synthetic B2 shape through normalizeV2ProjectedHistory on the current V2 line (base 37654026, tip of 2026-09-17_v2-checkpoint-provenance-guard, which carries the #425 fix):

    without providerContext: valid=true   aidx=[1,2] result={2,0} origLen=2
    with    providerContext: valid=false  aidx=[1]   result=null  origLen=1 ckptIdx=[2]
                            invalid-source "Tool origin source:1:part:0 has no exact lowered input/output match"
    

    Byte-for-byte identical to your report, including the rejection message and the checkpoint claiming index 2.

    Layer judgment: Your analysis is at the right layer — this is a distinct residual case of the #425 family, not a symptom of another bug. The #425 fix made claimCheckpointRanges claim only when exactly one unclaimed candidate exists in the window; it did not ask what that candidate is. When the single candidate is the role:"tool" message answering a correlated assistant call, the claim strips the result from its call, the post-checkpoint result-correlation loop (which deliberately skips claimed indices so decoded provider content isn't misattributed) can no longer find it, and loweredToolCorrelationIsExact fails closed for the whole session. The proposed fix (source-reserved index set passed into the claimer + wholly-reserved checkpoint emits no normalized message) addresses the root cause directly.

    Duplicate screen: Searched open+closed issues for claimCheckpointRanges / providerContext / checkpoint-tool-message overlap → no duplicates. Related-but-distinct from #425 (closed): #425 = public-context vs runner-history divergence across native checkpoints; this = the claimer stealing a correlated tool result inside an otherwise-compatible view.

    Plan: branch 2026-09-29_v2-checkpoint-reserved-tool off 37654026; implement the two-step fix in lib/v2/projection/normalize.ts (+ one Draft flag), add the T2.x tests you specified (result {messageIndex: 2, contentIndex: 0}, originalContent.length === 2 under providerContext, wholly-reserved checkpoint emitting no normalized message) plus guards for the partial-window remainder and the existing direct-view/incompatible-switch render-from-source paths. Devlog entry included. Dual-agent review, then PR closing this issue.

    中文摘要:已复现并确认根因(provider-checkpoint 区间认领把已关联的 tool-result 消息吞掉导致整会话 fail-closed),与你的分析一致,开始按方案实现。

  2. ranxianglei commented on Sep 29, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    [bot] 🏷 Fixed — PR: #468 (base 2026-09-17_v2-base, branch 2026-09-29_v2-checkpoint-reserved-tool).

    Verified your analysis first: reproduced the exact shape pre-fix (valid=false, invalid-source "Tool origin source:1:part:0 has no exact lowered input/output match", checkpoint claiming index 2) and confirmed the root cause as described — at claim time no window index is already claimed (the window sits strictly between correlated neighbours), so #425's exactly-one-candidate rule claims whatever single index is in the window, including a correlated tool result.

    Fix, two layers:

    1. Claim layer (as proposed): reservedForCorrelatedTools set (source-bound assistants' own indices ∪ role=tool results of their call IDs) computed after ID correlation; claimCheckpointRanges skips reserved candidates; a wholly-reserved window sets Draft.whollyReserved → buildProviderCheckpoint emits no normalized message (sidecar disclosure only, no contentless assistant turn). Partial windows keep the tool-free remainder per your guard.
    2. Restore layer (found in dual-agent code review, would have re-created the same symptom one layer downstream): the wholly-reserved shape is the first provider-checkpoint entry with no normalizedMessageId; restoreMissingV2OpaqueSources (restore.ts) rejects any protected entry lacking one, and lib/v2/context.ts turns that rejection into "preserve provider request" = ACP off for the session again. Such entries are now exempt — nothing exists to be missing or restored. Regression-tested at context level.

    Behavior changes (old → new):

    • Window = only correlated tool indices: claimed → session-wide rejection / tool result lost / ACP off ⇒ claims nothing, sidecar-only entry, session valid, result intact.
    • Mixed reserved+free window (>1 candidates): no claim ⇒ claims the free remainder.
    • Restore layer: wholly-reserved entry rejected ⇒ accepted (nothing restored).
    • Unchanged: compatible-view single-candidate claim, incompatible-switch non-claim, direct-view render-from-source, plain compactions — all pre-existing tests green.

    Verification: 3 new tests (2 unit in tests/v2-message-projection.test.ts incl. the partial-window guard, 1 context-level through restore+patch in tests/v2-context-patch.test.ts); each verified to fail with its lib change reverted. Full suite 1417/1418 — sole failure is env-only tests/soft-block.test.ts (hard-coded /tmp mkdir, read-only in this sandbox, fails identically on pristine base). Typecheck clean, touched files prettier-clean.

    Note: the branch carries the two unmerged #425-guard commits (71458d18, 37654026) as prerequisites since this fix refines the claim rule they introduced — labeled in the PR body. Awaiting CI + human merge.

  3. ranxianglei commented on Sep 29, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    [bot] 🏷 Review status update for PR #468:

    • Code review (dual-agent): one BLOCKER found and fixed before this reply — the restore-layer rejection (restoreMissingV2OpaqueSources would have re-disabled ACP on the new sidecar-only checkpoint shape); two minors addressed as contract-comment disclosures in types.ts.
    • Test review (independent agent): APPROVE. All 3 new tests mutation-verified — reverting normalize.ts fails the claim-layer tests at the exact assertions pinning the bug (valid=false pre-fix; partial-window test fails [3] vs []), reverting only restore.ts fails the context-level test in isolation while all other tests stay green. No tautological assertions; fixtures verified against source math to genuinely hit the wholly-reserved / partial-window paths.
    • CI note: pr-checks.yml only triggers on PRs targeting master, so no GitHub Actions run on this 2026-09-17_v2-base-targeted PR (same as the existing fix(v2): map repeated identical system text by ordered occurrence #429/fix(v2): stop compaction usage from becoming phantom system overhead (#421) #430 pattern). Local gates all pass on the final tree: typecheck clean, build success, full suite 1417/1418 (sole failure is the env-only /tmp hard-code in tests/soft-block.test.ts, identical on pristine base).
    • PR is mergeable: true. Ready for human merge whenever you are.
  4. ranxianglei commented on Sep 29, 2026

    @ranxianglei
    Owner

    Same routing note as in #455: per #442, opencode-acp stays on OpenCode 1.x and the in-repo OpenCode 2.x native port (lib/v2/* on v2-base) is retired — OpenCode 2.x goes through billion-context (bili opencode / bili plugin install opencode; delivered as billion-context#754). So the provider-checkpoint range claim swallowing a correlated tool message finding won't be picked up in this repo; if it still applies, please continue it at https://github.com/ranxianglei/billion-context/issues. Thanks for the detailed analysis.

    Для OpenCode 2.x используйте billion-context; этот репозиторий остаётся на OpenCode 1.x (#442).

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