Merge branch 'main' into cv-main-xx1jlt

This commit is contained in:
xintaofei
2026-03-13 22:08:52 +08:00
3 changed files with 131 additions and 2 deletions

View File

@@ -58,6 +58,19 @@ fn is_meta_message(value: &serde_json::Value) -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
/// Check if an assistant message is a synthetic placeholder (e.g. generated by
/// Claude Code for local commands like `/context` or `/model`).
/// These carry `model: "<synthetic>"` and all-zero usage, so they should be
/// excluded from conversation turns and stats.
fn is_synthetic_assistant(value: &serde_json::Value) -> bool {
value
.get("message")
.and_then(|m| m.get("model"))
.and_then(|m| m.as_str())
.map(|s| s == "<synthetic>")
.unwrap_or(false)
}
fn parse_model_capacity_suffix(model: &str) -> Option<u64> { fn parse_model_capacity_suffix(model: &str) -> Option<u64> {
let captures = model_capacity_suffix_regex().captures(model.trim())?; let captures = model_capacity_suffix_regex().captures(model.trim())?;
let value = captures.get(1)?.as_str().parse::<f64>().ok()?; let value = captures.get(1)?.as_str().parse::<f64>().ok()?;
@@ -236,6 +249,11 @@ impl ClaudeParser {
} }
if msg_type == "user" || msg_type == "assistant" { if msg_type == "user" || msg_type == "assistant" {
// Skip synthetic assistant placeholders for local commands
if msg_type == "assistant" && is_synthetic_assistant(&value) {
continue;
}
message_count += 1; message_count += 1;
// Extract model from assistant messages // Extract model from assistant messages
@@ -449,6 +467,10 @@ impl ClaudeParser {
} }
match msg_type { match msg_type {
"assistant" if is_synthetic_assistant(&value) => {
// Skip synthetic assistant placeholders for local commands
continue;
}
"user" => { "user" => {
let content = extract_user_content(&value); let content = extract_user_content(&value);
@@ -1036,6 +1058,103 @@ mod tests {
assert_eq!(resolved, PathBuf::from("/Users/default/.claude")); assert_eq!(resolved, PathBuf::from("/Users/default/.claude"));
} }
#[test]
fn synthetic_assistant_excluded_from_detail() {
let path = std::env::temp_dir().join(format!(
"codeg-claude-synthetic-{}.jsonl",
uuid::Uuid::new_v4()
));
let mut file = fs::File::create(&path).expect("create temp jsonl");
// Normal user message
writeln!(
file,
"{}",
json!({
"type": "user",
"sessionId": "synth-test",
"timestamp": "2026-03-01T10:00:00Z",
"uuid": "u1",
"cwd": "/tmp/demo",
"message": {
"content": [{"type": "text", "text": "hello"}]
}
})
)
.unwrap();
// Normal assistant message with real usage
writeln!(
file,
"{}",
json!({
"type": "assistant",
"sessionId": "synth-test",
"timestamp": "2026-03-01T10:00:02Z",
"uuid": "a1",
"message": {
"model": "claude-sonnet-4-6",
"content": [{"type": "text", "text": "world"}],
"usage": {
"input_tokens": 1000,
"output_tokens": 200,
"cache_creation_input_tokens": 300,
"cache_read_input_tokens": 400
}
}
})
)
.unwrap();
// Synthetic assistant from a local command like /context
writeln!(
file,
"{}",
json!({
"type": "assistant",
"sessionId": "synth-test",
"timestamp": "2026-03-01T10:01:00Z",
"uuid": "a2",
"message": {
"model": "<synthetic>",
"content": [{"type": "text", "text": "No response requested."}],
"usage": {
"input_tokens": 0,
"output_tokens": 0,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0
}
}
})
)
.unwrap();
let parser = ClaudeParser {
base_dir: PathBuf::new(),
};
let detail = parser
.parse_conversation_detail(&path, "synth-test")
.expect("parse detail");
fs::remove_file(&path).unwrap();
// Should have 2 turns (user + real assistant), synthetic is excluded
assert_eq!(detail.turns.len(), 2);
assert!(
!detail
.turns
.iter()
.any(|t| t.blocks.iter().any(|b| matches!(
b,
ContentBlock::Text { text } if text == "No response requested."
))),
"synthetic assistant content should not appear in turns"
);
// Stats should reflect only the real assistant usage
let stats = detail.session_stats.expect("session stats");
assert_eq!(stats.context_window_used_tokens, Some(1700));
assert_eq!(stats.context_window_max_tokens, Some(200_000));
let total = stats.total_tokens.expect("total tokens");
assert_eq!(total, 1900); // 1000 + 200 + 300 + 400
}
#[test] #[test]
fn extract_user_content_parses_claude_base64_image_block() { fn extract_user_content_parses_claude_base64_image_block() {
let value = json!({ let value = json!({

View File

@@ -65,8 +65,12 @@ export function StatusBarTokens() {
getConnSnapshot getConnSnapshot
) )
const liveContextUsed = activeConn?.usage?.used ?? null const rawLiveUsed = activeConn?.usage?.used ?? null
const liveContextMax = activeConn?.usage?.size ?? null const rawLiveSize = activeConn?.usage?.size ?? null
// Treat live used=0 as "no data" so we fall back to sessionStats —
// Claude Code sends used=0 for synthetic local commands (/context etc.)
const liveContextUsed = rawLiveUsed != null && rawLiveUsed > 0 ? rawLiveUsed : null
const liveContextMax = rawLiveSize != null && rawLiveSize > 0 ? rawLiveSize : null
const contextUsed = const contextUsed =
liveContextUsed ?? sessionStats?.context_window_used_tokens ?? null liveContextUsed ?? sessionStats?.context_window_used_tokens ?? null

View File

@@ -975,6 +975,12 @@ function connectionsReducer(
case "USAGE_UPDATE": { case "USAGE_UPDATE": {
const conn = state.get(action.contextKey) const conn = state.get(action.contextKey)
if (!conn) return state if (!conn) return state
// Ignore usage updates that reset used to 0 when we already have
// valid data — these come from synthetic responses for local commands
// like /context and would overwrite the real context window usage.
if (action.usage.used === 0 && conn.usage && conn.usage.used > 0) {
return state
}
if ( if (
conn.usage?.used === action.usage.used && conn.usage?.used === action.usage.used &&
conn.usage?.size === action.usage.size conn.usage?.size === action.usage.size