diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54f0b82..4520d53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,11 +33,12 @@ jobs: path: ~/.cache/pip key: ${{ runner.os }}-pip-3.12-${{ hashFiles('pyproject.toml') }} - run: pip install -e ".[dev]" - - run: mypy call_use/ --ignore-missing-imports + - run: mypy call_use/ --ignore-missing-imports --check-untyped-defs test: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: python-version: ["3.11", "3.12", "3.13"] steps: @@ -66,13 +67,41 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - - run: pip install bandit safety + - run: pip install bandit pip-audit - run: bandit -r call_use/ -c pyproject.toml -ll - - run: safety check --short-report + - run: pip-audit --strict + + commit-lint: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install commitizen + - run: cz check --rev-range origin/${{ github.base_ref }}..HEAD + + pr-size: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Check PR size + run: | + LINES=$(git diff --stat origin/${{ github.base_ref }}...HEAD -- '*.py' | tail -1 | awk '{print $4+$6}') + echo "Changed lines: ${LINES:-0}" + if [ "${LINES:-0}" -gt 500 ]; then + echo "::warning::PR exceeds 500 line guideline (${LINES} lines changed). Consider splitting." + fi build: runs-on: ubuntu-latest - needs: [lint, typecheck, test] + needs: [lint, typecheck, test, security] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 diff --git a/Makefile b/Makefile index a80edad..4159f11 100644 --- a/Makefile +++ b/Makefile @@ -20,11 +20,11 @@ format: ruff format call_use/ tests/ typecheck: - mypy call_use/ --ignore-missing-imports + mypy call_use/ --ignore-missing-imports --check-untyped-defs security: bandit -r call_use/ -c pyproject.toml -ll - safety check --short-report || true + pip-audit --strict build: clean python3 -m build diff --git a/call_use/agent.py b/call_use/agent.py index cead7e5..b92c1e6 100644 --- a/call_use/agent.py +++ b/call_use/agent.py @@ -255,11 +255,13 @@ async def on_enter(self): def _handle_data(dp): task = asyncio.create_task(self._on_data_received(dp)) - task.add_done_callback( - lambda t: ( - t.exception() and logger.error("data handler error", exc_info=t.exception()) - ) - ) + + def _log_task_error(t: asyncio.Task) -> None: + exc = t.exception() + if exc: + logger.error("data handler error", exc_info=exc) + + task.add_done_callback(_log_task_error) self._room.on("data_received", _handle_data) await self._set_state(CallStateEnum.connected) @@ -639,7 +641,7 @@ def _on_conversation_item(ev): if getattr(msg, "role", None) != "assistant": return text = msg.text_content if hasattr(msg, "text_content") else str(msg) - if text: + if text and self._evidence: asyncio.create_task(self._evidence.emit_transcript("agent", text)) @session.on("function_tools_executed") @@ -653,7 +655,7 @@ def _on_tools_executed(ev): keys = args.get("keys", "") else: keys = "" - if keys: + if keys and self._evidence: asyncio.create_task(self._evidence.emit_dtmf(keys)) # Initial greeting — called AFTER session.start(), NOT in on_enter() diff --git a/call_use/cli.py b/call_use/cli.py index bbc6efc..136a4e6 100644 --- a/call_use/cli.py +++ b/call_use/cli.py @@ -451,7 +451,7 @@ def setup(): click.echo(click.style(f" \u2713 {provider['name']}", fg="green")) click.echo() - for key_def in provider["keys"]: # type: ignore[union-attr] + for key_def in provider["keys"]: # type: ignore[union-attr, attr-defined] _prompt_key(key_def, values) # type: ignore[arg-type] # --- STT key --- diff --git a/call_use/sdk.py b/call_use/sdk.py index 03b70dd..01dbd4c 100644 --- a/call_use/sdk.py +++ b/call_use/sdk.py @@ -197,6 +197,7 @@ def _on_data(dp): async def _handle_approval(): loop = asyncio.get_running_loop() + assert self._on_approval is not None result = await loop.run_in_executor(None, self._on_approval, event.data) await self._send_approval_response( room_name, diff --git a/tests/test_agent.py b/tests/test_agent.py index dab44c7..ebeb460 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -502,6 +502,47 @@ def capture_on(event_name, handler): # Give the task time to run await asyncio.sleep(0.1) + async def test_on_enter_data_handler_error_callback_calls_logger(self): + """on_enter error callback logs when _on_data_received raises (line 262).""" + agent = _make_agent() + agent._current_state = CallStateEnum.connected + agent._room = MagicMock() + agent._lk_api = MagicMock() + agent._lk_api.room.update_room_metadata = AsyncMock() + agent._room.local_participant.identity = "agent-abc" + agent._room.name = "test-room" + + # Make _on_data_received raise so the done-callback error branch fires + agent._on_data_received = AsyncMock(side_effect=RuntimeError("boom")) + + registered_handler = None + + def capture_on(event_name, handler): + nonlocal registered_handler + registered_handler = handler + + agent._room.on = capture_on + + await agent.on_enter() + assert registered_handler is not None + + dp = MagicMock() + + with pytest.MonkeyPatch.context() as mp: + import call_use.agent as agent_mod + + mock_logger = MagicMock() + mp.setattr(agent_mod, "logger", mock_logger) + + registered_handler(dp) + # Let the task and done-callback run + await asyncio.sleep(0.1) + + mock_logger.error.assert_called_once() + args = mock_logger.error.call_args + assert args[0][0] == "data handler error" + assert isinstance(args[1]["exc_info"], RuntimeError) + async def test_on_enter_no_room_returns_early(self): """on_enter returns early if room is None.""" agent = _make_agent()