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 { 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' });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue