Repository navigation
Session permanently stuck after auto-compaction: post-compaction auto-trigger fires tool_use without tool_result error and is non-recoverable #27594
Description
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_resultorphaning 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
- 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/bashtool calls). - 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 getssummary:true,finish:"stop". - 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: notail_start_id
- User message:
- 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>. - Type
continuein the TUI → same failure recurs identically. - Delete the failed compaction messages + auto-trigger from the DB → resume → same error, different
callIDpointing to the next tool call chronologically.
Actual behavior
compaction.processCompactionproduces a 400-rejected request.- The failed assistant message is persisted to
messagetable with theAPIErrorpayload (nopartsrow 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:trueboundary 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 orphantool_useblocks 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
APIErrorshould be terminal: don't auto-fire another compaction on the next user prompt. Surface the error and require explicit/compact(or newopencode 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()
PYEOFAfter 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)
- 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. - Treat compaction
APIErroras terminal instead of auto-replaying on the next user input. Surface the error with/compactguidance. Optionally shipopencode session repair <id>so users don't need to hand-edit SQLite. - Make boundary selection (
splitTurn) tool_use-aware: after choosing a candidatestart, 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. - 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 everytool_usewith the matchingtool_resultbefore dispatch would fix both.
Happy to PR (1)+(2)+(4) if maintainers agree on the approach.
Activity
github-actions commentedon May 14, 2026
This issue might be a duplicate of existing issues. Please check:
compaction.tsand the infinite re-trigger behaviortool_useblocks after compaction splits historyIf these don't fully overlap with your specific bug (the second trigger missing
tail_start_idand the session being permanently unrecoverable), feel free to keep this open — the DB evidence andsplitTurnanalysis you've provided is genuinely distinct and useful.magnuslo commentedon May 14, 2026
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
filterCompactedreconstructing history such thattool_useblocks lose their matchingtool_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 with400, 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_useorphaning 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_resulterror but referencing a differentcallID— 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 stepconvertToModelMessagesperforms) is making a deterministic but unsafe boundary choice, and the boundary moves with the data. Single-part surgery is insufficient — every adjacenttool_useis at risk.What actually unstuck the session
Bulk-converting all 578
toolparts in the session totextparts (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):
(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/convertToModelMessagesturns 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(oropencode session repair, ref my issue's suggested fixes section).rekram1-node commentedon Jun 9, 2026
believe this was fixed
viiqswim commentedon Jun 29, 2026
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:
(or just drop the single .mjs into ~/.config/opencode/plugin/, the README has both ways).
Roughly how it works:
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.