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.
- {spooling-0.2.11/spooling.egg-info → spooling-0.2.12}/PKG-INFO +6 -2
- {spooling-0.2.11 → spooling-0.2.12}/README.md +5 -1
- {spooling-0.2.11 → spooling-0.2.12}/pyproject.toml +1 -1
- {spooling-0.2.11 → spooling-0.2.12}/spooling/cli.py +6 -4
- {spooling-0.2.11 → spooling-0.2.12}/spooling/ingest.py +51 -4
- {spooling-0.2.11 → spooling-0.2.12}/spooling/parser.py +7 -1
- {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/base.py +5 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/claude_code.py +2 -0
- {spooling-0.2.11 → spooling-0.2.12/spooling.egg-info}/PKG-INFO +6 -2
- {spooling-0.2.11 → spooling-0.2.12}/spooling.egg-info/SOURCES.txt +1 -0
- spooling-0.2.12/tests/test_agent_spans.py +172 -0
- {spooling-0.2.11 → spooling-0.2.12}/LICENSE +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/setup.cfg +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/__init__.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/agent.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/classifiers.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/cloud.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/config.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/connectors/__init__.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/connectors/base.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/connectors/bigquery.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/connectors/clickhouse.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/connectors/factory.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/connectors/mongodb.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/connectors/mysql.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/connectors/postgresql.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/connectors/rest/__init__.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/connectors/rest/base.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/connectors/rest/shopify_connectors.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/connectors/snowflake.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/connectors/types.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/db.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/embeddings.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/evals.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/experiments.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/insights.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/mcp_server.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/optimize.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/pricing.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/__init__.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/antigravity.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/codex.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/copilot.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/cortex_code.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/cursor.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/gemini.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/github.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/gitlab.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/kiro.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/opencode.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/session_file.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/providers/windsurf.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/redact.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/remote_otel.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/schema.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/sdk.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/search.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/server.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/sessions.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/stats.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/subscription_pricing.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/tracing.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/tunnel.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/tunnel_auth.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling/watcher.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling.egg-info/dependency_links.txt +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling.egg-info/entry_points.txt +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling.egg-info/requires.txt +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/spooling.egg-info/top_level.txt +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/tests/test_claude_code_provider.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/tests/test_cloud_errors.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/tests/test_insights.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/tests/test_mcp_auth_middleware.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/tests/test_session_ids.py +0 -0
- {spooling-0.2.11 → spooling-0.2.12}/tests/test_storage_optimizations.py +0 -0
- {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.
|
|
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 (
|
|
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 (
|
|
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
|
|
|
@@ -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
|
-
|
|
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 (
|
|
1479
|
-
"Run 'spooling sync' after using one, or try 'spooling agents --cloud'
|
|
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
|
-
|
|
462
|
-
|
|
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
|
|
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,
|
|
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
|
|
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.
|
|
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 (
|
|
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
|
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|