opencode-db 0.4.7__tar.gz → 0.4.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {opencode_db-0.4.7/opencode_db.egg-info → opencode_db-0.4.9}/PKG-INFO +1 -1
  2. {opencode_db-0.4.7 → opencode_db-0.4.9}/cmd_costs.py +10 -8
  3. {opencode_db-0.4.7 → opencode_db-0.4.9}/cmd_delete.py +7 -4
  4. {opencode_db-0.4.7 → opencode_db-0.4.9}/cmd_export.py +22 -10
  5. {opencode_db-0.4.7 → opencode_db-0.4.9}/cmd_info.py +7 -5
  6. {opencode_db-0.4.7 → opencode_db-0.4.9}/cmd_search.py +8 -7
  7. {opencode_db-0.4.7 → opencode_db-0.4.9}/cmd_todos.py +7 -5
  8. {opencode_db-0.4.7 → opencode_db-0.4.9}/cmd_tree.py +7 -5
  9. {opencode_db-0.4.7 → opencode_db-0.4.9}/db.py +16 -10
  10. {opencode_db-0.4.7 → opencode_db-0.4.9/opencode_db.egg-info}/PKG-INFO +1 -1
  11. {opencode_db-0.4.7 → opencode_db-0.4.9}/opencode_db.egg-info/SOURCES.txt +1 -0
  12. {opencode_db-0.4.7 → opencode_db-0.4.9}/pyproject.toml +1 -1
  13. {opencode_db-0.4.7 → opencode_db-0.4.9}/tests/test_commands.py +89 -0
  14. {opencode_db-0.4.7 → opencode_db-0.4.9}/tests/test_db.py +125 -0
  15. opencode_db-0.4.9/tests/test_formatters.py +173 -0
  16. {opencode_db-0.4.7 → opencode_db-0.4.9}/LICENSE +0 -0
  17. {opencode_db-0.4.7 → opencode_db-0.4.9}/README.md +0 -0
  18. {opencode_db-0.4.7 → opencode_db-0.4.9}/README_PYPI.md +0 -0
  19. {opencode_db-0.4.7 → opencode_db-0.4.9}/cli.py +0 -0
  20. {opencode_db-0.4.7 → opencode_db-0.4.9}/cmd_help.py +0 -0
  21. {opencode_db-0.4.7 → opencode_db-0.4.9}/cmd_list.py +0 -0
  22. {opencode_db-0.4.7 → opencode_db-0.4.9}/cmd_projects.py +0 -0
  23. {opencode_db-0.4.7 → opencode_db-0.4.9}/cmd_prune.py +0 -0
  24. {opencode_db-0.4.7 → opencode_db-0.4.9}/cmd_stats.py +0 -0
  25. {opencode_db-0.4.7 → opencode_db-0.4.9}/cmd_vacuum.py +0 -0
  26. {opencode_db-0.4.7 → opencode_db-0.4.9}/commands/__init__.py +0 -0
  27. {opencode_db-0.4.7 → opencode_db-0.4.9}/config.py +0 -0
  28. {opencode_db-0.4.7 → opencode_db-0.4.9}/formatters.py +0 -0
  29. {opencode_db-0.4.7 → opencode_db-0.4.9}/i18n.py +0 -0
  30. {opencode_db-0.4.7 → opencode_db-0.4.9}/opencode_db.egg-info/dependency_links.txt +0 -0
  31. {opencode_db-0.4.7 → opencode_db-0.4.9}/opencode_db.egg-info/entry_points.txt +0 -0
  32. {opencode_db-0.4.7 → opencode_db-0.4.9}/opencode_db.egg-info/top_level.txt +0 -0
  33. {opencode_db-0.4.7 → opencode_db-0.4.9}/setup.cfg +0 -0
  34. {opencode_db-0.4.7 → opencode_db-0.4.9}/tests/test_i18n.py +0 -0
  35. {opencode_db-0.4.7 → opencode_db-0.4.9}/tests/test_utils.py +0 -0
  36. {opencode_db-0.4.7 → opencode_db-0.4.9}/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opencode-db
3
- Version: 0.4.7
3
+ Version: 0.4.9
4
4
  Summary: OpenCode database CLI manager — browse, export, analyze, clean up sessions
5
5
  Project-URL: Homepage, https://github.com/VasilevNStas/opencode-db
6
6
  Project-URL: Source, https://github.com/VasilevNStas/opencode-db
@@ -3,9 +3,7 @@
3
3
  Стоимость берётся напрямую из поля session.cost БД OpenCode.
4
4
  """
5
5
 
6
- from typing import Literal
7
-
8
- from db import get_session_info, get_session_title, parse_model
6
+ from db import SessionError, get_session_info, get_session_title, parse_model
9
7
  from i18n import _
10
8
  from utils import format_cost, format_tokens
11
9
 
@@ -19,7 +17,7 @@ def register(subparsers) -> None:
19
17
  p.add_argument("--limit", type=int, default=30, help="Max rows")
20
18
 
21
19
 
22
- def run(args, db) -> Literal[0]:
20
+ def run(args, db) -> int:
23
21
  if args.session_id:
24
22
  return _show_session(db, args.session_id, args.json)
25
23
 
@@ -29,8 +27,12 @@ def run(args, db) -> Literal[0]:
29
27
  return _show_list(db, args.project, args.limit, args.json)
30
28
 
31
29
 
32
- def _show_session(db, session_id, as_json) -> Literal[0]:
33
- info = get_session_info(db, session_id)
30
+ def _show_session(db, session_id, as_json) -> int:
31
+ try:
32
+ info = get_session_info(db, session_id)
33
+ except SessionError as e:
34
+ print(e.message)
35
+ return 1
34
36
  title = get_session_title(info)
35
37
  model = parse_model(info)
36
38
 
@@ -73,7 +75,7 @@ def _show_session(db, session_id, as_json) -> Literal[0]:
73
75
  return 0
74
76
 
75
77
 
76
- def _show_total(db, project_id, as_json) -> Literal[0]:
78
+ def _show_total(db, project_id, as_json) -> int:
77
79
  where = "1=1"
78
80
  params = []
79
81
 
@@ -126,7 +128,7 @@ def _show_total(db, project_id, as_json) -> Literal[0]:
126
128
  return 0
127
129
 
128
130
 
129
- def _show_list(db, project_id, limit, as_json) -> Literal[0]:
131
+ def _show_list(db, project_id, limit, as_json) -> int:
130
132
  where = "1=1"
131
133
  params = []
132
134
 
@@ -1,8 +1,7 @@
1
1
  """Команда delete: удаление сессии."""
2
2
 
3
- from typing import Literal
4
-
5
3
  from db import (
4
+ SessionError,
6
5
  get_message_count,
7
6
  get_part_count,
8
7
  get_session_info,
@@ -20,8 +19,12 @@ def register(subparsers) -> None:
20
19
  p.add_argument("--force", "-f", action="store_true", help="Skip confirmation")
21
20
 
22
21
 
23
- def run(args, db) -> Literal[0]:
24
- info = get_session_info(db, args.session_id)
22
+ def run(args, db) -> int:
23
+ try:
24
+ info = get_session_info(db, args.session_id)
25
+ except SessionError as e:
26
+ print(e.message)
27
+ return 1
25
28
  session_id = info["id"]
26
29
  title = get_session_title(info)
27
30
  model = parse_model(info)
@@ -7,9 +7,9 @@
7
7
  """
8
8
 
9
9
  import os
10
- from typing import Literal
11
10
 
12
11
  from db import (
12
+ SessionError,
13
13
  get_latest_session,
14
14
  get_messages,
15
15
  get_recent_sessions,
@@ -47,20 +47,28 @@ def register(subparsers) -> None:
47
47
  )
48
48
 
49
49
 
50
- def _export_by_id(db, session_id, output_dir, force, note, full) -> Literal[1] | Literal[0]:
51
- session_info = get_session_info(db, session_id)
50
+ def _export_by_id(db, session_id, output_dir, force, note, full) -> int:
51
+ try:
52
+ session_info = get_session_info(db, session_id)
53
+ except SessionError as e:
54
+ print(e.message)
55
+ return 1
52
56
  return _do_export(db, session_info, output_dir, force, note, full)
53
57
 
54
58
 
55
- def _export_latest(db, output_dir, force, note, full) -> Literal[1] | Literal[0]:
56
- session_id = get_latest_session(db)
57
- session_info = get_session_info(db, session_id)
59
+ def _export_latest(db, output_dir, force, note, full) -> int:
60
+ try:
61
+ session_id = get_latest_session(db)
62
+ session_info = get_session_info(db, session_id)
63
+ except SessionError as e:
64
+ print(e.message)
65
+ return 1
58
66
  title = get_session_title(session_info)
59
67
  print(_("session.latest", title=title))
60
68
  return _do_export(db, session_info, output_dir, force, note, full)
61
69
 
62
70
 
63
- def _export_interactive(db, output_dir, force, note, full) -> Literal[1] | Literal[0]:
71
+ def _export_interactive(db, output_dir, force, note, full) -> int:
64
72
  sessions = get_recent_sessions(db)
65
73
 
66
74
  if not sessions:
@@ -102,11 +110,15 @@ def _export_interactive(db, output_dir, force, note, full) -> Literal[1] | Liter
102
110
  session_id = sessions[idx]["id"]
103
111
  title = get_session_title(sessions[idx])
104
112
  print(_("export.interactive_exporting", title=title))
105
- session_info = get_session_info(db, session_id)
113
+ try:
114
+ session_info = get_session_info(db, session_id)
115
+ except SessionError as e:
116
+ print(e.message)
117
+ return 1
106
118
  return _do_export(db, session_info, output_dir, force, note, full)
107
119
 
108
120
 
109
- def _do_export(db, session_info, output_dir, force, note, full=False) -> Literal[1] | Literal[0]:
121
+ def _do_export(db, session_info, output_dir, force, note, full=False) -> int:
110
122
  """Внутренняя функция: экспорт диалога."""
111
123
  title = get_session_title(session_info)
112
124
  model = parse_model(session_info)
@@ -176,7 +188,7 @@ def _do_export(db, session_info, output_dir, force, note, full=False) -> Literal
176
188
  return 0
177
189
 
178
190
 
179
- def run(args, db) -> Literal[1] | Literal[0]:
191
+ def run(args, db) -> int:
180
192
  output_dir = args.output or os.getcwd()
181
193
  full = args.full
182
194
 
@@ -1,9 +1,7 @@
1
1
  """Команда info: детальная информация о сессии."""
2
2
 
3
- # import db as db_module
4
- from typing import Literal
5
-
6
3
  from db import (
4
+ SessionError,
7
5
  get_children_sessions,
8
6
  get_message_count,
9
7
  get_parent_session,
@@ -24,8 +22,12 @@ def register(subparsers) -> None:
24
22
  p.add_argument("--json", action="store_true", help="JSON output")
25
23
 
26
24
 
27
- def run(args, db) -> Literal[0]:
28
- info = get_session_info(db, args.session_id)
25
+ def run(args, db) -> int:
26
+ try:
27
+ info = get_session_info(db, args.session_id)
28
+ except SessionError as e:
29
+ print(e.message)
30
+ return 1
29
31
  session_id = info["id"]
30
32
  msg_count = get_message_count(db, session_id)
31
33
  part_count = get_part_count(db, session_id)
@@ -1,9 +1,8 @@
1
1
  """Команда search: поиск по тексту сообщений и частей."""
2
2
 
3
3
  import json
4
- from typing import Literal
5
4
 
6
- from db import resolve_session_id
5
+ from db import SessionError, resolve_session_id
7
6
  from i18n import _
8
7
  from utils import format_ts
9
8
 
@@ -16,9 +15,7 @@ def register(subparsers) -> None:
16
15
  p.add_argument("--json", action="store_true", help="JSON output")
17
16
 
18
17
 
19
- def _search_in_part_data(
20
- data, query_lower
21
- ) -> tuple[Literal[False], Literal[""]] | tuple[Literal[True], str]:
18
+ def _search_in_part_data(data, query_lower) -> tuple[bool, str]:
22
19
  """Ищет query в part.data (JSON), проверяя текстовые поля."""
23
20
  if not data:
24
21
  return False, ""
@@ -45,14 +42,18 @@ def _search_in_part_data(
45
42
  return False, ""
46
43
 
47
44
 
48
- def run(args, db) -> Literal[0]:
45
+ def run(args, db) -> int:
49
46
  query_lower = args.query.lower()
50
47
 
51
48
  conditions = []
52
49
  params = []
53
50
 
54
51
  if args.session:
55
- full_id = resolve_session_id(db, args.session)
52
+ try:
53
+ full_id = resolve_session_id(db, args.session)
54
+ except SessionError as e:
55
+ print(e.message)
56
+ return 1
56
57
  conditions.append("p.session_id = ?")
57
58
  params.append(full_id)
58
59
 
@@ -1,8 +1,6 @@
1
1
  """Команда todos: просмотр задач (todo) из сессий."""
2
2
 
3
- from typing import Literal
4
-
5
- from db import resolve_session_id
3
+ from db import SessionError, resolve_session_id
6
4
  from i18n import _
7
5
  from utils import format_ts
8
6
 
@@ -19,12 +17,16 @@ def register(subparsers) -> None:
19
17
  p.add_argument("--json", action="store_true", help="JSON output")
20
18
 
21
19
 
22
- def run(args, db) -> Literal[0]:
20
+ def run(args, db) -> int:
23
21
  conditions = []
24
22
  params = []
25
23
 
26
24
  if args.session_id:
27
- full_id = resolve_session_id(db, args.session_id)
25
+ try:
26
+ full_id = resolve_session_id(db, args.session_id)
27
+ except SessionError as e:
28
+ print(e.message)
29
+ return 1
28
30
  conditions.append("t.session_id = ?")
29
31
  params.append(full_id)
30
32
 
@@ -1,8 +1,6 @@
1
1
  """Команда tree: дерево ветвления сессий."""
2
2
 
3
- from typing import Literal
4
-
5
- from db import get_session_title, resolve_session_id
3
+ from db import SessionError, get_session_title, resolve_session_id
6
4
  from i18n import _
7
5
  from utils import format_ts
8
6
 
@@ -55,9 +53,13 @@ def _build_tree(db, parent_id, depth, max_depth, indent=0, visited=None):
55
53
  return lines
56
54
 
57
55
 
58
- def run(args, db) -> Literal[0]:
56
+ def run(args, db) -> int:
59
57
  if args.session_id:
60
- full_id = resolve_session_id(db, args.session_id)
58
+ try:
59
+ full_id = resolve_session_id(db, args.session_id)
60
+ except SessionError as e:
61
+ print(e.message)
62
+ return 1
61
63
  info = db.execute(
62
64
  "SELECT id, title, time_created FROM session WHERE id = ?", (full_id,)
63
65
  ).fetchone()
@@ -15,6 +15,15 @@ import sys
15
15
  from config import OPENCODE_DB
16
16
  from i18n import _
17
17
 
18
+
19
+ class SessionError(Exception):
20
+ """Ошибка, связанная с сессией: не найдена, неоднозначный ID и т.п."""
21
+
22
+ def __init__(self, message: str):
23
+ self.message = message
24
+ super().__init__(message)
25
+
26
+
18
27
  # Ожидаемая схема БД: таблица → список обязательных колонок
19
28
  _EXPECTED_SCHEMA = {
20
29
  "session": [
@@ -95,15 +104,14 @@ def resolve_session_id(db, session_id):
95
104
  ).fetchall()
96
105
 
97
106
  if not rows:
98
- print(_("session.not_found", session_id=session_id))
99
- sys.exit(1)
107
+ raise SessionError(_("session.not_found", session_id=session_id))
100
108
 
101
109
  if len(rows) > 1:
102
- print(_("session.multiple_match", session_id=session_id))
110
+ msg = [_("session.multiple_match", session_id=session_id)]
103
111
  for r in rows:
104
- print(f" {r['id']}")
105
- print(_("session.specify_id"))
106
- sys.exit(1)
112
+ msg.append(f" {r['id']}")
113
+ msg.append(_("session.specify_id"))
114
+ raise SessionError("\n".join(msg))
107
115
 
108
116
  return rows[0]["id"]
109
117
 
@@ -127,8 +135,7 @@ def get_session_info(db, session_id):
127
135
  ).fetchone()
128
136
 
129
137
  if not row:
130
- print(_("session.not_found", session_id=session_id))
131
- sys.exit(1)
138
+ raise SessionError(_("session.not_found", session_id=session_id))
132
139
 
133
140
  return row
134
141
 
@@ -338,8 +345,7 @@ def get_latest_session(db):
338
345
  """).fetchone()
339
346
 
340
347
  if not row:
341
- print(_("session.no_sessions"))
342
- sys.exit(1)
348
+ raise SessionError(_("session.no_sessions"))
343
349
 
344
350
  return row["id"]
345
351
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opencode-db
3
- Version: 0.4.7
3
+ Version: 0.4.9
4
4
  Summary: OpenCode database CLI manager — browse, export, analyze, clean up sessions
5
5
  Project-URL: Homepage, https://github.com/VasilevNStas/opencode-db
6
6
  Project-URL: Source, https://github.com/VasilevNStas/opencode-db
@@ -29,5 +29,6 @@ opencode_db.egg-info/entry_points.txt
29
29
  opencode_db.egg-info/top_level.txt
30
30
  tests/test_commands.py
31
31
  tests/test_db.py
32
+ tests/test_formatters.py
32
33
  tests/test_i18n.py
33
34
  tests/test_utils.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "opencode-db"
7
- version = "0.4.7"
7
+ version = "0.4.9"
8
8
  description = "OpenCode database CLI manager — browse, export, analyze, clean up sessions"
9
9
  readme = "README_PYPI.md"
10
10
  requires-python = ">=3.12"
@@ -6,6 +6,18 @@ def _ns(**kwargs) -> argparse.Namespace:
6
6
  return argparse.Namespace(**kwargs)
7
7
 
8
8
 
9
+ def _count_sessions(db) -> int:
10
+ return db.execute("SELECT COUNT(*) FROM session").fetchone()[0]
11
+
12
+
13
+ def _count_messages(db) -> int:
14
+ return db.execute("SELECT COUNT(*) FROM message").fetchone()[0]
15
+
16
+
17
+ def _count_parts(db) -> int:
18
+ return db.execute("SELECT COUNT(*) FROM part").fetchone()[0]
19
+
20
+
9
21
  class TestListCommand:
10
22
  def test_list_default(self, db):
11
23
  from cmd_list import run
@@ -418,3 +430,80 @@ class TestExportCommand:
418
430
  output=str(tmp_path),
419
431
  )
420
432
  assert run(args, db) == 0
433
+
434
+
435
+ class TestPruneCommand:
436
+ def test_prune_filter_required(self, db) -> None:
437
+ from cmd_prune import run
438
+
439
+ args = _ns(older_than=None, keep_last=None, project=None, dry_run=False, force=False)
440
+ assert run(args, db) == 1
441
+
442
+ def test_prune_dry_run_older_than(self, db) -> None:
443
+ from cmd_prune import run
444
+
445
+ args = _ns(older_than="30d", keep_last=None, project=None, dry_run=True, force=True)
446
+ assert run(args, db) == 0
447
+
448
+ def test_prune_dry_run_keep_last(self, db) -> None:
449
+ from cmd_prune import run
450
+
451
+ args = _ns(older_than=None, keep_last=2, project=None, dry_run=True, force=True)
452
+ assert run(args, db) == 0
453
+ assert _count_sessions(db) == 4
454
+
455
+ def test_prune_force_older_than(self, db) -> None:
456
+ from cmd_prune import run
457
+
458
+ args = _ns(older_than="1d", keep_last=None, project=None, dry_run=False, force=True)
459
+ result = run(args, db)
460
+ assert result == 0
461
+ remaining = _count_sessions(db)
462
+ assert remaining < 4
463
+
464
+ def test_prune_by_project(self, db) -> None:
465
+ from cmd_prune import run
466
+
467
+ args = _ns(older_than=None, keep_last=None, project="proj_002", dry_run=True, force=True)
468
+ assert run(args, db) == 0
469
+
470
+ def test_prune_bad_spec(self, db) -> None:
471
+ from cmd_prune import run
472
+
473
+ args = _ns(older_than="invalid", keep_last=None, project=None, dry_run=False, force=True)
474
+ assert run(args, db) == 1
475
+
476
+ def test_prune_canceled(self, db) -> None:
477
+ from cmd_prune import run
478
+
479
+ args = _ns(older_than="30d", keep_last=None, project=None, dry_run=False, force=False)
480
+ with patch("cmd_prune.confirm", return_value=False):
481
+ assert run(args, db) == 0
482
+ assert _count_sessions(db) == 4
483
+
484
+ def test_prune_deletes_messages_and_parts(self, db) -> None:
485
+ from cmd_prune import run
486
+
487
+ total_msg = _count_messages(db)
488
+ total_parts = _count_parts(db)
489
+ assert total_msg > 0
490
+ assert total_parts > 0
491
+
492
+ args = _ns(older_than="1d", keep_last=None, project=None, dry_run=False, force=True)
493
+ assert run(args, db) == 0
494
+
495
+ assert _count_messages(db) < total_msg
496
+ assert _count_parts(db) < total_parts
497
+
498
+ def test_prune_keep_last_preserves_recent(self, db) -> None:
499
+ from cmd_prune import run
500
+
501
+ args = _ns(older_than=None, keep_last=10, project=None, dry_run=True, force=True)
502
+ assert run(args, db) == 0
503
+
504
+ def test_prune_none_to_delete(self, db) -> None:
505
+ from cmd_prune import run
506
+
507
+ args = _ns(older_than="100y", keep_last=None, project=None, dry_run=False, force=True)
508
+ assert run(args, db) == 0
509
+ assert _count_sessions(db) == 4
@@ -1,7 +1,13 @@
1
+ import json
2
+
3
+ import pytest
4
+
1
5
  from db import (
6
+ SessionError,
2
7
  get_children_sessions,
3
8
  get_latest_session,
4
9
  get_message_count,
10
+ get_messages,
5
11
  get_parent_session,
6
12
  get_part_count,
7
13
  get_project_count,
@@ -12,6 +18,7 @@ from db import (
12
18
  get_session_title,
13
19
  get_session_todos,
14
20
  parse_model,
21
+ resolve_session_id,
15
22
  verify_schema,
16
23
  )
17
24
 
@@ -150,3 +157,121 @@ class TestGetRecentSessions:
150
157
  def test_custom_limit(self, db) -> None:
151
158
  sessions = get_recent_sessions(db, limit=2)
152
159
  assert len(sessions) == 2
160
+
161
+
162
+ class TestResolveSessionId:
163
+ def test_empty_id_returns_none(self, db) -> None:
164
+ assert resolve_session_id(db, None) is None
165
+ assert resolve_session_id(db, "") is None
166
+
167
+
168
+ class TestSessionError:
169
+ def test_resolve_session_id_not_found(self, db) -> None:
170
+ with pytest.raises(SessionError):
171
+ get_session_info(db, "nonexistent")
172
+
173
+ def test_resolve_session_id_multiple(self, db) -> None:
174
+ db.execute(
175
+ "INSERT INTO session (id, time_created) VALUES (?, ?)",
176
+ ("ses_test_abc", 1000),
177
+ )
178
+ db.execute(
179
+ "INSERT INTO session (id, time_created) VALUES (?, ?)",
180
+ ("ses_test_xyz", 2000),
181
+ )
182
+ db.commit()
183
+ with pytest.raises(SessionError):
184
+ get_session_info(db, "ses_test")
185
+
186
+ def test_get_latest_session_no_sessions(self, db) -> None:
187
+ db.execute("DELETE FROM session")
188
+ db.commit()
189
+ with pytest.raises(SessionError):
190
+ get_latest_session(db)
191
+
192
+ def test_get_session_info_not_found(self, db) -> None:
193
+ with pytest.raises(SessionError):
194
+ get_session_info(db, "zzz_nope")
195
+
196
+
197
+ class TestGetMessages:
198
+ def test_returns_messages_with_parts(self, db) -> None:
199
+ messages = get_messages(db, "ses_001")
200
+ assert len(messages) == 2
201
+ assert messages[0]["role"] == "user"
202
+ assert messages[1]["role"] == "assistant"
203
+
204
+ def test_parts_are_grouped_by_message(self, db) -> None:
205
+ messages = get_messages(db, "ses_001")
206
+ assert len(messages) == 2
207
+ assert len(messages[0]["parts"]) == 1
208
+ assert messages[0]["parts"][0]["type"] == "text"
209
+
210
+ def test_multiple_parts_per_message(self, db) -> None:
211
+ messages = get_messages(db, "ses_001")
212
+ parts = messages[1]["parts"]
213
+ assert len(parts) == 3
214
+ types = [p["type"] for p in parts]
215
+ assert types == ["text", "reasoning", "tool"]
216
+
217
+ def test_text_part_content(self, db) -> None:
218
+ messages = get_messages(db, "ses_001")
219
+ text_part = messages[0]["parts"][0]
220
+ assert text_part["type"] == "text"
221
+ assert "Hello" in text_part["text"]
222
+
223
+ def test_reasoning_part(self, db) -> None:
224
+ messages = get_messages(db, "ses_001")
225
+ parts = messages[1]["parts"]
226
+ reasoning = [p for p in parts if p["type"] == "reasoning"]
227
+ assert len(reasoning) == 1
228
+ assert "think" in reasoning[0]["text"].lower()
229
+
230
+ def test_tool_part(self, db) -> None:
231
+ messages = get_messages(db, "ses_001")
232
+ parts = messages[1]["parts"]
233
+ tool = [p for p in parts if p["type"] == "tool"]
234
+ assert len(tool) == 1
235
+ assert tool[0]["tool"] == "read"
236
+ assert tool[0]["status"] == "completed"
237
+
238
+ def test_sound_part(self, db) -> None:
239
+ messages = get_messages(db, "ses_003")
240
+ assert len(messages) == 2
241
+ parts = messages[0]["parts"]
242
+ assert len(parts) == 1
243
+ assert parts[0]["type"] == "sound"
244
+
245
+ def test_empty_session(self, db) -> None:
246
+ messages = get_messages(db, "ses_004")
247
+ assert messages == []
248
+
249
+ def test_bash_tool_part(self, db) -> None:
250
+ messages = get_messages(db, "ses_003")
251
+ tool_part = messages[1]["parts"][0]
252
+ assert tool_part["type"] == "tool"
253
+ assert tool_part["tool"] == "bash"
254
+
255
+ def test_skips_invalid_json_part(self, db) -> None:
256
+ db.execute(
257
+ "INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)",
258
+ ("msg_bad", "ses_001", 100500, json.dumps({"role": "user"})),
259
+ )
260
+ db.execute(
261
+ "INSERT INTO part (id, message_id, session_id, time_created, data) VALUES (?, ?, ?, ?, ?)",
262
+ ("part_bad", "msg_bad", "ses_001", 100500, "not valid json {"),
263
+ )
264
+ messages = get_messages(db, "ses_001")
265
+ assert len(messages) == 2
266
+
267
+ def test_skips_empty_part_data(self, db) -> None:
268
+ db.execute(
269
+ "INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)",
270
+ ("msg_empty", "ses_001", 100600, json.dumps({"role": "user"})),
271
+ )
272
+ db.execute(
273
+ "INSERT INTO part (id, message_id, session_id, time_created, data) VALUES (?, ?, ?, ?, ?)",
274
+ ("part_empty", "msg_empty", "ses_001", 100600, None),
275
+ )
276
+ messages = get_messages(db, "ses_001")
277
+ assert len(messages) == 2
@@ -0,0 +1,173 @@
1
+ from formatters import format_part_to_md
2
+
3
+
4
+ class TestFormatPartToMd:
5
+ def test_text_part(self):
6
+ result = format_part_to_md({"type": "text", "text": "Hello world"})
7
+ assert result == "Hello world"
8
+
9
+ def test_text_part_strips_whitespace(self):
10
+ result = format_part_to_md({"type": "text", "text": " hello "})
11
+ assert result == "hello"
12
+
13
+ def test_text_part_empty_returns_empty(self):
14
+ result = format_part_to_md({"type": "text", "text": ""})
15
+ assert result == ""
16
+
17
+ def test_text_part_missing_text(self):
18
+ result = format_part_to_md({"type": "text"})
19
+ assert result == ""
20
+
21
+ def test_reasoning_part(self):
22
+ result = format_part_to_md({"type": "reasoning", "text": "Let me think..."})
23
+ assert "Let me think..." in result
24
+ assert "reasoning" in result.lower() or "💭" in result
25
+
26
+ def test_reasoning_empty_returns_empty(self):
27
+ result = format_part_to_md({"type": "reasoning", "text": " "})
28
+ assert result == ""
29
+
30
+ def test_sound_part(self):
31
+ result = format_part_to_md({"type": "sound"})
32
+ assert result != ""
33
+
34
+ def test_tool_read_part(self):
35
+ result = format_part_to_md(
36
+ {
37
+ "type": "tool",
38
+ "tool": "read",
39
+ "input": {"filePath": "/home/user/file.py"},
40
+ "output": "content",
41
+ "status": "completed",
42
+ }
43
+ )
44
+ assert "read" in result
45
+ assert "file.py" in result
46
+
47
+ def test_tool_write_part(self):
48
+ result = format_part_to_md(
49
+ {
50
+ "type": "tool",
51
+ "tool": "write",
52
+ "input": {"filePath": "/home/user/new.py"},
53
+ "output": "",
54
+ "status": "completed",
55
+ }
56
+ )
57
+ assert "write" in result
58
+
59
+ def test_tool_edit_part(self):
60
+ result = format_part_to_md(
61
+ {
62
+ "type": "tool",
63
+ "tool": "edit",
64
+ "input": {"filePath": "/home/user/file.py"},
65
+ "output": "",
66
+ "status": "completed",
67
+ }
68
+ )
69
+ assert "edit" in result
70
+
71
+ def test_tool_glob_part(self):
72
+ result = format_part_to_md(
73
+ {
74
+ "type": "tool",
75
+ "tool": "glob",
76
+ "input": {"pattern": "**/*.py", "path": "/home/user"},
77
+ "output": "",
78
+ "status": "completed",
79
+ }
80
+ )
81
+ assert "glob" in result
82
+ assert "**/*.py" in result
83
+
84
+ def test_tool_grep_part(self):
85
+ result = format_part_to_md(
86
+ {
87
+ "type": "tool",
88
+ "tool": "grep",
89
+ "input": {"pattern": "def hello"},
90
+ "output": "def hello():\n pass",
91
+ "status": "completed",
92
+ }
93
+ )
94
+ assert "grep" in result
95
+ assert "def hello" in result
96
+
97
+ def test_tool_custom_with_input_output(self):
98
+ result = format_part_to_md(
99
+ {
100
+ "type": "tool",
101
+ "tool": "bash",
102
+ "input": {"command": "ls -la"},
103
+ "output": "total 42\n-rw-r--r-- 1 user staff 1234 Jan 1 00:00 file.py",
104
+ "status": "completed",
105
+ }
106
+ )
107
+ assert "bash" in result
108
+ assert "ls -la" in result
109
+ assert "total 42" in result
110
+
111
+ def test_tool_error_status(self):
112
+ result = format_part_to_md(
113
+ {
114
+ "type": "tool",
115
+ "tool": "bash",
116
+ "input": {"command": "rm -rf /"},
117
+ "output": "permission denied",
118
+ "status": "error",
119
+ }
120
+ )
121
+ assert "❌" in result
122
+
123
+ def test_tool_running_status(self):
124
+ result = format_part_to_md(
125
+ {
126
+ "type": "tool",
127
+ "tool": "bash",
128
+ "input": {"command": "sleep 10"},
129
+ "output": "",
130
+ "status": "running",
131
+ }
132
+ )
133
+ assert "⏳" in result
134
+
135
+ def test_tool_no_output(self):
136
+ result = format_part_to_md(
137
+ {
138
+ "type": "tool",
139
+ "tool": "bash",
140
+ "input": {"command": "echo hi"},
141
+ "output": "",
142
+ "status": "completed",
143
+ }
144
+ )
145
+ assert "echo hi" in result
146
+
147
+ def test_full_mode_does_not_truncate_path(self):
148
+ long_path = "/a/" + "very/" * 50 + "file.py"
149
+ result = format_part_to_md(
150
+ {
151
+ "type": "tool",
152
+ "tool": "read",
153
+ "input": {"filePath": long_path},
154
+ "output": "",
155
+ "status": "completed",
156
+ },
157
+ full=True,
158
+ )
159
+ assert long_path in result
160
+ assert "..." not in result
161
+
162
+ def test_full_mode_shows_full_output(self):
163
+ result = format_part_to_md(
164
+ {
165
+ "type": "tool",
166
+ "tool": "bash",
167
+ "input": {"command": "cat bigfile.txt"},
168
+ "output": "line\n" * 500,
169
+ "status": "completed",
170
+ },
171
+ full=True,
172
+ )
173
+ assert "line" in result
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes