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

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 callIDtoolu_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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions