From f8e04be3f974a2c802ae1d3abe617620995d3a26 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 02:56:17 +0000 Subject: [PATCH 01/10] Initial plan From b0c9756a4907f4d85828937356863a0cd0a71d7d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 02:59:18 +0000 Subject: [PATCH 02/10] Propagate runtime model and token usage to review summaries --- src/code_review_bot/agent/acp.py | 12 +++- src/code_review_bot/agent/protocol.py | 1 + src/code_review_bot/review/publish/debug.py | 1 + .../review/publish/formatter.py | 21 ++++++- .../review/publish/platform.py | 1 + src/code_review_bot/review/runner.py | 61 ++++++++++++++++++- src/code_review_bot/skill/protocol.py | 19 +++++- tests/test_acp_runtime.py | 30 +++++++++ tests/test_review_publisher.py | 49 ++++++++++++++- tests/test_review_runner.py | 35 +++++++++++ 10 files changed, 222 insertions(+), 8 deletions(-) diff --git a/src/code_review_bot/agent/acp.py b/src/code_review_bot/agent/acp.py index 6ade0b2..a2466b3 100644 --- a/src/code_review_bot/agent/acp.py +++ b/src/code_review_bot/agent/acp.py @@ -227,6 +227,7 @@ async def request_permission( # in _meta which the server ignores for model selection. env = {**(env or {}), "ANTHROPIC_MODEL": config.model} usage_dict: dict[str, Any] = {} + response_model: str | None = None async with spawn_agent_process( client, config.command, @@ -272,8 +273,13 @@ async def request_permission( session_id=session.session_id, prompt=[text_block(combined_prompt)], ) - if response is not None and response.usage is not None: - usage_dict = _usage_to_dict(response.usage) + if response is not None: + raw_model = getattr(response, "model", None) + response_model = ( + raw_model if isinstance(raw_model, str) and raw_model.strip() else None + ) + if response.usage is not None: + usage_dict = _usage_to_dict(response.usage) except Exception: _log_acp_error( _proc, @@ -290,7 +296,7 @@ async def request_permission( _log_usage(usage_dict) text = client.collected_text.strip() parts: list[dict[str, Any]] = [{"type": "text", "text": text}] if text else [] - return AgentRunResult(text=text, parts=parts, usage=usage_dict) + return AgentRunResult(text=text, parts=parts, usage=usage_dict, model=response_model) def _log_acp_error(proc: object, **context: object) -> None: diff --git a/src/code_review_bot/agent/protocol.py b/src/code_review_bot/agent/protocol.py index b72bdff..a88ac64 100644 --- a/src/code_review_bot/agent/protocol.py +++ b/src/code_review_bot/agent/protocol.py @@ -9,6 +9,7 @@ class AgentRunResult: text: str parts: list[dict[str, Any]] usage: dict[str, Any] + model: str | None = None class CodingAgent(Protocol): diff --git a/src/code_review_bot/review/publish/debug.py b/src/code_review_bot/review/publish/debug.py index 5e0b82d..ac9a6d4 100644 --- a/src/code_review_bot/review/publish/debug.py +++ b/src/code_review_bot/review/publish/debug.py @@ -47,6 +47,7 @@ async def publish( skill_name=skill_name, skill_version=skill_version, fingerprints=fingerprints, + runtime=result.runtime, ) summary_note_clean = _strip_metadata_comment(summary_note) diff --git a/src/code_review_bot/review/publish/formatter.py b/src/code_review_bot/review/publish/formatter.py index 00ea59b..8cbfca6 100644 --- a/src/code_review_bot/review/publish/formatter.py +++ b/src/code_review_bot/review/publish/formatter.py @@ -3,7 +3,7 @@ import json from code_review_bot.platforms.models import ChangeRequest -from code_review_bot.skill.protocol import Finding, count_findings_by_severity +from code_review_bot.skill.protocol import Finding, RuntimeMetadata, count_findings_by_severity BOT_METADATA_PREFIX = "") return "\n".join(lines) + + +def _format_runtime_line(runtime: RuntimeMetadata | None) -> str: + model = runtime.model if runtime and runtime.model else "unavailable" + input_tokens = _format_token_value(runtime.input_tokens if runtime else None) + output_tokens = _format_token_value(runtime.output_tokens if runtime else None) + total_tokens = _format_token_value(runtime.total_tokens if runtime else None) + return ( + f"Model: {model} · Tokens: input {input_tokens} / " + f"output {output_tokens} / total {total_tokens}" + ) + + +def _format_token_value(value: int | None) -> str: + if value is None: + return "unavailable" + return f"{value:,}" diff --git a/src/code_review_bot/review/publish/platform.py b/src/code_review_bot/review/publish/platform.py index f7bb697..c296e23 100644 --- a/src/code_review_bot/review/publish/platform.py +++ b/src/code_review_bot/review/publish/platform.py @@ -45,6 +45,7 @@ async def publish( skill_name=skill_name, skill_version=skill_version, fingerprints=fingerprints, + runtime=result.runtime, ) if publish_summary: await self.adapter.publish_summary(cr.project_ref, cr.cr_id, body) diff --git a/src/code_review_bot/review/runner.py b/src/code_review_bot/review/runner.py index 3210c76..7c1fa45 100644 --- a/src/code_review_bot/review/runner.py +++ b/src/code_review_bot/review/runner.py @@ -3,7 +3,7 @@ from code_review_bot.agent.json_extract import complete_json_with_retries from code_review_bot.agent.protocol import CodingAgent from code_review_bot.review.models import ReviewTaskContext -from code_review_bot.skill.protocol import SkillResult +from code_review_bot.skill.protocol import RuntimeMetadata, SkillResult class PromptBuildingSkill(Protocol): @@ -22,6 +22,25 @@ def __init__(self, agent: CodingAgent, max_json_retries: int = 1) -> None: async def review(self, skill: PromptBuildingSkill, context: ReviewTaskContext) -> SkillResult: prompt = skill.build_prompt(context) + model_candidates: list[str] = [] + token_keys = ("input_tokens", "output_tokens", "total_tokens") + token_totals = {key: 0 for key in token_keys} + token_available = {key: True for key in token_keys} + token_seen = {key: False for key in token_keys} + + def collect_runtime_metadata(result: object) -> None: + model = getattr(result, "model", None) + if isinstance(model, str) and model.strip(): + model_candidates.append(model.strip()) + usage = getattr(result, "usage", {}) + usage_map = usage if isinstance(usage, dict) else {} + for key in token_keys: + value = usage_map.get(key) + if isinstance(value, int) and not isinstance(value, bool): + token_totals[key] += value + token_seen[key] = True + continue + token_available[key] = False async def run(current_prompt: str) -> str: result = await self.agent.run_once( @@ -29,11 +48,49 @@ async def run(current_prompt: str) -> str: agent="plan", additional_directories=getattr(skill, "additional_directories", []), ) + collect_runtime_metadata(result) return result.text - return await complete_json_with_retries( + parsed = await complete_json_with_retries( prompt, SkillResult, run, max_retries=self.max_json_retries, ) + model_value: str | None + unique_models: list[str] = [] + for value in model_candidates: + if value not in unique_models: + unique_models.append(value) + if not unique_models: + model_value = None + elif len(unique_models) == 1: + model_value = unique_models[0] + else: + model_value = f"multiple ({', '.join(unique_models)})" + runtime = RuntimeMetadata( + model=model_value, + input_tokens=( + token_totals["input_tokens"] + if token_seen["input_tokens"] and token_available["input_tokens"] + else None + ), + output_tokens=( + token_totals["output_tokens"] + if token_seen["output_tokens"] and token_available["output_tokens"] + else None + ), + total_tokens=( + token_totals["total_tokens"] + if token_seen["total_tokens"] and token_available["total_tokens"] + else None + ), + ) + if ( + runtime.model is None + and runtime.input_tokens is None + and runtime.output_tokens is None + and runtime.total_tokens is None + ): + return parsed + return parsed.with_runtime(runtime) diff --git a/src/code_review_bot/skill/protocol.py b/src/code_review_bot/skill/protocol.py index 0fdc10a..3103a62 100644 --- a/src/code_review_bot/skill/protocol.py +++ b/src/code_review_bot/skill/protocol.py @@ -1,6 +1,6 @@ from typing import Any, Literal, Protocol -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, PrivateAttr, field_validator _SEVERITIES = ("critical", "high", "medium", "low") @@ -77,6 +77,23 @@ class SkillResult(BaseModel): summary: str findings: list[Finding] = Field(default_factory=list) + _runtime: "RuntimeMetadata | None" = PrivateAttr(default=None) + + @property + def runtime(self) -> "RuntimeMetadata | None": + return self._runtime + + def with_runtime(self, runtime: "RuntimeMetadata | None") -> "SkillResult": + copied = self.model_copy(deep=True) + copied._runtime = runtime + return copied + + +class RuntimeMetadata(BaseModel): + model: str | None = None + input_tokens: int | None = None + output_tokens: int | None = None + total_tokens: int | None = None class ReviewSkill(Protocol): diff --git a/tests/test_acp_runtime.py b/tests/test_acp_runtime.py index 1ca7b49..79302c8 100644 --- a/tests/test_acp_runtime.py +++ b/tests/test_acp_runtime.py @@ -85,3 +85,33 @@ async def fake_spawn_agent_process( assert connection.model_calls == expected_model_calls assert connection.config_calls == expected_config_calls assert captured_env.get("ANTHROPIC_MODEL") == expected_env_model + + +@pytest.mark.asyncio +async def test_acp_runtime_returns_runtime_model_from_agent_response( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + connection = _Connection() + + async def prompt_with_model(**kwargs: object) -> SimpleNamespace: + return SimpleNamespace(usage=None, model="provider/runtime-model") + + connection.prompt = prompt_with_model # type: ignore[method-assign] + + @asynccontextmanager + async def fake_spawn_agent_process( + client: object, + command: str, + *args: str, + env: dict[str, str] | None = None, + **kwargs: object, + ) -> AsyncIterator[tuple[_Connection, SimpleNamespace]]: + yield connection, SimpleNamespace(stdin=None, stdout=None, stderr=None, _transport=None) + + monkeypatch.setattr(acp_sdk, "spawn_agent_process", fake_spawn_agent_process) + agent = AcpCodingAgent(AcpAgentConfig(command="agent", verbose=False), tmp_path) + + run = await agent.run_once("review") + + assert run.model == "provider/runtime-model" diff --git a/tests/test_review_publisher.py b/tests/test_review_publisher.py index c95a9ab..b6f0bab 100644 --- a/tests/test_review_publisher.py +++ b/tests/test_review_publisher.py @@ -4,7 +4,7 @@ from code_review_bot.review.publish.debug import DebugMarkdownPublisher from code_review_bot.review.publish.formatter import BOT_METADATA_PREFIX, format_review_note from code_review_bot.review.publish.platform import PlatformPublisher -from code_review_bot.skill.protocol import Finding, SkillResult +from code_review_bot.skill.protocol import Finding, RuntimeMetadata, SkillResult def make_change_request() -> ChangeRequest: @@ -38,6 +38,17 @@ def make_finding(**overrides: object) -> Finding: return Finding(**data) +def make_runtime(**overrides: object) -> RuntimeMetadata: + data: dict[str, object] = { + "model": "provider/model", + "input_tokens": 12345, + "output_tokens": 678, + "total_tokens": 13023, + } + data.update(overrides) + return RuntimeMetadata(**data) + + class FakeAdapter: """In-memory PlatformAdapter for tests.""" @@ -182,6 +193,42 @@ async def test_formatter_includes_unlocated_finding() -> None: assert "A risky pattern" in body +@pytest.mark.asyncio +async def test_formatter_includes_runtime_line() -> None: + body = format_review_note( + cr=make_change_request(), + summary="Reviewed", + severity_counts={"critical": 0, "high": 1, "medium": 0, "low": 0}, + located_count=1, + unlocated_findings=[], + skill_name="default", + skill_version="1", + fingerprints=["fp1"], + runtime=make_runtime(), + ) + + assert "Model: provider/model · Tokens: input 12,345 / output 678 / total 13,023" in body + + +@pytest.mark.asyncio +async def test_formatter_marks_unavailable_runtime_values() -> None: + body = format_review_note( + cr=make_change_request(), + summary="Reviewed", + severity_counts={"critical": 0, "high": 1, "medium": 0, "low": 0}, + located_count=1, + unlocated_findings=[], + skill_name="default", + skill_version="1", + fingerprints=["fp1"], + runtime=make_runtime(model=None, output_tokens=None), + ) + + assert ( + "Model: unavailable · Tokens: input 12,345 / output unavailable / total 13,023" in body + ) + + @pytest.mark.asyncio async def test_publisher_renders_resolved_findings() -> None: adapter = FakeAdapter() diff --git a/tests/test_review_runner.py b/tests/test_review_runner.py index 3958b10..a4ad97a 100644 --- a/tests/test_review_runner.py +++ b/tests/test_review_runner.py @@ -67,6 +67,7 @@ async def test_coding_agent_review_runner_uses_plan_agent_and_parses_json() -> N result = await CodingAgentReviewRunner(agent).review(FakeSkill(), make_task_context()) assert result.summary == "ok" + assert result.runtime is None assert agent.agent == "plan" assert agent.prompts == ["review prompt"] assert agent.additional_directories == ["/tmp/skills/code-review"] @@ -90,3 +91,37 @@ async def run_once(self, prompt: str, **kwargs: object) -> AgentRunResult: assert result.summary == "retry ok" assert call_count == 2 + + +@pytest.mark.asyncio +async def test_coding_agent_review_runner_aggregates_runtime_across_retries() -> None: + call_count = 0 + + class RetryAgent: + async def run_once(self, prompt: str, **kwargs: object) -> AgentRunResult: + nonlocal call_count + call_count += 1 + if call_count == 1: + return AgentRunResult( + text="not json", + parts=[], + usage={"input_tokens": 10, "output_tokens": 2, "total_tokens": 12}, + model="provider/model-a", + ) + return AgentRunResult( + text='{"summary":"retry ok","findings":[]}', + parts=[], + usage={"input_tokens": 20, "output_tokens": 3, "total_tokens": 23}, + model="provider/model-a", + ) + + result = await CodingAgentReviewRunner(RetryAgent(), max_json_retries=1).review( + FakeSkill(), make_task_context() + ) + + assert call_count == 2 + assert result.runtime is not None + assert result.runtime.model == "provider/model-a" + assert result.runtime.input_tokens == 30 + assert result.runtime.output_tokens == 5 + assert result.runtime.total_tokens == 35 From 1fec46a4ac01f91995a62c688d450649c155bd09 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 03:00:05 +0000 Subject: [PATCH 03/10] Refine runtime aggregation messaging --- src/code_review_bot/review/runner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/code_review_bot/review/runner.py b/src/code_review_bot/review/runner.py index 7c1fa45..a14402b 100644 --- a/src/code_review_bot/review/runner.py +++ b/src/code_review_bot/review/runner.py @@ -36,7 +36,7 @@ def collect_runtime_metadata(result: object) -> None: usage_map = usage if isinstance(usage, dict) else {} for key in token_keys: value = usage_map.get(key) - if isinstance(value, int) and not isinstance(value, bool): + if isinstance(value, int): token_totals[key] += value token_seen[key] = True continue @@ -67,7 +67,7 @@ async def run(current_prompt: str) -> str: elif len(unique_models) == 1: model_value = unique_models[0] else: - model_value = f"multiple ({', '.join(unique_models)})" + model_value = f"multiple models used: {', '.join(unique_models)}" runtime = RuntimeMetadata( model=model_value, input_tokens=( From 6d2f16954f69d2f2f09522fe0d35ccde642ceba7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 03:00:59 +0000 Subject: [PATCH 04/10] Simplify runtime aggregation logic --- src/code_review_bot/review/runner.py | 34 ++++++++++++++-------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/code_review_bot/review/runner.py b/src/code_review_bot/review/runner.py index a14402b..5e622d8 100644 --- a/src/code_review_bot/review/runner.py +++ b/src/code_review_bot/review/runner.py @@ -25,10 +25,12 @@ async def review(self, skill: PromptBuildingSkill, context: ReviewTaskContext) - model_candidates: list[str] = [] token_keys = ("input_tokens", "output_tokens", "total_tokens") token_totals = {key: 0 for key in token_keys} - token_available = {key: True for key in token_keys} - token_seen = {key: False for key in token_keys} + token_valid_counts = {key: 0 for key in token_keys} + run_count = 0 def collect_runtime_metadata(result: object) -> None: + nonlocal run_count + run_count += 1 model = getattr(result, "model", None) if isinstance(model, str) and model.strip(): model_candidates.append(model.strip()) @@ -38,9 +40,7 @@ def collect_runtime_metadata(result: object) -> None: value = usage_map.get(key) if isinstance(value, int): token_totals[key] += value - token_seen[key] = True - continue - token_available[key] = False + token_valid_counts[key] += 1 async def run(current_prompt: str) -> str: result = await self.agent.run_once( @@ -58,10 +58,7 @@ async def run(current_prompt: str) -> str: max_retries=self.max_json_retries, ) model_value: str | None - unique_models: list[str] = [] - for value in model_candidates: - if value not in unique_models: - unique_models.append(value) + unique_models = list(dict.fromkeys(model_candidates)) if not unique_models: model_value = None elif len(unique_models) == 1: @@ -72,25 +69,28 @@ async def run(current_prompt: str) -> str: model=model_value, input_tokens=( token_totals["input_tokens"] - if token_seen["input_tokens"] and token_available["input_tokens"] + if run_count > 0 and token_valid_counts["input_tokens"] == run_count else None ), output_tokens=( token_totals["output_tokens"] - if token_seen["output_tokens"] and token_available["output_tokens"] + if run_count > 0 and token_valid_counts["output_tokens"] == run_count else None ), total_tokens=( token_totals["total_tokens"] - if token_seen["total_tokens"] and token_available["total_tokens"] + if run_count > 0 and token_valid_counts["total_tokens"] == run_count else None ), ) - if ( - runtime.model is None - and runtime.input_tokens is None - and runtime.output_tokens is None - and runtime.total_tokens is None + if not any( + value is not None + for value in ( + runtime.model, + runtime.input_tokens, + runtime.output_tokens, + runtime.total_tokens, + ) ): return parsed return parsed.with_runtime(runtime) From 4441d8b01befbf14052ad80284f35b4a0ca5f616 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 03:01:52 +0000 Subject: [PATCH 05/10] Polish runtime metadata formatting --- .../review/publish/formatter.py | 2 ++ src/code_review_bot/review/runner.py | 9 ++++--- tests/test_review_runner.py | 25 +++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/code_review_bot/review/publish/formatter.py b/src/code_review_bot/review/publish/formatter.py index 8cbfca6..1527932 100644 --- a/src/code_review_bot/review/publish/formatter.py +++ b/src/code_review_bot/review/publish/formatter.py @@ -68,6 +68,8 @@ def format_review_note( f"{finding.description}~~" ) lines.append("") + if lines and lines[-1] != "": + lines.append("") lines.append(_format_runtime_line(runtime)) lines.append( f"_Generated by [whhe/code-review-bot]({CODE_REVIEW_BOT_URL}) · " diff --git a/src/code_review_bot/review/runner.py b/src/code_review_bot/review/runner.py index 5e622d8..b239815 100644 --- a/src/code_review_bot/review/runner.py +++ b/src/code_review_bot/review/runner.py @@ -57,14 +57,17 @@ async def run(current_prompt: str) -> str: run, max_retries=self.max_json_retries, ) - model_value: str | None + model_value: str | None = None unique_models = list(dict.fromkeys(model_candidates)) if not unique_models: - model_value = None + pass elif len(unique_models) == 1: model_value = unique_models[0] else: - model_value = f"multiple models used: {', '.join(unique_models)}" + preview = ", ".join(unique_models[:2]) + extra = len(unique_models) - 2 + suffix = f", and {extra} more" if extra > 0 else "" + model_value = f"multiple models used: {preview}{suffix}" runtime = RuntimeMetadata( model=model_value, input_tokens=( diff --git a/tests/test_review_runner.py b/tests/test_review_runner.py index a4ad97a..21469ab 100644 --- a/tests/test_review_runner.py +++ b/tests/test_review_runner.py @@ -125,3 +125,28 @@ async def run_once(self, prompt: str, **kwargs: object) -> AgentRunResult: assert result.runtime.input_tokens == 30 assert result.runtime.output_tokens == 5 assert result.runtime.total_tokens == 35 + + +@pytest.mark.asyncio +async def test_coding_agent_review_runner_reports_multiple_models_used() -> None: + call_count = 0 + + class RetryAgent: + async def run_once(self, prompt: str, **kwargs: object) -> AgentRunResult: + nonlocal call_count + call_count += 1 + if call_count == 1: + return AgentRunResult(text="not json", parts=[], usage={}, model="provider/model-a") + return AgentRunResult( + text='{"summary":"retry ok","findings":[]}', + parts=[], + usage={}, + model="provider/model-b", + ) + + result = await CodingAgentReviewRunner(RetryAgent(), max_json_retries=1).review( + FakeSkill(), make_task_context() + ) + + assert result.runtime is not None + assert result.runtime.model == "multiple models used: provider/model-a, provider/model-b" From 0340e1484c900ef5f8cd22ba4365e13255cecfef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 03:02:31 +0000 Subject: [PATCH 06/10] Refine runtime model and token aggregation helpers --- src/code_review_bot/review/runner.py | 29 ++++++++++------------------ 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/src/code_review_bot/review/runner.py b/src/code_review_bot/review/runner.py index b239815..4faa285 100644 --- a/src/code_review_bot/review/runner.py +++ b/src/code_review_bot/review/runner.py @@ -64,27 +64,18 @@ async def run(current_prompt: str) -> str: elif len(unique_models) == 1: model_value = unique_models[0] else: - preview = ", ".join(unique_models[:2]) - extra = len(unique_models) - 2 - suffix = f", and {extra} more" if extra > 0 else "" - model_value = f"multiple models used: {preview}{suffix}" + model_value = f"multiple models used: {', '.join(unique_models)}" + + def aggregate_tokens(key: str) -> int | None: + if run_count == 0 or token_valid_counts[key] != run_count: + return None + return token_totals[key] + runtime = RuntimeMetadata( model=model_value, - input_tokens=( - token_totals["input_tokens"] - if run_count > 0 and token_valid_counts["input_tokens"] == run_count - else None - ), - output_tokens=( - token_totals["output_tokens"] - if run_count > 0 and token_valid_counts["output_tokens"] == run_count - else None - ), - total_tokens=( - token_totals["total_tokens"] - if run_count > 0 and token_valid_counts["total_tokens"] == run_count - else None - ), + input_tokens=aggregate_tokens("input_tokens"), + output_tokens=aggregate_tokens("output_tokens"), + total_tokens=aggregate_tokens("total_tokens"), ) if not any( value is not None From 25ae407fa1462f30bc82b190c5218b22e6abc06a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 03:03:12 +0000 Subject: [PATCH 07/10] Clarify complete-usage aggregation behavior --- src/code_review_bot/review/runner.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/code_review_bot/review/runner.py b/src/code_review_bot/review/runner.py index 4faa285..32c8e4a 100644 --- a/src/code_review_bot/review/runner.py +++ b/src/code_review_bot/review/runner.py @@ -59,14 +59,14 @@ async def run(current_prompt: str) -> str: ) model_value: str | None = None unique_models = list(dict.fromkeys(model_candidates)) - if not unique_models: - pass - elif len(unique_models) == 1: + if len(unique_models) == 1: model_value = unique_models[0] - else: + elif len(unique_models) > 1: model_value = f"multiple models used: {', '.join(unique_models)}" def aggregate_tokens(key: str) -> int | None: + # Show totals only when every call reported the metric so the final + # summary never presents partial usage as complete usage. if run_count == 0 or token_valid_counts[key] != run_count: return None return token_totals[key] From 7a5ab350e4f5261b526571b53815a3869ad5777e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 03:03:58 +0000 Subject: [PATCH 08/10] Address review nits in runtime footer logic --- src/code_review_bot/review/publish/formatter.py | 2 +- src/code_review_bot/review/runner.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/code_review_bot/review/publish/formatter.py b/src/code_review_bot/review/publish/formatter.py index 1527932..5165d15 100644 --- a/src/code_review_bot/review/publish/formatter.py +++ b/src/code_review_bot/review/publish/formatter.py @@ -68,7 +68,7 @@ def format_review_note( f"{finding.description}~~" ) lines.append("") - if lines and lines[-1] != "": + if lines[-1] != "": lines.append("") lines.append(_format_runtime_line(runtime)) lines.append( diff --git a/src/code_review_bot/review/runner.py b/src/code_review_bot/review/runner.py index 32c8e4a..cbe3fd4 100644 --- a/src/code_review_bot/review/runner.py +++ b/src/code_review_bot/review/runner.py @@ -67,7 +67,7 @@ async def run(current_prompt: str) -> str: def aggregate_tokens(key: str) -> int | None: # Show totals only when every call reported the metric so the final # summary never presents partial usage as complete usage. - if run_count == 0 or token_valid_counts[key] != run_count: + if token_valid_counts[key] != run_count: return None return token_totals[key] From b67f1b156f42d8196257b8de5836fc8bdc01ac3b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 03:04:47 +0000 Subject: [PATCH 09/10] Add missing-runtime formatter coverage --- .../review/publish/formatter.py | 2 +- src/code_review_bot/review/runner.py | 3 ++- tests/test_review_publisher.py | 20 +++++++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/code_review_bot/review/publish/formatter.py b/src/code_review_bot/review/publish/formatter.py index 5165d15..1527932 100644 --- a/src/code_review_bot/review/publish/formatter.py +++ b/src/code_review_bot/review/publish/formatter.py @@ -68,7 +68,7 @@ def format_review_note( f"{finding.description}~~" ) lines.append("") - if lines[-1] != "": + if lines and lines[-1] != "": lines.append("") lines.append(_format_runtime_line(runtime)) lines.append( diff --git a/src/code_review_bot/review/runner.py b/src/code_review_bot/review/runner.py index cbe3fd4..84f886a 100644 --- a/src/code_review_bot/review/runner.py +++ b/src/code_review_bot/review/runner.py @@ -66,7 +66,8 @@ async def run(current_prompt: str) -> str: def aggregate_tokens(key: str) -> int | None: # Show totals only when every call reported the metric so the final - # summary never presents partial usage as complete usage. + # summary never presents partial usage as complete usage. A partial + # sum can under-report total tokens and look authoritative. if token_valid_counts[key] != run_count: return None return token_totals[key] diff --git a/tests/test_review_publisher.py b/tests/test_review_publisher.py index b6f0bab..5429329 100644 --- a/tests/test_review_publisher.py +++ b/tests/test_review_publisher.py @@ -229,6 +229,26 @@ async def test_formatter_marks_unavailable_runtime_values() -> None: ) +@pytest.mark.asyncio +async def test_formatter_marks_unavailable_when_runtime_missing() -> None: + body = format_review_note( + cr=make_change_request(), + summary="Reviewed", + severity_counts={"critical": 0, "high": 1, "medium": 0, "low": 0}, + located_count=1, + unlocated_findings=[], + skill_name="default", + skill_version="1", + fingerprints=["fp1"], + runtime=None, + ) + + assert ( + "Model: unavailable · Tokens: input unavailable / output unavailable / total unavailable" + in body + ) + + @pytest.mark.asyncio async def test_publisher_renders_resolved_findings() -> None: adapter = FakeAdapter() From 7f83bde86e0d40edd2d5914371e3a26dce20bf7b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 03:22:53 +0000 Subject: [PATCH 10/10] Fix bool handling in runtime token aggregation --- src/code_review_bot/review/runner.py | 2 +- tests/test_review_runner.py | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/code_review_bot/review/runner.py b/src/code_review_bot/review/runner.py index 84f886a..91100e0 100644 --- a/src/code_review_bot/review/runner.py +++ b/src/code_review_bot/review/runner.py @@ -38,7 +38,7 @@ def collect_runtime_metadata(result: object) -> None: usage_map = usage if isinstance(usage, dict) else {} for key in token_keys: value = usage_map.get(key) - if isinstance(value, int): + if isinstance(value, int) and not isinstance(value, bool): token_totals[key] += value token_valid_counts[key] += 1 diff --git a/tests/test_review_runner.py b/tests/test_review_runner.py index 21469ab..4b2a5e1 100644 --- a/tests/test_review_runner.py +++ b/tests/test_review_runner.py @@ -150,3 +150,25 @@ async def run_once(self, prompt: str, **kwargs: object) -> AgentRunResult: assert result.runtime is not None assert result.runtime.model == "multiple models used: provider/model-a, provider/model-b" + + +@pytest.mark.asyncio +async def test_coding_agent_review_runner_does_not_treat_bool_usage_as_int() -> None: + class BoolUsageAgent: + async def run_once(self, prompt: str, **kwargs: object) -> AgentRunResult: + return AgentRunResult( + text='{"summary":"ok","findings":[]}', + parts=[], + usage={"input_tokens": True, "output_tokens": 3, "total_tokens": 3}, + model="provider/model-a", + ) + + result = await CodingAgentReviewRunner(BoolUsageAgent()).review( + FakeSkill(), make_task_context() + ) + + assert result.runtime is not None + assert result.runtime.model == "provider/model-a" + assert result.runtime.input_tokens is None + assert result.runtime.output_tokens == 3 + assert result.runtime.total_tokens == 3