fix(sidecar): sanitize JSONL session files before resume (strips empty text blocks with cache_control)

This commit is contained in:
Hibryda 2026-03-27 03:26:16 +01:00
parent 6e3853e0a1
commit 31a3335651

View file

@ -8,7 +8,8 @@ import { execSync } from 'child_process';
import { existsSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { query, type Query } from '@anthropic-ai/claude-agent-sdk';
import { query, listSessions, type Query } from '@anthropic-ai/claude-agent-sdk';
import { readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
const rl = createInterface({ input: stdin });
@ -121,12 +122,17 @@ async function handleQuery(msg: QueryMessage) {
if (resumeMode === 'continue') {
continueOpt = true;
log(`Session ${sessionId}: continuing most recent session`);
// Sanitize the most recent session file before SDK reads it
sanitizeSessionFiles(cwd || process.cwd());
} else if (resumeMode === 'resume' && resumeSessionId) {
resumeOpt = resumeSessionId;
log(`Session ${sessionId}: resuming SDK session ${resumeSessionId}`);
// Sanitize the specific session file
sanitizeSessionFile(cwd || process.cwd(), resumeSessionId);
} else if (resumeSessionId && !resumeMode) {
// Legacy: direct resumeSessionId without resumeMode
resumeOpt = resumeSessionId;
sanitizeSessionFile(cwd || process.cwd(), resumeSessionId);
}
const q = query({
@ -238,5 +244,108 @@ if (claudePath) {
log('WARNING: Claude CLI not found — agent sessions will fail');
}
// ── Session sanitizer ──────────────────────────────────────────────────────
// Fixes "cache_control cannot be set for empty text blocks" API error on resume.
// The SDK stores empty text blocks with cache_control during streaming;
// the API rejects them on replay. We strip them before the SDK reads the file.
function encodeCwd(cwdPath: string): string {
return cwdPath.replace(/[^a-zA-Z0-9]/g, '-');
}
function sanitizeSessionFile(cwdPath: string, sdkSessionId: string): void {
const encoded = encodeCwd(cwdPath);
const filePath = join(homedir(), '.claude', 'projects', encoded, `${sdkSessionId}.jsonl`);
try {
sanitizeJsonlFile(filePath);
} catch (err) {
log(`sanitize: could not clean ${filePath}: ${err}`);
}
}
function sanitizeSessionFiles(cwdPath: string): void {
// For 'continue' mode, sanitize the most recent session
try {
const sessions = listSessions({ dir: cwdPath, limit: 1 });
if (sessions && sessions.length > 0) {
sanitizeSessionFile(cwdPath, (sessions[0] as Record<string, unknown>).sessionId as string);
}
} catch (err) {
log(`sanitize: listSessions failed: ${err}`);
// Fallback: try to find and sanitize the most recent .jsonl file
const encoded = encodeCwd(cwdPath);
const dir = join(homedir(), '.claude', 'projects', encoded);
try {
const files = readdirSync(dir).filter(f => f.endsWith('.jsonl'));
if (files.length > 0) {
// Sort by mtime descending
files.sort((a, b) => {
try {
return statSync(join(dir, b)).mtimeMs - statSync(join(dir, a)).mtimeMs;
} catch { return 0; }
});
sanitizeJsonlFile(join(dir, files[0]));
}
} catch { /* dir doesn't exist */ }
}
}
function sanitizeJsonlFile(filePath: string): void {
if (!existsSync(filePath)) return;
const content = readFileSync(filePath, 'utf-8');
const lines = content.split('\n');
let modified = false;
const cleaned = lines.map(line => {
if (!line.trim()) return line;
try {
const obj = JSON.parse(line);
if (cleanContentBlocks(obj)) {
modified = true;
return JSON.stringify(obj);
}
} catch { /* skip unparseable lines */ }
return line;
});
if (modified) {
writeFileSync(filePath, cleaned.join('\n'));
log(`sanitize: cleaned empty text blocks from ${filePath}`);
}
}
function cleanContentBlocks(obj: Record<string, unknown>): boolean {
let changed = false;
// Check message.content array
const msg = obj.message as Record<string, unknown> | undefined;
if (msg?.content && Array.isArray(msg.content)) {
const before = msg.content.length;
msg.content = (msg.content as Array<Record<string, unknown>>).filter(block => {
// Remove empty text blocks (with or without cache_control)
if (block.type === 'text' && (!block.text || !(block.text as string).trim())) {
return false;
}
return true;
});
if (msg.content.length !== before) changed = true;
}
// Also check top-level content array (some formats)
if (obj.content && Array.isArray(obj.content)) {
const before = (obj.content as unknown[]).length;
obj.content = (obj.content as Array<Record<string, unknown>>).filter(block => {
if (block.type === 'text' && (!block.text || !(block.text as string).trim())) {
return false;
}
return true;
});
if ((obj.content as unknown[]).length !== before) changed = true;
}
return changed;
}
log('Sidecar started');
send({ type: 'ready' });