spooling 0.2.11__tar.gz → 0.2.12__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 (76) hide show
  1. {spooling-0.2.11/spooling.egg-info → spooling-0.2.12}/PKG-INFO +6 -2
  2. {spooling-0.2.11 → spooling-0.2.12}/README.md +5 -1
  3. {spooling-0.2.11 → spooling-0.2.12}/pyproject.toml +1 -1
  4. {spooling-0.2.11 → spooling-0.2.12}/spooling/cli.py +6 -4
  5. {spooling-0.2.11 → spooling-0.2.12}/spooling/ingest.py +51 -4
  6. {spooling-0.2.11 → spooling-0.2.12}/spooling/parser.py +7 -1
  7. {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/base.py +5 -0
  8. {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/claude_code.py +2 -0
  9. {spooling-0.2.11 → spooling-0.2.12/spooling.egg-info}/PKG-INFO +6 -2
  10. {spooling-0.2.11 → spooling-0.2.12}/spooling.egg-info/SOURCES.txt +1 -0
  11. spooling-0.2.12/tests/test_agent_spans.py +172 -0
  12. {spooling-0.2.11 → spooling-0.2.12}/LICENSE +0 -0
  13. {spooling-0.2.11 → spooling-0.2.12}/setup.cfg +0 -0
  14. {spooling-0.2.11 → spooling-0.2.12}/spooling/__init__.py +0 -0
  15. {spooling-0.2.11 → spooling-0.2.12}/spooling/agent.py +0 -0
  16. {spooling-0.2.11 → spooling-0.2.12}/spooling/classifiers.py +0 -0
  17. {spooling-0.2.11 → spooling-0.2.12}/spooling/cloud.py +0 -0
  18. {spooling-0.2.11 → spooling-0.2.12}/spooling/config.py +0 -0
  19. {spooling-0.2.11 → spooling-0.2.12}/spooling/connectors/__init__.py +0 -0
  20. {spooling-0.2.11 → spooling-0.2.12}/spooling/connectors/base.py +0 -0
  21. {spooling-0.2.11 → spooling-0.2.12}/spooling/connectors/bigquery.py +0 -0
  22. {spooling-0.2.11 → spooling-0.2.12}/spooling/connectors/clickhouse.py +0 -0
  23. {spooling-0.2.11 → spooling-0.2.12}/spooling/connectors/factory.py +0 -0
  24. {spooling-0.2.11 → spooling-0.2.12}/spooling/connectors/mongodb.py +0 -0
  25. {spooling-0.2.11 → spooling-0.2.12}/spooling/connectors/mysql.py +0 -0
  26. {spooling-0.2.11 → spooling-0.2.12}/spooling/connectors/postgresql.py +0 -0
  27. {spooling-0.2.11 → spooling-0.2.12}/spooling/connectors/rest/__init__.py +0 -0
  28. {spooling-0.2.11 → spooling-0.2.12}/spooling/connectors/rest/base.py +0 -0
  29. {spooling-0.2.11 → spooling-0.2.12}/spooling/connectors/rest/shopify_connectors.py +0 -0
  30. {spooling-0.2.11 → spooling-0.2.12}/spooling/connectors/snowflake.py +0 -0
  31. {spooling-0.2.11 → spooling-0.2.12}/spooling/connectors/types.py +0 -0
  32. {spooling-0.2.11 → spooling-0.2.12}/spooling/db.py +0 -0
  33. {spooling-0.2.11 → spooling-0.2.12}/spooling/embeddings.py +0 -0
  34. {spooling-0.2.11 → spooling-0.2.12}/spooling/evals.py +0 -0
  35. {spooling-0.2.11 → spooling-0.2.12}/spooling/experiments.py +0 -0
  36. {spooling-0.2.11 → spooling-0.2.12}/spooling/insights.py +0 -0
  37. {spooling-0.2.11 → spooling-0.2.12}/spooling/mcp_server.py +0 -0
  38. {spooling-0.2.11 → spooling-0.2.12}/spooling/optimize.py +0 -0
  39. {spooling-0.2.11 → spooling-0.2.12}/spooling/pricing.py +0 -0
  40. {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/__init__.py +0 -0
  41. {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/antigravity.py +0 -0
  42. {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/codex.py +0 -0
  43. {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/copilot.py +0 -0
  44. {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/cortex_code.py +0 -0
  45. {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/cursor.py +0 -0
  46. {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/gemini.py +0 -0
  47. {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/github.py +0 -0
  48. {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/gitlab.py +0 -0
  49. {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/kiro.py +0 -0
  50. {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/opencode.py +0 -0
  51. {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/session_file.py +0 -0
  52. {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/windsurf.py +0 -0
  53. {spooling-0.2.11 → spooling-0.2.12}/spooling/redact.py +0 -0
  54. {spooling-0.2.11 → spooling-0.2.12}/spooling/remote_otel.py +0 -0
  55. {spooling-0.2.11 → spooling-0.2.12}/spooling/schema.py +0 -0
  56. {spooling-0.2.11 → spooling-0.2.12}/spooling/sdk.py +0 -0
  57. {spooling-0.2.11 → spooling-0.2.12}/spooling/search.py +0 -0
  58. {spooling-0.2.11 → spooling-0.2.12}/spooling/server.py +0 -0
  59. {spooling-0.2.11 → spooling-0.2.12}/spooling/sessions.py +0 -0
  60. {spooling-0.2.11 → spooling-0.2.12}/spooling/stats.py +0 -0
  61. {spooling-0.2.11 → spooling-0.2.12}/spooling/subscription_pricing.py +0 -0
  62. {spooling-0.2.11 → spooling-0.2.12}/spooling/tracing.py +0 -0
  63. {spooling-0.2.11 → spooling-0.2.12}/spooling/tunnel.py +0 -0
  64. {spooling-0.2.11 → spooling-0.2.12}/spooling/tunnel_auth.py +0 -0
  65. {spooling-0.2.11 → spooling-0.2.12}/spooling/watcher.py +0 -0
  66. {spooling-0.2.11 → spooling-0.2.12}/spooling.egg-info/dependency_links.txt +0 -0
  67. {spooling-0.2.11 → spooling-0.2.12}/spooling.egg-info/entry_points.txt +0 -0
  68. {spooling-0.2.11 → spooling-0.2.12}/spooling.egg-info/requires.txt +0 -0
  69. {spooling-0.2.11 → spooling-0.2.12}/spooling.egg-info/top_level.txt +0 -0
  70. {spooling-0.2.11 → spooling-0.2.12}/tests/test_claude_code_provider.py +0 -0
  71. {spooling-0.2.11 → spooling-0.2.12}/tests/test_cloud_errors.py +0 -0
  72. {spooling-0.2.11 → spooling-0.2.12}/tests/test_insights.py +0 -0
  73. {spooling-0.2.11 → spooling-0.2.12}/tests/test_mcp_auth_middleware.py +0 -0
  74. {spooling-0.2.11 → spooling-0.2.12}/tests/test_session_ids.py +0 -0
  75. {spooling-0.2.11 → spooling-0.2.12}/tests/test_storage_optimizations.py +0 -0
  76. {spooling-0.2.11 → spooling-0.2.12}/tests/test_tunnel_auth.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spooling
3
- Version: 0.2.11
3
+ Version: 0.2.12
4
4
  Summary: Local session tracker and semantic search for AI coding assistants
5
5
  Author: Parsed Analytics, Inc.
6
6
  License: MIT
@@ -176,7 +176,11 @@ Common options: `--days N`, `--project TEXT`, `--provider/-p NAME`, `--limit/-n
176
176
 
177
177
  `tools --agent` and `agents --tool` look inside an agent's own runs. `traces --agent/--tool` matches whole traces, like the Traces page.
178
178
 
179
- `spooling tools --cloud` and `spooling agents --cloud` show workspace-wide totals from Spooling Cloud. The cloud endpoints only support `--limit`, so combining `--cloud` with other filters is an error. Agent data is only recorded for providers that expose subagents (for example Claude Code's `Task` tool), so `spooling agents` can be empty for other providers.
179
+ `spooling tools --cloud` and `spooling agents --cloud` show workspace-wide totals from Spooling Cloud. The cloud endpoints only support `--limit`, so combining `--cloud` with other filters is an error. Agent data is only recorded for providers that expose subagents (currently Claude Code, via its `Agent` tool, previously called `Task`), so `spooling agents` is empty for other providers.
180
+
181
+ After upgrading, `spooling sync` re-reads your Claude Code sessions once to pick up agents. To force a re-read of any provider's files at any time, run `spooling sync --reparse` (optionally with `-p claude-code`).
182
+
183
+ Agent runs show duration and the prompt that started them. Their own tokens, cost and tool calls are not attributed yet, because Claude Code writes subagent activity to separate transcript files that Spooling does not read yet.
180
184
 
181
185
  ### `spooling search <query>`
182
186
 
@@ -140,7 +140,11 @@ Common options: `--days N`, `--project TEXT`, `--provider/-p NAME`, `--limit/-n
140
140
 
141
141
  `tools --agent` and `agents --tool` look inside an agent's own runs. `traces --agent/--tool` matches whole traces, like the Traces page.
142
142
 
143
- `spooling tools --cloud` and `spooling agents --cloud` show workspace-wide totals from Spooling Cloud. The cloud endpoints only support `--limit`, so combining `--cloud` with other filters is an error. Agent data is only recorded for providers that expose subagents (for example Claude Code's `Task` tool), so `spooling agents` can be empty for other providers.
143
+ `spooling tools --cloud` and `spooling agents --cloud` show workspace-wide totals from Spooling Cloud. The cloud endpoints only support `--limit`, so combining `--cloud` with other filters is an error. Agent data is only recorded for providers that expose subagents (currently Claude Code, via its `Agent` tool, previously called `Task`), so `spooling agents` is empty for other providers.
144
+
145
+ After upgrading, `spooling sync` re-reads your Claude Code sessions once to pick up agents. To force a re-read of any provider's files at any time, run `spooling sync --reparse` (optionally with `-p claude-code`).
146
+
147
+ Agent runs show duration and the prompt that started them. Their own tokens, cost and tool calls are not attributed yet, because Claude Code writes subagent activity to separate transcript files that Spooling does not read yet.
144
148
 
145
149
  ### `spooling search <query>`
146
150
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "spooling"
3
- version = "0.2.11"
3
+ version = "0.2.12"
4
4
  description = "Local session tracker and semantic search for AI coding assistants"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Parsed Analytics, Inc." }]
@@ -65,10 +65,11 @@ def init():
65
65
  @cli.command()
66
66
  @click.option("--no-embed", is_flag=True, help="Skip embedding (faster sync)")
67
67
  @click.option("--provider", "-p", default=None, help="Only sync a specific provider (claude-code, jsonl-session, codex, cursor, copilot, windsurf)")
68
- def sync(no_embed, provider):
68
+ @click.option("--reparse", is_flag=True, help="Re-read session files even if they look unchanged (rebuilds traces/spans)")
69
+ def sync(no_embed, provider, reparse):
69
70
  """Sync AI coding sessions to the database."""
70
71
  from spooling.ingest import sync as do_sync
71
- do_sync(embed=not no_embed, provider_filter=provider)
72
+ do_sync(embed=not no_embed, provider_filter=provider, reparse=reparse)
72
73
 
73
74
 
74
75
  @cli.command()
@@ -1475,8 +1476,9 @@ def agents_cmd(tools, cloud_mode, days, project, provider, limit, as_json):
1475
1476
  if not rows:
1476
1477
  console.print(
1477
1478
  "[yellow]No agent spans found locally.[/yellow]\n"
1478
- "[dim]Agent runs are recorded for providers that expose subagents (e.g. Claude Code's Task tool). "
1479
- "Run 'spooling sync' after using one, or try 'spooling agents --cloud'.[/dim]"
1479
+ "[dim]Agent runs are recorded for providers that expose subagents (currently Claude Code, via its "
1480
+ "Agent/Task tool). Run 'spooling sync' after using one, or try 'spooling agents --cloud'.\n"
1481
+ "If you use Claude Code and still see this, run 'spooling sync --reparse -p claude-code'.[/dim]"
1480
1482
  )
1481
1483
  return
1482
1484
  table = Table(title="Agents" + (f" that called {', '.join(tools)}" if tools else ""), show_lines=False)
@@ -458,8 +458,42 @@ def _sync_remote_provider(conn, provider, prov_info: dict, embed: bool) -> tuple
458
458
  return total_sessions, total_messages, total_chunks
459
459
 
460
460
 
461
- def sync(embed: bool = True, provider_filter: str | None = None):
462
- """Sync sessions from all connected providers to the database."""
461
+ _REVISION_PREFIX = "__parser_revision__:"
462
+
463
+
464
+ def _needs_reread(conn, synced: dict, provider_type: str, revision: int, reparse: bool) -> bool:
465
+ """True if this provider's already-synced files must be re-read this run.
466
+
467
+ Either the user asked (``--reparse``), or a parser fix shipped
468
+ (``provider.parser_revision`` is newer than the one recorded at last sync)
469
+ and there are previously synced files that were parsed the old way.
470
+ """
471
+ if reparse:
472
+ return True
473
+ if not revision:
474
+ return False
475
+ stored = synced.get(_REVISION_PREFIX + provider_type)
476
+ if stored is not None and stored >= revision:
477
+ return False
478
+ row = conn.execute(
479
+ "SELECT COUNT(*) AS n FROM sync_state WHERE provider_id = %s AND file_path NOT LIKE %s",
480
+ (provider_type, _REVISION_PREFIX + "%"),
481
+ ).fetchone()
482
+ return bool(row["n"])
483
+
484
+
485
+ def _record_revision(conn, synced: dict, provider_type: str, revision: int) -> None:
486
+ if revision and synced.get(_REVISION_PREFIX + provider_type) != revision:
487
+ _mark_synced(conn, _REVISION_PREFIX + provider_type, revision, provider_type)
488
+ synced[_REVISION_PREFIX + provider_type] = revision
489
+
490
+
491
+ def sync(embed: bool = True, provider_filter: str | None = None, reparse: bool = False):
492
+ """Sync sessions from all connected providers to the database.
493
+
494
+ ``reparse`` re-reads files even if they look unchanged (also done
495
+ automatically, once, when a provider's parser improves).
496
+ """
463
497
  conn = get_connection()
464
498
  connected = _get_connected_providers(conn)
465
499
 
@@ -533,20 +567,31 @@ def sync(embed: bool = True, provider_filter: str | None = None):
533
567
  if not files:
534
568
  continue
535
569
 
570
+ revision = getattr(provider, "parser_revision", 0)
571
+ reread = _needs_reread(conn, synced, prov_info["type"], revision, reparse)
572
+ # When re-reading, pretend nothing was synced so every file is parsed again.
573
+ prov_synced = {} if reread else synced
574
+
536
575
  # Filter to new or changed files. Incremental providers (many sessions
537
576
  # per file) are always scanned; they skip unchanged sessions themselves.
538
577
  to_process = []
539
578
  for f in files:
540
579
  size = f.stat().st_size
541
- if provider.incremental or str(f) not in synced or synced[str(f)] != size:
580
+ if provider.incremental or str(f) not in prov_synced or prov_synced[str(f)] != size:
542
581
  to_process.append(f)
543
582
 
544
583
  if not to_process:
584
+ _record_revision(conn, synced, prov_info["type"], revision)
585
+ conn.commit()
545
586
  continue
546
587
 
547
588
  console.print(
548
589
  f"[bold]{provider.name}:[/bold] Found {len(to_process)} new/updated session files."
549
590
  )
591
+ if reread and not reparse:
592
+ console.print(
593
+ f" [dim]Re-reading {provider.name} sessions once to pick up improved parsing.[/dim]"
594
+ )
550
595
 
551
596
  total_messages = 0
552
597
  total_chunks = 0
@@ -570,7 +615,7 @@ def sync(embed: bool = True, provider_filter: str | None = None):
570
615
  batch_sessions: list[tuple] = [] # (file, [sessions])
571
616
 
572
617
  for f in batch_files:
573
- sessions = parse_file(provider, f, synced)
618
+ sessions = parse_file(provider, f, prov_synced)
574
619
  for session in sessions:
575
620
  _store_session(conn, session)
576
621
  if session.trace is not None:
@@ -601,6 +646,8 @@ def sync(embed: bool = True, provider_filter: str | None = None):
601
646
  _mark_synced(conn, str(f), f.stat().st_size, prov_info["type"])
602
647
  conn.commit()
603
648
 
649
+ _record_revision(conn, synced, prov_info["type"], revision)
650
+
604
651
  # Update provider stats
605
652
  conn.execute(
606
653
  """UPDATE providers SET
@@ -30,6 +30,12 @@ _SYSTEM_PREFIXES = re.compile(
30
30
  _XML_TAG_STRIP = re.compile(r"<[^>]+>[^<]*</[^>]+>\s*")
31
31
 
32
32
 
33
+ # Claude Code's subagent-launching tool. It was called "Task" and is now called
34
+ # "Agent"; both carry ``subagent_type`` + ``prompt``/``description`` and must
35
+ # produce an agent span.
36
+ AGENT_TOOL_NAMES = ("Task", "Agent")
37
+
38
+
33
39
  @dataclass
34
40
  class ToolCallDetail:
35
41
  tool_use_id: str
@@ -569,7 +575,7 @@ def _build_trace(
569
575
  tool_id = tu.get("id") or f"tu-{uid}-{tool_name}"
570
576
  tool_input = tu.get("input") if isinstance(tu.get("input"), dict) else None
571
577
 
572
- if tool_name == "Task":
578
+ if tool_name in AGENT_TOOL_NAMES:
573
579
  # Agent span — child of whatever parent_span is.
574
580
  sub_type = (tool_input or {}).get("subagent_type") or "generic"
575
581
  prompt = (tool_input or {}).get("prompt") or (tool_input or {}).get("description")
@@ -61,6 +61,11 @@ class Provider(ABC):
61
61
  # ``parse_session_file`` so unchanged sessions are skipped cheaply.
62
62
  incremental: bool = False
63
63
 
64
+ # Bump when a parser fix changes what is extracted from files that were
65
+ # already synced (e.g. a new span type). Ingest then re-reads this
66
+ # provider's files once, instead of skipping them as "unchanged".
67
+ parser_revision: int = 0
68
+
64
69
  def __init_subclass__(cls, **kwargs):
65
70
  super().__init_subclass__(**kwargs)
66
71
  # Don't register the abstract RemoteProvider base.
@@ -37,6 +37,8 @@ def _claude_config_dirs() -> list[Path]:
37
37
  class ClaudeCodeProvider(Provider):
38
38
  type_id = "claude-code"
39
39
  name = "Claude Code"
40
+ # 2: subagent calls named "Agent" (not just "Task") now create agent spans.
41
+ parser_revision = 2
40
42
 
41
43
  def default_data_path(self) -> Path:
42
44
  candidates = [d / "projects" for d in _claude_config_dirs()]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spooling
3
- Version: 0.2.11
3
+ Version: 0.2.12
4
4
  Summary: Local session tracker and semantic search for AI coding assistants
5
5
  Author: Parsed Analytics, Inc.
6
6
  License: MIT
@@ -176,7 +176,11 @@ Common options: `--days N`, `--project TEXT`, `--provider/-p NAME`, `--limit/-n
176
176
 
177
177
  `tools --agent` and `agents --tool` look inside an agent's own runs. `traces --agent/--tool` matches whole traces, like the Traces page.
178
178
 
179
- `spooling tools --cloud` and `spooling agents --cloud` show workspace-wide totals from Spooling Cloud. The cloud endpoints only support `--limit`, so combining `--cloud` with other filters is an error. Agent data is only recorded for providers that expose subagents (for example Claude Code's `Task` tool), so `spooling agents` can be empty for other providers.
179
+ `spooling tools --cloud` and `spooling agents --cloud` show workspace-wide totals from Spooling Cloud. The cloud endpoints only support `--limit`, so combining `--cloud` with other filters is an error. Agent data is only recorded for providers that expose subagents (currently Claude Code, via its `Agent` tool, previously called `Task`), so `spooling agents` is empty for other providers.
180
+
181
+ After upgrading, `spooling sync` re-reads your Claude Code sessions once to pick up agents. To force a re-read of any provider's files at any time, run `spooling sync --reparse` (optionally with `-p claude-code`).
182
+
183
+ Agent runs show duration and the prompt that started them. Their own tokens, cost and tool calls are not attributed yet, because Claude Code writes subagent activity to separate transcript files that Spooling does not read yet.
180
184
 
181
185
  ### `spooling search <query>`
182
186
 
@@ -64,6 +64,7 @@ spooling/providers/kiro.py
64
64
  spooling/providers/opencode.py
65
65
  spooling/providers/session_file.py
66
66
  spooling/providers/windsurf.py
67
+ tests/test_agent_spans.py
67
68
  tests/test_claude_code_provider.py
68
69
  tests/test_cloud_errors.py
69
70
  tests/test_insights.py
@@ -0,0 +1,172 @@
1
+ """Claude Code subagent calls ("Agent", formerly "Task") become agent spans,
2
+ and sessions synced before that fix are re-read once."""
3
+
4
+ import json
5
+
6
+ import pytest
7
+ from click.testing import CliRunner
8
+
9
+ from spooling import db as db_mod
10
+ from spooling import ingest, insights
11
+ from spooling.cli import cli
12
+ from spooling.parser import parse_session_file
13
+ from spooling.providers import base as base_mod
14
+
15
+ SID = "33333333-aaaa-4bbb-8ccc-000000000003"
16
+ CWD = "/Users/dev/code/my-app"
17
+
18
+
19
+ def _rec(type_, uuid, parent, ts, **kw):
20
+ r = {"type": type_, "uuid": uuid, "parentUuid": parent, "isSidechain": False,
21
+ "timestamp": ts, "cwd": CWD, "gitBranch": "main", "version": "2.1.0", "sessionId": SID}
22
+ r.update(kw)
23
+ return r
24
+
25
+
26
+ def session_with_subagent(tool_name: str, subagent_type: str | None = "Explore"):
27
+ inp = {"description": "Audit the app", "prompt": "Do a thorough audit"}
28
+ if subagent_type:
29
+ inp["subagent_type"] = subagent_type
30
+ return [
31
+ _rec("user", "u1", None, "2026-11-01T10:00:00Z",
32
+ message={"role": "user", "content": "audit the app"}),
33
+ _rec("assistant", "a1", "u1", "2026-11-01T10:00:05Z",
34
+ message={"role": "assistant", "model": "claude-sonnet-4-5", "content": [
35
+ {"type": "tool_use", "id": "toolu_agent", "name": tool_name, "input": inp},
36
+ {"type": "tool_use", "id": "toolu_bash", "name": "Bash", "input": {"command": "ls"}}],
37
+ "usage": {"input_tokens": 10, "output_tokens": 5}}),
38
+ _rec("user", "u2", "a1", "2026-11-01T10:00:30Z",
39
+ message={"role": "user", "content": [
40
+ {"type": "tool_result", "tool_use_id": "toolu_agent", "content": "audit done"},
41
+ {"type": "tool_result", "tool_use_id": "toolu_bash", "content": "a b c"}]}),
42
+ ]
43
+
44
+
45
+ def write(root, records):
46
+ d = root / "projects" / "-Users-dev-code-my-app"
47
+ d.mkdir(parents=True, exist_ok=True)
48
+ f = d / f"{SID}.jsonl"
49
+ f.write_text("\n".join(json.dumps(r) for r in records) + "\n")
50
+ return f
51
+
52
+
53
+ @pytest.fixture
54
+ def home(tmp_path, monkeypatch):
55
+ h = tmp_path / "claude"
56
+ monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(h))
57
+ monkeypatch.setattr(base_mod, "PATH_OVERRIDES_FILE", tmp_path / "paths.json")
58
+ monkeypatch.setattr(db_mod, "DB_PATH", tmp_path / "spooling.db")
59
+ return h
60
+
61
+
62
+ def agent_spans(conn):
63
+ return [dict(r) for r in conn.execute(
64
+ "SELECT name, agent_type, agent_prompt FROM spans WHERE kind = 'agent'").fetchall()]
65
+
66
+
67
+ class TestParser:
68
+ @pytest.mark.parametrize("tool", ["Agent", "Task"])
69
+ def test_subagent_tool_creates_agent_span(self, home, tool):
70
+ f = write(home, session_with_subagent(tool))
71
+ trace = parse_session_file(f, provider_id="claude-code", project_from_cwd=True).trace
72
+ agents = [s for s in trace.spans if s.kind.value == "agent"]
73
+ assert len(agents) == 1
74
+ assert agents[0].agent_type == "Explore"
75
+ assert agents[0].agent_prompt == "Do a thorough audit"
76
+ # the agent call is not also recorded as a plain tool span
77
+ assert not [s for s in trace.spans if s.kind.value == "tool" and s.tool_name in ("Agent", "Task")]
78
+ # other tools are unaffected
79
+ assert [s.tool_name for s in trace.spans if s.kind.value == "tool"] == ["Bash"]
80
+
81
+ def test_missing_subagent_type_defaults_to_generic(self, home):
82
+ f = write(home, session_with_subagent("Agent", subagent_type=None))
83
+ trace = parse_session_file(f, provider_id="claude-code", project_from_cwd=True).trace
84
+ assert [s.agent_type for s in trace.spans if s.kind.value == "agent"] == ["generic"]
85
+
86
+ def test_agent_span_is_closed_by_its_result(self, home):
87
+ f = write(home, session_with_subagent("Agent"))
88
+ trace = parse_session_file(f, provider_id="claude-code", project_from_cwd=True).trace
89
+ agent = next(s for s in trace.spans if s.kind.value == "agent")
90
+ assert agent.ended_at is not None and agent.duration_ms > 0
91
+
92
+
93
+ class TestReread:
94
+ def _old_sync(self, home):
95
+ """Simulate a database synced by a version that ignored 'Agent' calls."""
96
+ f = write(home, session_with_subagent("Agent"))
97
+ ingest.sync(embed=False, provider_filter="claude-code")
98
+ conn = db_mod.get_connection()
99
+ # What an old install looks like: spans parsed the old way, no revision recorded.
100
+ conn.execute("DELETE FROM spans WHERE kind = 'agent'")
101
+ conn.execute("DELETE FROM sync_state WHERE file_path LIKE '__parser_revision__:%'")
102
+ conn.commit()
103
+ assert agent_spans(conn) == []
104
+ conn.close()
105
+ return f
106
+
107
+ def test_fresh_install_records_revision_without_rereading(self, home, capsys):
108
+ write(home, session_with_subagent("Agent"))
109
+ ingest.sync(embed=False, provider_filter="claude-code")
110
+ conn = db_mod.get_connection()
111
+ assert len(agent_spans(conn)) == 1
112
+ rev = conn.execute("SELECT last_size FROM sync_state WHERE file_path = '__parser_revision__:claude-code'").fetchone()
113
+ assert rev["last_size"] == 2
114
+ conn.close()
115
+ assert "improved parsing" not in capsys.readouterr().out
116
+
117
+ def test_upgrade_rereads_old_sessions_once(self, home, capsys):
118
+ self._old_sync(home)
119
+ capsys.readouterr()
120
+
121
+ ingest.sync(embed=False, provider_filter="claude-code")
122
+ conn = db_mod.get_connection()
123
+ assert [a["agent_type"] for a in agent_spans(conn)] == ["Explore"]
124
+ # no duplicate sessions/traces from the re-read
125
+ assert conn.execute("SELECT COUNT(*) AS n FROM sessions").fetchone()["n"] == 1
126
+ assert conn.execute("SELECT COUNT(*) AS n FROM traces").fetchone()["n"] == 1
127
+ conn.close()
128
+ assert "improved parsing" in capsys.readouterr().out
129
+
130
+ # second sync: revision recorded, nothing is re-read any more
131
+ ingest.sync(embed=False, provider_filter="claude-code")
132
+ assert "improved parsing" not in capsys.readouterr().out
133
+
134
+ def test_reparse_flag_forces_reread_of_unchanged_files(self, home):
135
+ write(home, session_with_subagent("Agent"))
136
+ ingest.sync(embed=False, provider_filter="claude-code")
137
+ conn = db_mod.get_connection()
138
+ conn.execute("DELETE FROM spans WHERE kind = 'agent'")
139
+ conn.commit()
140
+ conn.close()
141
+
142
+ ingest.sync(embed=False, provider_filter="claude-code") # unchanged -> skipped
143
+ conn = db_mod.get_connection()
144
+ assert agent_spans(conn) == []
145
+ conn.close()
146
+
147
+ ingest.sync(embed=False, provider_filter="claude-code", reparse=True)
148
+ conn = db_mod.get_connection()
149
+ assert len(agent_spans(conn)) == 1
150
+ conn.close()
151
+
152
+ def test_other_providers_not_forced(self, home):
153
+ """A revision bump on claude-code must not make other providers re-read."""
154
+ from spooling.providers.opencode import OpencodeProvider
155
+ assert OpencodeProvider.parser_revision == 0
156
+ conn = db_mod.get_connection()
157
+ assert ingest._needs_reread(conn, {}, "opencode", 0, reparse=False) is False
158
+ conn.close()
159
+
160
+ def test_end_to_end_agents_command(self, home):
161
+ self._old_sync(home)
162
+ r = CliRunner().invoke(cli, ["agents", "--json"])
163
+ assert json.loads(r.output) == []
164
+ ingest.sync(embed=False, provider_filter="claude-code")
165
+ data = json.loads(CliRunner().invoke(cli, ["agents", "--json"]).output)
166
+ assert [(d["name"], d["runs"]) for d in data] == [("Explore", 1)]
167
+
168
+ def test_sync_cli_reparse_flag(self, home, monkeypatch):
169
+ seen = {}
170
+ monkeypatch.setattr(ingest, "sync", lambda **kw: seen.update(kw))
171
+ CliRunner().invoke(cli, ["sync", "--reparse", "-p", "claude-code", "--no-embed"])
172
+ assert seen == {"embed": False, "provider_filter": "claude-code", "reparse": True}
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
File without changes
File without changes