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}