Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ codex_stream_read_limit = 8388608
| `/cd [path]` | 切换目录或打开目录浏览器 |
| `/home` | 将工作目录重置到 Home |
| `/sessions` | 打开历史会话浏览器 |
| `/compact` | 压缩当前 `resume` 会话上下文 |

## 模式说明

Expand All @@ -219,6 +220,7 @@ codex_stream_read_limit = 8388608
- 优先使用 `codex app-server`
- 为同一聊天维持 native thread
- 更适合连续编码、持续追问和多轮调试
- 支持在 Telegram 中用 `/compact` 压缩较早对话上下文

### `exec`

Expand Down
83 changes: 83 additions & 0 deletions docs/maintenance/2026-03-17-telegram-context-compaction.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions src/nonebot_plugin_codex/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
109 changes: 109 additions & 0 deletions src/nonebot_plugin_codex/native_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
*,
Expand Down Expand Up @@ -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:
Expand All @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions src/nonebot_plugin_codex/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions src/nonebot_plugin_codex/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions src/nonebot_plugin_codex/telegram_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ class TelegramCommandSpec:
description="打开历史会话浏览器",
usage="/sessions",
),
TelegramCommandSpec(
name="compact",
description="压缩当前 resume 会话上下文",
usage="/compact",
),
)


Expand Down
Loading