Created
February 25, 2026 23:12
-
-
Save Thinkscape/bd30c6a17aafb2d810256c471236e807 to your computer and use it in GitHub Desktop.
Fix Claude Code session JSONL files affected by the whitespace-only text block bug.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Fix Claude Code session JSONL files affected by the whitespace-only text block bug. | |
| https://github.com/anthropics/claude-code/issues/23142 | |
| When a streaming response is interrupted before real content is emitted, Claude Code | |
| may freeze a whitespace-only text block (e.g. "\n\n" or "") into the conversation | |
| history. The API then rejects all subsequent requests with: | |
| 400 "messages: text content blocks must contain non-whitespace text" | |
| This script removes those broken lines from session JSONL files. | |
| Usage: | |
| # Fix a specific session file: | |
| python3 fix-claude-sessions.py ~/.claude/projects/.../session.jsonl | |
| # Scan and fix ALL session files across all projects: | |
| python3 fix-claude-sessions.py --all | |
| # Dry run (show what would be fixed without modifying files): | |
| python3 fix-claude-sessions.py --all --dry-run | |
| """ | |
| import argparse | |
| import json | |
| import os | |
| import shutil | |
| import sys | |
| from pathlib import Path | |
| def is_bad_line(obj: dict) -> bool: | |
| """Check if a parsed JSONL object is a whitespace-only assistant message.""" | |
| msg = obj.get("message", {}) | |
| if msg.get("role") != "assistant": | |
| return False | |
| content = msg.get("content") | |
| if not isinstance(content, list) or not content: | |
| return False | |
| text_blocks = [b for b in content if isinstance(b, dict) and b.get("type") == "text"] | |
| non_text_blocks = [b for b in content if isinstance(b, dict) and b.get("type") != "text"] | |
| # Only flag if ALL content blocks are whitespace-only text (no tool_use, etc.) | |
| if not text_blocks or non_text_blocks: | |
| return False | |
| return all(b.get("text", "").strip() == "" for b in text_blocks) | |
| def fix_session_file(filepath: Path, dry_run: bool = False) -> int: | |
| """Fix a single session JSONL file. Returns number of removed lines.""" | |
| lines = filepath.read_text().splitlines(keepends=True) | |
| good_lines = [] | |
| removed = 0 | |
| for i, raw_line in enumerate(lines, 1): | |
| stripped = raw_line.strip() | |
| if not stripped: | |
| good_lines.append(raw_line) | |
| continue | |
| try: | |
| obj = json.loads(stripped) | |
| except json.JSONDecodeError: | |
| good_lines.append(raw_line) | |
| continue | |
| if is_bad_line(obj): | |
| removed += 1 | |
| if dry_run: | |
| text = obj["message"]["content"][0].get("text", "") | |
| print(f" Line {i}: whitespace-only text block ({repr(text)})") | |
| else: | |
| good_lines.append(raw_line) | |
| if removed > 0 and not dry_run: | |
| # Back up original | |
| backup = filepath.with_suffix(".jsonl.bak") | |
| shutil.copy2(filepath, backup) | |
| filepath.write_text("".join(good_lines)) | |
| return removed | |
| def find_all_session_files() -> list[Path]: | |
| """Find all session JSONL files across all Claude Code projects.""" | |
| claude_dir = Path.home() / ".claude" / "projects" | |
| if not claude_dir.exists(): | |
| return [] | |
| return sorted(claude_dir.rglob("*.jsonl")) | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description="Fix Claude Code session files with whitespace-only text blocks" | |
| ) | |
| parser.add_argument( | |
| "file", | |
| nargs="?", | |
| help="Path to a specific session JSONL file to fix", | |
| ) | |
| parser.add_argument( | |
| "--all", | |
| action="store_true", | |
| help="Scan and fix all session files in ~/.claude/projects/", | |
| ) | |
| parser.add_argument( | |
| "--dry-run", | |
| action="store_true", | |
| help="Show what would be fixed without modifying files", | |
| ) | |
| args = parser.parse_args() | |
| if not args.file and not args.all: | |
| parser.print_help() | |
| sys.exit(1) | |
| if args.file: | |
| files = [Path(args.file)] | |
| else: | |
| files = find_all_session_files() | |
| print(f"Found {len(files)} session file(s)") | |
| total_fixed = 0 | |
| files_fixed = 0 | |
| for f in files: | |
| if not f.exists(): | |
| print(f"File not found: {f}", file=sys.stderr) | |
| continue | |
| removed = fix_session_file(f, dry_run=args.dry_run) | |
| if removed > 0: | |
| files_fixed += 1 | |
| total_fixed += removed | |
| action = "would remove" if args.dry_run else "removed" | |
| print(f"{'[DRY RUN] ' if args.dry_run else ''}{f.name}: {action} {removed} bad line(s)") | |
| if total_fixed == 0: | |
| print("No problematic lines found.") | |
| else: | |
| action = "Would fix" if args.dry_run else "Fixed" | |
| print(f"\n{action} {total_fixed} line(s) across {files_fixed} file(s).") | |
| if not args.dry_run: | |
| print("Backups saved with .jsonl.bak extension.") | |
| if __name__ == "__main__": | |
| main() |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
See anthropics/claude-code#23142