From b005eaef0d451399a78e25f8b433e554a8bb0d60 Mon Sep 17 00:00:00 2001 From: ttiee <469784630@qq.com> Date: Tue, 17 Mar 2026 21:16:36 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(telegram):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20Telegram=20=E4=BC=9A=E8=AF=9D=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E6=96=87=E5=8E=8B=E7=BC=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + .../2026-03-17-telegram-context-compaction.md | 83 +++++++++++++ src/nonebot_plugin_codex/__init__.py | 5 + src/nonebot_plugin_codex/native_client.py | 109 ++++++++++++++++++ src/nonebot_plugin_codex/service.py | 27 +++++ src/nonebot_plugin_codex/telegram.py | 7 ++ src/nonebot_plugin_codex/telegram_commands.py | 5 + tests/test_native_client.py | 105 +++++++++++++++++ tests/test_service.py | 75 +++++++++++- tests/test_telegram_commands.py | 4 +- tests/test_telegram_handlers.py | 18 +++ 11 files changed, 438 insertions(+), 2 deletions(-) create mode 100644 docs/maintenance/2026-03-17-telegram-context-compaction.md diff --git a/README.md b/README.md index beb1589..eaf2445 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,7 @@ codex_stream_read_limit = 8388608 | `/cd [path]` | 切换目录或打开目录浏览器 | | `/home` | 将工作目录重置到 Home | | `/sessions` | 打开历史会话浏览器 | +| `/compact` | 压缩当前 `resume` 会话上下文 | ## 模式说明 @@ -219,6 +220,7 @@ codex_stream_read_limit = 8388608 - 优先使用 `codex app-server` - 为同一聊天维持 native thread - 更适合连续编码、持续追问和多轮调试 +- 支持在 Telegram 中用 `/compact` 压缩较早对话上下文 ### `exec` diff --git a/docs/maintenance/2026-03-17-telegram-context-compaction.md b/docs/maintenance/2026-03-17-telegram-context-compaction.md new file mode 100644 index 0000000..4580809 --- /dev/null +++ b/docs/maintenance/2026-03-17-telegram-context-compaction.md @@ -0,0 +1,83 @@ +# Telegram Context Compaction Follow-Up + +## Summary + +`nonebot-plugin-codex` currently supports long-lived `resume` conversations in Telegram, but it does not expose Codex's transcript compaction workflow. + +OpenAI's current Codex CLI documentation includes a `/compact` slash command for summarizing earlier conversation history to free context, while this plugin currently offers no Telegram equivalent and does not surface automatic compaction notices from the underlying Codex session. + +This leaves long-running Telegram conversations behind the current Codex UX available in the CLI and IDE surfaces. + +Reference docs: + +- CLI slash commands: `https://developers.openai.com/codex/cli/slash-commands` +- Local verification target: `codex-cli 0.105.0` + +## Reproduction Steps + +1. Configure the plugin and connect it to a local Codex CLI installation. +2. Start a persistent chat in Telegram with `/codex`. +3. Continue the same `resume` conversation for enough turns that context management matters. +4. Try to manually compact the current conversation from Telegram. +5. Compare that workflow with current Codex CLI behavior, which documents `/compact`. + +## Expected Behavior + +- Telegram users can manually compact the current `resume` conversation from the bot, ideally with a `/compact` command. +- When Codex compacts context automatically, Telegram users receive a visible notice similar to the CLI and IDE experience. +- The feature stays scoped to conversations where compaction is meaningful and supported, especially the active `resume` thread. +- Command documentation and tests cover the new behavior. + +## Actual Behavior + +- The plugin command menu does not include `/compact`. +- The NoneBot command registration layer does not handle a `compact` command. +- The service layer does not expose a public API for compacting the current chat session. +- The native Codex client wrapper does not surface compaction-related notifications to Telegram progress output. +- As a result, long Telegram sessions can continue to reuse context, but users do not have a manual compaction path and do not get explicit compaction feedback. + +## Affected Commands And Files + +Commands: + +- `/codex` +- `/exec` +- `/sessions` +- desired new command: `/compact` + +Primary files: + +- `src/nonebot_plugin_codex/telegram_commands.py` +- `src/nonebot_plugin_codex/__init__.py` +- `src/nonebot_plugin_codex/telegram.py` +- `src/nonebot_plugin_codex/service.py` +- `src/nonebot_plugin_codex/native_client.py` +- `tests/test_telegram_commands.py` +- `tests/test_telegram_handlers.py` +- `tests/test_service.py` +- `tests/test_native_client.py` +- `README.md` + +## Proposed Scope + +1. Add a Telegram `/compact` command and register it in the synced Telegram command menu. +2. Implement service-layer compaction for the active `resume` thread, with clear user-facing errors when no resumable thread is bound. +3. Extend the native Codex client wrapper so compaction events from the app-server are surfaced as progress or notice text. +4. Add tests for command registration, handler behavior, service behavior, and native client event handling. +5. Update README command documentation to mention Telegram-side transcript compaction support and any mode limitations. + +## Verification Targets + +- `pdm run pytest -q` +- `pdm run ruff check .` +- focused tests for: + - Telegram command menu generation + - Telegram handler compaction flow + - service-level session compaction + - native client compaction event handling + +## Notes + +- Local focused regression baseline currently passes without this feature: + - `pdm run pytest tests/test_native_client.py tests/test_service.py tests/test_telegram_commands.py tests/test_telegram_handlers.py -q` +- Manual local CLI inspection confirms this repository currently lacks `/compact` wiring, while the local Codex CLI installation advertises compaction-related capability. diff --git a/src/nonebot_plugin_codex/__init__.py b/src/nonebot_plugin_codex/__init__.py index 38c521e..81518fa 100644 --- a/src/nonebot_plugin_codex/__init__.py +++ b/src/nonebot_plugin_codex/__init__.py @@ -102,6 +102,7 @@ async def _sync_telegram_commands(bot: Bot) -> None: cd_cmd = on_command("cd", priority=10, block=True) home_cmd = on_command("home", priority=10, block=True) sessions_cmd = on_command("sessions", priority=10, block=True) + compact_cmd = on_command("compact", priority=10, block=True) follow_up = on_message(priority=20, block=True, rule=handlers.is_active_follow_up) browser_callback = on_type( CallbackQueryEvent, @@ -218,6 +219,10 @@ async def _handle_home(bot: Bot, event: MessageEvent) -> None: async def _handle_sessions(bot: Bot, event: MessageEvent) -> None: await handlers.handle_sessions(bot, event) + @compact_cmd.handle() + async def _handle_compact(bot: Bot, event: MessageEvent) -> None: + await handlers.handle_compact(bot, event) + @browser_callback.handle() async def _handle_browser_callback(bot: Bot, event: CallbackQueryEvent) -> None: await handlers.handle_browser_callback(bot, event) diff --git a/src/nonebot_plugin_codex/native_client.py b/src/nonebot_plugin_codex/native_client.py index c1426ad..4ac1911 100644 --- a/src/nonebot_plugin_codex/native_client.py +++ b/src/nonebot_plugin_codex/native_client.py @@ -93,6 +93,27 @@ def _normalize_agent_key(agent_key: object, *, main_thread_id: str) -> str: return "main" if agent_key == main_thread_id else agent_key +def _extract_compaction_notice(payload: object) -> str | None: + if not isinstance(payload, dict): + return None + for key in ( + "summary", + "summaryText", + "text", + "compactionSummary", + "compaction_summary", + "notice", + "message", + ): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + item = payload.get("item") + if isinstance(item, dict): + return _extract_compaction_notice(item) + return None + + def _format_collab_tool_progress( item: dict[str, Any], *, @@ -305,6 +326,7 @@ async def run_turn( final_text = "" pending_agent_messages: dict[str, str] = {} last_streamed_text: dict[str, str] = {} + last_compaction_notice: dict[str, str] = {} async def emit_stream_update(agent_key: str, text: str) -> None: if last_streamed_text.get(agent_key) == text: @@ -315,6 +337,15 @@ async def emit_stream_update(agent_key: str, text: str) -> None: NativeAgentUpdate(agent_key=agent_key, text=text), ) + async def emit_compaction_notice(agent_key: str, text: str) -> None: + if last_compaction_notice.get(agent_key) == text: + return + last_compaction_notice[agent_key] = text + await _maybe_call( + on_progress, + NativeAgentUpdate(agent_key=agent_key, text=text), + ) + await self._request( "turn/start", self._turn_start_params( @@ -373,6 +404,14 @@ async def emit_stream_update(agent_key: str, text: str) -> None: for update in collab_updates: await _maybe_call(on_progress, update) continue + if item_type == "contextCompaction": + notice = _extract_compaction_notice(item) or ( + "正在压缩较早对话上下文…" + if method == "item/started" + else "已压缩较早对话上下文。" + ) + await emit_compaction_notice(agent_key, notice) + continue if item_type == "agentMessage": item_id = item.get("id") if isinstance(item_id, str) and item_id: @@ -408,6 +447,15 @@ async def emit_stream_update(agent_key: str, text: str) -> None: ) continue + if method == "thread/compacted": + agent_key = _normalize_agent_key( + params.get("threadId"), + main_thread_id=thread_id, + ) + notice = _extract_compaction_notice(params) or "已压缩较早对话上下文。" + await emit_compaction_notice(agent_key, notice) + continue + if method == "turn/completed": turn = params.get("turn") if not isinstance(turn, dict): @@ -441,6 +489,67 @@ async def emit_stream_update(agent_key: str, text: str) -> None: diagnostics=diagnostics, ) + async def compact_thread( + self, + thread_id: str, + *, + on_progress: Callback | None = None, + timeout: float = 30.0, + ) -> str: + diagnostics: list[str] = [] + last_notice = "" + + async def emit_notice(notice: str) -> None: + nonlocal last_notice + if last_notice == notice: + return + last_notice = notice + await _maybe_call( + on_progress, + NativeAgentUpdate(agent_key="main", text=notice), + ) + + await self._request( + "thread/compact/start", + {"threadId": thread_id}, + diagnostics=diagnostics, + ) + + while True: + try: + message = await asyncio.wait_for( + self._read_message(diagnostics), + timeout=timeout, + ) + except asyncio.TimeoutError: + return last_notice or "已开始压缩当前 resume 会话上下文。" + + if message is None: + continue + + method = message.get("method") + params = message.get("params") + if not isinstance(method, str) or not isinstance(params, dict): + continue + + if method in {"item/started", "item/completed"}: + item = params.get("item") + if not isinstance(item, dict) or item.get("type") != "contextCompaction": + continue + notice = _extract_compaction_notice(item) or ( + "正在压缩当前 resume 会话上下文…" + if method == "item/started" + else "已压缩当前 resume 会话上下文。" + ) + await emit_notice(notice) + continue + + if method == "thread/compacted": + notice = _extract_compaction_notice(params) or last_notice + final_notice = notice or "已压缩当前 resume 会话上下文。" + await emit_notice(final_notice) + return final_notice + async def list_threads(self) -> list[NativeThreadSummary]: threads: list[NativeThreadSummary] = [] cursor: str | None = None diff --git a/src/nonebot_plugin_codex/service.py b/src/nonebot_plugin_codex/service.py index f2f9a09..0c57fd9 100644 --- a/src/nonebot_plugin_codex/service.py +++ b/src/nonebot_plugin_codex/service.py @@ -2696,6 +2696,33 @@ async def apply_browser_directory( ) return notice + async def compact_chat(self, chat_key: str) -> str: + self._ensure_not_running(chat_key) + if self.native_client is None: + raise RuntimeError("当前环境不支持 resume 会话压缩。") + + session = self.activate_chat(chat_key) + if session.active_mode != "resume" or not session.native_thread_id: + raise ValueError("当前聊天没有可压缩的 resume 会话。") + + native_runner = self._spawn_native_client() + if native_runner is None: + raise RuntimeError("当前环境不支持 resume 会话压缩。") + try: + preferences = self.get_preferences(chat_key) + thread = await native_runner.resume_thread( + session.native_thread_id, + workdir=preferences.workdir, + model=preferences.model, + reasoning_effort=preferences.reasoning_effort, + permission_mode=preferences.permission_mode, + ) + self._set_native_thread_id(session, thread.thread_id) + notice = await native_runner.compact_thread(session.native_thread_id) + finally: + await self._close_native_runner(native_runner) + return notice or "已压缩当前 resume 会话上下文。" + def render_directory_browser(self, chat_key: str) -> tuple[str, InlineKeyboardMarkup]: browser = self.get_browser(chat_key) preferences = self.get_preferences(chat_key) diff --git a/src/nonebot_plugin_codex/telegram.py b/src/nonebot_plugin_codex/telegram.py index 5f5d417..50d0b99 100644 --- a/src/nonebot_plugin_codex/telegram.py +++ b/src/nonebot_plugin_codex/telegram.py @@ -912,6 +912,13 @@ async def handle_sessions(self, bot: Bot, event: MessageEvent) -> None: except (ValueError, RuntimeError) as exc: await self.send_event_message(bot, event, self.error_text(exc)) + async def handle_compact(self, bot: Bot, event: MessageEvent) -> None: + try: + notice = await self.service.compact_chat(self.chat_key(event)) + await self.send_event_message(bot, event, notice) + except (ValueError, RuntimeError) as exc: + await self.send_event_message(bot, event, self.error_text(exc)) + async def handle_browser_callback(self, bot: Bot, event: CallbackQueryEvent) -> None: if not isinstance(event.data, str): await bot.answer_callback_query( diff --git a/src/nonebot_plugin_codex/telegram_commands.py b/src/nonebot_plugin_codex/telegram_commands.py index dfb27a8..0d0a39a 100644 --- a/src/nonebot_plugin_codex/telegram_commands.py +++ b/src/nonebot_plugin_codex/telegram_commands.py @@ -98,6 +98,11 @@ class TelegramCommandSpec: description="打开历史会话浏览器", usage="/sessions", ), + TelegramCommandSpec( + name="compact", + description="压缩当前 resume 会话上下文", + usage="/compact", + ), ) diff --git a/tests/test_native_client.py b/tests/test_native_client.py index 245f79b..b6359c8 100644 --- a/tests/test_native_client.py +++ b/tests/test_native_client.py @@ -138,6 +138,111 @@ async def launcher(*args: Any, **kwargs: Any) -> FakeProcess: assert result.final_text == "hello" +@pytest.mark.asyncio +async def test_native_client_run_turn_reports_context_compaction_progress() -> None: + process = FakeProcess( + stdout=FakeStdout( + [ + json.dumps({"jsonrpc": "2.0", "id": 1, "result": {}}) + "\n", + json.dumps({"jsonrpc": "2.0", "id": 2, "result": {}}) + "\n", + json.dumps({"jsonrpc": "2.0", "method": "turn/started", "params": {}}) + + "\n", + json.dumps( + { + "jsonrpc": "2.0", + "method": "item/completed", + "params": { + "threadId": "thread-1", + "item": { + "id": "compact-1", + "type": "contextCompaction", + "summary": "已压缩较早对话上下文。", + }, + }, + } + ) + + "\n", + json.dumps( + { + "jsonrpc": "2.0", + "method": "item/agentMessage/delta", + "params": {"delta": "hello"}, + } + ) + + "\n", + json.dumps( + { + "jsonrpc": "2.0", + "method": "turn/completed", + "params": { + "threadId": "thread-1", + "turn": {"status": "completed", "error": None}, + }, + } + ) + + "\n", + ] + ), + stdin=FakeStdin(), + ) + + async def launcher(*_args: Any, **_kwargs: Any) -> FakeProcess: + return process + + client = NativeCodexClient(binary="codex", launcher=launcher) + progress: list[Any] = [] + + result = await client.run_turn( + "thread-1", + "hello", + on_progress=progress.append, + ) + + assert [(entry.agent_key, entry.text) for entry in progress] == [ + ("main", "开始处理请求"), + ("main", "已压缩较早对话上下文。"), + ] + assert result.exit_code == 0 + assert result.final_text == "hello" + + +@pytest.mark.asyncio +async def test_native_client_compact_thread_waits_for_compaction_notice() -> None: + process = FakeProcess( + stdout=FakeStdout( + [ + json.dumps({"jsonrpc": "2.0", "id": 1, "result": {}}) + "\n", + json.dumps({"jsonrpc": "2.0", "id": 2, "result": {}}) + "\n", + json.dumps( + { + "jsonrpc": "2.0", + "method": "thread/compacted", + "params": { + "threadId": "thread-1", + "summary": "已压缩当前 resume 会话上下文。", + }, + } + ) + + "\n", + ] + ), + stdin=FakeStdin(), + ) + + async def launcher(*_args: Any, **_kwargs: Any) -> FakeProcess: + return process + + client = NativeCodexClient(binary="codex", launcher=launcher) + progress: list[Any] = [] + + notice = await client.compact_thread("thread-1", on_progress=progress.append) + + assert notice == "已压缩当前 resume 会话上下文。" + assert [(entry.agent_key, entry.text) for entry in progress] == [ + ("main", "已压缩当前 resume 会话上下文。"), + ] + + @pytest.mark.asyncio async def test_native_client_ignores_commentary_text_and_reports_subagent_progress( ) -> None: diff --git a/tests/test_service.py b/tests/test_service.py index 27a42f6..a38f41e 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -20,9 +20,13 @@ class DummyNativeClient: def __init__(self, threads: list[NativeThreadSummary] | None = None) -> None: self._threads = threads or [] + self.compact_calls: list[str] = [] + self.compact_notice = "已压缩当前 resume 会话上下文。" + self.resume_calls: list[str] = [] + self.require_resume_before_compact = False def clone(self) -> DummyNativeClient: - return DummyNativeClient(list(self._threads)) + return self async def close(self, timeout: float = 5.0) -> None: return None @@ -30,6 +34,30 @@ async def close(self, timeout: float = 5.0) -> None: async def list_threads(self) -> list[NativeThreadSummary]: return list(self._threads) + async def resume_thread( + self, + thread_id: str, + *, + workdir: str, + model: str, + reasoning_effort: str, + permission_mode: str, + ) -> NativeThreadSummary: + self.resume_calls.append(thread_id) + return NativeThreadSummary( + thread_id=thread_id, + thread_name="Native Session", + updated_at="2025-03-01T00:00:00Z", + cwd=workdir, + source_kind="cli", + ) + + async def compact_thread(self, thread_id: str) -> str: + if self.require_resume_before_compact and thread_id not in self.resume_calls: + raise RuntimeError(f"thread not found: {thread_id}") + self.compact_calls.append(thread_id) + return self.compact_notice + def make_service( tmp_path: Path, @@ -358,6 +386,51 @@ async def test_apply_history_session_uses_existing_cwd_when_original_missing( assert f"当前工作目录:{current_dir.resolve()}" in notice +@pytest.mark.asyncio +async def test_compact_chat_uses_bound_native_resume_thread( + tmp_path: Path, model_cache_file: Path +) -> None: + service = make_service(tmp_path, model_cache_file) + session = service.activate_chat("private_1") + session.active_mode = "resume" + session.native_thread_id = "native-1" + session.thread_id = "native-1" + + notice = await service.compact_chat("private_1") + + assert notice == "已压缩当前 resume 会话上下文。" + assert service.native_client.resume_calls == ["native-1"] + assert service.native_client.compact_calls == ["native-1"] + + +@pytest.mark.asyncio +async def test_compact_chat_resumes_thread_before_compacting( + tmp_path: Path, model_cache_file: Path +) -> None: + service = make_service(tmp_path, model_cache_file) + service.native_client.require_resume_before_compact = True + session = service.activate_chat("private_1") + session.active_mode = "resume" + session.native_thread_id = "native-1" + session.thread_id = "native-1" + + notice = await service.compact_chat("private_1") + + assert notice == "已压缩当前 resume 会话上下文。" + assert service.native_client.resume_calls == ["native-1"] + assert service.native_client.compact_calls == ["native-1"] + + +@pytest.mark.asyncio +async def test_compact_chat_requires_bound_native_resume_thread( + tmp_path: Path, model_cache_file: Path +) -> None: + service = make_service(tmp_path, model_cache_file) + + with pytest.raises(ValueError, match="当前聊天没有可压缩的 resume 会话。"): + await service.compact_chat("private_1") + + @pytest.mark.asyncio async def test_refresh_history_sessions_keeps_exec_list_entries_lightweight_until_open( tmp_path: Path, model_cache_file: Path diff --git a/tests/test_telegram_commands.py b/tests/test_telegram_commands.py index f9c18a6..ebc35a6 100644 --- a/tests/test_telegram_commands.py +++ b/tests/test_telegram_commands.py @@ -26,6 +26,7 @@ def test_build_telegram_commands_uses_expected_order_and_chinese_descriptions() "cd", "home", "sessions", + "compact", ] assert [command.model_dump() for command in build_telegram_commands()] == [ @@ -46,6 +47,7 @@ def test_build_telegram_commands_uses_expected_order_and_chinese_descriptions() {"command": "cd", "description": "切换目录或打开目录浏览器"}, {"command": "home", "description": "把工作目录重置到 Home"}, {"command": "sessions", "description": "打开历史会话浏览器"}, + {"command": "compact", "description": "压缩当前 resume 会话上下文"}, ] @@ -53,5 +55,5 @@ def test_build_plugin_usage_lists_all_commands() -> None: assert build_plugin_usage() == ( "/codex [prompt], /help, /start, /panel, /status, /mode, /exec, /new, " "/stop, /models, /model, /effort, /permission, /pwd, /cd, /home, " - "/sessions" + "/sessions, /compact" ) diff --git a/tests/test_telegram_handlers.py b/tests/test_telegram_handlers.py index d92b69d..b87ce4d 100644 --- a/tests/test_telegram_handlers.py +++ b/tests/test_telegram_handlers.py @@ -168,6 +168,8 @@ def __init__(self) -> None: self.workspace_token = "workspace" self.workspace_version = 1 self.workspace_closed = False + self.compact_calls: list[str] = [] + self.compact_notice = "已压缩当前 resume 会话上下文。" self.run_updates: list[tuple[str, Any]] = [] self.run_progress_updates: list[Any] = [] self.run_stream_updates: list[Any] = [] @@ -224,6 +226,10 @@ async def reset_chat(self, chat_key: str, *, keep_active: bool) -> ChatSession: self.session = ChatSession(active=keep_active) return self.session + async def compact_chat(self, chat_key: str) -> str: + self.compact_calls.append(chat_key) + return self.compact_notice + def open_directory_browser(self, chat_key: str) -> SimpleNamespace: return SimpleNamespace(token="token") @@ -732,6 +738,18 @@ async def test_handle_sessions_opens_history_browser() -> None: assert bot.sent[0]["text"] == "Codex 历史会话" +@pytest.mark.asyncio +async def test_handle_compact_compacts_current_resume_chat() -> None: + service = FakeService() + handlers = TelegramHandlers(service) + bot = FakeBot() + + await handlers.handle_compact(bot, FakeEvent("")) + + assert service.compact_calls == ["private_1"] + assert bot.sent[0]["text"] == "已压缩当前 resume 会话上下文。" + + @pytest.mark.asyncio @pytest.mark.parametrize("handler_name", ["handle_panel", "handle_status"]) async def test_panel_and_status_open_workspace_panel(handler_name: str) -> None: