diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index 66ef6f3b..0bdeedc7 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -435,9 +435,9 @@ class SlackChannel(BaseChannel): marker = f"[{marker_type}: {name}]" url = str(file_info.get("url_private_download") or file_info.get("url_private") or "") if not url: - return None, f"[{marker_type}: {name}: missing download url]" + return None, self._download_failure_marker(marker_type, name, "missing download url") if not self.config.bot_token: - return None, f"[{marker_type}: {name}: missing bot token]" + return None, self._download_failure_marker(marker_type, name, "missing bot token") filename = safe_filename(f"{file_id}_{name}") path = Path(get_media_dir("slack")) / filename @@ -454,7 +454,14 @@ class SlackChannel(BaseChannel): return str(path), marker except Exception as e: logger.warning("Failed to download Slack file {}: {}", file_id, e) - return None, f"[{marker_type}: {name}: download failed]" + return None, self._download_failure_marker(marker_type, name, "download failed") + + @staticmethod + def _download_failure_marker(marker_type: str, name: str, reason: str) -> str: + return ( + f"[{marker_type}: {name}: {reason}; not available to nanobot. " + "Check Slack files:read scope, reinstall the Slack app, and ensure the bot can access the file.]" + ) @staticmethod def _looks_like_html_download(response: httpx.Response) -> bool: diff --git a/tests/channels/test_slack_channel.py b/tests/channels/test_slack_channel.py index 49ae3908..630685ee 100644 --- a/tests/channels/test_slack_channel.py +++ b/tests/channels/test_slack_channel.py @@ -643,6 +643,14 @@ def test_slack_download_rejects_login_html() -> None: assert SlackChannel._looks_like_html_download(markdown_response) is False +def test_slack_download_failure_marker_is_actionable() -> None: + marker = SlackChannel._download_failure_marker("image", "screenshot.png", "download failed") + + assert "not available to nanobot" in marker + assert "files:read" in marker + assert "reinstall the Slack app" in marker + + def test_slack_channel_uses_channel_aware_allow_policy() -> None: channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus()) assert channel.is_allowed("U1") is True diff --git a/tests/utils/test_token_estimation.py b/tests/utils/test_token_estimation.py new file mode 100644 index 00000000..c3257b31 --- /dev/null +++ b/tests/utils/test_token_estimation.py @@ -0,0 +1,32 @@ +from nanobot.utils.helpers import estimate_prompt_tokens_chain + + +class _NoCounterProvider: + pass + + +class _BrokenCounterProvider: + def estimate_prompt_tokens(self, messages, tools=None, model=None): + raise RuntimeError("counter unavailable") + + +def test_estimate_prompt_tokens_chain_falls_back_without_provider_counter() -> None: + tokens, source = estimate_prompt_tokens_chain( + _NoCounterProvider(), + "test-model", + [{"role": "user", "content": "hello"}], + ) + + assert tokens > 0 + assert source == "tiktoken" + + +def test_estimate_prompt_tokens_chain_falls_back_when_provider_counter_fails() -> None: + tokens, source = estimate_prompt_tokens_chain( + _BrokenCounterProvider(), + "test-model", + [{"role": "user", "content": "hello"}], + ) + + assert tokens > 0 + assert source == "tiktoken"