You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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).
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".
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
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 continue in the TUI → same failure recurs identically.
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.
[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:
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, timeSESSION_ID = "ses_REPLACE_ME"DB = "/Users/$USER/.local/share/opencode/opencode.db" # adjust for your OSconn = 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)
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 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.
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.
Session permanently stuck after auto-compaction: post-compaction auto-trigger fires
tool_use without tool_resulterror and is non-recoverableDescription
After a successful auto-compaction, opencode immediately injects a second
{"type":"compaction","auto":true}trigger without atail_start_id. The retried compaction call is rejected by Anthropic with: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
callIDends up orphaned (see "Whack-a-mole evidence" below) suggestsfilterCompacted/ the subsequentconvertToModelMessagespass is making a stable but unsafe boundary choice, matching the analysis in #14367.OpenCode version
1.14.50Operating System
macOS 26.4.1 (Build 25E253)
Terminal
WarpTerminal
Plugins
oh-my-opencode
Steps to reproduce
read/edit/bashtool calls).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".[restore checkpointed session agent configuration after compaction]metadata.compaction_continue=true{"type":"compaction","auto":true}— note: notail_start_idmessages.2: tool_use ids were found without tool_result blocks immediately after: <callID>.continuein the TUI → same failure recurs identically.callIDpointing to the next tool call chronologically.Actual behavior
compaction.processCompactionproduces a 400-rejected request.messagetable with theAPIErrorpayload (nopartsrow written — the request never started streaming).Expected behavior
Either:
summary:trueboundary that adequately reduces context, ORtail_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, ORAPIErrorshould 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_01MJXpPoskEVL1mbvyxHzeCvinstead of the originaltoolu_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.2in the Anthropic payload, but thecallIDshifts deterministically as you remove individual offenders. This is highly consistent with thefilterCompacted-reorders-and-cuts hypothesis from #14367 — the reorder picks a stable boundary, and the first survivingtool_useblock 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:
{type:compaction, auto:true, overflow:false, tail_start_id:msg_TAIL}finish:"stop", summary text written. This worked.[restore checkpointed session agent configuration after compaction]metadata.compaction_continue=true{type:compaction, auto:true}— notail_start_idAPIErrormessages.2: tool_use ... without tool_result: toolu_01KP2w4jJkqw1UmxfrgoF9A3continueAPIErrorAPIErrorcallID—toolu_01MJXpPoskEVL1mbvyxHzeCvAPIErrorcallID— orphan keeps shiftingThe originally-referenced
toolu_01KP2w4jJkqw1UmxfrgoF9A3is a benignreadcall whosestate.statusiscompletedwithoutputpopulated. The opencodeToolPartitself 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_continuetext, but a second compaction trigger with notail_start_idalso 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 onselect()→splitTurn()to find a fresh split.splitTurn: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_resultpairing aftertoModelMessagesEffect+convertToModelMessagesruns.Combined with
filterCompacted's reorder block (per #14367), the converted output appears to land atool_useatmessages[2]whose matchingtool_resultis not atmessages[3]. The whack-a-mole shift betweencallIDs confirms the unsafe-boundary hypothesis.Working workaround
After running, every
toolpart in the session becomes atextpart with the same content embedded. Notool_useblocks exist anywhere, so the orphan-at-messages.2cannot 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)
summary:true. Add a check before creating any new{type:"compaction",auto:true}trigger.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.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.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.