|
11 | 11 | _try_claude_ai_json, |
12 | 12 | _try_claude_code_jsonl, |
13 | 13 | _try_codex_jsonl, |
| 14 | + _try_gemini_jsonl, |
14 | 15 | _try_normalize_json, |
15 | 16 | _try_slack_json, |
16 | 17 | normalize, |
@@ -450,6 +451,168 @@ def test_codex_jsonl_payload_not_dict(): |
450 | 451 | assert result is not None |
451 | 452 |
|
452 | 453 |
|
| 454 | +# ── _try_gemini_jsonl ────────────────────────────────────────────────── |
| 455 | +# |
| 456 | +# Gemini CLI sessions live at ``~/.gemini/tmp/<project_hash>/chats/`` as |
| 457 | +# JSONL. The schema (per google-gemini/gemini-cli#15292): |
| 458 | +# |
| 459 | +# {"type":"session_metadata","sessionId":"...","projectHash":"...",...} |
| 460 | +# {"type":"user","id":"msg1","content":[{"text":"Hello"}]} |
| 461 | +# {"type":"gemini","id":"msg2","content":[{"text":"Hi"}]} |
| 462 | +# {"type":"message_update","id":"msg2","tokens":{"input":10,"output":5}} |
| 463 | +# |
| 464 | +# Detection requires a ``session_metadata`` record so this parser does |
| 465 | +# not false-positive against Claude Code or Codex JSONL. ``message_update`` |
| 466 | +# entries (token-count deltas only) are skipped — they carry no message |
| 467 | +# text. ``content`` is an array of ``{"text": "..."}`` blocks; we join |
| 468 | +# all text blocks for a given message. |
| 469 | + |
| 470 | + |
| 471 | +def test_gemini_jsonl_valid(): |
| 472 | + lines = [ |
| 473 | + json.dumps({"type": "session_metadata", "sessionId": "abc", "projectHash": "h"}), |
| 474 | + json.dumps({"type": "user", "id": "m1", "content": [{"text": "Hello"}]}), |
| 475 | + json.dumps({"type": "gemini", "id": "m2", "content": [{"text": "Hi there"}]}), |
| 476 | + ] |
| 477 | + result = _try_gemini_jsonl("\n".join(lines)) |
| 478 | + assert result is not None |
| 479 | + assert "> Hello" in result |
| 480 | + assert "Hi there" in result |
| 481 | + |
| 482 | + |
| 483 | +def test_gemini_jsonl_multi_turn(): |
| 484 | + lines = [ |
| 485 | + json.dumps({"type": "session_metadata", "sessionId": "s"}), |
| 486 | + json.dumps({"type": "user", "content": [{"text": "Q1"}]}), |
| 487 | + json.dumps({"type": "gemini", "content": [{"text": "A1"}]}), |
| 488 | + json.dumps({"type": "user", "content": [{"text": "Q2"}]}), |
| 489 | + json.dumps({"type": "gemini", "content": [{"text": "A2"}]}), |
| 490 | + ] |
| 491 | + result = _try_gemini_jsonl("\n".join(lines)) |
| 492 | + assert result is not None |
| 493 | + assert "> Q1" in result |
| 494 | + assert "A1" in result |
| 495 | + assert "> Q2" in result |
| 496 | + assert "A2" in result |
| 497 | + |
| 498 | + |
| 499 | +def test_gemini_jsonl_no_session_metadata(): |
| 500 | + """Without session_metadata, parser returns None — guards against false |
| 501 | + positives on Claude Code / Codex JSONL passed through the dispatch chain.""" |
| 502 | + lines = [ |
| 503 | + json.dumps({"type": "user", "content": [{"text": "Hi"}]}), |
| 504 | + json.dumps({"type": "gemini", "content": [{"text": "Hello"}]}), |
| 505 | + ] |
| 506 | + result = _try_gemini_jsonl("\n".join(lines)) |
| 507 | + assert result is None |
| 508 | + |
| 509 | + |
| 510 | +def test_gemini_jsonl_skips_message_update(): |
| 511 | + """message_update records carry only token counts — must be ignored, |
| 512 | + not turned into empty drawers or duplicated assistant turns.""" |
| 513 | + lines = [ |
| 514 | + json.dumps({"type": "session_metadata"}), |
| 515 | + json.dumps({"type": "user", "content": [{"text": "Q"}]}), |
| 516 | + json.dumps({"type": "gemini", "content": [{"text": "A"}]}), |
| 517 | + json.dumps({"type": "message_update", "id": "m2", "tokens": {"input": 10, "output": 5}}), |
| 518 | + ] |
| 519 | + result = _try_gemini_jsonl("\n".join(lines)) |
| 520 | + assert result is not None |
| 521 | + assert "tokens" not in result |
| 522 | + assert "input" not in result |
| 523 | + |
| 524 | + |
| 525 | +def test_gemini_jsonl_too_few_messages(): |
| 526 | + """Mirror codex/claude_code behavior: < 2 conversational messages = None.""" |
| 527 | + lines = [ |
| 528 | + json.dumps({"type": "session_metadata"}), |
| 529 | + json.dumps({"type": "user", "content": [{"text": "only one msg"}]}), |
| 530 | + ] |
| 531 | + result = _try_gemini_jsonl("\n".join(lines)) |
| 532 | + assert result is None |
| 533 | + |
| 534 | + |
| 535 | +def test_gemini_jsonl_multi_block_content(): |
| 536 | + """A single message can have multiple text blocks in its content array |
| 537 | + (e.g. a thinking block + a final answer). Both should be concatenated |
| 538 | + into one transcript turn, in order.""" |
| 539 | + lines = [ |
| 540 | + json.dumps({"type": "session_metadata"}), |
| 541 | + json.dumps({"type": "user", "content": [{"text": "Q"}]}), |
| 542 | + json.dumps( |
| 543 | + { |
| 544 | + "type": "gemini", |
| 545 | + "content": [{"text": "First part."}, {"text": "Second part."}], |
| 546 | + } |
| 547 | + ), |
| 548 | + ] |
| 549 | + result = _try_gemini_jsonl("\n".join(lines)) |
| 550 | + assert result is not None |
| 551 | + assert "First part." in result |
| 552 | + assert "Second part." in result |
| 553 | + |
| 554 | + |
| 555 | +def test_gemini_jsonl_empty_content_skipped(): |
| 556 | + """A message whose content array yields no text should be skipped, not |
| 557 | + emit an empty turn that would corrupt the transcript.""" |
| 558 | + lines = [ |
| 559 | + json.dumps({"type": "session_metadata"}), |
| 560 | + json.dumps({"type": "user", "content": []}), |
| 561 | + json.dumps({"type": "user", "content": [{"text": "real Q"}]}), |
| 562 | + json.dumps({"type": "gemini", "content": [{"text": "real A"}]}), |
| 563 | + ] |
| 564 | + result = _try_gemini_jsonl("\n".join(lines)) |
| 565 | + assert result is not None |
| 566 | + assert "> real Q" in result |
| 567 | + assert "real A" in result |
| 568 | + |
| 569 | + |
| 570 | +def test_gemini_jsonl_invalid_json_lines_skipped(): |
| 571 | + """A malformed line in the middle of the stream must not abort parsing — |
| 572 | + the rest of the session should still produce a transcript.""" |
| 573 | + lines = [ |
| 574 | + json.dumps({"type": "session_metadata"}), |
| 575 | + "not-valid-json{", |
| 576 | + json.dumps({"type": "user", "content": [{"text": "Q"}]}), |
| 577 | + json.dumps({"type": "gemini", "content": [{"text": "A"}]}), |
| 578 | + ] |
| 579 | + result = _try_gemini_jsonl("\n".join(lines)) |
| 580 | + assert result is not None |
| 581 | + assert "> Q" in result |
| 582 | + |
| 583 | + |
| 584 | +def test_gemini_jsonl_does_not_match_codex(): |
| 585 | + """Codex JSONL passed in must NOT be parsed by the gemini adapter — the |
| 586 | + dispatch chain in _try_normalize_json relies on each adapter returning |
| 587 | + None when it doesn't recognize a format.""" |
| 588 | + lines = [ |
| 589 | + json.dumps({"type": "session_meta", "payload": {}}), |
| 590 | + json.dumps({"type": "event_msg", "payload": {"type": "user_message", "message": "Q"}}), |
| 591 | + json.dumps({"type": "event_msg", "payload": {"type": "agent_message", "message": "A"}}), |
| 592 | + ] |
| 593 | + result = _try_gemini_jsonl("\n".join(lines)) |
| 594 | + assert result is None |
| 595 | + |
| 596 | + |
| 597 | +def test_gemini_jsonl_messages_before_session_metadata_discarded(): |
| 598 | + """user/gemini turns that appear before the session_metadata sentinel must |
| 599 | + be silently discarded, not counted as conversational messages. Only turns |
| 600 | + after the sentinel contribute to the transcript.""" |
| 601 | + lines = [ |
| 602 | + json.dumps({"type": "user", "content": [{"text": "preamble Q"}]}), |
| 603 | + json.dumps({"type": "gemini", "content": [{"text": "preamble A"}]}), |
| 604 | + json.dumps({"type": "session_metadata", "sessionId": "s"}), |
| 605 | + json.dumps({"type": "user", "content": [{"text": "real Q"}]}), |
| 606 | + json.dumps({"type": "gemini", "content": [{"text": "real A"}]}), |
| 607 | + ] |
| 608 | + result = _try_gemini_jsonl("\n".join(lines)) |
| 609 | + assert result is not None |
| 610 | + assert "preamble Q" not in result |
| 611 | + assert "preamble A" not in result |
| 612 | + assert "> real Q" in result |
| 613 | + assert "real A" in result |
| 614 | + |
| 615 | + |
453 | 616 | # ── _try_claude_ai_json ─────────────────────────────────────────────── |
454 | 617 |
|
455 | 618 |
|
|
0 commit comments