Skip to main content

tokmesh_core/sessions/
tencent_buddy.rs

1//! Shared parsers for Tencent CodeBuddy / WorkBuddy session formats.
2
3use super::{normalize_workspace_key, workspace_label_from_key, UnifiedMessage};
4use crate::{provider_identity, TokenBreakdown};
5use chrono::TimeZone;
6use serde::Deserialize;
7use std::collections::HashMap;
8use std::io::{BufRead, BufReader};
9use std::path::Path;
10
11const DEFAULT_PROVIDER: &str = "tencent";
12pub(crate) const TENCENT_BUDDY_PARSER_BASE_VERSION: u32 = 1;
13
14#[derive(Debug, Deserialize)]
15struct BuddyLine {
16    id: Option<String>,
17    timestamp: Option<i64>,
18    #[serde(rename = "type")]
19    line_type: Option<String>,
20    role: Option<String>,
21    status: Option<String>,
22    #[serde(rename = "sessionId")]
23    session_id: Option<String>,
24    cwd: Option<String>,
25    message: Option<BuddyMessage>,
26    #[serde(rename = "providerData")]
27    provider_data: Option<BuddyProviderData>,
28}
29
30#[derive(Debug, Deserialize)]
31struct BuddyMessage {
32    model: Option<String>,
33    usage: Option<BuddyUsage>,
34}
35
36#[derive(Debug, Deserialize)]
37struct BuddyProviderData {
38    model: Option<String>,
39    #[serde(rename = "requestModelId")]
40    request_model_id: Option<String>,
41    #[serde(rename = "messageId")]
42    message_id: Option<String>,
43    #[serde(rename = "traceId")]
44    trace_id: Option<String>,
45    usage: Option<BuddyUsage>,
46    #[serde(rename = "rawUsage")]
47    raw_usage: Option<BuddyUsage>,
48}
49
50#[derive(Debug, Clone, Deserialize)]
51struct BuddyUsage {
52    #[serde(rename = "cachedMissTokens")]
53    cached_miss_tokens: Option<i64>,
54    #[serde(rename = "cacheMissTokens")]
55    cache_miss_tokens: Option<i64>,
56    #[serde(rename = "input_tokens")]
57    input_tokens: Option<i64>,
58    #[serde(rename = "inputTokens")]
59    input_tokens_camel: Option<i64>,
60    prompt_tokens: Option<i64>,
61    #[serde(rename = "output_tokens")]
62    output_tokens: Option<i64>,
63    #[serde(rename = "outputTokens")]
64    output_tokens_camel: Option<i64>,
65    completion_tokens: Option<i64>,
66    #[serde(rename = "cache_read_input_tokens")]
67    cache_read_input_tokens: Option<i64>,
68    #[serde(rename = "cacheReadInputTokens")]
69    cache_read_input_tokens_camel: Option<i64>,
70    #[serde(rename = "cacheTokens")]
71    cache_tokens: Option<i64>,
72    prompt_cache_hit_tokens: Option<i64>,
73    cached_tokens: Option<i64>,
74    #[serde(rename = "cache_creation_input_tokens")]
75    cache_creation_input_tokens: Option<i64>,
76    #[serde(rename = "cacheCreationInputTokens")]
77    cache_creation_input_tokens_camel: Option<i64>,
78    #[serde(rename = "cachedWriteTokens")]
79    cached_write_tokens: Option<i64>,
80    prompt_cache_write_tokens: Option<i64>,
81    #[serde(rename = "completion_thinking_tokens")]
82    completion_thinking_tokens: Option<i64>,
83    #[serde(rename = "completionThinkingTokens")]
84    completion_thinking_tokens_camel: Option<i64>,
85    #[serde(rename = "reasoningTokens")]
86    reasoning_tokens: Option<i64>,
87}
88
89impl BuddyUsage {
90    fn to_breakdown(&self) -> Option<TokenBreakdown> {
91        let tokens = TokenBreakdown {
92            input: first_present(&[
93                self.cached_miss_tokens,
94                self.cache_miss_tokens,
95                self.input_tokens,
96                self.input_tokens_camel,
97                self.prompt_tokens,
98            ]),
99            output: first_present(&[
100                self.output_tokens,
101                self.output_tokens_camel,
102                self.completion_tokens,
103            ]),
104            cache_read: first_positive(&[
105                self.cache_read_input_tokens,
106                self.cache_read_input_tokens_camel,
107                self.cache_tokens,
108                self.prompt_cache_hit_tokens,
109                self.cached_tokens,
110            ]),
111            cache_write: first_positive(&[
112                self.cache_creation_input_tokens,
113                self.cache_creation_input_tokens_camel,
114                self.cached_write_tokens,
115                self.prompt_cache_write_tokens,
116            ]),
117            reasoning: first_present(&[
118                self.completion_thinking_tokens,
119                self.completion_thinking_tokens_camel,
120                self.reasoning_tokens,
121            ]),
122        };
123
124        (tokens.total() > 0).then_some(tokens)
125    }
126}
127
128fn first_present(values: &[Option<i64>]) -> i64 {
129    values.iter().copied().flatten().next().unwrap_or(0).max(0)
130}
131
132fn first_positive(values: &[Option<i64>]) -> i64 {
133    values
134        .iter()
135        .copied()
136        .flatten()
137        .find(|count| *count > 0)
138        .or_else(|| values.iter().copied().flatten().next())
139        .unwrap_or(0)
140        .max(0)
141}
142
143pub(crate) fn parse_jsonl_file(
144    client: &'static str,
145    default_model: &'static str,
146    path: &Path,
147) -> Vec<UnifiedMessage> {
148    let file = match std::fs::File::open(path) {
149        Ok(file) => file,
150        Err(_) => return Vec::new(),
151    };
152
153    let fallback_session_id = path
154        .file_stem()
155        .and_then(|name| name.to_str())
156        .unwrap_or("unknown")
157        .to_string();
158    let fallback_timestamp = super::utils::file_modified_timestamp_ms(path);
159    let mut keyed_indices: HashMap<String, usize> = HashMap::new();
160    let mut messages: Vec<UnifiedMessage> = Vec::new();
161
162    for line in BufReader::new(file).lines() {
163        let Ok(line) = line else {
164            continue;
165        };
166        let trimmed = line.trim();
167        if trimmed.is_empty() {
168            continue;
169        }
170
171        let mut bytes = trimmed.as_bytes().to_vec();
172        let item = match simd_json::from_slice::<BuddyLine>(&mut bytes) {
173            Ok(item) => item,
174            Err(_) => continue,
175        };
176
177        let is_assistant_message = item.line_type.as_deref() == Some("message")
178            && item.role.as_deref() == Some("assistant");
179        let is_function_call = item.line_type.as_deref() == Some("function_call");
180        if !is_assistant_message && !is_function_call {
181            continue;
182        }
183
184        if item
185            .status
186            .as_deref()
187            .is_some_and(|status| status != "completed")
188        {
189            continue;
190        }
191
192        let usage = item
193            .message
194            .as_ref()
195            .and_then(|message| message.usage.as_ref())
196            .or_else(|| {
197                item.provider_data
198                    .as_ref()
199                    .and_then(|provider| provider.usage.as_ref())
200            })
201            .or_else(|| {
202                item.provider_data
203                    .as_ref()
204                    .and_then(|provider| provider.raw_usage.as_ref())
205            });
206        let Some(tokens) = usage.and_then(BuddyUsage::to_breakdown) else {
207            continue;
208        };
209
210        let provider_data = item.provider_data.as_ref();
211        let model_id = provider_data
212            .and_then(|provider| provider.model.as_deref())
213            .or_else(|| provider_data.and_then(|provider| provider.request_model_id.as_deref()))
214            .or_else(|| {
215                item.message
216                    .as_ref()
217                    .and_then(|message| message.model.as_deref())
218            })
219            .filter(|model| !model.trim().is_empty())
220            .unwrap_or(default_model)
221            .to_string();
222        let provider_id = provider_identity::inferred_provider_from_model(&model_id)
223            .unwrap_or(DEFAULT_PROVIDER)
224            .to_string();
225        let session_id = item
226            .session_id
227            .unwrap_or_else(|| fallback_session_id.clone());
228        let timestamp = item.timestamp.unwrap_or(fallback_timestamp);
229
230        let mut message = UnifiedMessage::new(
231            client,
232            model_id,
233            provider_id,
234            session_id.clone(),
235            timestamp,
236            tokens,
237            0.0,
238        );
239
240        if let Some(workspace_key) = item.cwd.as_deref().and_then(normalize_workspace_key) {
241            let workspace_label = workspace_label_from_key(&workspace_key);
242            message.set_workspace(Some(workspace_key), workspace_label);
243        }
244
245        let dedup_key = provider_data
246            .and_then(|provider| provider.message_id.as_deref())
247            .or_else(|| provider_data.and_then(|provider| provider.trace_id.as_deref()))
248            .or(item.id.as_deref())
249            .map(|key| format!("{client}:{session_id}:{key}"));
250        message.dedup_key = dedup_key.clone();
251
252        if let Some(key) = dedup_key {
253            if let Some(existing_index) = keyed_indices.get(&key).copied() {
254                if message.tokens.total() >= messages[existing_index].tokens.total() {
255                    messages[existing_index] = message;
256                }
257                continue;
258            }
259            keyed_indices.insert(key, messages.len());
260        }
261
262        messages.push(message);
263    }
264
265    messages
266}
267
268pub(crate) fn parse_extension_log_file(
269    client: &'static str,
270    default_model: &'static str,
271    path: &Path,
272) -> Vec<UnifiedMessage> {
273    let file = match std::fs::File::open(path) {
274        Ok(file) => file,
275        Err(_) => return Vec::new(),
276    };
277
278    let fallback_timestamp = super::utils::file_modified_timestamp_ms(path);
279    let fallback_session_id = path
280        .file_stem()
281        .and_then(|name| name.to_str())
282        .unwrap_or("extension-log")
283        .to_string();
284    let mut models_by_agent: HashMap<String, String> = HashMap::new();
285    let mut messages = Vec::new();
286
287    for line in BufReader::new(file).lines() {
288        let Ok(line) = line else {
289            continue;
290        };
291
292        if line.contains("[CraftInvokableAgent]") && line.contains("Model prepared:") {
293            if let Some((agent_id, model_id)) = parse_model_prepared_line(&line) {
294                models_by_agent.insert(agent_id, model_id);
295            }
296            continue;
297        }
298
299        if !line.contains("[AgentReporter]")
300            || !line.contains("Agent execution successful with usage:")
301        {
302            continue;
303        }
304
305        let Some(agent_id) = bracket_value_after(&line, "[AgentReporter]") else {
306            continue;
307        };
308        let Some(usage_json) = line.split("Agent execution successful with usage:").nth(1) else {
309            continue;
310        };
311        let usage_json = usage_json.trim();
312        let Some(json_end) = usage_json.rfind('}') else {
313            continue;
314        };
315        let mut bytes = usage_json.as_bytes()[..=json_end].to_vec();
316        let usage = match simd_json::from_slice::<BuddyUsage>(&mut bytes) {
317            Ok(usage) => usage,
318            Err(_) => continue,
319        };
320        let Some(tokens) = usage.to_breakdown() else {
321            continue;
322        };
323
324        let timestamp = parse_log_timestamp_ms(&line).unwrap_or(fallback_timestamp);
325        let model_id = models_by_agent
326            .get(&agent_id)
327            .cloned()
328            .unwrap_or_else(|| default_model.to_string());
329        let provider_id = provider_identity::inferred_provider_from_model(&model_id)
330            .unwrap_or(DEFAULT_PROVIDER)
331            .to_string();
332        let mut message = UnifiedMessage::new(
333            client,
334            model_id,
335            provider_id,
336            agent_id.clone(),
337            timestamp,
338            tokens,
339            0.0,
340        );
341        // Key on the SECOND, not the millisecond. The same [AgentReporter] line
342        // is written to multiple sinks (the extension's own log AND the host's
343        // output-channel log), each prefixed by its own logger a few ms apart, so
344        // a millisecond key double-counts every mirrored execution. Distinct
345        // executions of the same agent with identical usage are seconds-to-minutes
346        // apart, so second granularity still keeps them separate (the timestamp
347        // was added to this key precisely to protect those).
348        let dedup_second = timestamp.div_euclid(1000);
349        message.dedup_key = Some(format!(
350            "{client}:extension-log:{agent_id}:{dedup_second}:{}:{}:{}:{}:{}",
351            message.tokens.input,
352            message.tokens.output,
353            message.tokens.cache_read,
354            message.tokens.cache_write,
355            message.tokens.reasoning
356        ));
357
358        if let Some(workspace_key) = workspace_from_log_path(path) {
359            let workspace_label = workspace_label_from_key(&workspace_key);
360            message.set_workspace(Some(workspace_key), workspace_label);
361        }
362        if message.session_id.is_empty() {
363            message.session_id = fallback_session_id.clone();
364        }
365
366        messages.push(message);
367    }
368
369    messages
370}
371
372pub(crate) fn is_jsonl_source(path: &Path) -> bool {
373    path.extension()
374        .and_then(|ext| ext.to_str())
375        .is_some_and(|ext| ext.eq_ignore_ascii_case("jsonl"))
376}
377
378pub(crate) fn is_extension_log_source(path: &Path) -> bool {
379    path.extension()
380        .and_then(|ext| ext.to_str())
381        .is_some_and(|ext| ext.eq_ignore_ascii_case("log"))
382}
383
384fn parse_model_prepared_line(line: &str) -> Option<(String, String)> {
385    let agent_id = bracket_value_after(line, "[CraftInvokableAgent]")?;
386    let marker = "Model prepared:";
387    let after_marker = line.split(marker).nth(1)?.trim();
388    let model_id = after_marker
389        .rsplit_once('(')
390        .and_then(|(_, tail)| tail.split_once(')').map(|(model, _)| model.trim()))
391        .filter(|model| !model.is_empty())
392        .unwrap_or(after_marker)
393        .to_string();
394    Some((agent_id, model_id))
395}
396
397fn bracket_value_after(line: &str, marker: &str) -> Option<String> {
398    let after_marker = line.split(marker).nth(1)?;
399    let start = after_marker.find('[')?;
400    let after_open = &after_marker[start + 1..];
401    let end = after_open.find(']')?;
402    let value = after_open[..end].trim();
403    (!value.is_empty()).then(|| value.to_string())
404}
405
406fn parse_log_timestamp_ms(line: &str) -> Option<i64> {
407    let raw = if let Some(rest) = line.strip_prefix('[') {
408        rest.split_once(']')?.0.trim()
409    } else {
410        line.split_once(" [")
411            .map(|(timestamp, _)| timestamp.trim())
412            .unwrap_or(line.trim())
413    };
414
415    let (date, time) = raw.split_once(' ')?;
416    let separator = if date.contains('/') { '/' } else { '-' };
417    let parts = date
418        .split(separator)
419        .filter_map(|part| part.parse::<u32>().ok())
420        .collect::<Vec<_>>();
421    if parts.len() != 3 {
422        return super::utils::parse_timestamp_str(raw);
423    }
424
425    let normalized = format!("{:04}-{:02}-{:02} {}", parts[0], parts[1], parts[2], time);
426    parse_local_naive_timestamp_ms(&normalized)
427        .or_else(|| super::utils::parse_timestamp_str(&normalized))
428}
429
430fn parse_local_naive_timestamp_ms(value: &str) -> Option<i64> {
431    for format in ["%Y-%m-%d %H:%M:%S%.f", "%Y-%m-%d %H:%M:%S"] {
432        if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(value, format) {
433            return match chrono::Local.from_local_datetime(&naive) {
434                chrono::LocalResult::Single(dt) => Some(dt.timestamp_millis()),
435                chrono::LocalResult::Ambiguous(earlier, _) => Some(earlier.timestamp_millis()),
436                chrono::LocalResult::None => None,
437            };
438        }
439    }
440
441    None
442}
443
444fn workspace_from_log_path(path: &Path) -> Option<String> {
445    let stem = path.file_stem()?.to_str()?;
446    let workspace = stem.split("__").next().unwrap_or(stem);
447    normalize_workspace_key(workspace)
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453
454    #[test]
455    fn parse_jsonl_file_reads_assistant_usage() {
456        let dir = tempfile::tempdir().unwrap();
457        let project_dir = dir.path().join("projects").join("c-Users-alice-repo");
458        std::fs::create_dir_all(&project_dir).unwrap();
459        let path = project_dir.join("session-1.jsonl");
460        std::fs::write(
461            &path,
462            r#"{"id":"user-1","timestamp":1780000000000,"type":"message","role":"user","sessionId":"session-1","cwd":"/Users/alice/repo"}
463{"id":"assistant-1","timestamp":1780000000100,"type":"message","role":"assistant","status":"completed","sessionId":"session-1","cwd":"/Users/alice/repo","providerData":{"model":"glm-5.2","messageId":"msg-1"},"message":{"usage":{"input_tokens":24486,"output_tokens":3,"total_tokens":24489,"cache_read_input_tokens":14720}}}"#,
464        )
465        .unwrap();
466
467        let messages = parse_jsonl_file("codebuddy", "codebuddy", &path);
468
469        assert_eq!(messages.len(), 1);
470        let message = &messages[0];
471        assert_eq!(message.client, "codebuddy");
472        assert_eq!(message.model_id, "glm-5.2");
473        // `inferred_provider_from_model` recognizes "glm" and infers "zai"
474        // (Zhipu AI), taking precedence over the DEFAULT_PROVIDER ("tencent")
475        // fallback — consistent with how every other model family (claude,
476        // gpt, etc.) is attributed to its real provider rather than the
477        // client name. DEFAULT_PROVIDER only applies when inference can't
478        // identify the model at all.
479        assert_eq!(message.provider_id, "zai");
480        assert_eq!(message.session_id, "session-1");
481        assert_eq!(message.tokens.input, 24486);
482        assert_eq!(message.tokens.output, 3);
483        assert_eq!(message.tokens.cache_read, 14720);
484        assert_eq!(message.workspace_label.as_deref(), Some("repo"));
485        assert_eq!(
486            message.dedup_key.as_deref(),
487            Some("codebuddy:session-1:msg-1")
488        );
489    }
490
491    #[test]
492    fn parse_jsonl_file_reads_function_call_usage() {
493        let dir = tempfile::tempdir().unwrap();
494        let path = dir.path().join("session-2.jsonl");
495        std::fs::write(
496            &path,
497            r#"{"id":"call-1","timestamp":1780000000100,"type":"function_call","sessionId":"session-2","providerData":{"requestModelId":"glm-5.2","messageId":"msg-2","rawUsage":{"prompt_tokens":10,"completion_tokens":2,"prompt_cache_hit_tokens":3,"prompt_cache_write_tokens":4,"completion_thinking_tokens":5}}}"#,
498        )
499        .unwrap();
500
501        let messages = parse_jsonl_file("workbuddy", "workbuddy", &path);
502
503        assert_eq!(messages.len(), 1);
504        assert_eq!(messages[0].client, "workbuddy");
505        assert_eq!(messages[0].tokens.input, 10);
506        assert_eq!(messages[0].tokens.output, 2);
507        assert_eq!(messages[0].tokens.cache_read, 3);
508        assert_eq!(messages[0].tokens.cache_write, 4);
509        assert_eq!(messages[0].tokens.reasoning, 5);
510    }
511
512    #[test]
513    fn parse_extension_log_file_splits_cached_miss_and_cache_tokens() {
514        let dir = tempfile::tempdir().unwrap();
515        let path = dir.path().join("moza-configurator__session.log");
516        std::fs::write(
517            &path,
518            r#"[2026/7/1 16:56:01.100] [info] [CraftInvokableAgent] [agent-1] Model prepared: Kimi-K2.7-Code (kimi-k2.7)
519[2026/7/1 16:56:02.200] [info] [AgentReporter] [agent-1] Agent execution successful with usage: {"inputTokens":140732,"outputTokens":635,"totalTokens":141367,"cacheTokens":76032,"cachedWriteTokens":0,"cachedMissTokens":64700}"#,
520        )
521        .unwrap();
522
523        let messages = parse_extension_log_file("codebuddy", "codebuddy", &path);
524
525        assert_eq!(messages.len(), 1);
526        assert_eq!(messages[0].model_id, "kimi-k2.7");
527        assert_eq!(messages[0].tokens.input, 64700);
528        assert_eq!(messages[0].tokens.output, 635);
529        assert_eq!(messages[0].tokens.cache_read, 76032);
530        assert_eq!(messages[0].tokens.total(), 141367);
531        assert_eq!(
532            messages[0].workspace_label.as_deref(),
533            Some("moza-configurator")
534        );
535    }
536
537    #[test]
538    fn parse_extension_log_file_keeps_repeated_agent_usage_at_different_times() {
539        let dir = tempfile::tempdir().unwrap();
540        let path = dir.path().join("session.log");
541        std::fs::write(
542            &path,
543            r#"[2026/7/1 16:56:01.100] [info] [CraftInvokableAgent] [agent-1] Model prepared: GLM-5.2 (glm-5.2)
544[2026/7/1 16:56:02.200] [info] [AgentReporter] [agent-1] Agent execution successful with usage: {"inputTokens":10,"outputTokens":2,"totalTokens":12}
545[2026/7/1 16:57:02.200] [info] [AgentReporter] [agent-1] Agent execution successful with usage: {"inputTokens":10,"outputTokens":2,"totalTokens":12}"#,
546        )
547        .unwrap();
548
549        let messages = parse_extension_log_file("codebuddy", "codebuddy", &path);
550
551        assert_eq!(messages.len(), 2);
552        assert_ne!(messages[0].dedup_key, messages[1].dedup_key);
553    }
554
555    #[test]
556    fn parse_extension_log_file_mirrored_sinks_share_dedup_key_despite_ms_skew() {
557        // The same agent execution is logged to the extension's own sink and to
558        // the host's output-channel sink; each writer stamps its own prefix a few
559        // ms apart (and in a different format). Both copies must produce the same
560        // dedup key so cross-file dedup collapses them.
561        let dir = tempfile::tempdir().unwrap();
562
563        let extension_sink = dir.path().join("proj__session.log");
564        std::fs::write(
565            &extension_sink,
566            r#"[2026/7/1 16:56:02.200] [info] [AgentReporter] [agent-1] Agent execution successful with usage: {"inputTokens":140732,"outputTokens":635,"totalTokens":141367}"#,
567        )
568        .unwrap();
569
570        let host_sink = dir.path().join("proj__host.log");
571        std::fs::write(
572            &host_sink,
573            r#"2026-07-01 16:56:02.201 [info] [AgentReporter] [agent-1] Agent execution successful with usage: {"inputTokens":140732,"outputTokens":635,"totalTokens":141367}"#,
574        )
575        .unwrap();
576
577        let from_extension = parse_extension_log_file("codebuddy", "codebuddy", &extension_sink);
578        let from_host = parse_extension_log_file("codebuddy", "codebuddy", &host_sink);
579
580        assert_eq!(from_extension.len(), 1);
581        assert_eq!(from_host.len(), 1);
582        assert!(from_extension[0].dedup_key.is_some());
583        assert_eq!(from_extension[0].dedup_key, from_host[0].dedup_key);
584    }
585}