Skip to main content

vct_core/session/
detector.rs

1//! Content-based provider classification for session JSON and JSONL data.
2//!
3//! Given the parsed records from a session file, this module decides which
4//! supported assistant wrote it ([`ExtensionType`]). Two entry
5//! points exist for two call shapes: [`detect_extension_type`] commits
6//! eagerly on a fully-materialised slice (the `Vec<Value>` fallback path),
7//! while [`classify_records`] returns `None` on indeterminate input so a
8//! streaming caller can keep peeking lines until a marker appears.
9use crate::models::ExtensionType;
10use crate::session::grok::is_grok_signals;
11use anyhow::{Result, bail};
12use serde_json::Value;
13
14#[cfg(test)]
15std::thread_local! {
16    static RECORD_INSPECTIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
17}
18
19#[cfg(test)]
20pub(crate) fn reset_record_inspections() {
21    RECORD_INSPECTIONS.set(0);
22}
23
24#[cfg(test)]
25pub(crate) fn record_inspections() -> usize {
26    RECORD_INSPECTIONS.get()
27}
28
29/// Detects the AI provider format by analyzing distinctive fields in the
30/// session data.
31///
32/// Thin eager wrapper over [`classify_records`]: returns whatever marker the
33/// records carry, falling back to [`ExtensionType::Codex`] when none is
34/// found (a marker-less JSONL stream is almost always a Codex log, whose
35/// `type` discriminators just happen to be absent in this slice).
36///
37/// Detection strategy:
38/// - Grok: a single object with `primaryModelId` + `contextTokensUsed` and
39///   either `contextWindowTokens` or `toolsUsed`
40/// - Gemini: first line is a session-meta record with `sessionId` and
41///   `projectHash` fields but *no* `messages` array (legacy single-object
42///   Gemini exports are no longer supported)
43/// - Copilot: first line is a `type == "session.start"` event whose
44///   `data.producer` field identifies a Copilot agent (e.g.
45///   `copilot-agent`, `copilot-cli`). Legacy single-object dumps under
46///   `~/.copilot/history-session-state/` are no longer supported.
47/// - Claude Code: contains `parentUuid` field in log entries
48/// - Codex: contains a record whose `type` is one of `session_meta`,
49///   `turn_context`, `event_msg`, or `response_item` — **or** as a final
50///   fallback when no other marker is present
51///
52/// Callers walking a streaming source should prefer
53/// [`classify_records`] instead: it returns `None` when the records seen
54/// so far are indeterminate, letting the caller decide whether to read more
55/// before committing to a provider (see `parser::stream_parse_autodetect`).
56///
57/// # Errors
58///
59/// Returns an error when `data` is empty — an empty slice carries no marker
60/// to classify and is treated as a caller bug rather than silently defaulted.
61///
62/// # Examples
63///
64/// ```
65/// use serde_json::json;
66/// use vct_core::session::detector::detect_extension_type;
67/// use vct_core::ExtensionType;
68///
69/// let records = [json!({ "parentUuid": "abc", "type": "user" })];
70/// assert_eq!(detect_extension_type(&records).unwrap(), ExtensionType::ClaudeCode);
71/// ```
72pub fn detect_extension_type(data: &[Value]) -> Result<ExtensionType> {
73    if data.is_empty() {
74        bail!("Cannot detect extension type from empty data");
75    }
76
77    Ok(classify_records(data).unwrap_or(ExtensionType::Codex))
78}
79
80/// Streaming-friendly classifier that only commits to a provider when the
81/// records carry a distinctive marker.
82///
83/// Returns `None` when every record seen so far is indeterminate (a Claude
84/// metadata preamble, an empty record, a record without any recognised
85/// `type` discriminator, …). The streaming auto-detect path uses the
86/// `None` signal to decide whether to peek one more JSONL line before
87/// falling back to the default.
88///
89/// Why this matters: the previous design buffered a fixed `AUTODETECT_PEEK_LINES`
90/// (8) records and then called [`detect_extension_type`], which silently
91/// committed to Codex (the default) once that buffer was exhausted. A Claude
92/// session whose `parentUuid`-bearing record sat past a long metadata
93/// prelude could then be mis-classified as Codex and have its usage
94/// silently dropped. With this function the caller can keep reading until
95/// a positive signal appears, so there is no arbitrary limit to the
96/// preamble length we tolerate.
97///
98/// # Examples
99///
100/// ```
101/// use serde_json::json;
102/// use vct_core::session::detector::classify_records;
103/// use vct_core::ExtensionType;
104///
105/// // A marker-less Claude metadata preamble stays indeterminate...
106/// let preamble = [json!({ "type": "file-history-snapshot" })];
107/// assert!(classify_records(&preamble).is_none());
108///
109/// // ...until a `parentUuid`-bearing record arrives.
110/// let with_marker = [
111///     json!({ "type": "file-history-snapshot" }),
112///     json!({ "parentUuid": "abc", "type": "user" }),
113/// ];
114/// assert_eq!(classify_records(&with_marker), Some(ExtensionType::ClaudeCode));
115/// ```
116pub fn classify_records(data: &[Value]) -> Option<ExtensionType> {
117    let mut classifier = RecordClassifier::default();
118    data.iter().find_map(|record| classifier.push(record))
119}
120
121/// Stateful provider classifier for a record stream.
122///
123/// Each record is inspected exactly once. This keeps single-file auto
124/// detection linear even when a Claude session has a long metadata preamble.
125#[derive(Debug, Default)]
126pub(crate) struct RecordClassifier {
127    seen_first: bool,
128    classified: Option<ExtensionType>,
129}
130
131impl RecordClassifier {
132    /// Adds one record and returns the first confident provider verdict.
133    pub(crate) fn push(&mut self, record: &Value) -> Option<ExtensionType> {
134        if self.classified.is_some() {
135            return self.classified;
136        }
137
138        #[cfg(test)]
139        RECORD_INSPECTIONS.set(RECORD_INSPECTIONS.get() + 1);
140
141        let first = !self.seen_first;
142        self.seen_first = true;
143
144        if first {
145            if is_grok_signals(record) {
146                self.classified = Some(ExtensionType::Grok);
147                return self.classified;
148            }
149
150            if let Some(object) = record.as_object() {
151                if object.contains_key("sessionId")
152                    && object.contains_key("projectHash")
153                    && !object.contains_key("messages")
154                {
155                    self.classified = Some(ExtensionType::Gemini);
156                    return self.classified;
157                }
158
159                if object.get("type").and_then(Value::as_str) == Some("session.start")
160                    && object
161                        .get("data")
162                        .and_then(|data| data.get("producer"))
163                        .and_then(Value::as_str)
164                        .is_some_and(|producer| producer.starts_with("copilot"))
165                {
166                    self.classified = Some(ExtensionType::Copilot);
167                    return self.classified;
168                }
169            }
170        }
171
172        let object = record.as_object()?;
173
174        if object.contains_key("parentUuid") {
175            self.classified = Some(ExtensionType::ClaudeCode);
176        } else if object
177            .get("type")
178            .and_then(Value::as_str)
179            .is_some_and(|record_type| {
180                matches!(
181                    record_type,
182                    "session_meta" | "turn_context" | "event_msg" | "response_item"
183                )
184            })
185        {
186            self.classified = Some(ExtensionType::Codex);
187        }
188
189        self.classified
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use serde_json::{Value, json};
197
198    #[test]
199    fn test_detect_grok_signals() {
200        let data = vec![json!({
201            "primaryModelId": "grok-4.5",
202            "contextTokensUsed": 12_345,
203            "contextWindowTokens": 200_000,
204            "toolsUsed": ["read_file"]
205        })];
206
207        assert_eq!(detect_extension_type(&data).unwrap(), ExtensionType::Grok);
208    }
209
210    #[test]
211    fn test_detect_gemini_jsonl_meta_header() {
212        // Gemini CLI writes one event per line under `chats/`. The first
213        // line is a pure session-meta record carrying `sessionId` +
214        // `projectHash` and *no* `messages` array — the detector must
215        // recognise it even when further event lines follow in the same slice.
216        let data = vec![
217            json!({
218                "sessionId": "0ab84937-9fe7-4284-986a-33c832af0b6a",
219                "projectHash": "9da8b3dfb8655182ac1f0e66601c367e34f8d18447a29759eeba4d7e45dc60ea",
220                "startTime": "2026-04-23T12:52:52.759Z",
221                "lastUpdated": "2026-04-23T12:52:52.759Z",
222                "kind": "main"
223            }),
224            json!({
225                "id": "0cf1a565-3230-4426-bdfc-d4d7af19f867",
226                "timestamp": "2026-04-23T12:53:02.597Z",
227                "type": "info",
228                "content": "Empty GEMINI.md created."
229            }),
230            json!({
231                "id": "8828dd6a-d778-464f-8160-eb2e1604a122",
232                "timestamp": "2026-04-23T12:53:05.283Z",
233                "type": "gemini",
234                "model": "gemini-3-flash-preview",
235                "tokens": {
236                    "input": 13906,
237                    "output": 185,
238                    "cached": 0,
239                    "thoughts": 306,
240                    "tool": 0,
241                    "total": 14397
242                }
243            }),
244        ];
245
246        let result = detect_extension_type(&data).unwrap();
247        assert_eq!(result, ExtensionType::Gemini);
248    }
249
250    #[test]
251    fn test_detect_gemini_rejects_legacy_single_object() {
252        // Legacy Gemini single-object exports used to be detected as Gemini, but
253        // the analyzer no longer supports that shape. We explicitly guard
254        // against mis-classifying a record with an inline `messages` array as
255        // Gemini so it falls through to Codex (and fails clearly) instead of
256        // silently producing an empty analysis.
257        let data = vec![json!({
258            "sessionId": "test-session",
259            "projectHash": "abc123",
260            "messages": []
261        })];
262
263        let result = detect_extension_type(&data).unwrap();
264        assert_ne!(result, ExtensionType::Gemini);
265    }
266
267    #[test]
268    fn test_detect_copilot_rejects_legacy_single_object() {
269        // Older Copilot CLI releases wrote a single-object dump with
270        // `sessionId` + `startTime` + `timeline`. We no longer support that
271        // shape — the detector should fall through to the default (Codex)
272        // rather than mis-routing the file to the JSONL analyzer, which
273        // would silently produce an empty analysis.
274        let data = vec![json!({
275            "sessionId": "test-session",
276            "startTime": 1234567890,
277            "timeline": []
278        })];
279
280        let result = detect_extension_type(&data).unwrap();
281        assert_ne!(result, ExtensionType::Copilot);
282    }
283
284    #[test]
285    fn test_detect_copilot_jsonl_session_start() {
286        // Modern Copilot CLI writes one event per line; the first event is always
287        // `type == "session.start"` with `data.producer == "copilot-agent"`.
288        let data = vec![
289            json!({
290                "type": "session.start",
291                "data": {
292                    "sessionId": "d2e098d0-e0d6-4d6b-914b-c4c5543b17e3",
293                    "version": 1,
294                    "producer": "copilot-agent",
295                    "copilotVersion": "1.0.34",
296                    "startTime": "2026-04-23T12:56:32.850Z",
297                    "context": {
298                        "cwd": "/home/wei/repo/VibeCodingTracker",
299                        "gitRoot": "/home/wei/repo/VibeCodingTracker",
300                        "branch": "main",
301                        "repository": "Mai0313/VibeCodingTracker",
302                        "hostType": "github",
303                        "repositoryHost": "github.com"
304                    }
305                },
306                "id": "eac2d9cb-d62b-4c32-9178-ac8e83d5dfad",
307                "timestamp": "2026-04-23T12:56:32.876Z",
308                "parentId": null
309            }),
310            json!({
311                "type": "session.mode_changed",
312                "data": {"previousMode": "interactive", "newMode": "autopilot"}
313            }),
314        ];
315
316        let result = detect_extension_type(&data).unwrap();
317        assert_eq!(result, ExtensionType::Copilot);
318    }
319
320    #[test]
321    fn test_detect_copilot_jsonl_rejects_non_copilot_producer() {
322        // A `session.start` event without a copilot producer tag should not
323        // trigger the Copilot branch — guards against false positives if
324        // another provider ever adopts the same discriminator name.
325        let data = vec![json!({
326            "type": "session.start",
327            "data": {
328                "sessionId": "abc",
329                "producer": "some-other-tool"
330            }
331        })];
332
333        let result = detect_extension_type(&data).unwrap();
334        assert_ne!(result, ExtensionType::Copilot);
335    }
336
337    #[test]
338    fn test_detect_claude_code_format() {
339        // Test Claude Code format detection with parentUuid field
340        let data = vec![
341            json!({
342                "parentUuid": "parent-uuid",
343                "type": "assistant_message",
344                "content": "test"
345            }),
346            json!({
347                "parentUuid": "parent-uuid-2",
348                "type": "user_message"
349            }),
350        ];
351
352        let result = detect_extension_type(&data).unwrap();
353        assert_eq!(result, ExtensionType::ClaudeCode);
354    }
355
356    #[test]
357    fn test_detect_codex_format_default() {
358        // Test Codex format detection (default when no distinctive markers found)
359        let data = vec![json!({
360            "timestamp": 1234567890,
361            "model": "gpt-4",
362            "usage": {}
363        })];
364
365        let result = detect_extension_type(&data).unwrap();
366        assert_eq!(result, ExtensionType::Codex);
367    }
368
369    #[test]
370    fn test_detect_claude_code_in_first_few_records() {
371        // Test that detection works within first 5 records
372        let mut data = vec![json!({"field": "value1"}), json!({"field": "value2"})];
373
374        // Add Claude marker in third record
375        data.push(json!({
376            "parentUuid": "test-uuid",
377            "content": "test"
378        }));
379
380        let result = detect_extension_type(&data).unwrap();
381        assert_eq!(result, ExtensionType::ClaudeCode);
382    }
383
384    #[test]
385    fn test_detect_claude_code_past_long_preamble() {
386        // Regression guard: Claude Code sessions can carry an arbitrary
387        // number of metadata preamble records (`permission-mode`,
388        // `file-history-snapshot`, `queue-operation`, …) before the first
389        // `parentUuid`-bearing line. The detector has no upper bound — it
390        // scans the entire slice the caller hands it. A previous
391        // implementation capped the scan at 5 records and silently
392        // mis-classified long-preamble sessions as Codex.
393        let mut data: Vec<Value> = (0..50)
394            .map(|i| json!({"type": "file-history-snapshot", "idx": i}))
395            .collect();
396        data.push(json!({
397            "parentUuid": "deep-uuid",
398            "type": "user"
399        }));
400
401        let result = detect_extension_type(&data).unwrap();
402        assert_eq!(result, ExtensionType::ClaudeCode);
403    }
404
405    // ============================================================================
406    // classify_records — the streaming-friendly variant
407    // ============================================================================
408
409    #[test]
410    fn test_classify_returns_none_on_indeterminate_records() {
411        // A Claude metadata preamble with no `parentUuid` yet has no
412        // distinctive marker on any provider — `classify_records` must return
413        // `None` so the streaming auto-detect loop keeps reading more lines
414        // instead of committing to a default too early.
415        let preamble: Vec<Value> = (0..5)
416            .map(|i| json!({"type": "file-history-snapshot", "idx": i}))
417            .collect();
418        assert!(classify_records(&preamble).is_none());
419    }
420
421    #[test]
422    fn test_classify_commits_when_claude_marker_arrives() {
423        // Streaming behaviour: once the caller appends a record containing
424        // `parentUuid`, classification flips from `None` to `Some(ClaudeCode)`.
425        let mut buffer: Vec<Value> = (0..3)
426            .map(|i| json!({"type": "file-history-snapshot", "idx": i}))
427            .collect();
428        assert!(classify_records(&buffer).is_none());
429
430        buffer.push(json!({"parentUuid": "abc-123", "type": "user"}));
431        assert_eq!(classify_records(&buffer), Some(ExtensionType::ClaudeCode));
432    }
433
434    #[test]
435    fn test_classify_commits_on_codex_type_marker() {
436        // Codex rollout logs use a small set of `type` enum values on each
437        // record — any one of them is a positive signal.
438        for codex_type in ["session_meta", "turn_context", "event_msg", "response_item"] {
439            let data = vec![json!({
440                "type": codex_type,
441                "timestamp": "2026-04-23T00:00:00Z",
442                "payload": {}
443            })];
444            assert_eq!(
445                classify_records(&data),
446                Some(ExtensionType::Codex),
447                "type={} should classify as Codex",
448                codex_type
449            );
450        }
451    }
452
453    #[test]
454    fn test_classify_gemini_meta_header_first_line() {
455        // Gemini's first-line meta record is enough to commit without needing
456        // any subsequent event lines.
457        let data = vec![json!({
458            "sessionId": "s",
459            "projectHash": "p",
460            "kind": "main"
461        })];
462        assert_eq!(classify_records(&data), Some(ExtensionType::Gemini));
463    }
464
465    #[test]
466    fn test_classify_copilot_jsonl_first_line() {
467        // Modern Copilot CLI's first line is `type: "session.start"` with a
468        // copilot producer — one line is enough.
469        let data = vec![json!({
470            "type": "session.start",
471            "data": {"sessionId": "s", "producer": "copilot-agent"}
472        })];
473        assert_eq!(classify_records(&data), Some(ExtensionType::Copilot));
474    }
475
476    #[test]
477    fn test_detect_empty_data_error() {
478        // Test that empty data returns an error
479        let data: Vec<Value> = vec![];
480
481        let result = detect_extension_type(&data);
482        assert!(result.is_err());
483        assert!(result.unwrap_err().to_string().contains("empty data"));
484    }
485
486    #[test]
487    fn test_detect_multiple_objects_without_markers() {
488        // Test that multiple objects without distinctive markers default to Codex
489        let data = vec![
490            json!({"timestamp": 123}),
491            json!({"model": "gpt-4"}),
492            json!({"usage": {}}),
493        ];
494
495        let result = detect_extension_type(&data).unwrap();
496        assert_eq!(result, ExtensionType::Codex);
497    }
498
499    #[test]
500    fn test_detect_gemini_with_extra_fields() {
501        // Unknown extra fields on the Gemini JSONL meta-header must not stop
502        // detection — the analyzer relies on `sessionId` + `projectHash` + the
503        // absence of a `messages` array and ignores everything else.
504        let data = vec![json!({
505            "sessionId": "test",
506            "projectHash": "hash",
507            "startTime": "2026-04-23T00:00:00Z",
508            "kind": "main",
509            "extraField": "extra"
510        })];
511
512        let result = detect_extension_type(&data).unwrap();
513        assert_eq!(result, ExtensionType::Gemini);
514    }
515
516    #[test]
517    fn test_detect_copilot_with_extra_fields() {
518        // Unknown extra fields on the Copilot `session.start` event must not
519        // stop detection — the classifier only relies on
520        // `type == "session.start"` + `data.producer` starting with `copilot`.
521        let data = vec![json!({
522            "type": "session.start",
523            "data": {
524                "sessionId": "test",
525                "producer": "copilot-agent",
526                "extraField": "extra"
527            },
528            "id": "abc",
529            "timestamp": "2026-04-23T00:00:00Z",
530            "extraTop": 42
531        })];
532
533        let result = detect_extension_type(&data).unwrap();
534        assert_eq!(result, ExtensionType::Copilot);
535    }
536
537    #[test]
538    fn test_detect_partial_gemini_fields() {
539        // A record missing either `sessionId` or `projectHash` must not be
540        // classified as Gemini even when it looks superficially similar.
541        let without_project_hash = vec![json!({
542            "sessionId": "test"
543        })];
544        let result = detect_extension_type(&without_project_hash).unwrap();
545        assert_eq!(result, ExtensionType::Codex);
546
547        let without_session_id = vec![json!({
548            "projectHash": "hash"
549        })];
550        let result = detect_extension_type(&without_session_id).unwrap();
551        assert_eq!(result, ExtensionType::Codex);
552    }
553
554    #[test]
555    fn test_detect_partial_copilot_fields() {
556        // A `session.start` event without a copilot-flavoured producer must
557        // not be classified as Copilot — guards against false positives when
558        // other providers ever adopt the same discriminator.
559        let data = vec![json!({
560            "type": "session.start",
561            "data": {
562                "sessionId": "test"
563                // no `producer` field at all
564            }
565        })];
566
567        let result = detect_extension_type(&data).unwrap();
568        assert_eq!(result, ExtensionType::Codex); // Should default to Codex
569    }
570}