From 1395b8bcc1b9725c82c42a9f213f06de95eb66d3 Mon Sep 17 00:00:00 2001 From: Time4Mind <119820237+Time4Mind@users.noreply.github.com> Date: Sat, 23 May 2026 14:44:51 +0300 Subject: [PATCH 1/2] chore(voice): remove dead OpenAI transcription backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VOICE_BACKEND=openai was unreachable: config.py coerces any value not in (auto/whisper/apple/off) to auto, and the per-user voice setting allowlist excludes openai too — so config.voice_backend could never equal 'openai' and the _openai_transcribe path was never taken. Remove it entirely: - transcribe.py: drop _openai_transcribe, the httpx _client/_get_client, close_client, the dispatch branch, and the openai docstring lines. - config.py: drop the dead openai_api_key / openai_base_url fields (SENSITIVE_ENV_VARS keeps scrubbing OPENAI_API_KEY from subprocess env — that is a standalone safety measure, not tied to the removed reader). - bot/messages.py: drop the unreachable VOICE_BACKEND=openai warning. - bot/app.py: drop the close_transcribe_client import + shutdown call. - tests: delete the openai-only test_transcribe.py; trim test_config.py's openai field tests (keep the env-scrub test). - docs: deploy.md / architecture.md / secrets.md / README*.md. 548 tests pass · ruff clean · pyright 0 errors. Co-Authored-By: Claude Opus 4.7 --- .claude/rules/architecture.md | 2 +- .claude/rules/secrets.md | 1 - README.md | 3 +- README_CN.md | 2 +- README_RU.md | 3 +- doc/deploy.md | 2 - src/ccbot/bot/app.py | 3 - src/ccbot/bot/messages.py | 8 --- src/ccbot/config.py | 6 -- src/ccbot/transcribe.py | 44 +----------- tests/ccbot/test_config.py | 22 ++---- tests/ccbot/test_transcribe.py | 124 --------------------------------- 12 files changed, 10 insertions(+), 210 deletions(-) delete mode 100644 tests/ccbot/test_transcribe.py diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index d72c6029..75264961 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -72,7 +72,7 @@ Additional modules: screenshot.py ─ Terminal text → PNG rendering (ANSI color, font fallback) - transcribe.py ─ Voice-to-text transcription via whisper.cpp / Apple / OpenAI + transcribe.py ─ Voice-to-text transcription via whisper.cpp / Apple Speech i18n.py ─ Per-user UI strings (en / ru / zh) naming.py ─ Haiku-generated session names + readable summaries usage.py ─ Token usage aggregator + per-session token alerts diff --git a/.claude/rules/secrets.md b/.claude/rules/secrets.md index 4b035e7a..21e20e40 100644 --- a/.claude/rules/secrets.md +++ b/.claude/rules/secrets.md @@ -10,7 +10,6 @@ where **not** to put) credentials when working on or with ccbot. | ccbot Telegram bot token | `~/.ccbot/.env` → `TELEGRAM_BOT_TOKEN=…` (or `./.env` in cwd) | | ccbot allowlist (Telegram user ids) | `~/.ccbot/.env` → `ALLOWED_USERS=…` | | ccbot outbound TG proxy (optional) | `~/.ccbot/.env` → `TG_PROXY_URL=…` | -| OpenAI fallback voice key (optional) | `~/.ccbot/.env` → `OPENAI_API_KEY=…` | | Claude Code login token | `claude auth status` — managed by the CLI, not a file in the repo | | whisper.cpp model | `~/.ccbot/models/ggml-medium.bin` (path overridable via `WHISPER_MODEL_PATH`) | | ccbot persisted state | `~/.ccbot/state.json` — non-secret, but contains user ids / paths | diff --git a/README.md b/README.md index e709cb30..273d799e 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,7 @@ ways that are intentional and not negotiable: writes `session_map.json`; the monitor polls it. No reliance on process-tree introspection or claude SDK. - **Voice transcription is local-first.** `whisper.cpp` (default) or - Apple Speech via PyObjC on macOS. The OpenAI fallback exists but is - off by default — no API key required to run. + Apple Speech via PyObjC on macOS — no API key required to run. The full design rationale lives in `doc/dm-multisession-spec.md`. The implementation map is in `doc/dm-multisession-plan.md`. diff --git a/README_CN.md b/README_CN.md index 7fa85a30..87f17c29 100644 --- a/README_CN.md +++ b/README_CN.md @@ -43,7 +43,7 @@ ccbot 让你可以: - **基于 hook 的会话跟踪。** Claude Code 的 `SessionStart` hook 写入 `session_map.json`;监控器轮询它。不依赖进程树检查或 claude SDK。 - **语音 — 本地优先。** `whisper.cpp`(默认)或 macOS 上通过 PyObjC - 的 Apple Speech。OpenAI fallback 存在但默认关闭 — 运行不需要 API key。 + 的 Apple Speech — 运行不需要 API key。 完整的设计动机在 `doc/dm-multisession-spec.md`。实现地图在 `doc/dm-multisession-plan.md`。 diff --git a/README_RU.md b/README_RU.md index 461ab005..a1f0b8a5 100644 --- a/README_RU.md +++ b/README_RU.md @@ -52,8 +52,7 @@ Claude Code живёт в терминале. Отошёл от стола — пишет `session_map.json`; монитор бота его опрашивает. Никаких process-tree introspection или claude SDK. - **Голос — local-first.** `whisper.cpp` (по умолчанию) или Apple - Speech через PyObjC на macOS. OpenAI-fallback есть, но выключен — - API-ключ для запуска не нужен. + Speech через PyObjC на macOS — API-ключ для запуска не нужен. Полная архитектурная мотивация — в `doc/dm-multisession-spec.md`. Карта реализации — в `doc/dm-multisession-plan.md`. diff --git a/doc/deploy.md b/doc/deploy.md index 33169fb9..35296b55 100644 --- a/doc/deploy.md +++ b/doc/deploy.md @@ -103,8 +103,6 @@ curl -s --max-time 8 -x "$TG_PROXY_URL" \ - `VOICE_BACKEND=whisper` → requires `WHISPER_BIN` (default `whisper-cli`) and `WHISPER_MODEL_PATH` (default `$CCBOT_DIR/models/ggml-medium.bin`, ~1.5GB). -- `VOICE_BACKEND=openai` → falls back to the legacy gpt-4o-transcribe - HTTP path; needs `OPENAI_API_KEY`. - `VOICE_BACKEND=off` → reject voice messages. ## State and disk usage diff --git a/src/ccbot/bot/app.py b/src/ccbot/bot/app.py index cfa69fe1..32efc9c0 100644 --- a/src/ccbot/bot/app.py +++ b/src/ccbot/bot/app.py @@ -31,7 +31,6 @@ from ..metrics import metrics_flush_loop from ..session import session_manager from ..session_monitor import NewMessage, SessionMonitor -from ..transcribe import close_client as close_transcribe_client from ._common import CC_COMMANDS from .callbacks import callback_handler from .commands.info import ( @@ -315,8 +314,6 @@ async def post_shutdown( await session_monitor.stop() logger.info("Session monitor stopped") - await close_transcribe_client() - def create_bot() -> "Application[Any, Any, Any, Any, Any, Any]": """Build the Application, wire all handlers, return it ready to run_polling.""" diff --git a/src/ccbot/bot/messages.py b/src/ccbot/bot/messages.py index eb398aa8..b9c2507e 100644 --- a/src/ccbot/bot/messages.py +++ b/src/ccbot/bot/messages.py @@ -607,14 +607,6 @@ async def voice_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N if config.voice_backend == "off": await safe_reply(update.message, "⚠ Voice is disabled (VOICE_BACKEND=off).") return - if config.voice_backend == "openai" and not config.openai_api_key: - await safe_reply( - update.message, - "⚠ VOICE_BACKEND=openai but OPENAI_API_KEY is unset.\n" - "Set the key or switch VOICE_BACKEND to whisper/auto.", - ) - return - wid = active_window(user.id) if wid is None: await safe_reply( diff --git a/src/ccbot/config.py b/src/ccbot/config.py index 12f48164..596c439a 100644 --- a/src/ccbot/config.py +++ b/src/ccbot/config.py @@ -131,12 +131,6 @@ def __init__(self) -> None: os.getenv("CCBOT_SHOW_HIDDEN_DIRS", "").lower() == "true" ) - # OpenAI API for voice message transcription (optional) - self.openai_api_key: str = os.getenv("OPENAI_API_KEY", "") - self.openai_base_url: str = os.getenv( - "OPENAI_BASE_URL", "https://api.openai.com/v1" - ) - # --- DM multi-session mode --- # Sessions self.max_sessions: int = int(os.getenv("MAX_SESSIONS", "10")) diff --git a/src/ccbot/transcribe.py b/src/ccbot/transcribe.py index ce77f80c..a8d2d4a4 100644 --- a/src/ccbot/transcribe.py +++ b/src/ccbot/transcribe.py @@ -7,14 +7,12 @@ - "apple": macOS Apple Speech via SFSpeechRecognizer (PyObjC). Falls back to whisper.cpp on permission denial / unavailable recognizer / missing pyobjc-framework-Speech. - - "openai": legacy OpenAI gpt-4o-transcribe HTTP API. - "off": voice messages rejected. -DM-multisession spec section 8 — J4 selected, but the OpenAI path is kept -for sites that already have an API key configured. +DM-multisession spec section 8 — J4 selected: transcription is local +(whisper.cpp / Apple Speech), no third-party API key required. Public API: transcribe_voice(ogg_data) -> str (raises ValueError on failure). -close_client() — no-op except for the OpenAI path. """ from __future__ import annotations @@ -25,21 +23,10 @@ import platform import tempfile -import httpx - from .config import config logger = logging.getLogger(__name__) -_client: httpx.AsyncClient | None = None - - -def _get_client() -> httpx.AsyncClient: - global _client - if _client is None or _client.is_closed: - _client = httpx.AsyncClient(timeout=30.0) - return _client - async def _run(cmd: list[str], stdin: bytes | None = None) -> tuple[int, bytes, bytes]: """Run a subprocess. Returns (returncode, stdout, stderr).""" @@ -201,24 +188,6 @@ async def _apple_speech_transcribe(ogg_data: bytes) -> str: pass -async def _openai_transcribe(ogg_data: bytes) -> str: - if not config.openai_api_key: - raise ValueError("OpenAI backend selected but OPENAI_API_KEY is unset") - url = f"{config.openai_base_url.rstrip('/')}/audio/transcriptions" - client = _get_client() - response = await client.post( - url, - headers={"Authorization": f"Bearer {config.openai_api_key}"}, - files={"file": ("voice.ogg", ogg_data, "audio/ogg")}, - data={"model": "gpt-4o-transcribe"}, - ) - response.raise_for_status() - text = response.json().get("text", "").strip() - if not text: - raise ValueError("Empty transcription returned by API") - return text - - async def transcribe_voice(ogg_data: bytes, user_id: int | None = None) -> str: """Dispatch to the configured backend; raise ValueError on failure. @@ -242,18 +211,9 @@ async def transcribe_voice(ogg_data: bytes, user_id: int | None = None) -> str: if backend == "auto": backend = "apple" if platform.system() == "Darwin" else "whisper" - if backend == "openai": - return await _openai_transcribe(ogg_data) if backend == "whisper": return await _whisper_cpp_transcribe(ogg_data) if backend == "apple": return await _apple_speech_transcribe(ogg_data) raise ValueError(f"Unknown VOICE_BACKEND: {backend}") - - -async def close_client() -> None: - global _client - if _client is not None and not _client.is_closed: - await _client.aclose() - _client = None diff --git a/tests/ccbot/test_config.py b/tests/ccbot/test_config.py index 95cf35f9..5862ce62 100644 --- a/tests/ccbot/test_config.py +++ b/tests/ccbot/test_config.py @@ -93,25 +93,11 @@ def test_ccbot_projects_path_takes_priority(self, monkeypatch): @pytest.mark.usefixtures("_base_env") -class TestConfigOpenAI: - def test_openai_defaults(self, monkeypatch): - monkeypatch.delenv("OPENAI_API_KEY", raising=False) - monkeypatch.delenv("OPENAI_BASE_URL", raising=False) - cfg = Config() - assert cfg.openai_api_key == "" - assert cfg.openai_base_url == "https://api.openai.com/v1" - - def test_openai_api_key(self, monkeypatch): - monkeypatch.setenv("OPENAI_API_KEY", "sk-test-123") - cfg = Config() - assert cfg.openai_api_key == "sk-test-123" - - def test_openai_base_url(self, monkeypatch): - monkeypatch.setenv("OPENAI_BASE_URL", "https://proxy.example.com/v1") - cfg = Config() - assert cfg.openai_base_url == "https://proxy.example.com/v1" - +class TestSensitiveEnvScrub: def test_openai_api_key_scrubbed_from_env(self, monkeypatch): + # OPENAI_API_KEY is in SENSITIVE_ENV_VARS, so Config() scrubs it from + # the environment — it must not leak to Claude Code subprocesses + # spawned via tmux (ccbot itself no longer reads it). import os monkeypatch.setenv("OPENAI_API_KEY", "sk-secret") diff --git a/tests/ccbot/test_transcribe.py b/tests/ccbot/test_transcribe.py deleted file mode 100644 index 010421a2..00000000 --- a/tests/ccbot/test_transcribe.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Unit tests for transcribe — voice-to-text via OpenAI API.""" - -from unittest.mock import AsyncMock, patch - -import httpx -import pytest - -from ccbot import transcribe - - -@pytest.fixture(autouse=True) -def _reset_client(): - """Ensure each test starts with a fresh client.""" - transcribe._client = None - yield - transcribe._client = None - - -@pytest.fixture -def mock_config(): - """Patch config with test values; force VOICE_BACKEND=openai.""" - with patch.object(transcribe, "config") as cfg: - cfg.openai_api_key = "sk-test-key" - cfg.openai_base_url = "https://api.openai.com/v1" - cfg.voice_backend = "openai" - yield cfg - - -def _mock_response(*, json_data: dict, status_code: int = 200) -> httpx.Response: - """Build a fake httpx.Response.""" - request = httpx.Request("POST", "https://api.openai.com/v1/audio/transcriptions") - resp = httpx.Response(status_code=status_code, json=json_data, request=request) - return resp - - -class TestTranscribeVoice: - @pytest.mark.asyncio - async def test_success(self, mock_config): - resp = _mock_response(json_data={"text": "Hello world"}) - with patch.object( - httpx.AsyncClient, "post", new_callable=AsyncMock, return_value=resp - ) as mock_post: - result = await transcribe.transcribe_voice(b"fake-ogg-data") - - assert result == "Hello world" - mock_post.assert_called_once() - call_kwargs = mock_post.call_args - assert "Bearer sk-test-key" in str(call_kwargs) - - @pytest.mark.asyncio - async def test_empty_transcription_raises(self, mock_config): - resp = _mock_response(json_data={"text": ""}) - with patch.object( - httpx.AsyncClient, "post", new_callable=AsyncMock, return_value=resp - ): - with pytest.raises(ValueError, match="Empty transcription"): - await transcribe.transcribe_voice(b"fake-ogg-data") - - @pytest.mark.asyncio - async def test_whitespace_only_raises(self, mock_config): - resp = _mock_response(json_data={"text": " "}) - with patch.object( - httpx.AsyncClient, "post", new_callable=AsyncMock, return_value=resp - ): - with pytest.raises(ValueError, match="Empty transcription"): - await transcribe.transcribe_voice(b"fake-ogg-data") - - @pytest.mark.asyncio - async def test_missing_text_field_raises(self, mock_config): - resp = _mock_response(json_data={"result": "something"}) - with patch.object( - httpx.AsyncClient, "post", new_callable=AsyncMock, return_value=resp - ): - with pytest.raises(ValueError, match="Empty transcription"): - await transcribe.transcribe_voice(b"fake-ogg-data") - - @pytest.mark.asyncio - async def test_api_error_raises(self, mock_config): - resp = _mock_response(json_data={"error": "Unauthorized"}, status_code=401) - with patch.object( - httpx.AsyncClient, "post", new_callable=AsyncMock, return_value=resp - ): - with pytest.raises(httpx.HTTPStatusError): - await transcribe.transcribe_voice(b"fake-ogg-data") - - @pytest.mark.asyncio - async def test_custom_base_url(self, mock_config): - mock_config.openai_base_url = "https://proxy.example.com/v1" - resp = _mock_response(json_data={"text": "Transcribed"}) - with patch.object( - httpx.AsyncClient, "post", new_callable=AsyncMock, return_value=resp - ) as mock_post: - result = await transcribe.transcribe_voice(b"fake-ogg-data") - - assert result == "Transcribed" - url_arg = mock_post.call_args[0][0] - assert url_arg == "https://proxy.example.com/v1/audio/transcriptions" - - @pytest.mark.asyncio - async def test_base_url_trailing_slash_stripped(self, mock_config): - mock_config.openai_base_url = "https://proxy.example.com/v1/" - resp = _mock_response(json_data={"text": "OK"}) - with patch.object( - httpx.AsyncClient, "post", new_callable=AsyncMock, return_value=resp - ) as mock_post: - await transcribe.transcribe_voice(b"fake-ogg-data") - - url_arg = mock_post.call_args[0][0] - assert url_arg == "https://proxy.example.com/v1/audio/transcriptions" - - -class TestCloseClient: - @pytest.mark.asyncio - async def test_close_client_when_open(self): - transcribe._client = httpx.AsyncClient() - assert transcribe._client is not None - await transcribe.close_client() - assert transcribe._client is None - - @pytest.mark.asyncio - async def test_close_client_when_none(self): - assert transcribe._client is None - await transcribe.close_client() - assert transcribe._client is None From e0d1bb71daa2ac7fc9eb6e46b9b052a005191d0a Mon Sep 17 00:00:00 2001 From: Time4Mind <119820237+Time4Mind@users.noreply.github.com> Date: Sat, 23 May 2026 14:56:35 +0300 Subject: [PATCH 2/2] chore: remove dead code (modules, functions, scaffolding) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two read-only audit agents (vulture-based + semantic reachability) swept the repo. Removes confirmed-dead code (zero real callers; verified each by grep, not just tool output): Whole modules: - handlers/context_poll.py — its loop was only referenced by a commented-out create_task in app.py (the /context-into-pane approach was abandoned for JSONL math). Drops the module + the _context_poll_task global / shutdown guard / commented launch in app.py (rationale kept as a comment). - handlers/response_builder.py + its test — build_response_parts had no src caller (markdown/splitting moved to the send layer); test-kept only. Dead functions/members: - interactive_ui: set_interactive_mode, clear_interactive_mode, adopt_interactive_msg, render_interactive_keyboard (the switcher-tap bg-prompt path actually uses enter_kb_mode; docs corrected). - bg_status.has_panel_content; directory_browser.build_window_picker; history.get_cached_total_pages; switcher.strip_active_switcher; card_model.Event.is_tool; terminal_parser.STATUS_SPINNERS (union of two still-used frozensets); session.py: _encode_cwd, clear_active_session, find_session_by_claude_id, set_session_goal, find_users_for_claude_session. - ruff --fix dropped the now-unused imports (Bot, BadRequest, CB_WIN_*). Docs: architecture.md / dm-architecture.md references to the removed interactive_ui helpers corrected to the real mechanism (enter_kb_mode / paint_card_on_carrier). 540 tests pass · ruff clean · pyright 0 errors. Co-Authored-By: Claude Opus 4.7 --- .claude/rules/architecture.md | 8 +- .claude/rules/dm-architecture.md | 2 +- src/ccbot/bot/app.py | 32 +-- src/ccbot/handlers/__init__.py | 1 - src/ccbot/handlers/bg_status.py | 5 - src/ccbot/handlers/card_model.py | 4 - src/ccbot/handlers/context_poll.py | 199 ------------------ src/ccbot/handlers/directory_browser.py | 49 ----- src/ccbot/handlers/history.py | 24 +-- src/ccbot/handlers/interactive_ui.py | 33 --- src/ccbot/handlers/response_builder.py | 97 --------- src/ccbot/handlers/switcher.py | 24 +-- src/ccbot/session.py | 43 ---- src/ccbot/terminal_parser.py | 1 - tests/ccbot/handlers/test_response_builder.py | 60 ------ 15 files changed, 13 insertions(+), 569 deletions(-) delete mode 100644 src/ccbot/handlers/context_poll.py delete mode 100644 src/ccbot/handlers/response_builder.py delete mode 100644 tests/ccbot/handlers/test_response_builder.py diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index 75264961..d844cc38 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -140,10 +140,10 @@ Handler modules (handlers/): quota_alerts.py ─ Background /usage modal poll (default 10 min) → 5h/weekly band crossings 50/75/90 % inbox.py ─ photo/document inbox under /.ccbot-inbox/ - interactive_ui.py ─ AskUserQuestion / ExitPlanMode / Permission UI + - adopt_interactive_msg / render_interactive_keyboard - (used by switcher tap to claim the carrier as the - interactive UI for a bg session whose prompt was stashed) + interactive_ui.py ─ AskUserQuestion / ExitPlanMode / Permission UI + (handle_interactive_ui + _build_interactive_keyboard). + A switcher tap surfaces a bg session's stashed prompt + via notifications.enter_kb_mode on the claimed carrier. directory_browser.py─ Directory + session picker UI builders switcher.py ─ Inline session-switcher keyboard menu.py ─ Footer / More / Settings keyboard composition; diff --git a/.claude/rules/dm-architecture.md b/.claude/rules/dm-architecture.md index 084d4681..06580a44 100644 --- a/.claude/rules/dm-architecture.md +++ b/.claude/rules/dm-architecture.md @@ -116,7 +116,7 @@ When the user taps a session button in the main switcher: 1. `transfer_card_to_carrier` pauses the FROM session's card and claims the carrier message_id for the TO session. 2. `set_active_session(user, target)` flips the routing pointer. -3. If the TO session has a stashed `bg_status.pending_interactive_ui` *and* the live pane still shows the prompt, the carrier is repainted with the prompt + the regular CB_ASK_* keyboard (`adopt_interactive_msg`). Otherwise the carrier is painted with `send_history` — the full paginated transcript view, with the standard footer ridden along as `extra_rows` so management controls stay reachable. +3. If the TO session has a stashed `bg_status.pending_interactive_ui` *and* the live pane still shows the prompt, the carrier is claimed as the live card and flipped into kb-mode (`enter_kb_mode`) so the CB_ASK_* keyboard drives the prompt. Otherwise the carrier is painted as the session's live card (`paint_card_on_carrier` — header + paginated body + bg-panel + footer) and receives subsequent claude events in place. 4. `bg_status.mark_seen` + `prune_seen` drop the just-viewed badge from the panel. Pagination (`CB_HISTORY_PREV/NEXT`) preserves the original `extra_rows` by stamping `context.user_data['_history_origin']` (`switcher` or `menu_list`) when the history view is first painted; the pagination handler rebuilds the matching footer from this hint. There is no explicit "History" button in the footer — pagination buttons themselves are the navigation affordance, and the user lands on the paginated view via switcher tap, Menu → List, or `/screenshot Back` (both `m` and `l` origins now paint history). diff --git a/src/ccbot/bot/app.py b/src/ccbot/bot/app.py index 32efc9c0..db2015af 100644 --- a/src/ccbot/bot/app.py +++ b/src/ccbot/bot/app.py @@ -89,7 +89,6 @@ _status_poll_task: asyncio.Task[None] | None = None _card_timer_task: asyncio.Task[None] | None = None _quota_alerts_task: asyncio.Task[None] | None = None -_context_poll_task: asyncio.Task[None] | None = None _metrics_flush_task: asyncio.Task[None] | None = None @@ -100,7 +99,6 @@ async def post_init(application: "Application[Any, Any, Any, Any, Any, Any]") -> _status_poll_task, \ _card_timer_task, \ _quota_alerts_task, \ - _context_poll_task, \ _metrics_flush_task, \ _conflict_app @@ -172,15 +170,11 @@ async def message_callback(msg: NewMessage) -> None: _quota_alerts_task = asyncio.create_task(quota_alerts_loop(application.bot)) logger.info("Quota alerts task started") - # Context-poll loop disabled — sending /context to live panes - # writes the modal's markdown output INTO the session's JSONL as - # a user-turn, which then renders on the live card as a fake - # ``[Request interrupted by user] ## Context Usage…`` block AND - # eats real tokens from claude's own context window every cycle. - # Context % is now computed from JSONL math (input + cache reads - # vs. 1M default for Claude 4.x models). See ``usage.context_pct_for_session``. - # _context_poll_task = asyncio.create_task(context_poll_loop(application.bot)) - # logger.info("Context poll task started") + # Per-session context % is computed from JSONL math + # (usage.context_pct_for_session) — NOT by polling /context into panes. + # Polling wrote the modal's markdown into each session's JSONL as a fake + # user-turn (polluting the live card + burning tokens), so that path was + # removed. See doc/dm-multisession-spec.md §4.6. _metrics_flush_task = asyncio.create_task(metrics_flush_loop()) logger.info("Metrics flush task started") @@ -248,12 +242,7 @@ async def post_shutdown( application: "Application[Any, Any, Any, Any, Any, Any]", ) -> None: """Stop background tasks, flush queues, close HTTP clients.""" - global \ - _status_poll_task, \ - _card_timer_task, \ - _quota_alerts_task, \ - _context_poll_task, \ - _metrics_flush_task + global _status_poll_task, _card_timer_task, _quota_alerts_task, _metrics_flush_task if _status_poll_task: _status_poll_task.cancel() @@ -282,15 +271,6 @@ async def post_shutdown( _quota_alerts_task = None logger.info("Quota alerts stopped") - if _context_poll_task: - _context_poll_task.cancel() - try: - await _context_poll_task - except asyncio.CancelledError: - pass - _context_poll_task = None - logger.info("Context poll stopped") - if _metrics_flush_task: _metrics_flush_task.cancel() try: diff --git a/src/ccbot/handlers/__init__.py b/src/ccbot/handlers/__init__.py index 40d7b88d..3f62e3ec 100644 --- a/src/ccbot/handlers/__init__.py +++ b/src/ccbot/handlers/__init__.py @@ -8,5 +8,4 @@ - directory_browser: Directory selection UI - interactive_ui: Interactive UI (AskUserQuestion, Permission Prompt, etc.) - status_polling: Terminal status line polling - - response_builder: Build paginated response messages """ diff --git a/src/ccbot/handlers/bg_status.py b/src/ccbot/handlers/bg_status.py index 0f9d6365..0b34eac4 100644 --- a/src/ccbot/handlers/bg_status.py +++ b/src/ccbot/handlers/bg_status.py @@ -97,11 +97,6 @@ def _touch(entry: BgStatus) -> None: entry.last_change = time.time() -def has_panel_content(user_id: int) -> bool: - """True if the user has any bg-status entry worth rendering.""" - return bool(_bg.get(user_id)) - - def update_status( user_id: int, session_id: str, diff --git a/src/ccbot/handlers/card_model.py b/src/ccbot/handlers/card_model.py index ac4de713..695a2c59 100644 --- a/src/ccbot/handlers/card_model.py +++ b/src/ccbot/handlers/card_model.py @@ -146,10 +146,6 @@ class Event: is_error: bool = False image_data: list[tuple[str, bytes]] | None = None # tool_result images - @property - def is_tool(self) -> bool: - return self.type in ("tool_use", "tool_result") - @dataclass class CardState: diff --git a/src/ccbot/handlers/context_poll.py b/src/ccbot/handlers/context_poll.py deleted file mode 100644 index abbb3bdf..00000000 --- a/src/ccbot/handlers/context_poll.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Background poll of ``/context`` per live session. - -Per pivot #43 follow-up: the user wants the *real* context-fill -percentage as reported by Claude Code's own ``/context`` command — -not derived from JSONL token math (which guessed at the model's -context-window denominator and got it wrong on extended-window -models). - -The loop fires every ``CONTEXT_POLL_INTERVAL`` seconds and, for each -user's active / idle session: - - 1. Skip if the pane shows a status spinner (``parse_status_line`` - non-None) — claude is busy, sending ``/context`` would queue it - mid-turn and potentially fight whatever's running. - 2. Send ``/context`` to the tmux pane. - 3. Poll the pane for up to 4 s looking for the - ``k/k tokens (%)`` line. - 4. Send Escape to dismiss the modal. - 5. If a pct was parsed, stash it on the live card state and the - bg-status entry for this session, then refresh the panel for - the user once at the end. - -Archived / lost / completed sessions are skipped — they have no live -tmux window anyway, and the user explicitly said "НЕ архивных и НЕ -lost". -""" - -from __future__ import annotations - -import asyncio -import logging -import os -import re - -from telegram import Bot - -from ..config import config -from ..session import session_manager -from ..terminal_parser import parse_status_line -from ..tmux_manager import tmux_manager - -logger = logging.getLogger(__name__) - - -# How often to refresh context-pct for every live session. The user -# asked for 5-10 min — 8 min splits the difference, env-overridable. -CONTEXT_POLL_INTERVAL = float(os.getenv("CCBOT_CONTEXT_POLL_INTERVAL", "480")) - -# Per-session pacing: 200 ms × _CONTEXT_POLL_STEPS = up to 4 s waiting -# for the modal to render after sending /context. The modal usually -# paints in well under a second. -_CONTEXT_POLL_STEPS = 20 -_CONTEXT_POLL_TICK = 0.2 - -# Inter-session settle so we don't fire /context into N panes in the -# same wall-clock second when the user has many live sessions. -_INTER_SESSION_DELAY = 1.0 - - -_CONTEXT_LINE = re.compile( - r"(\d+(?:\.\d+)?)\s*[kKmM]?\s*/\s*(\d+(?:\.\d+)?)\s*[kKmM]?\s+tokens\s*\((\d+)\s*%\)" -) - - -def _parse_context_pct(pane_text: str) -> int | None: - """Pull the percentage from a ``X.Yk/Zk tokens (N%)`` line.""" - m = _CONTEXT_LINE.search(pane_text) - if not m: - return None - try: - return int(m.group(3)) - except (TypeError, ValueError): - return None - - -async def _capture_with_scrollback(window_id: str) -> str | None: - """Read ~100 lines of scrollback so the /context body stays visible - even on narrow panes where the modal can be taller than the - viewport.""" - try: - proc = await asyncio.create_subprocess_exec( - "tmux", - "capture-pane", - "-p", - "-S", - "-100", - "-t", - window_id, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, _ = await proc.communicate() - if proc.returncode == 0: - return stdout.decode("utf-8", errors="replace") - except Exception as e: - logger.debug("context_poll capture failed: %s", e) - return None - - -async def _read_context_pct(window_id: str) -> int | None: - """Send /context, poll for the percentage, dismiss with Escape. - - Returns the parsed percentage or None when: - - the window vanished, - - the pane is busy (spinner detected) — we'd be queuing /context - mid-turn, which is risky, - - the modal didn't paint within the budget. - """ - w = await tmux_manager.find_window_by_id(window_id) - if not w: - return None - pre = await tmux_manager.capture_pane(w.window_id) - if pre and parse_status_line(pre) is not None: - # Claude is mid-turn — defer to next interval. Sending /context - # now would queue it and the subsequent Escape might land on - # something else. - return None - - pct: int | None = None - try: - await tmux_manager.send_keys(w.window_id, "/context") - for _ in range(_CONTEXT_POLL_STEPS): - await asyncio.sleep(_CONTEXT_POLL_TICK) - pane_text = await _capture_with_scrollback(w.window_id) - if not pane_text: - continue - pct = _parse_context_pct(pane_text) - if pct is not None: - break - finally: - # Always dismiss — leaving the modal up would block the user - # the next time they look at the pane. - try: - await tmux_manager.send_keys( - w.window_id, "Escape", enter=False, literal=False - ) - except Exception as e: - logger.debug("context_poll dismiss failed: %s", e) - return pct - - -async def context_poll_loop(bot: Bot) -> None: - """Run forever, refreshing context-fill % for every live session.""" - logger.info("context_poll_loop started interval=%.0fs", CONTEXT_POLL_INTERVAL) - # Stagger the first run a bit so we don't pile onto a fresh-boot - # claude startup. - await asyncio.sleep(30.0) - while True: - try: - await _one_pass(bot) - except asyncio.CancelledError: - raise - except Exception as e: - logger.debug("context_poll pass crashed: %s", e) - await asyncio.sleep(CONTEXT_POLL_INTERVAL) - - -async def _one_pass(bot: Bot) -> None: - """Hit every live session once for every allowed user.""" - from . import bg_status - from .notifications import refresh_panel, set_card_context_pct - - for user_id in config.allowed_users: - # ``states`` filter excludes archived/completed/lost — per the - # user's explicit ask. Iterate sequentially so we don't burst - # /context into many panes at once. - sessions = session_manager.list_user_sessions( - user_id, states=("active", "idle") - ) - touched = False - for sess in sessions: - if not sess.window_id: - continue - try: - pct = await _read_context_pct(sess.window_id) - except Exception as e: - logger.debug("context_poll read failed sess=%s: %s", sess.id, e) - pct = None - if pct is not None: - set_card_context_pct(user_id, sess.id, pct) - bg_status.set_context_pct(user_id, sess.id, pct) - touched = True - logger.info( - "context_poll sess=%s pct=%d", - sess.id, - pct, - extra={ - "event": "context_poll_update", - "user_id": user_id, - "session_id": sess.id, - "pct": pct, - }, - ) - await asyncio.sleep(_INTER_SESSION_DELAY) - if touched: - try: - await refresh_panel(bot, user_id) - except Exception as e: - logger.debug("context_poll refresh_panel failed: %s", e) diff --git a/src/ccbot/handlers/directory_browser.py b/src/ccbot/handlers/directory_browser.py index 88cb0531..374bf931 100644 --- a/src/ccbot/handlers/directory_browser.py +++ b/src/ccbot/handlers/directory_browser.py @@ -7,7 +7,6 @@ Key components: - DIRS_PER_PAGE: Number of directories shown per page - User state keys for tracking browse/picker session - - build_window_picker: Build unbound window picker UI - build_directory_browser: Build directory browser UI - clear_window_picker_state: Clear picker state from user_data - clear_browse_state: Clear browsing state from user_data @@ -35,9 +34,6 @@ CB_SESSION_NEW, CB_SESSION_PAGE, CB_SESSION_SELECT, - CB_WIN_BIND, - CB_WIN_CANCEL, - CB_WIN_NEW, ) # Directories per page in directory browser @@ -82,51 +78,6 @@ def clear_session_picker_state(user_data: dict[str, Any] | None) -> None: user_data.pop(SESSIONS_KEY, None) -def build_window_picker( - windows: list[tuple[str, str, str]], -) -> tuple[str, InlineKeyboardMarkup, list[str]]: - """Build window picker UI for unbound tmux windows. - - Args: - windows: List of (window_id, window_name, cwd) tuples. - - Returns: (text, keyboard, window_ids) where window_ids is the ordered list[Any] for caching. - """ - window_ids = [wid for wid, _, _ in windows] - - lines = [ - "*Bind to Existing Window*\n", - "These windows are running but not bound to any topic.", - "Pick one to attach it here, or start a new session.\n", - ] - for _wid, name, cwd in windows: - display_cwd = cwd.replace(str(Path.home()), "~") - lines.append(f"• `{name}` — {display_cwd}") - - buttons: list[list[InlineKeyboardButton]] = [] - for i in range(0, len(windows), 2): - row = [] - for j in range(min(2, len(windows) - i)): - name = windows[i + j][1] - display = name[:12] + "…" if len(name) > 13 else name - row.append( - InlineKeyboardButton( - f"🖥 {display}", callback_data=f"{CB_WIN_BIND}{i + j}" - ) - ) - buttons.append(row) - - buttons.append( - [ - InlineKeyboardButton("➕ New Session", callback_data=CB_WIN_NEW), - InlineKeyboardButton("≡ Menu", callback_data=CB_WIN_CANCEL), - ] - ) - - text = "\n".join(lines) - return text, InlineKeyboardMarkup(buttons), window_ids - - def build_directory_browser( current_path: str, page: int = 0 ) -> tuple[str, InlineKeyboardMarkup, list[str]]: diff --git a/src/ccbot/handlers/history.py b/src/ccbot/handlers/history.py index 81a31ab9..4f1fdd66 100644 --- a/src/ccbot/handlers/history.py +++ b/src/ccbot/handlers/history.py @@ -158,26 +158,6 @@ async def render_archived_history_pages( return list(pages), total -def get_cached_total_pages(window_id: str) -> int | None: - """Return the cached total-page count for ``window_id`` or None. - - Used by callers that need to target a specific page (e.g. the - live-card's ◀ Older button → page-before-last) without paying the - parse cost. Returns ``None`` when no entry is cached — the caller - should ``prewarm_pages_cache`` first. - - Note: returns the count from the cached entry regardless of whether - the JSONL has grown since (mtime/size mismatch). The count is then - "stale-but-stable" between prewarms — exactly what the live-card - pagination counter wants so the keyboard doesn't blink between - streaming events. - """ - entry = _pages_cache.get(window_id) - if entry is None: - return None - return len(entry[2]) - - _last_prewarm_attempt: dict[str, float] = {} # Live, fire-and-forget prewarm tasks. We keep a strong reference so @@ -202,9 +182,7 @@ def kick_prewarm(window_id: str, min_interval: float = 3.0) -> None: Use before any keyboard build that needs the cached page count (e.g. the live-card pagination counter): the counter shows up once the background task lands, and stays stable across subsequent - renders even when the cache goes stale relative to the growing - JSONL (``get_cached_total_pages`` returns the cached value - regardless of mtime/size). + renders even when the cache goes stale relative to the growing JSONL. """ import asyncio import time diff --git a/src/ccbot/handlers/interactive_ui.py b/src/ccbot/handlers/interactive_ui.py index e2ef92a5..142e424a 100644 --- a/src/ccbot/handlers/interactive_ui.py +++ b/src/ccbot/handlers/interactive_ui.py @@ -53,18 +53,6 @@ def get_interactive_window(user_id: int) -> str | None: return _active_interactive_window.get(user_id) -def set_interactive_mode(user_id: int, window_id: str) -> None: - """Mark a window as the user's currently-shown interactive UI.""" - logger.debug("Set interactive mode: user=%d, window_id=%s", user_id, window_id) - _active_interactive_window[user_id] = window_id - - -def clear_interactive_mode(user_id: int) -> None: - """Clear active interactive UI marker for a user (without deleting message).""" - logger.debug("Clear interactive mode: user=%d", user_id) - _active_interactive_window.pop(user_id, None) - - def get_interactive_msg_id(user_id: int, window_id: str) -> int | None: """Get the interactive message ID for a (user, window) pair.""" return _interactive_msgs.get((user_id, window_id)) @@ -278,24 +266,3 @@ def clear_interactive_for_window(user_id: int, window_id: str) -> None: _interactive_msgs.pop((user_id, window_id), None) if _active_interactive_window.get(user_id) == window_id: _active_interactive_window.pop(user_id, None) - - -def adopt_interactive_msg(user_id: int, window_id: str, msg_id: int) -> None: - """Register an externally-rendered message as the user's interactive UI - for ``window_id``. Used by the switcher-tap handler when the carrier - message is repurposed to host a bg session's stashed prompt — the - subsequent CB_ASK_* callbacks call ``handle_interactive_ui`` which - looks up this map to know which message to edit in place. - """ - _interactive_msgs[(user_id, window_id)] = msg_id - _active_interactive_window[user_id] = window_id - - -def render_interactive_keyboard( - window_id: str, ui_name: str = "" -) -> InlineKeyboardMarkup: - """Public re-export of the keyboard builder — wanted by the switcher - tap path so it can paint a bg session's pending prompt without - going through ``handle_interactive_ui`` (which would re-capture - the pane and lose the snapshot we already took).""" - return _build_interactive_keyboard(window_id, ui_name=ui_name) diff --git a/src/ccbot/handlers/response_builder.py b/src/ccbot/handlers/response_builder.py deleted file mode 100644 index 87e1fc7f..00000000 --- a/src/ccbot/handlers/response_builder.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Response message building for Telegram delivery. - -Builds paginated response messages from Claude Code output: - - Handles different content types (text, thinking, tool_use, tool_result) - - Splits long messages into pages within Telegram's 4096 char limit - - Truncates thinking content to keep messages compact - -Markdown conversion is NOT done here — the send layer (message_sender, -message_queue) handles convert_markdown() so each message is converted -exactly once. - -Key function: - - build_response_parts: Build paginated response messages -""" - -from ..markdown_v2 import convert_markdown_tables -from ..telegram_sender import split_message -from ..transcript_parser import TranscriptParser - - -def build_response_parts( - text: str, - is_complete: bool, - content_type: str = "text", - role: str = "assistant", -) -> list[str]: - """Build paginated response messages for Telegram. - - Returns a list of raw markdown strings, each within Telegram's 4096 char limit. - Multi-part messages get a [1/N] suffix. - Markdown-to-MarkdownV2 conversion is done by the send layer, not here. - """ - text = text.strip() - - # User messages: add emoji prefix (no newline) - if role == "user": - prefix = "👤 " - separator = "" - # User messages are typically short, no special processing needed - if len(text) > 3000: - text = text[:3000] + "…" - return [f"{prefix}{text}"] - - # Truncate thinking content to keep it compact - if content_type == "thinking" and is_complete: - start_tag = TranscriptParser.EXPANDABLE_QUOTE_START - end_tag = TranscriptParser.EXPANDABLE_QUOTE_END - max_thinking = 500 - if start_tag in text and end_tag in text: - inner = text[text.index(start_tag) + len(start_tag) : text.index(end_tag)] - if len(inner) > max_thinking: - inner = inner[:max_thinking] + "\n\n… (thinking truncated)" - text = start_tag + inner + end_tag - elif len(text) > max_thinking: - text = text[:max_thinking] + "\n\n… (thinking truncated)" - - # Format based on content type - if content_type == "thinking": - # Thinking: prefix with "∴ Thinking…" and single newline - prefix = "∴ Thinking…" - separator = "\n" - else: - # Plain text: no prefix - prefix = "" - separator = "" - - # If text contains expandable quote sentinels, don't split — - # the quote must stay atomic. Truncation is handled by - # _render_expandable_quote in markdown_v2.py. - if TranscriptParser.EXPANDABLE_QUOTE_START in text: - if prefix: - return [f"{prefix}{separator}{text}"] - return [text] - - # Convert tables to card-style before splitting so tables aren't broken - # across messages. The send layer's convert_markdown() call is idempotent. - text = convert_markdown_tables(text) - - # Split first, then assemble each chunk. - # Use conservative max to leave room for MarkdownV2 expansion at send layer. - max_text = 3000 - len(prefix) - len(separator) - - text_chunks = split_message(text, max_length=max_text) - total = len(text_chunks) - - if total == 1: - if prefix: - return [f"{prefix}{separator}{text_chunks[0]}"] - return [text_chunks[0]] - - parts = [] - for i, chunk in enumerate(text_chunks, 1): - if prefix: - parts.append(f"{prefix}{separator}{chunk}\n\n[{i}/{total}]") - else: - parts.append(f"{chunk}\n\n[{i}/{total}]") - return parts diff --git a/src/ccbot/handlers/switcher.py b/src/ccbot/handlers/switcher.py index 4e0b994e..9976a1d7 100644 --- a/src/ccbot/handlers/switcher.py +++ b/src/ccbot/handlers/switcher.py @@ -6,7 +6,6 @@ Public API: build_switcher_keyboard(user_id) -> InlineKeyboardMarkup | None - strip_active_switcher(bot, user_id) -> None build_session_preview(sess) -> str State of "where the live switcher currently lives" is held in @@ -17,8 +16,7 @@ import logging -from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup -from telegram.error import BadRequest +from telegram import InlineKeyboardButton, InlineKeyboardMarkup from ..config import config from ..session import Session, session_manager @@ -125,26 +123,6 @@ def build_switcher_keyboard( return InlineKeyboardMarkup(rows) -async def strip_active_switcher(bot: Bot, user_id: int) -> None: - """Strip the inline keyboard from the user's previously-attached switcher - message, if any. Cheap no-op if nothing is tracked. - """ - msg_id = session_manager.get_last_switcher_msg(user_id) - if msg_id is None: - return - try: - await bot.edit_message_reply_markup( - chat_id=user_id, message_id=msg_id, reply_markup=None - ) - except BadRequest as e: - # Most common: message too old, message is not modified. - logger.debug("strip_active_switcher: %s", e) - except Exception as e: - logger.debug("strip_active_switcher unexpected: %s", e) - finally: - session_manager.clear_last_switcher_msg(user_id) - - def build_session_preview( sess: Session, *, diff --git a/src/ccbot/session.py b/src/ccbot/session.py index 6eebeaaa..2dff3e3c 100644 --- a/src/ccbot/session.py +++ b/src/ccbot/session.py @@ -388,13 +388,6 @@ def clear_window_session(self, window_id: str) -> None: self.save_state() logger.info("Cleared session for window_id %s", window_id) - @staticmethod - def _encode_cwd(cwd: str) -> str: - """Backwards-compatible re-export of ``session_claude_io.encode_cwd``.""" - from . import session_claude_io - - return session_claude_io.encode_cwd(cwd) - async def list_sessions_for_directory(self, cwd: str) -> list[ClaudeSession]: """List existing Claude sessions for a directory (newest first, max 10).""" from . import session_claude_io @@ -494,12 +487,6 @@ def set_active_session(self, user_id: int, session_id: str) -> None: }, ) - def clear_active_session(self, user_id: int) -> None: - """Drop the active-session pointer for a user (e.g. all sessions archived).""" - if user_id in self.active_sessions: - del self.active_sessions[user_id] - self.save_state() - def list_user_sessions( self, user_id: int, @@ -523,12 +510,6 @@ def find_session_by_window(self, window_id: str) -> "Session | None": return s return None - def find_session_by_claude_id(self, claude_session_id: str) -> "Session | None": - for s in self.sessions.values(): - if s.claude_session_id == claude_session_id: - return s - return None - def create_session( self, *, @@ -854,13 +835,6 @@ def set_session_window(self, session_id: str, window_id: str) -> None: sess.last_event_at = time.time() self.save_state() - def set_session_goal(self, session_id: str, goal: str) -> None: - sess = self.sessions.get(session_id) - if not sess: - return - sess.goal = goal - self.save_state() - def set_session_claude_id(self, session_id: str, claude_session_id: str) -> None: sess = self.sessions.get(session_id) if not sess: @@ -884,23 +858,6 @@ def clear_last_switcher_msg(self, user_id: int) -> None: # --- Reverse map: claude_session_id -> user(s) via active_sessions --- - async def find_users_for_claude_session( - self, - claude_session_id: str, - ) -> list[tuple[int, "Session"]]: - """Return [(user_id, Session)] for every user whose active session matches. - - Reverse-map of (user_id -> active Session) by claude_session_id. - Background sessions of a user are NOT returned here — outbound for those - flows through their own per-session live cards (see C7). - """ - out: list[tuple[int, "Session"]] = [] - for user_id, sid in self.active_sessions.items(): - sess = self.sessions.get(sid) - if sess and sess.claude_session_id == claude_session_id: - out.append((user_id, sess)) - return out - def all_user_sessions_with_claude_id( self, claude_session_id: str ) -> list[tuple[int, "Session"]]: diff --git a/src/ccbot/terminal_parser.py b/src/ccbot/terminal_parser.py index e4c38d20..59e3c190 100644 --- a/src/ccbot/terminal_parser.py +++ b/src/ccbot/terminal_parser.py @@ -329,7 +329,6 @@ def is_interactive_ui(pane_text: str) -> bool: # real busy status (``● Gallivanting… (53s · ↑2.3k tokens)``). SPINNER_ONLY = frozenset(["✻", "✽", "✶", "✳", "✢"]) SPINNER_AMBIGUOUS = frozenset(["●", "·"]) -STATUS_SPINNERS = SPINNER_ONLY | SPINNER_AMBIGUOUS _STATUS_TIME_STATS_RE = re.compile(r"\(\s*\d+(?:m\s*\d+)?\s*[smh]") diff --git a/tests/ccbot/handlers/test_response_builder.py b/tests/ccbot/handlers/test_response_builder.py deleted file mode 100644 index 00272b58..00000000 --- a/tests/ccbot/handlers/test_response_builder.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Tests for response_builder.build_response_parts.""" - -from ccbot.handlers.response_builder import build_response_parts -from ccbot.transcript_parser import TranscriptParser - -EXP_START = TranscriptParser.EXPANDABLE_QUOTE_START -EXP_END = TranscriptParser.EXPANDABLE_QUOTE_END - - -class TestBuildResponseParts: - def test_user_message_has_emoji_prefix(self): - parts = build_response_parts("hello", is_complete=True, role="user") - assert len(parts) == 1 - assert "\U0001f464" in parts[0] - - def test_user_message_truncated_at_3000_chars(self): - long_text = "a" * 4000 - parts = build_response_parts(long_text, is_complete=True, role="user") - assert len(parts) == 1 - short_parts = build_response_parts("b" * 100, is_complete=True, role="user") - assert len(parts[0]) < len(long_text) - assert len(short_parts[0]) < len(parts[0]) - - def test_thinking_content_truncated_at_500_chars(self): - inner = "x" * 800 - text = f"{EXP_START}{inner}{EXP_END}" - parts = build_response_parts(text, is_complete=True, content_type="thinking") - assert len(parts) == 1 - assert "truncated" in parts[0].lower() - - def test_plain_text_single_part(self): - parts = build_response_parts("short text", is_complete=True) - assert len(parts) == 1 - - def test_plain_text_multi_part_has_page_suffix(self): - long_text = "\n".join(f"line {i} " + "padding" * 50 for i in range(200)) - parts = build_response_parts(long_text, is_complete=True) - assert len(parts) > 1 - assert "1/" in parts[0] - - def test_expandable_quote_stays_atomic(self): - inner = "thought " * 100 - text = f"{EXP_START}{inner}{EXP_END}" - parts = build_response_parts(text, is_complete=False, content_type="thinking") - assert len(parts) == 1 - - def test_thinking_has_prefix(self): - parts = build_response_parts( - "some thought", is_complete=True, content_type="thinking" - ) - assert len(parts) == 1 - assert "Thinking" in parts[0] - - def test_assistant_text_no_prefix(self): - parts = build_response_parts( - "hello world", is_complete=True, content_type="text", role="assistant" - ) - assert len(parts) == 1 - assert "\U0001f464" not in parts[0] - assert "Thinking" not in parts[0]