diff --git a/codeframe/cli/stats_commands.py b/codeframe/cli/stats_commands.py index ae6ca66b..9263a031 100644 --- a/codeframe/cli/stats_commands.py +++ b/codeframe/cli/stats_commands.py @@ -77,7 +77,7 @@ def _format_number(n: int) -> str: @stats_app.command() def tokens( - task: Optional[int] = typer.Option( + task: Optional[str] = typer.Option( None, "--task", "-t", help="Filter by task ID for per-task breakdown" ), ): @@ -275,7 +275,7 @@ def export_data( output: str = typer.Option( ..., "--output", "-o", help="Output file path" ), - task: Optional[int] = typer.Option( + task: Optional[str] = typer.Option( None, "--task", "-t", help="Filter by task ID" ), ): diff --git a/codeframe/lib/metrics_tracker.py b/codeframe/lib/metrics_tracker.py index cc40254c..822e9f04 100644 --- a/codeframe/lib/metrics_tracker.py +++ b/codeframe/lib/metrics_tracker.py @@ -296,7 +296,7 @@ def record_token_usage_sync( return usage_id - def get_task_token_summary(self, task_id: int) -> Dict[str, Any]: + def get_task_token_summary(self, task_id: Union[int, str]) -> Dict[str, Any]: """Get aggregated token usage summary for a single task. Args: diff --git a/codeframe/platform_store/repositories/token_repository.py b/codeframe/platform_store/repositories/token_repository.py index fc037c91..f02303a8 100644 --- a/codeframe/platform_store/repositories/token_repository.py +++ b/codeframe/platform_store/repositories/token_repository.py @@ -4,7 +4,7 @@ """ from datetime import datetime, timedelta, timezone -from typing import List, Optional, Dict, Any, TYPE_CHECKING +from typing import List, Optional, Dict, Any, Union, TYPE_CHECKING import logging @@ -164,11 +164,11 @@ def get_token_usage( - def get_task_token_summary(self, task_id: int) -> Dict[str, Any]: + def get_task_token_summary(self, task_id: Union[int, str]) -> Dict[str, Any]: """Get aggregated token usage summary for a single task. Args: - task_id: Task ID to summarize + task_id: Task ID to summarize (v2 UUID string or legacy int) Returns: Dictionary with aggregated token data: @@ -208,14 +208,14 @@ def get_task_token_summary(self, task_id: int) -> Dict[str, Any]: def get_batch_token_usage( self, - task_ids: List[int], + task_ids: List[Union[int, str]], start_date: Optional[datetime] = None, end_date: Optional[datetime] = None, ) -> List[Dict[str, Any]]: """Get token usage records filtered by a list of task IDs. Args: - task_ids: List of task IDs to filter by + task_ids: List of task IDs to filter by (v2 UUID strings or legacy ints) start_date: Optional start of date range (inclusive) end_date: Optional end of date range (inclusive) diff --git a/tests/cli/test_stats_task_uuid.py b/tests/cli/test_stats_task_uuid.py new file mode 100644 index 00000000..f6199248 --- /dev/null +++ b/tests/cli/test_stats_task_uuid.py @@ -0,0 +1,102 @@ +"""Regression tests for `cf stats --task ` (issue #744 / P1.17). + +Before the fix, `tokens(task: Optional[int])` and `export_data(task: Optional[int])` +typed the option as `int`, so Typer rejected a v2 UUID task ID at coercion time +(exit code 2) and per-task cost/token reporting/export was broken for every real +v2 task. These tests lock in that a UUID string flows through the CLI and the +repository returns the saved UUID-keyed row. +""" + +from datetime import datetime, timezone +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from codeframe.cli.stats_commands import stats_app +from codeframe.core.models import CallType, TokenUsage +from codeframe.core.workspace import create_or_load_workspace +from codeframe.platform_store.database import Database + +pytestmark = pytest.mark.v2 + +TASK_UUID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + + +@pytest.fixture +def workspace_with_row(tmp_path: Path, monkeypatch) -> str: + """A workspace at cwd with one saved UUID-keyed token_usage row.""" + repo = tmp_path / "repo" + repo.mkdir() + ws = create_or_load_workspace(repo) + db = Database(str(ws.db_path)) + db.initialize() + try: + db.save_token_usage( + TokenUsage( + task_id=TASK_UUID, # v2 UUID string, not int + agent_id="react-agent", + project_id=0, + model_name="claude-sonnet-4-5", + input_tokens=1000, + output_tokens=500, + estimated_cost_usd=0.0105, + call_type=CallType.TASK_EXECUTION, + timestamp=datetime.now(timezone.utc), + ) + ) + finally: + db.close() + # stats_commands._get_db() looks for .codeframe/state.db relative to cwd. + monkeypatch.chdir(repo) + return TASK_UUID + + +def test_stats_tokens_accepts_uuid_and_returns_saved_row(workspace_with_row: str): + result = CliRunner().invoke(stats_app, ["tokens", "--task", workspace_with_row]) + assert result.exit_code == 0, result.output + assert "1,500" in result.output # total tokens + assert "1,000" in result.output # input tokens + assert "500" in result.output # output tokens + assert "$0.0105" in result.output + + +def test_stats_export_accepts_uuid(workspace_with_row: str, tmp_path: Path): + out = tmp_path / "task.csv" + result = CliRunner().invoke( + stats_app, + ["export", "--format", "csv", "--output", str(out), "--task", workspace_with_row], + ) + assert result.exit_code == 0, result.output + assert "Exported 1 records" in result.output + assert out.exists() + + +def test_repository_get_task_token_summary_by_uuid(tmp_path: Path): + """The repo layer returns the aggregated row for a UUID task_id.""" + repo = tmp_path / "repo" + repo.mkdir() + ws = create_or_load_workspace(repo) + db = Database(str(ws.db_path)) + db.initialize() + try: + db.save_token_usage( + TokenUsage( + task_id=TASK_UUID, + agent_id="react-agent", + project_id=0, + model_name="claude-sonnet-4-5", + input_tokens=1000, + output_tokens=500, + estimated_cost_usd=0.0105, + call_type=CallType.TASK_EXECUTION, + timestamp=datetime.now(timezone.utc), + ) + ) + summary = db.get_task_token_summary(TASK_UUID) + finally: + db.close() + + assert summary["task_id"] == TASK_UUID + assert summary["total_tokens"] == 1500 + assert summary["call_count"] == 1