fix(sidecar): sanitize JSONL session files before resume (strips empty text blocks with cache_control)
This commit is contained in:
parent
6e3853e0a1
commit
31a3335651
1 changed files with 110 additions and 1 deletions
|
|
@ -8,7 +8,8 @@ import { execSync } from 'child_process';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { homedir } from 'os';
|
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 });
|
const rl = createInterface({ input: stdin });
|
||||||
|
|
||||||
|
|
@ -121,12 +122,17 @@ async function handleQuery(msg: QueryMessage) {
|
||||||
if (resumeMode === 'continue') {
|
if (resumeMode === 'continue') {
|
||||||
continueOpt = true;
|
continueOpt = true;
|
||||||
log(`Session ${sessionId}: continuing most recent session`);
|
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) {
|
} else if (resumeMode === 'resume' && resumeSessionId) {
|
||||||
resumeOpt = resumeSessionId;
|
resumeOpt = resumeSessionId;
|
||||||
log(`Session ${sessionId}: resuming SDK session ${resumeSessionId}`);
|
log(`Session ${sessionId}: resuming SDK session ${resumeSessionId}`);
|
||||||
|
// Sanitize the specific session file
|
||||||
|
sanitizeSessionFile(cwd || process.cwd(), resumeSessionId);
|
||||||
} else if (resumeSessionId && !resumeMode) {
|
} else if (resumeSessionId && !resumeMode) {
|
||||||
// Legacy: direct resumeSessionId without resumeMode
|
// Legacy: direct resumeSessionId without resumeMode
|
||||||
resumeOpt = resumeSessionId;
|
resumeOpt = resumeSessionId;
|
||||||
|
sanitizeSessionFile(cwd || process.cwd(), resumeSessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const q = query({
|
const q = query({
|
||||||
|
|
@ -238,5 +244,108 @@ if (claudePath) {
|
||||||
log('WARNING: Claude CLI not found — agent sessions will fail');
|
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');
|
log('Sidecar started');
|
||||||
send({ type: 'ready' });
|
send({ type: 'ready' });
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue