[BUG] Manual /compact emits empty system message and breaks resumed sessions in 2.1.296

Status Open
Reported on v2.1.296
Maintainer reply None cached
Activity 1 comment · opened Oct 11, 2026

What's wrong?

On Claude Code 2.1.296, manual /compact finishes and writes a valid summary, but the next user turn fails with:

API Error: 400 messages.3: system content must contain at least one block

The outgoing messages array contains an empty mid-conversation system message. The main session becomes unusable until its compaction metadata is repaired. Retrying a prompt does not help.

This happened twice in the same Claude session. A sanitized, three-record Claude JSONL fixture also reproduces the malformed request using the standalone CLI in an empty workspace with --safe-mode, hooks disabled, and no MCP servers. The reproduction below captures the request on localhost and does not contact a model API.

What should happen?

The first prompt after compaction should run normally. Request normalization should never emit a system message whose content array is empty, including while replaying preserved messages after compaction.

Environment

  • Claude Code: 2.1.296 (Claude Code), also the public npm latest version when checked on 2026-10-11.
  • Model recorded in the affected Claude transcript: claude-opus-5-5.
  • OS: macOS 15.7.9, build 24G830, x86_64.
  • Shell: zsh.
  • Original invocation: non-interactive Claude Agent SDK, transcript entrypoint: "sdk-ts", API-key authentication through CLIProxyAPI 8.0.0, Homebrew build dated 2026-09-27.
  • Independent reproduction: standalone claude -p, safe mode, isolated configuration directory and workspace, fake API key, localhost capture endpoint.
  • Regression status: unknown. No earlier version was tested.

Steps to reproduce in the affected session

  1. Run a long conversation. The original session had completed many turns successfully.
  2. Submit /compact.
  3. Compaction succeeds and writes a compact_boundary plus a summary. Its metadata preserves the last assistant message through preservedSegment and preservedMessages.
  4. Send another prompt, for example you can continue..
  5. Observe the 400 error above. Repeated prompts fail too.

The two observed compactions had preTokens values of 805459 and 225216. The small replay fixture below reproduces the malformed request without generating a long conversation.

Evidence from Claude's own session transcript

Source: Claude's ~/.claude/projects/<encoded-workspace>/4a22fa49-b8f0-4802-b6d6-29b820a129f7.jsonl, including backups taken before repair. These are selected fields from Claude's JSONL records, not the host application's timeline. Private conversation content and local workspace paths are omitted.

The first compaction boundary was recorded at 2026-10-11T01:57:59.768Z. The next turn failed at 2026-10-11T01:58:04.524Z; another attempt failed at 2026-10-11T01:58:36.159Z.

After repair, normal session work resumed. A later /compact recreated the failure. The second boundary and subsequent error are shown below.

<details>
<summary>Second compaction boundary, selected original fields</summary>

{
  "type": "system",
  "subtype": "compact_boundary",
  "timestamp": "2026-10-11T04:03:33.652Z",
  "uuid": "d03c283b-45db-4225-afd9-70568b3071d7",
  "sessionId": "4a22fa49-b8f0-4802-b6d6-29b820a129f7",
  "version": "2.1.296",
  "entrypoint": "sdk-ts",
  "compactMetadata": {
    "trigger": "manual",
    "preTokens": 225216,
    "durationMs": 36328,
    "preservedSegment": {
      "headUuid": "eac5118a-02bf-4e2b-bddf-6d5b814a94df",
      "anchorUuid": "6723e071-b955-44d5-b627-45921aaee687",
      "tailUuid": "eac5118a-02bf-4e2b-bddf-6d5b814a94df"
    },
    "preservedMessages": {
      "anchorUuid": "6723e071-b955-44d5-b627-45921aaee687",
      "uuids": [
        "eac5118a-02bf-4e2b-bddf-6d5b814a94df"
      ],
      "allUuids": [
        "eac5118a-02bf-4e2b-bddf-6d5b814a94df"
      ]
    },
    "postTokens": 7939,
    "cumulativeDroppedTokens": 1015471
  }
}

</details>

<details>
<summary>Next turn's error record, selected original fields</summary>

{
  "type": "assistant",
  "timestamp": "2026-10-11T04:03:38.368Z",
  "sessionId": "4a22fa49-b8f0-4802-b6d6-29b820a129f7",
  "version": "2.1.296",
  "entrypoint": "sdk-ts",
  "isApiErrorMessage": true,
  "apiErrorStatus": 400,
  "message": {
    "role": "assistant",
    "content": [
      {
        "type": "text",
        "text": "API Error: 400 messages.3: system content must contain at least one block"
      }
    ]
  }
}

</details>

Independent request capture

The standalone reproduction below produced this request shape. Numbers identify zero-based message positions; the last value is the content block count, or string for string content.

request shape: [(0, 'user', 'string'), (1, 'system', 0), (2, 'assistant', 1), (3, 'user', 2), (4, 'system', 1)]
empty system messages: [{'role': 'system', 'content': [], 'output_config': {'effort': 'medium'}}]

The empty system message is an actual captured request object. The original live API error named messages.3. Subsequent inspection of CLIProxyAPI logs explains the index difference: the proxy receives the empty system message at index 1, then forwards it at index 3 after changing the surrounding message layout. See the proxy evidence below.

Minimal replay fixture and capture script

Save the following as session.jsonl and repro.py in the same directory, then run python3 repro.py with Claude Code 2.1.296 installed.

The fixture keeps the compaction links and preserved assistant message structure from the failing session. Its summary and assistant text are replaced with harmless placeholders, its session ID is synthetic, and workspace paths and other conversation records are removed. This is a reduced replay fixture, not a complete original transcript.

The script creates a separate temporary Claude configuration directory and workspace. It uses --fork-session --no-session-persistence, safe mode, disabled hooks, and an empty MCP configuration. It sends requests to its own localhost HTTP server, which deliberately returns a diagnostic 400 after capture. That diagnostic response is separate from the original live API error. The assertion checks the malformed outgoing request itself.

<details>
<summary>session.jsonl, three records</summary>

{"parentUuid":"3ea51677-589a-48db-b1bd-ab12dbadfb16","isSidechain":false,"apiBlockIndex":0,"requestedModel":"claude-opus-5-5","type":"assistant","uuid":"eac5118a-02bf-4e2b-bddf-6d5b814a94df","timestamp":"2026-10-11T02:41:29.810Z","effort":"medium","perTurnEffort":"medium","version":"2.1.296","sessionId":"11111111-1111-4111-8111-111111111111","message":{"id":"msg_redacted_preserved","type":"message","role":"assistant","model":"claude-opus-5-5","content":[{"type":"text","text":"The previous task is complete."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1}}}
{"parentUuid":null,"logicalParentUuid":"eac5118a-02bf-4e2b-bddf-6d5b814a94df","isSidechain":false,"type":"system","subtype":"compact_boundary","content":"Conversation compacted","isMeta":false,"timestamp":"2026-10-11T04:03:33.652Z","uuid":"d03c283b-45db-4225-afd9-70568b3071d7","level":"info","compactMetadata":{"trigger":"manual","preTokens":225216,"durationMs":36328,"preservedSegment":{"headUuid":"eac5118a-02bf-4e2b-bddf-6d5b814a94df","anchorUuid":"6723e071-b955-44d5-b627-45921aaee687","tailUuid":"eac5118a-02bf-4e2b-bddf-6d5b814a94df"},"preservedMessages":{"anchorUuid":"6723e071-b955-44d5-b627-45921aaee687","uuids":["eac5118a-02bf-4e2b-bddf-6d5b814a94df"],"allUuids":["eac5118a-02bf-4e2b-bddf-6d5b814a94df"]},"postTokens":7939,"cumulativeDroppedTokens":1015471},"version":"2.1.296","sessionId":"11111111-1111-4111-8111-111111111111"}
{"parentUuid":"d03c283b-45db-4225-afd9-70568b3071d7","isSidechain":false,"type":"user","message":{"role":"user","content":"Summary: The user asked for a short explanation. The last assistant response follows."},"isVisibleInTranscriptOnly":true,"isCompactSummary":true,"uuid":"6723e071-b955-44d5-b627-45921aaee687","timestamp":"2026-10-11T04:03:33.648Z","turnPosition":{"promptIndex":0,"turnIndex":165},"version":"2.1.296","sessionId":"11111111-1111-4111-8111-111111111111"}

</details>

<details>
<summary>repro.py, tested standalone reproduction</summary>

import json
import os
import re
import subprocess
import tempfile
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

# Save the three JSONL records from the issue as session.jsonl beside this script.
records = [json.loads(line) for line in Path(__file__).with_name("session.jsonl").read_text().splitlines()]
session_id = records[0]["sessionId"]
root = Path(tempfile.mkdtemp(prefix="claude-compaction-repro-"))
workspace = root / "workspace"
workspace.mkdir()
workspace = workspace.resolve()
config = root / "config"
project = config / "projects" / re.sub(r"[^a-zA-Z0-9]", "-", str(workspace))
project.mkdir(parents=True)
(project / (session_id + ".jsonl")).write_text("\n".join(json.dumps(record) for record in records) + "\n")
captured = []

class Capture(BaseHTTPRequestHandler):
    def log_message(self, *args):
        pass

    def do_POST(self):
        raw = self.rfile.read(int(self.headers.get("Content-Length", "0")))
        payload = json.loads(raw)
        if "messages" in payload:
            captured.append(payload["messages"])
        body = json.dumps({"type": "error", "error": {
            "type": "invalid_request_error", "message": "Local capture only."
        }}).encode()
        self.send_response(400)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(body)

server = ThreadingHTTPServer(("127.0.0.1", 0), Capture)
threading.Thread(target=server.serve_forever, daemon=True).start()
env = os.environ.copy()
for key in ("ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_CUSTOM_HEADERS", "CLAUDE_CODE_OAUTH_TOKEN"):
    env.pop(key, None)
env.update({
    "CLAUDE_CONFIG_DIR": str(config),
    "ANTHROPIC_BASE_URL": "http://127.0.0.1:" + str(server.server_port),
    "ANTHROPIC_API_KEY": "local-diagnostic-placeholder",
    "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
    "DISABLE_TELEMETRY": "1",
    "DISABLE_ERROR_REPORTING": "1",
})
try:
    result = subprocess.run([
        "claude", "-p", "Reply SESSION_OK. Do not use tools.",
        "--resume", session_id, "--fork-session", "--no-session-persistence",
        "--model", "claude-opus-5-5", "--safe-mode",
        "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}',
        "--settings", '{"disableAllHooks":true}', "--output-format", "json",
    ], cwd=workspace, env=env, capture_output=True, text=True, timeout=40)
finally:
    server.shutdown()
assert captured, result.stderr[-500:]
messages = captured[-1]
print("request shape:", [(i, item["role"], len(item["content"]) if isinstance(item["content"], list) else "string") for i, item in enumerate(messages)])
empty = [item for item in messages if item["role"] == "system" and not item.get("content")]
print("empty system messages:", empty)
assert empty, "This version did not reproduce the empty system message."

</details>

Temporary recovery and recurrence

With the session idle and its transcript backed up, removing preservedSegment and preservedMessages from the latest compactMetadata, then appending the preserved assistant text verbatim to the compact summary, restored the session after restarting the Claude process to reload it. A live validation returned SESSION_OK in the same Claude session ID.

This is a diagnostic workaround, not a supported repair procedure. It recovered the same session twice; it does not fix future compactions. The next manual compaction recreated the metadata and the failure.

This narrows the problem to compaction replay/request normalization involving preserved messages. It does not establish the exact faulty source line. An empty-system-message guard and coverage for resuming a compacted session that preserves its last assistant turn would help prevent this failure.

Related issue

#80881 reports the same validation wording during parallel subagents in Plan Mode on 2.1.218. This report concerns the main session after manual compaction on 2.1.296 and includes a captured empty mid-conversation system message plus a standalone replay fixture. Whether the underlying bug is shared is unknown.

Proxy environment clarification and additional evidence

The original live session uses CLIProxyAPI 8.0.0. The proxy error log error-v1-messages-2026-10-11T093338-00000782.log records the failing request at 2026-10-11T09:33:37.866893+05:30, matching the second Claude session failure above. Selected request objects and role/block-count summaries follow. No headers, credentials, or private message text are included.

Incoming request to the proxy, from its REQUEST BODY section:

{ "role": "system", "content": [], "output_config": { "effort": "medium" } }
Incoming message layout: [(0, user, string), (1, system, 0), (2, assistant, 1), (3, user, 7), (4, system, 1)]

Outgoing request, from the proxy's API REQUEST 1 section:

{ "role": "system", "content": [] }
Outgoing message layout: [(0, user, 2), (1, system, 1), (2, system, 1), (3, system, 0), (4, assistant, 1), (5, user, 7), (6, system, 1)]

The proxy's API RESPONSE section contains:

{"type":"error","error":{"type":"invalid_request_error","message":"messages.3: system content must contain at least one block"}}

This establishes that Claude generated the empty message before CLIProxyAPI received it, and the proxy retained that empty message in its outgoing request. CLIProxyAPI changes the surrounding message layout, which explains the reported index. Proxy compatibility may still be relevant; a live request bypassing CLIProxyAPI has not been tested. The standalone localhost reproduction above confirms the client-generated empty message without running CLIProxyAPI.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗