vct-core 2.3.1

Vibe Coding Tracker core library - parse local AI coding assistant session data into CodeAnalysis results
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
//! Content-based provider classification for session JSON and JSONL data.
//!
//! Given the parsed records from a session file, this module decides which
//! supported assistant wrote it ([`ExtensionType`]). Two entry
//! points exist for two call shapes: [`detect_extension_type`] commits
//! eagerly on a fully-materialised slice (the `Vec<Value>` fallback path),
//! while [`classify_records`] returns `None` on indeterminate input so a
//! streaming caller can keep peeking lines until a marker appears.
use crate::models::ExtensionType;
use crate::session::grok::is_grok_signals;
use anyhow::{Result, bail};
use serde_json::Value;

#[cfg(test)]
std::thread_local! {
    static RECORD_INSPECTIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}

#[cfg(test)]
pub(crate) fn reset_record_inspections() {
    RECORD_INSPECTIONS.set(0);
}

#[cfg(test)]
pub(crate) fn record_inspections() -> usize {
    RECORD_INSPECTIONS.get()
}

/// Detects the AI provider format by analyzing distinctive fields in the
/// session data.
///
/// Thin eager wrapper over [`classify_records`]: returns whatever marker the
/// records carry, falling back to [`ExtensionType::Codex`] when none is
/// found (a marker-less JSONL stream is almost always a Codex log, whose
/// `type` discriminators just happen to be absent in this slice).
///
/// Detection strategy:
/// - Grok: a single object with `primaryModelId` + `contextTokensUsed` and
///   either `contextWindowTokens` or `toolsUsed`
/// - Gemini: first line is a session-meta record with `sessionId` and
///   `projectHash` fields but *no* `messages` array (legacy single-object
///   Gemini exports are no longer supported)
/// - Copilot: first line is a `type == "session.start"` event whose
///   `data.producer` field identifies a Copilot agent (e.g.
///   `copilot-agent`, `copilot-cli`). Legacy single-object dumps under
///   `~/.copilot/history-session-state/` are no longer supported.
/// - Claude Code: contains `parentUuid` field in log entries
/// - Codex: contains a record whose `type` is one of `session_meta`,
///   `turn_context`, `event_msg`, or `response_item` — **or** as a final
///   fallback when no other marker is present
///
/// Callers walking a streaming source should prefer
/// [`classify_records`] instead: it returns `None` when the records seen
/// so far are indeterminate, letting the caller decide whether to read more
/// before committing to a provider (see `parser::stream_parse_autodetect`).
///
/// # Errors
///
/// Returns an error when `data` is empty — an empty slice carries no marker
/// to classify and is treated as a caller bug rather than silently defaulted.
///
/// # Examples
///
/// ```
/// use serde_json::json;
/// use vct_core::session::detector::detect_extension_type;
/// use vct_core::ExtensionType;
///
/// let records = [json!({ "parentUuid": "abc", "type": "user" })];
/// assert_eq!(detect_extension_type(&records).unwrap(), ExtensionType::ClaudeCode);
/// ```
pub fn detect_extension_type(data: &[Value]) -> Result<ExtensionType> {
    if data.is_empty() {
        bail!("Cannot detect extension type from empty data");
    }

    Ok(classify_records(data).unwrap_or(ExtensionType::Codex))
}

/// Streaming-friendly classifier that only commits to a provider when the
/// records carry a distinctive marker.
///
/// Returns `None` when every record seen so far is indeterminate (a Claude
/// metadata preamble, an empty record, a record without any recognised
/// `type` discriminator, …). The streaming auto-detect path uses the
/// `None` signal to decide whether to peek one more JSONL line before
/// falling back to the default.
///
/// Why this matters: the previous design buffered a fixed `AUTODETECT_PEEK_LINES`
/// (8) records and then called [`detect_extension_type`], which silently
/// committed to Codex (the default) once that buffer was exhausted. A Claude
/// session whose `parentUuid`-bearing record sat past a long metadata
/// prelude could then be mis-classified as Codex and have its usage
/// silently dropped. With this function the caller can keep reading until
/// a positive signal appears, so there is no arbitrary limit to the
/// preamble length we tolerate.
///
/// # Examples
///
/// ```
/// use serde_json::json;
/// use vct_core::session::detector::classify_records;
/// use vct_core::ExtensionType;
///
/// // A marker-less Claude metadata preamble stays indeterminate...
/// let preamble = [json!({ "type": "file-history-snapshot" })];
/// assert!(classify_records(&preamble).is_none());
///
/// // ...until a `parentUuid`-bearing record arrives.
/// let with_marker = [
///     json!({ "type": "file-history-snapshot" }),
///     json!({ "parentUuid": "abc", "type": "user" }),
/// ];
/// assert_eq!(classify_records(&with_marker), Some(ExtensionType::ClaudeCode));
/// ```
pub fn classify_records(data: &[Value]) -> Option<ExtensionType> {
    let mut classifier = RecordClassifier::default();
    data.iter().find_map(|record| classifier.push(record))
}

/// Stateful provider classifier for a record stream.
///
/// Each record is inspected exactly once. This keeps single-file auto
/// detection linear even when a Claude session has a long metadata preamble.
#[derive(Debug, Default)]
pub(crate) struct RecordClassifier {
    seen_first: bool,
    classified: Option<ExtensionType>,
}

impl RecordClassifier {
    /// Adds one record and returns the first confident provider verdict.
    pub(crate) fn push(&mut self, record: &Value) -> Option<ExtensionType> {
        if self.classified.is_some() {
            return self.classified;
        }

        #[cfg(test)]
        RECORD_INSPECTIONS.set(RECORD_INSPECTIONS.get() + 1);

        let first = !self.seen_first;
        self.seen_first = true;

        if first {
            if is_grok_signals(record) {
                self.classified = Some(ExtensionType::Grok);
                return self.classified;
            }

            if let Some(object) = record.as_object() {
                if object.contains_key("sessionId")
                    && object.contains_key("projectHash")
                    && !object.contains_key("messages")
                {
                    self.classified = Some(ExtensionType::Gemini);
                    return self.classified;
                }

                if object.get("type").and_then(Value::as_str) == Some("session.start")
                    && object
                        .get("data")
                        .and_then(|data| data.get("producer"))
                        .and_then(Value::as_str)
                        .is_some_and(|producer| producer.starts_with("copilot"))
                {
                    self.classified = Some(ExtensionType::Copilot);
                    return self.classified;
                }
            }
        }

        let object = record.as_object()?;

        if object.contains_key("parentUuid") {
            self.classified = Some(ExtensionType::ClaudeCode);
        } else if object
            .get("type")
            .and_then(Value::as_str)
            .is_some_and(|record_type| {
                matches!(
                    record_type,
                    "session_meta" | "turn_context" | "event_msg" | "response_item"
                )
            })
        {
            self.classified = Some(ExtensionType::Codex);
        }

        self.classified
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::{Value, json};

    #[test]
    fn test_detect_grok_signals() {
        let data = vec![json!({
            "primaryModelId": "grok-4.5",
            "contextTokensUsed": 12_345,
            "contextWindowTokens": 200_000,
            "toolsUsed": ["read_file"]
        })];

        assert_eq!(detect_extension_type(&data).unwrap(), ExtensionType::Grok);
    }

    #[test]
    fn test_detect_gemini_jsonl_meta_header() {
        // Gemini CLI writes one event per line under `chats/`. The first
        // line is a pure session-meta record carrying `sessionId` +
        // `projectHash` and *no* `messages` array — the detector must
        // recognise it even when further event lines follow in the same slice.
        let data = vec![
            json!({
                "sessionId": "0ab84937-9fe7-4284-986a-33c832af0b6a",
                "projectHash": "9da8b3dfb8655182ac1f0e66601c367e34f8d18447a29759eeba4d7e45dc60ea",
                "startTime": "2026-04-23T12:52:52.759Z",
                "lastUpdated": "2026-04-23T12:52:52.759Z",
                "kind": "main"
            }),
            json!({
                "id": "0cf1a565-3230-4426-bdfc-d4d7af19f867",
                "timestamp": "2026-04-23T12:53:02.597Z",
                "type": "info",
                "content": "Empty GEMINI.md created."
            }),
            json!({
                "id": "8828dd6a-d778-464f-8160-eb2e1604a122",
                "timestamp": "2026-04-23T12:53:05.283Z",
                "type": "gemini",
                "model": "gemini-3-flash-preview",
                "tokens": {
                    "input": 13906,
                    "output": 185,
                    "cached": 0,
                    "thoughts": 306,
                    "tool": 0,
                    "total": 14397
                }
            }),
        ];

        let result = detect_extension_type(&data).unwrap();
        assert_eq!(result, ExtensionType::Gemini);
    }

    #[test]
    fn test_detect_gemini_rejects_legacy_single_object() {
        // Legacy Gemini single-object exports used to be detected as Gemini, but
        // the analyzer no longer supports that shape. We explicitly guard
        // against mis-classifying a record with an inline `messages` array as
        // Gemini so it falls through to Codex (and fails clearly) instead of
        // silently producing an empty analysis.
        let data = vec![json!({
            "sessionId": "test-session",
            "projectHash": "abc123",
            "messages": []
        })];

        let result = detect_extension_type(&data).unwrap();
        assert_ne!(result, ExtensionType::Gemini);
    }

    #[test]
    fn test_detect_copilot_rejects_legacy_single_object() {
        // Older Copilot CLI releases wrote a single-object dump with
        // `sessionId` + `startTime` + `timeline`. We no longer support that
        // shape — the detector should fall through to the default (Codex)
        // rather than mis-routing the file to the JSONL analyzer, which
        // would silently produce an empty analysis.
        let data = vec![json!({
            "sessionId": "test-session",
            "startTime": 1234567890,
            "timeline": []
        })];

        let result = detect_extension_type(&data).unwrap();
        assert_ne!(result, ExtensionType::Copilot);
    }

    #[test]
    fn test_detect_copilot_jsonl_session_start() {
        // Modern Copilot CLI writes one event per line; the first event is always
        // `type == "session.start"` with `data.producer == "copilot-agent"`.
        let data = vec![
            json!({
                "type": "session.start",
                "data": {
                    "sessionId": "d2e098d0-e0d6-4d6b-914b-c4c5543b17e3",
                    "version": 1,
                    "producer": "copilot-agent",
                    "copilotVersion": "1.0.34",
                    "startTime": "2026-04-23T12:56:32.850Z",
                    "context": {
                        "cwd": "/home/wei/repo/VibeCodingTracker",
                        "gitRoot": "/home/wei/repo/VibeCodingTracker",
                        "branch": "main",
                        "repository": "Mai0313/VibeCodingTracker",
                        "hostType": "github",
                        "repositoryHost": "github.com"
                    }
                },
                "id": "eac2d9cb-d62b-4c32-9178-ac8e83d5dfad",
                "timestamp": "2026-04-23T12:56:32.876Z",
                "parentId": null
            }),
            json!({
                "type": "session.mode_changed",
                "data": {"previousMode": "interactive", "newMode": "autopilot"}
            }),
        ];

        let result = detect_extension_type(&data).unwrap();
        assert_eq!(result, ExtensionType::Copilot);
    }

    #[test]
    fn test_detect_copilot_jsonl_rejects_non_copilot_producer() {
        // A `session.start` event without a copilot producer tag should not
        // trigger the Copilot branch — guards against false positives if
        // another provider ever adopts the same discriminator name.
        let data = vec![json!({
            "type": "session.start",
            "data": {
                "sessionId": "abc",
                "producer": "some-other-tool"
            }
        })];

        let result = detect_extension_type(&data).unwrap();
        assert_ne!(result, ExtensionType::Copilot);
    }

    #[test]
    fn test_detect_claude_code_format() {
        // Test Claude Code format detection with parentUuid field
        let data = vec![
            json!({
                "parentUuid": "parent-uuid",
                "type": "assistant_message",
                "content": "test"
            }),
            json!({
                "parentUuid": "parent-uuid-2",
                "type": "user_message"
            }),
        ];

        let result = detect_extension_type(&data).unwrap();
        assert_eq!(result, ExtensionType::ClaudeCode);
    }

    #[test]
    fn test_detect_codex_format_default() {
        // Test Codex format detection (default when no distinctive markers found)
        let data = vec![json!({
            "timestamp": 1234567890,
            "model": "gpt-4",
            "usage": {}
        })];

        let result = detect_extension_type(&data).unwrap();
        assert_eq!(result, ExtensionType::Codex);
    }

    #[test]
    fn test_detect_claude_code_in_first_few_records() {
        // Test that detection works within first 5 records
        let mut data = vec![json!({"field": "value1"}), json!({"field": "value2"})];

        // Add Claude marker in third record
        data.push(json!({
            "parentUuid": "test-uuid",
            "content": "test"
        }));

        let result = detect_extension_type(&data).unwrap();
        assert_eq!(result, ExtensionType::ClaudeCode);
    }

    #[test]
    fn test_detect_claude_code_past_long_preamble() {
        // Regression guard: Claude Code sessions can carry an arbitrary
        // number of metadata preamble records (`permission-mode`,
        // `file-history-snapshot`, `queue-operation`, …) before the first
        // `parentUuid`-bearing line. The detector has no upper bound — it
        // scans the entire slice the caller hands it. A previous
        // implementation capped the scan at 5 records and silently
        // mis-classified long-preamble sessions as Codex.
        let mut data: Vec<Value> = (0..50)
            .map(|i| json!({"type": "file-history-snapshot", "idx": i}))
            .collect();
        data.push(json!({
            "parentUuid": "deep-uuid",
            "type": "user"
        }));

        let result = detect_extension_type(&data).unwrap();
        assert_eq!(result, ExtensionType::ClaudeCode);
    }

    // ============================================================================
    // classify_records — the streaming-friendly variant
    // ============================================================================

    #[test]
    fn test_classify_returns_none_on_indeterminate_records() {
        // A Claude metadata preamble with no `parentUuid` yet has no
        // distinctive marker on any provider — `classify_records` must return
        // `None` so the streaming auto-detect loop keeps reading more lines
        // instead of committing to a default too early.
        let preamble: Vec<Value> = (0..5)
            .map(|i| json!({"type": "file-history-snapshot", "idx": i}))
            .collect();
        assert!(classify_records(&preamble).is_none());
    }

    #[test]
    fn test_classify_commits_when_claude_marker_arrives() {
        // Streaming behaviour: once the caller appends a record containing
        // `parentUuid`, classification flips from `None` to `Some(ClaudeCode)`.
        let mut buffer: Vec<Value> = (0..3)
            .map(|i| json!({"type": "file-history-snapshot", "idx": i}))
            .collect();
        assert!(classify_records(&buffer).is_none());

        buffer.push(json!({"parentUuid": "abc-123", "type": "user"}));
        assert_eq!(classify_records(&buffer), Some(ExtensionType::ClaudeCode));
    }

    #[test]
    fn test_classify_commits_on_codex_type_marker() {
        // Codex rollout logs use a small set of `type` enum values on each
        // record — any one of them is a positive signal.
        for codex_type in ["session_meta", "turn_context", "event_msg", "response_item"] {
            let data = vec![json!({
                "type": codex_type,
                "timestamp": "2026-04-23T00:00:00Z",
                "payload": {}
            })];
            assert_eq!(
                classify_records(&data),
                Some(ExtensionType::Codex),
                "type={} should classify as Codex",
                codex_type
            );
        }
    }

    #[test]
    fn test_classify_gemini_meta_header_first_line() {
        // Gemini's first-line meta record is enough to commit without needing
        // any subsequent event lines.
        let data = vec![json!({
            "sessionId": "s",
            "projectHash": "p",
            "kind": "main"
        })];
        assert_eq!(classify_records(&data), Some(ExtensionType::Gemini));
    }

    #[test]
    fn test_classify_copilot_jsonl_first_line() {
        // Modern Copilot CLI's first line is `type: "session.start"` with a
        // copilot producer — one line is enough.
        let data = vec![json!({
            "type": "session.start",
            "data": {"sessionId": "s", "producer": "copilot-agent"}
        })];
        assert_eq!(classify_records(&data), Some(ExtensionType::Copilot));
    }

    #[test]
    fn test_detect_empty_data_error() {
        // Test that empty data returns an error
        let data: Vec<Value> = vec![];

        let result = detect_extension_type(&data);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("empty data"));
    }

    #[test]
    fn test_detect_multiple_objects_without_markers() {
        // Test that multiple objects without distinctive markers default to Codex
        let data = vec![
            json!({"timestamp": 123}),
            json!({"model": "gpt-4"}),
            json!({"usage": {}}),
        ];

        let result = detect_extension_type(&data).unwrap();
        assert_eq!(result, ExtensionType::Codex);
    }

    #[test]
    fn test_detect_gemini_with_extra_fields() {
        // Unknown extra fields on the Gemini JSONL meta-header must not stop
        // detection — the analyzer relies on `sessionId` + `projectHash` + the
        // absence of a `messages` array and ignores everything else.
        let data = vec![json!({
            "sessionId": "test",
            "projectHash": "hash",
            "startTime": "2026-04-23T00:00:00Z",
            "kind": "main",
            "extraField": "extra"
        })];

        let result = detect_extension_type(&data).unwrap();
        assert_eq!(result, ExtensionType::Gemini);
    }

    #[test]
    fn test_detect_copilot_with_extra_fields() {
        // Unknown extra fields on the Copilot `session.start` event must not
        // stop detection — the classifier only relies on
        // `type == "session.start"` + `data.producer` starting with `copilot`.
        let data = vec![json!({
            "type": "session.start",
            "data": {
                "sessionId": "test",
                "producer": "copilot-agent",
                "extraField": "extra"
            },
            "id": "abc",
            "timestamp": "2026-04-23T00:00:00Z",
            "extraTop": 42
        })];

        let result = detect_extension_type(&data).unwrap();
        assert_eq!(result, ExtensionType::Copilot);
    }

    #[test]
    fn test_detect_partial_gemini_fields() {
        // A record missing either `sessionId` or `projectHash` must not be
        // classified as Gemini even when it looks superficially similar.
        let without_project_hash = vec![json!({
            "sessionId": "test"
        })];
        let result = detect_extension_type(&without_project_hash).unwrap();
        assert_eq!(result, ExtensionType::Codex);

        let without_session_id = vec![json!({
            "projectHash": "hash"
        })];
        let result = detect_extension_type(&without_session_id).unwrap();
        assert_eq!(result, ExtensionType::Codex);
    }

    #[test]
    fn test_detect_partial_copilot_fields() {
        // A `session.start` event without a copilot-flavoured producer must
        // not be classified as Copilot — guards against false positives when
        // other providers ever adopt the same discriminator.
        let data = vec![json!({
            "type": "session.start",
            "data": {
                "sessionId": "test"
                // no `producer` field at all
            }
        })];

        let result = detect_extension_type(&data).unwrap();
        assert_eq!(result, ExtensionType::Codex); // Should default to Codex
    }
}