Skip to content

Session permanently stuck after auto-compaction: post-compaction auto-trigger fires tool_use without tool_result error and is non-recoverable #27594

Closed as not planned

Description

@magnuslo

Session permanently stuck after auto-compaction: post-compaction auto-trigger fires tool_use without tool_result error and is non-recoverable

Update (post github-actions duplicate-check): This issue overlaps mechanically with #14367 (same tool_use/tool_result orphaning root cause) and operationally with #15533 (same compaction.ts re-trigger logic). I've kept it open because the specific failure mode — permanently-stuck session, deterministic API 400 from a single-part orphan, whack-a-mole behavior as you remove individual offenders — is distinct and provides additional reproduction signal.

Original workaround in this issue is insufficient (deletes the failed messages but doesn't fix the underlying part-conversion problem). See the "Working workaround" section near the bottom for the correct fix.

Description

After a successful auto-compaction, opencode immediately injects a second {"type":"compaction","auto":true} trigger without a tail_start_id. The retried compaction call is rejected by Anthropic with:

messages.2: `tool_use` ids were found without `tool_result` blocks immediately after: toolu_XXXXXX.
Each `tool_use` block must have a corresponding `tool_result` block in the next message.

The session becomes permanently unrecoverable: every subsequent prompt (including continue) re-fires the broken compaction, hits the same error, and writes a new failed compaction message. The TUI offers no escape — only direct DB surgery resumes the session.

The deterministic, whack-a-mole nature of which callID ends up orphaned (see "Whack-a-mole evidence" below) suggests filterCompacted / the subsequent convertToModelMessages pass is making a stable but unsafe boundary choice, matching the analysis in #14367.

OpenCode version

1.14.50

Operating System

macOS 26.4.1 (Build 25E253)

Terminal

WarpTerminal

Plugins

oh-my-opencode

Steps to reproduce

  1. Drive a session long enough to trigger auto-compaction (~960k tokens of agent activity in my case; multi-hour ultrawork run with many read/edit/bash tool calls).
  2. First auto-compaction completes successfully. compaction.create() writes a user message with part {"type":"compaction","auto":true,"overflow":false,"tail_start_id":"msg_X"}. The compaction assistant message gets summary:true,finish:"stop".
  3. Post-compaction the system auto-emits:
    • User message: [restore checkpointed session agent configuration after compaction]
    • Synthetic user message with metadata.compaction_continue=true
    • A new user message with part {"type":"compaction","auto":true} — note: no tail_start_id
  4. The compaction agent runs again on this new trigger. Anthropic rejects with messages.2: tool_use ids were found without tool_result blocks immediately after: <callID>.
  5. Type continue in the TUI → same failure recurs identically.
  6. Delete the failed compaction messages + auto-trigger from the DB → resume → same error, different callID pointing to the next tool call chronologically.

Actual behavior

  • compaction.processCompaction produces a 400-rejected request.
  • The failed assistant message is persisted to message table with the APIError payload (no parts row written — the request never started streaming).
  • Every resume attempt loops back into the same failed compaction. Session is dead until manual DB intervention.

Expected behavior

Either:

  • The auto-injected follow-up compaction should NOT be created when the previous compaction already produced a summary:true boundary that adequately reduces context, OR
  • If a follow-up compaction IS required, it should reuse the prior tail_start_id (or compute a new one) and verify that the chosen split does not produce orphan tool_use blocks in the converted ModelMessages — i.e., fix the same root cause as Kiro API 400 error after compaction due to mismatched toolUses/toolResults #14367 but at the compaction-call path, OR
  • A failed compaction with APIError should be terminal: don't auto-fire another compaction on the next user prompt. Surface the error and require explicit /compact (or new opencode session repair).

Whack-a-mole evidence (post-publish update)

Initial fix (per workaround in original issue): delete 4 failed messages. Resume → same exact error but referencing toolu_01MJXpPoskEVL1mbvyxHzeCv instead of the original toolu_01KP2w4jJkqw1UmxfrgoF9A3. The new orphan is the chronologically next tool call after the one I removed.

Fix the next part too → resume → same error, third callID.

The orphan position is consistently messages.2 in the Anthropic payload, but the callID shifts deterministically as you remove individual offenders. This is highly consistent with the filterCompacted-reorders-and-cuts hypothesis from #14367 — the reorder picks a stable boundary, and the first surviving tool_use block at position 2 is whatever's left after your manual edits.

Only when all 578 tool parts in the session were converted to text parts did the session resume cleanly.

Evidence from the SQLite DB

Session ses_1d8ad403fffekoARGE0u2eoV0I, pulled from ~/.local/share/opencode/opencode.db.

Timeline around the failure:

Time (UTC) Message ID (abbrev) role/mode summary error Notes
19:10:51 first trigger user — — part: {type:compaction, auto:true, overflow:false, tail_start_id:msg_TAIL}
19:10:51 → 19:12:57 compaction OK assistant / compaction true — finish:"stop", summary text written. This worked.
19:12:57 restore user — — [restore checkpointed session agent configuration after compaction]
19:12:57 continue user — — synthetic, metadata.compaction_continue=true
19:12:57 second trigger user — — part: {type:compaction, auto:true} — no tail_start_id
19:12:57 failed compaction 1 assistant / compaction — APIError messages.2: tool_use ... without tool_result: toolu_01KP2w4jJkqw1UmxfrgoF9A3
later user types continue user — —
same failed compaction 2 assistant / compaction — APIError identical error, same callID
later (after delete + resume) APIError different callID — toolu_01MJXpPoskEVL1mbvyxHzeCv
later (after second delete + resume) APIError third callID — orphan keeps shifting

The originally-referenced toolu_01KP2w4jJkqw1UmxfrgoF9A3 is a benign read call whose state.status is completed with output populated. The opencode ToolPart itself is well-formed — the orphan is created during conversion / history filtering, not in storage.

Suspected source

packages/opencode/src/session/compaction.ts — the auto-continue path after a successful compaction emits the synthetic _continue text, but a second compaction trigger with no tail_start_id also appears in the DB by this point. I haven't fully traced the writer of that trigger — could be the overflow check, a plugin, or the v2 event system. Tracing this is the first practical step.

When that trigger has no tail_start_id, the compaction call relies on select() → splitTurn() to find a fresh split. splitTurn:

for (let start = input.turn.start + 1; start < input.turn.end; start++) {
  const size = yield* input.estimate({ ... })
  if (size > input.budget) continue
  return { start, id: input.messages[start]!.info.id } satisfies Tail
}

returns the first index whose suffix fits the budget. It does not verify that the chosen index is safe with respect to tool_use/tool_result pairing after toModelMessagesEffect + convertToModelMessages runs.

Combined with filterCompacted's reorder block (per #14367), the converted output appears to land a tool_use at messages[2] whose matching tool_result is not at messages[3]. The whack-a-mole shift between callIDs confirms the unsafe-boundary hypothesis.

Working workaround

⚠️ The version originally in this issue (delete 4 messages) is insufficient. Use this instead.

# 1. Backup the DB
cp ~/.local/share/opencode/opencode.db /tmp/opencode.db.backup

# 2. Identify your session id, then in Python (NOT raw SQL — JSON content has single quotes
#    that break sqlite3 CLI shell escaping):
python3 <<'PYEOF'
import sqlite3, json, time
SESSION_ID = "ses_REPLACE_ME"
DB = "/Users/$USER/.local/share/opencode/opencode.db"  # adjust for your OS

conn = sqlite3.connect(DB); cur = conn.cursor()

# Step 1: delete any failed compaction messages + their auto-trigger users
# Find them via:
#   SELECT id, json_extract(data,'$.error.name') FROM message
#   WHERE session_id=? AND json_extract(data,'$.error.name') IS NOT NULL
# and their parent user messages.

# Step 2: convert every tool part in the session to text (this is the key fix).
cur.execute(
    "SELECT id, data FROM part WHERE session_id=? AND json_extract(data,'$.type')='tool'",
    (SESSION_ID,)
)
now = int(time.time() * 1000)
for pid, raw in cur.fetchall():
    p = json.loads(raw)
    state = p.get("state", {})
    out = state.get("output", "")
    if len(out) > 8000:
        out = out[:8000] + f"\n[... truncated {len(out)-8000} chars ...]"
    inp = json.dumps(state.get("input", {}), separators=(",",":"))[:500]
    status = state.get("status", "?")
    if status == "completed":
        text = f"[Tool: {p['tool']}({inp})]\n<output>\n{out}\n</output>"
    elif status == "error":
        text = f"[Tool: {p['tool']}({inp}) — ERROR]\n<error>\n{state.get('error','')[:1000]}\n</error>"
    else:
        text = f"[Tool: {p['tool']}({inp}) — status: {status}]"
    new = {"type":"text","text":text,"synthetic":True}
    cur.execute("UPDATE part SET data=?, time_updated=? WHERE id=?",
                (json.dumps(new), now, pid))

conn.commit(); conn.close()
PYEOF

After running, every tool part in the session becomes a text part with the same content embedded. No tool_use blocks exist anywhere, so the orphan-at-messages.2 cannot happen on any subsequent compaction. Resume succeeds.

Trade-off: the agent on resume sees its prior tool calls as text narration rather than structured tool execution. It still has the full content (file paths, outputs, edit diffs, bash stdout) so it can continue work, but it may re-issue some tool calls if it wants fresh structured output. New tool calls after resume work normally — only the historical representation changes.

Possible fixes (revised)

  1. Don't re-trigger compaction immediately when the previous compaction wrote summary:true. Add a check before creating any new {type:"compaction",auto:true} trigger.
  2. Treat compaction APIError as terminal instead of auto-replaying on the next user input. Surface the error with /compact guidance. Optionally ship opencode session repair <id> so users don't need to hand-edit SQLite.
  3. Make boundary selection (splitTurn) tool_use-aware: after choosing a candidate start, run the converted ModelMessages through a validator and advance to the next safe boundary if an orphan would be produced. This addresses the underlying mechanism that Kiro API 400 error after compaction due to mismatched toolUses/toolResults #14367 is also touching.
  4. Consider sharing a single root fix with Kiro API 400 error after compaction due to mismatched toolUses/toolResults #14367 — both bugs converge on filterCompacted / conversion producing API-invalid message sequences. A defensive pass that pairs every tool_use with the matching tool_result before dispatch would fix both.

Happy to PR (1)+(2)+(4) if maintainers agree on the approach.

Activity

  1. github-actions commented on May 14, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    If these don't fully overlap with your specific bug (the second trigger missing tail_start_id and the session being permanently unrecoverable), feel free to keep this open — the DB evidence and splitTurn analysis you've provided is genuinely distinct and useful.

  2. magnuslo commented on May 14, 2026

    @magnuslo
    Author

    Thanks for the cross-link, very helpful. Confirming the relationship and adding new empirical evidence below.

    Relationship to linked issues

    #14367 — same family, very likely the same root cause at the mechanical level. That issue identifies filterCompacted reconstructing history such that tool_use blocks lose their matching tool_result. My bug is the operational consequence of the same defect when it lands in the auto-compaction loop: instead of a one-off 400, the session enters a permanent unrecoverable state because auto-compaction keeps re-firing the broken request on every prompt.

    #15533 — related but mechanically distinct. That bug is about a synthetic Continue… being injected when the assistant naturally ended its turn, causing semantic recompaction loops. Mine is a structural loop: the API call itself is rejected by Anthropic with 400, so no model output is ever produced — there's no "continue" to debate. The two interact (the synthetic continue does fire here too) but the immediate failure is the malformed payload, not the loop semantics.

    So this issue should stay open as: "tool_use orphaning bug specifically triggered by post-success auto-compaction re-trigger; session becomes permanently unrecoverable."

    New empirical evidence — the workaround in my original issue is NOT sufficient

    After publishing, I tried to resume the session using the workaround in my issue (delete the 4 broken messages). It failed identically on first prompt with the same messages.2: tool_use ... without tool_result error but referencing a different callID — the next chronologically-adjacent tool call.

    I converted that part too (changed {type:"tool", callID:"toolu_X", ...} → {type:"text", text:"[Tool: ... <output>...</output>"} in the DB). Same failure, third callID. The orphan deterministically shifts to the next un-fixed tool part.

    This whack-a-mole pattern is strong evidence for #14367's root cause: filterCompacted (or whatever subsequent step convertToModelMessages performs) is making a deterministic but unsafe boundary choice, and the boundary moves with the data. Single-part surgery is insufficient — every adjacent tool_use is at risk.

    What actually unstuck the session

    Bulk-converting all 578 tool parts in the session to text parts (preserving content as [Tool: name(input)]\n<output>…</output> synthetic text). Resume worked on the next try.

    Replacement workaround script (drop-in for the one in the issue body — the original is insufficient):

    import sqlite3, json, time
    SESSION_ID = "ses_YOUR_ID"
    DB = "/Users/$USER/.local/share/opencode/opencode.db"  # adjust per OS
    
    conn = sqlite3.connect(DB); cur = conn.cursor()
    
    # 1. Delete the failed compaction msgs + their auto-trigger users (see original issue)
    # 2. Convert every tool part in the session to text:
    cur.execute(
        "SELECT id, data FROM part WHERE session_id=? AND json_extract(data,'$.type')='tool'",
        (SESSION_ID,)
    )
    now = int(time.time() * 1000)
    for pid, raw in cur.fetchall():
        p = json.loads(raw)
        state = p.get("state", {})
        out = state.get("output", "")
        if len(out) > 8000:  # keep parts manageable
            out = out[:8000] + f"\n[... truncated {len(out)-8000} chars ...]"
        inp = json.dumps(state.get("input", {}), separators=(",",":"))[:500]
        status = state.get("status", "?")
        if status == "completed":
            text = f"[Tool: {p['tool']}({inp})]\n<output>\n{out}\n</output>"
        elif status == "error":
            text = f"[Tool: {p['tool']}({inp}) — ERROR]\n<error>\n{state.get('error','')[:1000]}\n</error>"
        else:
            text = f"[Tool: {p['tool']}({inp}) — status: {status}]"
        new = {"type":"text","text":text,"synthetic":True}
        cur.execute("UPDATE part SET data=?, time_updated=? WHERE id=?", (json.dumps(new), now, pid))
    
    conn.commit(); conn.close()

    (I'll edit the original issue body to point to this replacement instead of the original-workaround that doesn't fully work.)

    Practical implication for the fix

    Whatever the right fix in filterCompacted / convertToModelMessages turns out to be, please ensure it's defensive in both directions: not only at compaction-time, but also when assembling the conversation context for the next turn after a successful compaction. The latter is what creates the permanent-stuck state — a single bad assembly cascades into infinite retry because the auto-compaction trigger has no awareness that its previous output was rejected.

    A small additional belt-and-suspenders: when a compaction call returns APIError, don't auto-fire another compaction on the next user prompt. Surface the error and require explicit /compact (or opencode session repair, ref my issue's suggested fixes section).

  3. rekram1-node commented on Jun 9, 2026

    @rekram1-node
    Collaborator

    believe this was fixed

  4. viiqswim commented on Jun 29, 2026

    @viiqswim

    Dropping this here in case anyone's still stuck. I hit this constantly after auto-compaction and got tired of starting a fresh session every time, so I wrote a small OpenCode plugin to work around it:

    https://github.com/viiqswim/opencode-compaction-guard

    Add one line to your opencode.json:

    "plugin": ["opencode-compaction-guard@latest"]

    (or just drop the single .mjs into ~/.config/opencode/plugin/, the README has both ways).

    Roughly how it works:

    • Before each request, and before compaction, it finds any tool call stuck in pending/running and turns it into an error result, so every tool_use still has a matching tool_result.
    • If the 400 fires anyway, it catches the session error and reverts the failed turn for you (debounced so it can't loop, and you can /unrevert if you want it back). The session keeps going instead of dying.

    One thing I want to be upfront about, since it lines up with the root cause in this thread: that first part only covers the interrupted-tool case. The post-compaction slice you all described happens further down the pipeline than a plugin hook can reach, so for that one it leans on the auto-revert instead of actually preventing it. So it's a band-aid, not a real fix. The proper fix still has to happen in opencode itself.

    The two pieces are borrowed from #8497 (pending/running to error result) and #1662 (revert on error), just packaged up so it's easy to install.

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions