nonebot-plugin-codex 0.1.4__tar.gz → 0.1.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/PKG-INFO +12 -4
  2. {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/README.md +11 -3
  3. {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/pyproject.toml +1 -1
  4. {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/config.py +1 -1
  5. {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/native_client.py +24 -6
  6. nonebot_plugin_codex-0.1.5/src/nonebot_plugin_codex/protocol_io.py +131 -0
  7. {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/service.py +43 -7
  8. {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/tests/test_config.py +6 -0
  9. nonebot_plugin_codex-0.1.5/tests/test_native_client.py +401 -0
  10. {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/tests/test_service.py +105 -0
  11. nonebot_plugin_codex-0.1.4/tests/test_native_client.py +0 -128
  12. {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/LICENSE +0 -0
  13. {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/__init__.py +0 -0
  14. {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/runtime.py +0 -0
  15. {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/telegram.py +0 -0
  16. {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/telegram_commands.py +0 -0
  17. {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/telegram_rendering.py +0 -0
  18. {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/tests/__init__.py +0 -0
  19. {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/tests/conftest.py +0 -0
  20. {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/tests/test_plugin_entry.py +0 -0
  21. {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/tests/test_plugin_meta.py +0 -0
  22. {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/tests/test_release_notes.py +0 -0
  23. {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/tests/test_runtime.py +0 -0
  24. {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/tests/test_telegram_commands.py +0 -0
  25. {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/tests/test_telegram_handlers.py +0 -0
  26. {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/tests/test_telegram_rendering.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: nonebot-plugin-codex
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: Telegram bridge plugin for driving Codex from NoneBot
5
5
  Author-Email: ttiee <469784630@qq.com>
6
6
  License: GPL-3.0-or-later
@@ -40,6 +40,13 @@ _✨ 在 Telegram 里驱动 Codex CLI 的 NoneBot 插件 ✨_
40
40
 
41
41
  </div>
42
42
 
43
+ <p align="center">
44
+ <img src="docs/images/readme/1.jpg" width="24%" alt="nonebot-plugin-codex screenshot 1">
45
+ <img src="docs/images/readme/2.jpg" width="24%" alt="nonebot-plugin-codex screenshot 2">
46
+ <img src="docs/images/readme/3.jpg" width="24%" alt="nonebot-plugin-codex screenshot 3">
47
+ <img src="docs/images/readme/4.jpg" width="24%" alt="nonebot-plugin-codex screenshot 4">
48
+ </p>
49
+
43
50
  ## 项目介绍
44
51
 
45
52
  `nonebot-plugin-codex` 是一个面向 Telegram 场景的 NoneBot 插件,用来把本机 `codex` CLI 暴露为可对话、可续聊、可管理工作目录的聊天式开发助手。
@@ -180,8 +187,8 @@ codex_diagnostic_history = 20
180
187
  # 单条 Telegram 消息的分片长度,过长回复会自动拆分
181
188
  codex_chunk_size = 3500
182
189
 
183
- # 读取 Codex stdout / stderr 的缓冲区大小
184
- codex_stream_read_limit = 1048576
190
+ # 单条 Codex 协议消息允许的最大字节数
191
+ codex_stream_read_limit = 8388608
185
192
 
186
193
  ```
187
194
 
@@ -189,7 +196,8 @@ codex_stream_read_limit = 1048576
189
196
 
190
197
  - `codex_binary`:如果宿主机不是直接执行 `codex`,改成实际绝对路径。
191
198
  - `codex_workdir`:默认工作目录,也是 `/cd` 相对路径解析与目录浏览器 Home 的基准。
192
- - 其余项分别控制停止超时、进度保留条数、诊断输出条数、Telegram 分片长度和流读取上限。
199
+ - `codex_stream_read_limit`:限制单条 Codex 协议帧的最大字节数,不是 Telegram 消息分片长度。
200
+ - 其余项分别控制停止超时、进度保留条数、诊断输出条数和 Telegram 分片长度。
193
201
  - 插件自己的配置数据由 `nonebot-plugin-localstore` 自动管理。
194
202
  - 模型缓存、Codex CLI 配置和历史会话目录默认读取 `~/.codex/*`,属于插件内部实现路径。
195
203
 
@@ -27,6 +27,13 @@ _✨ 在 Telegram 里驱动 Codex CLI 的 NoneBot 插件 ✨_
27
27
 
28
28
  </div>
29
29
 
30
+ <p align="center">
31
+ <img src="docs/images/readme/1.jpg" width="24%" alt="nonebot-plugin-codex screenshot 1">
32
+ <img src="docs/images/readme/2.jpg" width="24%" alt="nonebot-plugin-codex screenshot 2">
33
+ <img src="docs/images/readme/3.jpg" width="24%" alt="nonebot-plugin-codex screenshot 3">
34
+ <img src="docs/images/readme/4.jpg" width="24%" alt="nonebot-plugin-codex screenshot 4">
35
+ </p>
36
+
30
37
  ## 项目介绍
31
38
 
32
39
  `nonebot-plugin-codex` 是一个面向 Telegram 场景的 NoneBot 插件,用来把本机 `codex` CLI 暴露为可对话、可续聊、可管理工作目录的聊天式开发助手。
@@ -167,8 +174,8 @@ codex_diagnostic_history = 20
167
174
  # 单条 Telegram 消息的分片长度,过长回复会自动拆分
168
175
  codex_chunk_size = 3500
169
176
 
170
- # 读取 Codex stdout / stderr 的缓冲区大小
171
- codex_stream_read_limit = 1048576
177
+ # 单条 Codex 协议消息允许的最大字节数
178
+ codex_stream_read_limit = 8388608
172
179
 
173
180
  ```
174
181
 
@@ -176,7 +183,8 @@ codex_stream_read_limit = 1048576
176
183
 
177
184
  - `codex_binary`:如果宿主机不是直接执行 `codex`,改成实际绝对路径。
178
185
  - `codex_workdir`:默认工作目录,也是 `/cd` 相对路径解析与目录浏览器 Home 的基准。
179
- - 其余项分别控制停止超时、进度保留条数、诊断输出条数、Telegram 分片长度和流读取上限。
186
+ - `codex_stream_read_limit`:限制单条 Codex 协议帧的最大字节数,不是 Telegram 消息分片长度。
187
+ - 其余项分别控制停止超时、进度保留条数、诊断输出条数和 Telegram 分片长度。
180
188
  - 插件自己的配置数据由 `nonebot-plugin-localstore` 自动管理。
181
189
  - 模型缓存、Codex CLI 配置和历史会话目录默认读取 `~/.codex/*`,属于插件内部实现路径。
182
190
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nonebot-plugin-codex"
3
- version = "0.1.4"
3
+ version = "0.1.5"
4
4
  description = "Telegram bridge plugin for driving Codex from NoneBot"
5
5
  authors = [
6
6
  { name = "ttiee", email = "469784630@qq.com" },
@@ -12,4 +12,4 @@ class Config(BaseModel):
12
12
  codex_progress_history: int = 6
13
13
  codex_diagnostic_history: int = 20
14
14
  codex_chunk_size: int = 3500
15
- codex_stream_read_limit: int = 1024 * 1024
15
+ codex_stream_read_limit: int = 8 * 1024 * 1024
@@ -7,6 +7,8 @@ from dataclasses import field, dataclass
7
7
  from typing import Any
8
8
  from collections.abc import Callable, Awaitable
9
9
 
10
+ from .protocol_io import NdjsonProcessReader, ProtocolStreamError
11
+
10
12
  Callback = Callable[[str], object]
11
13
  ProcessLauncher = Callable[..., Awaitable[Any]]
12
14
 
@@ -93,7 +95,7 @@ class NativeCodexClient:
93
95
  launcher: ProcessLauncher | None = None,
94
96
  client_name: str = "tg_bot",
95
97
  client_version: str = "0",
96
- stream_read_limit: int = 1024 * 1024,
98
+ stream_read_limit: int = 8 * 1024 * 1024,
97
99
  ) -> None:
98
100
  self.binary = binary
99
101
  self.launcher = launcher or asyncio.create_subprocess_exec
@@ -101,6 +103,7 @@ class NativeCodexClient:
101
103
  self.client_version = client_version
102
104
  self.stream_read_limit = stream_read_limit
103
105
  self._process: Any = None
106
+ self._reader: NdjsonProcessReader | None = None
104
107
  self._initialized = False
105
108
  self._next_request_id = 1
106
109
 
@@ -115,10 +118,14 @@ class NativeCodexClient:
115
118
 
116
119
  async def close(self, timeout: float = 5.0) -> None:
117
120
  process = self._process
121
+ reader = self._reader
118
122
  self._process = None
123
+ self._reader = None
119
124
  self._initialized = False
120
125
  self._next_request_id = 1
121
126
  await _terminate_process(process, timeout)
127
+ if reader is not None:
128
+ await reader.wait_closed()
122
129
 
123
130
  async def start_thread(
124
131
  self,
@@ -322,9 +329,13 @@ class NativeCodexClient:
322
329
  "stdio://",
323
330
  stdin=asyncio.subprocess.PIPE,
324
331
  stdout=asyncio.subprocess.PIPE,
325
- stderr=asyncio.subprocess.STDOUT,
332
+ stderr=asyncio.subprocess.PIPE,
326
333
  limit=self.stream_read_limit,
327
334
  )
335
+ self._reader = NdjsonProcessReader(
336
+ self._process,
337
+ frame_limit=self.stream_read_limit,
338
+ )
328
339
  request_id = self._allocate_request_id()
329
340
  await self._write_message(
330
341
  {
@@ -398,12 +409,19 @@ class NativeCodexClient:
398
409
  return result
399
410
 
400
411
  async def _read_message(self, diagnostics: list[str]) -> dict[str, Any] | None:
401
- if self._process is None or getattr(self._process, "stdout", None) is None:
412
+ if self._process is None or self._reader is None:
402
413
  raise RuntimeError("Codex app-server 尚未启动。")
403
- raw_line = await self._process.stdout.readline()
404
- if not raw_line:
414
+
415
+ diagnostics.extend(self._reader.drain_stderr_lines())
416
+ try:
417
+ line = await self._reader.read_stdout_line()
418
+ except ProtocolStreamError as exc:
419
+ diagnostics.extend(self._reader.drain_stderr_lines())
420
+ raise RuntimeError(str(exc)) from exc
421
+ diagnostics.extend(self._reader.drain_stderr_lines())
422
+
423
+ if line is None:
405
424
  raise RuntimeError("Codex app-server 已提前退出。")
406
- line = raw_line.decode("utf-8", errors="replace").strip()
407
425
  if not line:
408
426
  return None
409
427
  try:
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import Any
5
+
6
+ READ_CHUNK_SIZE = 4096
7
+
8
+
9
+ def oversized_frame_message(limit: int) -> str:
10
+ return (
11
+ "Codex 返回的单条协议消息超过 "
12
+ f"`codex_stream_read_limit`(当前 {limit} 字节)。"
13
+ )
14
+
15
+
16
+ def incomplete_frame_message() -> str:
17
+ return "Codex 返回了不完整的协议消息。"
18
+
19
+
20
+ def truncated_stderr_message(limit: int) -> str:
21
+ return (
22
+ "Codex stderr 单行输出超过 "
23
+ f"`codex_stream_read_limit`(当前 {limit} 字节),已截断。"
24
+ )
25
+
26
+
27
+ class ProtocolStreamError(RuntimeError):
28
+ pass
29
+
30
+
31
+ class NdjsonProcessReader:
32
+ def __init__(
33
+ self,
34
+ process: Any,
35
+ *,
36
+ frame_limit: int,
37
+ read_chunk_size: int = READ_CHUNK_SIZE,
38
+ ) -> None:
39
+ self._stdout = getattr(process, "stdout", None)
40
+ self._stderr = getattr(process, "stderr", None)
41
+ self._frame_limit = frame_limit
42
+ self._read_chunk_size = max(1, read_chunk_size)
43
+ self._stdout_buffer = bytearray()
44
+ self._stderr_buffer = bytearray()
45
+ self._stderr_lines: list[str] = []
46
+ self._stderr_skipping_line = False
47
+ self._stderr_task: asyncio.Task[None] | None = None
48
+ if self._stderr is not None:
49
+ self._stderr_task = asyncio.create_task(self._drain_stderr())
50
+
51
+ async def read_stdout_line(self) -> str | None:
52
+ if self._stdout is None:
53
+ raise RuntimeError("Codex 协议 stdout 不可用。")
54
+
55
+ while True:
56
+ newline_index = self._stdout_buffer.find(b"\n")
57
+ if newline_index >= 0:
58
+ frame = bytes(self._stdout_buffer[: newline_index + 1])
59
+ del self._stdout_buffer[: newline_index + 1]
60
+ if newline_index > self._frame_limit:
61
+ raise ProtocolStreamError(oversized_frame_message(self._frame_limit))
62
+ return frame.decode("utf-8", errors="replace").strip()
63
+
64
+ if len(self._stdout_buffer) > self._frame_limit:
65
+ self._stdout_buffer.clear()
66
+ raise ProtocolStreamError(oversized_frame_message(self._frame_limit))
67
+
68
+ chunk = await self._stdout.read(self._read_chunk_size)
69
+ if not chunk:
70
+ if self._stdout_buffer:
71
+ self._stdout_buffer.clear()
72
+ raise ProtocolStreamError(incomplete_frame_message())
73
+ return None
74
+ self._stdout_buffer.extend(chunk)
75
+
76
+ def drain_stderr_lines(self) -> list[str]:
77
+ lines = list(self._stderr_lines)
78
+ self._stderr_lines.clear()
79
+ return lines
80
+
81
+ async def wait_closed(self) -> None:
82
+ if self._stderr_task is not None:
83
+ await self._stderr_task
84
+
85
+ async def _drain_stderr(self) -> None:
86
+ if self._stderr is None:
87
+ return
88
+
89
+ while True:
90
+ chunk = await self._stderr.read(self._read_chunk_size)
91
+ if not chunk:
92
+ break
93
+ self._stderr_buffer.extend(chunk)
94
+ self._consume_stderr_buffer(final=False)
95
+
96
+ self._consume_stderr_buffer(final=True)
97
+
98
+ def _consume_stderr_buffer(self, *, final: bool) -> None:
99
+ while True:
100
+ if self._stderr_skipping_line:
101
+ newline_index = self._stderr_buffer.find(b"\n")
102
+ if newline_index < 0:
103
+ if final:
104
+ self._stderr_buffer.clear()
105
+ self._stderr_skipping_line = False
106
+ return
107
+ del self._stderr_buffer[: newline_index + 1]
108
+ self._stderr_skipping_line = False
109
+ continue
110
+
111
+ newline_index = self._stderr_buffer.find(b"\n")
112
+ if newline_index >= 0:
113
+ frame = bytes(self._stderr_buffer[: newline_index + 1])
114
+ del self._stderr_buffer[: newline_index + 1]
115
+ line = frame.decode("utf-8", errors="replace").strip()
116
+ if line:
117
+ self._stderr_lines.append(line)
118
+ continue
119
+
120
+ if len(self._stderr_buffer) > self._frame_limit:
121
+ self._stderr_lines.append(truncated_stderr_message(self._frame_limit))
122
+ self._stderr_buffer.clear()
123
+ self._stderr_skipping_line = True
124
+ return
125
+
126
+ if final:
127
+ line = self._stderr_buffer.decode("utf-8", errors="replace").strip()
128
+ self._stderr_buffer.clear()
129
+ if line:
130
+ self._stderr_lines.append(line)
131
+ return
@@ -18,6 +18,7 @@ from dataclasses import field, asdict, dataclass
18
18
  from nonebot.adapters.telegram.model import InlineKeyboardButton, InlineKeyboardMarkup
19
19
 
20
20
  from .native_client import NativeCodexClient
21
+ from .protocol_io import NdjsonProcessReader, ProtocolStreamError
21
22
 
22
23
  ProgressCallback = Callable[[str], Awaitable[None]]
23
24
  StreamTextCallback = Callable[[str], Awaitable[None]]
@@ -52,7 +53,7 @@ class CodexBridgeSettings:
52
53
  progress_history: int = 6
53
54
  diagnostic_history: int = 20
54
55
  chunk_size: int = 3500
55
- stream_read_limit: int = 1024 * 1024
56
+ stream_read_limit: int = 8 * 1024 * 1024
56
57
  models_cache_path: Path = field(
57
58
  default_factory=lambda: Path.home() / ".codex" / "models_cache.json"
58
59
  )
@@ -468,6 +469,16 @@ def _append_diagnostic(session: ChatSession, line: str, limit: int) -> None:
468
469
  del session.diagnostics[:-limit]
469
470
 
470
471
 
472
+ def _drain_protocol_reader_diagnostics(
473
+ session: ChatSession,
474
+ reader: NdjsonProcessReader,
475
+ *,
476
+ limit: int,
477
+ ) -> None:
478
+ for line in reader.drain_stderr_lines():
479
+ _append_diagnostic(session, line, limit)
480
+
481
+
471
482
  def _apply_event(
472
483
  session: ChatSession,
473
484
  event: dict[str, Any],
@@ -2994,11 +3005,15 @@ class CodexBridgeService:
2994
3005
  process = await self.launcher(
2995
3006
  *argv,
2996
3007
  stdout=asyncio.subprocess.PIPE,
2997
- stderr=asyncio.subprocess.STDOUT,
3008
+ stderr=asyncio.subprocess.PIPE,
2998
3009
  cwd=preferences.workdir,
2999
3010
  limit=self.settings.stream_read_limit,
3000
3011
  )
3001
3012
  session.process = process
3013
+ reader = NdjsonProcessReader(
3014
+ process,
3015
+ frame_limit=self.settings.stream_read_limit,
3016
+ )
3002
3017
 
3003
3018
  if on_progress is not None:
3004
3019
  await on_progress(
@@ -3012,13 +3027,23 @@ class CodexBridgeService:
3012
3027
  )
3013
3028
  )
3014
3029
 
3015
- stdout = getattr(process, "stdout", None)
3030
+ exit_code: int | None = None
3031
+ cancelled = False
3016
3032
  try:
3017
- while stdout is not None:
3018
- raw_line = await stdout.readline()
3019
- if not raw_line:
3033
+ while True:
3034
+ _drain_protocol_reader_diagnostics(
3035
+ session,
3036
+ reader,
3037
+ limit=self.settings.diagnostic_history,
3038
+ )
3039
+ line = await reader.read_stdout_line()
3040
+ _drain_protocol_reader_diagnostics(
3041
+ session,
3042
+ reader,
3043
+ limit=self.settings.diagnostic_history,
3044
+ )
3045
+ if line is None:
3020
3046
  break
3021
- line = raw_line.decode("utf-8", errors="replace").strip()
3022
3047
  if not line:
3023
3048
  continue
3024
3049
  event = parse_event_line(line)
@@ -3042,10 +3067,21 @@ class CodexBridgeService:
3042
3067
 
3043
3068
  exit_code = await process.wait()
3044
3069
  cancelled = session.cancel_requested
3070
+ except ProtocolStreamError as exc:
3071
+ _append_diagnostic(session, str(exc), self.settings.diagnostic_history)
3072
+ await terminate_process(process, self.settings.kill_timeout)
3073
+ exit_code = 1
3074
+ cancelled = session.cancel_requested
3045
3075
  except Exception:
3046
3076
  await terminate_process(process, self.settings.kill_timeout)
3047
3077
  raise
3048
3078
  finally:
3079
+ await reader.wait_closed()
3080
+ _drain_protocol_reader_diagnostics(
3081
+ session,
3082
+ reader,
3083
+ limit=self.settings.diagnostic_history,
3084
+ )
3049
3085
  session.running = False
3050
3086
  session.process = None
3051
3087
  session.cancel_requested = False
@@ -43,6 +43,12 @@ def test_config_accepts_supported_field_values() -> None:
43
43
  assert config.codex_stream_read_limit == 4096
44
44
 
45
45
 
46
+ def test_config_defaults_stream_limit_to_8_mib() -> None:
47
+ config = Config()
48
+
49
+ assert config.codex_stream_read_limit == 8 * 1024 * 1024
50
+
51
+
46
52
  def test_config_ignores_removed_legacy_fields() -> None:
47
53
  config = Config(
48
54
  legacy_binary="legacy-bin",
@@ -0,0 +1,401 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from dataclasses import dataclass
9
+
10
+ import pytest
11
+
12
+ from nonebot_plugin_codex.native_client import NativeCodexClient
13
+
14
+
15
+ class FakeStdout:
16
+ def __init__(self, lines: list[str]) -> None:
17
+ self._lines = [line.encode("utf-8") for line in lines]
18
+
19
+ async def readline(self) -> bytes:
20
+ if self._lines:
21
+ return self._lines.pop(0)
22
+ await asyncio.sleep(0)
23
+ return b""
24
+
25
+ async def read(self, _size: int = -1) -> bytes:
26
+ return await self.readline()
27
+
28
+
29
+ class FakeStdin:
30
+ def __init__(self) -> None:
31
+ self.buffer: list[str] = []
32
+
33
+ def write(self, data: bytes) -> None:
34
+ self.buffer.append(data.decode("utf-8"))
35
+
36
+ async def drain(self) -> None:
37
+ return None
38
+
39
+
40
+ @dataclass
41
+ class FakeProcess:
42
+ stdout: FakeStdout
43
+ stdin: FakeStdin
44
+ stderr: FakeStdout | None = None
45
+ returncode: int | None = None
46
+
47
+ def terminate(self) -> None:
48
+ self.returncode = 0
49
+
50
+ def kill(self) -> None:
51
+ self.returncode = -9
52
+
53
+ async def wait(self) -> int:
54
+ self.returncode = self.returncode or 0
55
+ return self.returncode
56
+
57
+
58
+ @pytest.mark.asyncio
59
+ async def test_native_client_start_resume_and_stream_text() -> None:
60
+ requests: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
61
+ process = FakeProcess(
62
+ stdout=FakeStdout(
63
+ [
64
+ json.dumps({"jsonrpc": "2.0", "id": 1, "result": {}}) + "\n",
65
+ json.dumps(
66
+ {
67
+ "jsonrpc": "2.0",
68
+ "id": 2,
69
+ "result": {
70
+ "thread": {
71
+ "id": "thread-1",
72
+ "name": "Thread One",
73
+ "updatedAt": "2025-03-01T00:00:00Z",
74
+ "cwd": "/tmp/work",
75
+ "source": "cli",
76
+ }
77
+ },
78
+ }
79
+ )
80
+ + "\n",
81
+ json.dumps({"jsonrpc": "2.0", "id": 3, "result": {}}) + "\n",
82
+ json.dumps({"jsonrpc": "2.0", "method": "turn/started", "params": {}})
83
+ + "\n",
84
+ json.dumps(
85
+ {
86
+ "jsonrpc": "2.0",
87
+ "method": "item/agentMessage/delta",
88
+ "params": {"delta": "hello"},
89
+ }
90
+ )
91
+ + "\n",
92
+ json.dumps(
93
+ {
94
+ "jsonrpc": "2.0",
95
+ "method": "turn/completed",
96
+ "params": {
97
+ "threadId": "thread-1",
98
+ "turn": {"status": "completed", "error": None},
99
+ },
100
+ }
101
+ )
102
+ + "\n",
103
+ ]
104
+ ),
105
+ stdin=FakeStdin(),
106
+ )
107
+
108
+ async def launcher(*args: Any, **kwargs: Any) -> FakeProcess:
109
+ requests.append((args, kwargs))
110
+ return process
111
+
112
+ client = NativeCodexClient(binary="codex", launcher=launcher)
113
+ progress: list[str] = []
114
+ streamed: list[str] = []
115
+
116
+ thread = await client.start_thread(
117
+ workdir="/tmp/work",
118
+ model="gpt-5",
119
+ reasoning_effort="xhigh",
120
+ permission_mode="safe",
121
+ )
122
+ result = await client.run_turn(
123
+ thread.thread_id,
124
+ "hello",
125
+ on_progress=progress.append,
126
+ on_stream_text=streamed.append,
127
+ )
128
+
129
+ assert requests[0][0][:3] == ("codex", "app-server", "--listen")
130
+ assert thread.thread_id == "thread-1"
131
+ assert progress == ["开始处理请求"]
132
+ assert streamed == ["hello"]
133
+ assert result.exit_code == 0
134
+ assert result.final_text == ""
135
+
136
+
137
+ @pytest.mark.asyncio
138
+ async def test_native_client_reads_large_stdout_frame_without_readline_limit(
139
+ tmp_path: Path,
140
+ ) -> None:
141
+ long_text = "A" * (2 * 1024 * 1024)
142
+ thread_payload = {
143
+ "jsonrpc": "2.0",
144
+ "id": 2,
145
+ "result": {
146
+ "thread": {
147
+ "id": "thread-1",
148
+ "name": "Thread One",
149
+ "updatedAt": "2025-03-01T00:00:00Z",
150
+ "cwd": "/tmp/work",
151
+ "source": "cli",
152
+ }
153
+ },
154
+ }
155
+ item_payload = {
156
+ "jsonrpc": "2.0",
157
+ "method": "item/completed",
158
+ "params": {
159
+ "threadId": "thread-1",
160
+ "item": {
161
+ "id": "msg-1",
162
+ "type": "agentMessage",
163
+ "text": long_text,
164
+ },
165
+ },
166
+ }
167
+ completed_payload = {
168
+ "jsonrpc": "2.0",
169
+ "method": "turn/completed",
170
+ "params": {
171
+ "threadId": "thread-1",
172
+ "turn": {"status": "completed", "error": None},
173
+ },
174
+ }
175
+ script = (
176
+ "import json, sys\n"
177
+ f"long_text = {long_text!r}\n"
178
+ "messages = [\n"
179
+ " {'jsonrpc': '2.0', 'id': 1, 'result': {}},\n"
180
+ f" {thread_payload!r},\n"
181
+ " {'jsonrpc': '2.0', 'id': 3, 'result': {}},\n"
182
+ f" {item_payload!r},\n"
183
+ f" {completed_payload!r},\n"
184
+ "]\n"
185
+ "for message in messages:\n"
186
+ " sys.stdout.write(json.dumps(message) + '\\n')\n"
187
+ " sys.stdout.flush()\n"
188
+ )
189
+ script_path = tmp_path / "large_native_stdout.py"
190
+ script_path.write_text(script, encoding="utf-8")
191
+
192
+ async def launcher(*_args: Any, **_kwargs: Any):
193
+ return await asyncio.create_subprocess_exec(
194
+ sys.executable,
195
+ str(script_path),
196
+ stdin=asyncio.subprocess.PIPE,
197
+ stdout=asyncio.subprocess.PIPE,
198
+ stderr=asyncio.subprocess.PIPE,
199
+ limit=1024,
200
+ )
201
+
202
+ client = NativeCodexClient(
203
+ binary="codex",
204
+ launcher=launcher,
205
+ stream_read_limit=8 * 1024 * 1024,
206
+ )
207
+ try:
208
+ thread = await client.start_thread(
209
+ workdir="/tmp/work",
210
+ model="gpt-5",
211
+ reasoning_effort="xhigh",
212
+ permission_mode="safe",
213
+ )
214
+ result = await client.run_turn(thread.thread_id, "hello")
215
+
216
+ assert result.exit_code == 0
217
+ assert result.final_text == long_text
218
+ finally:
219
+ await client.close()
220
+
221
+
222
+ @pytest.mark.asyncio
223
+ async def test_native_client_ignores_large_stderr_frames() -> None:
224
+ huge_stderr = "E" * 4096
225
+ thread_payload = {
226
+ "jsonrpc": "2.0",
227
+ "id": 2,
228
+ "result": {
229
+ "thread": {
230
+ "id": "thread-1",
231
+ "name": "Thread One",
232
+ "updatedAt": "2025-03-01T00:00:00Z",
233
+ "cwd": "/tmp/work",
234
+ "source": "cli",
235
+ }
236
+ },
237
+ }
238
+ script = (
239
+ "import json, sys\n"
240
+ f"huge_stderr = {huge_stderr!r}\n"
241
+ "sys.stdout.write("
242
+ "json.dumps({'jsonrpc': '2.0', 'id': 1, 'result': {}}) + '\\n'"
243
+ ")\n"
244
+ "sys.stdout.flush()\n"
245
+ "sys.stderr.write(huge_stderr + '\\n')\n"
246
+ "sys.stderr.flush()\n"
247
+ f"sys.stdout.write(json.dumps({thread_payload!r}) + '\\n')\n"
248
+ "sys.stdout.flush()\n"
249
+ )
250
+
251
+ async def launcher(*_args: Any, **kwargs: Any):
252
+ return await asyncio.create_subprocess_exec(
253
+ sys.executable,
254
+ "-c",
255
+ script,
256
+ stdin=asyncio.subprocess.PIPE,
257
+ stdout=asyncio.subprocess.PIPE,
258
+ stderr=kwargs.get("stderr", asyncio.subprocess.PIPE),
259
+ limit=int(kwargs.get("limit", 1024)),
260
+ )
261
+
262
+ client = NativeCodexClient(binary="codex", launcher=launcher, stream_read_limit=1024)
263
+ try:
264
+ thread = await client.start_thread(
265
+ workdir="/tmp/work",
266
+ model="gpt-5",
267
+ reasoning_effort="xhigh",
268
+ permission_mode="safe",
269
+ )
270
+
271
+ assert thread.thread_id == "thread-1"
272
+ finally:
273
+ await client.close()
274
+
275
+
276
+ @pytest.mark.asyncio
277
+ async def test_native_client_reports_friendly_error_for_oversized_frame() -> None:
278
+ long_text = "A" * 4096
279
+ thread_payload = {
280
+ "jsonrpc": "2.0",
281
+ "id": 2,
282
+ "result": {
283
+ "thread": {
284
+ "id": "thread-1",
285
+ "name": "Thread One",
286
+ "updatedAt": "2025-03-01T00:00:00Z",
287
+ "cwd": "/tmp/work",
288
+ "source": "cli",
289
+ }
290
+ },
291
+ }
292
+ item_payload = {
293
+ "jsonrpc": "2.0",
294
+ "method": "item/completed",
295
+ "params": {
296
+ "threadId": "thread-1",
297
+ "item": {
298
+ "id": "msg-1",
299
+ "type": "agentMessage",
300
+ "text": long_text,
301
+ },
302
+ },
303
+ }
304
+ script = (
305
+ "import json, sys\n"
306
+ f"long_text = {long_text!r}\n"
307
+ "sys.stdout.write("
308
+ "json.dumps({'jsonrpc': '2.0', 'id': 1, 'result': {}}) + '\\n'"
309
+ ")\n"
310
+ "sys.stdout.flush()\n"
311
+ f"sys.stdout.write(json.dumps({thread_payload!r}) + '\\n')\n"
312
+ "sys.stdout.flush()\n"
313
+ "sys.stdout.write("
314
+ "json.dumps({'jsonrpc': '2.0', 'id': 3, 'result': {}}) + '\\n'"
315
+ ")\n"
316
+ "sys.stdout.flush()\n"
317
+ f"sys.stdout.write(json.dumps({item_payload!r}) + '\\n')\n"
318
+ "sys.stdout.flush()\n"
319
+ )
320
+
321
+ async def launcher(*_args: Any, **kwargs: Any):
322
+ return await asyncio.create_subprocess_exec(
323
+ sys.executable,
324
+ "-c",
325
+ script,
326
+ stdin=asyncio.subprocess.PIPE,
327
+ stdout=asyncio.subprocess.PIPE,
328
+ stderr=kwargs.get("stderr", asyncio.subprocess.PIPE),
329
+ limit=int(kwargs.get("limit", 1024)),
330
+ )
331
+
332
+ client = NativeCodexClient(binary="codex", launcher=launcher, stream_read_limit=1024)
333
+ try:
334
+ thread = await client.start_thread(
335
+ workdir="/tmp/work",
336
+ model="gpt-5",
337
+ reasoning_effort="xhigh",
338
+ permission_mode="safe",
339
+ )
340
+
341
+ with pytest.raises(RuntimeError, match="codex_stream_read_limit"):
342
+ await client.run_turn(thread.thread_id, "hello")
343
+ finally:
344
+ await client.close()
345
+
346
+
347
+ @pytest.mark.asyncio
348
+ async def test_native_client_reports_incomplete_protocol_frame() -> None:
349
+ thread_payload = {
350
+ "jsonrpc": "2.0",
351
+ "id": 2,
352
+ "result": {
353
+ "thread": {
354
+ "id": "thread-1",
355
+ "name": "Thread One",
356
+ "updatedAt": "2025-03-01T00:00:00Z",
357
+ "cwd": "/tmp/work",
358
+ "source": "cli",
359
+ }
360
+ },
361
+ }
362
+ script = (
363
+ "import json, sys\n"
364
+ "sys.stdout.write("
365
+ "json.dumps({'jsonrpc': '2.0', 'id': 1, 'result': {}}) + '\\n'"
366
+ ")\n"
367
+ "sys.stdout.flush()\n"
368
+ f"sys.stdout.write(json.dumps({thread_payload!r}) + '\\n')\n"
369
+ "sys.stdout.flush()\n"
370
+ "sys.stdout.write("
371
+ "json.dumps({'jsonrpc': '2.0', 'id': 3, 'result': {}}) + '\\n'"
372
+ ")\n"
373
+ "sys.stdout.flush()\n"
374
+ "sys.stdout.write('{\"jsonrpc\":\"2.0\",\"method\":\"turn/completed\"')\n"
375
+ "sys.stdout.flush()\n"
376
+ )
377
+
378
+ async def launcher(*_args: Any, **kwargs: Any):
379
+ return await asyncio.create_subprocess_exec(
380
+ sys.executable,
381
+ "-c",
382
+ script,
383
+ stdin=asyncio.subprocess.PIPE,
384
+ stdout=asyncio.subprocess.PIPE,
385
+ stderr=kwargs.get("stderr", asyncio.subprocess.PIPE),
386
+ limit=int(kwargs.get("limit", 1024)),
387
+ )
388
+
389
+ client = NativeCodexClient(binary="codex", launcher=launcher, stream_read_limit=1024)
390
+ try:
391
+ thread = await client.start_thread(
392
+ workdir="/tmp/work",
393
+ model="gpt-5",
394
+ reasoning_effort="xhigh",
395
+ permission_mode="safe",
396
+ )
397
+
398
+ with pytest.raises(RuntimeError, match="不完整的协议消息"):
399
+ await client.run_turn(thread.thread_id, "hello")
400
+ finally:
401
+ await client.close()
@@ -1,7 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import json
4
5
  from pathlib import Path
6
+ import sys
5
7
 
6
8
  import pytest
7
9
 
@@ -33,6 +35,8 @@ def make_service(
33
35
  model_cache_file: Path,
34
36
  *,
35
37
  threads: list[NativeThreadSummary] | None = None,
38
+ launcher=None,
39
+ stream_read_limit: int = 1024 * 1024,
36
40
  ) -> CodexBridgeService:
37
41
  codex_config = tmp_path / "config.toml"
38
42
  codex_config.write_text('model = "gpt-5"\nmodel_reasoning_effort = "xhigh"\n')
@@ -42,6 +46,7 @@ def make_service(
42
46
  workdir=str(tmp_path),
43
47
  models_cache_path=model_cache_file,
44
48
  codex_config_path=codex_config,
49
+ stream_read_limit=stream_read_limit,
45
50
  preferences_path=(
46
51
  tmp_path / "data" / "nonebot_plugin_codex" / "preferences.json"
47
52
  ),
@@ -49,6 +54,7 @@ def make_service(
49
54
  sessions_dir=tmp_path / ".codex" / "sessions",
50
55
  archived_sessions_dir=tmp_path / ".codex" / "archived_sessions",
51
56
  ),
57
+ launcher=launcher,
52
58
  native_client=DummyNativeClient(threads),
53
59
  which_resolver=lambda _: "/usr/bin/codex",
54
60
  )
@@ -539,3 +545,102 @@ async def test_apply_effort_setting_panel_accepts_model_supported_medium(
539
545
 
540
546
  assert "medium" in notice
541
547
  assert service.get_preferences("private_1").reasoning_effort == "medium"
548
+
549
+
550
+ @pytest.mark.asyncio
551
+ async def test_run_prompt_exec_ignores_large_stderr_frames(
552
+ tmp_path: Path,
553
+ model_cache_file: Path,
554
+ ) -> None:
555
+ huge_stderr = "E" * 4096
556
+ completed_payload = {
557
+ "type": "item.completed",
558
+ "item": {"type": "agent_message", "text": "done"},
559
+ }
560
+ script = (
561
+ "import json, sys\n"
562
+ f"huge_stderr = {huge_stderr!r}\n"
563
+ "messages = [\n"
564
+ " {'type': 'thread.started', 'thread_id': 'exec-1'},\n"
565
+ " {'type': 'turn.started'},\n"
566
+ f" {completed_payload!r},\n"
567
+ "]\n"
568
+ "sys.stderr.write(huge_stderr + '\\n')\n"
569
+ "sys.stderr.flush()\n"
570
+ "for message in messages:\n"
571
+ " sys.stdout.write(json.dumps(message) + '\\n')\n"
572
+ " sys.stdout.flush()\n"
573
+ )
574
+
575
+ async def launcher(*_args, **kwargs):
576
+ return await asyncio.create_subprocess_exec(
577
+ sys.executable,
578
+ "-c",
579
+ script,
580
+ stdin=asyncio.subprocess.PIPE,
581
+ stdout=asyncio.subprocess.PIPE,
582
+ stderr=kwargs.get("stderr", asyncio.subprocess.PIPE),
583
+ limit=int(kwargs.get("limit", 1024)),
584
+ )
585
+
586
+ service = make_service(
587
+ tmp_path,
588
+ model_cache_file,
589
+ launcher=launcher,
590
+ stream_read_limit=1024,
591
+ )
592
+
593
+ result = await service.run_prompt("private_1", "hello", mode_override="exec")
594
+
595
+ assert result.exit_code == 0
596
+ assert result.final_text == "done"
597
+
598
+
599
+ @pytest.mark.asyncio
600
+ async def test_run_prompt_exec_returns_friendly_protocol_error_and_cleans_up(
601
+ tmp_path: Path,
602
+ model_cache_file: Path,
603
+ ) -> None:
604
+ long_text = "A" * 4096
605
+ completed_payload = {
606
+ "type": "item.completed",
607
+ "item": {"type": "agent_message", "text": long_text},
608
+ }
609
+ script = (
610
+ "import json, sys\n"
611
+ f"long_text = {long_text!r}\n"
612
+ "messages = [\n"
613
+ " {'type': 'thread.started', 'thread_id': 'exec-1'},\n"
614
+ " {'type': 'turn.started'},\n"
615
+ f" {completed_payload!r},\n"
616
+ "]\n"
617
+ "for message in messages:\n"
618
+ " sys.stdout.write(json.dumps(message) + '\\n')\n"
619
+ " sys.stdout.flush()\n"
620
+ )
621
+
622
+ async def launcher(*_args, **kwargs):
623
+ return await asyncio.create_subprocess_exec(
624
+ sys.executable,
625
+ "-c",
626
+ script,
627
+ stdin=asyncio.subprocess.PIPE,
628
+ stdout=asyncio.subprocess.PIPE,
629
+ stderr=kwargs.get("stderr", asyncio.subprocess.PIPE),
630
+ limit=int(kwargs.get("limit", 1024)),
631
+ )
632
+
633
+ service = make_service(
634
+ tmp_path,
635
+ model_cache_file,
636
+ launcher=launcher,
637
+ stream_read_limit=1024,
638
+ )
639
+
640
+ result = await service.run_prompt("private_1", "hello", mode_override="exec")
641
+ session = service.get_session("private_1")
642
+
643
+ assert result.exit_code == 1
644
+ assert any("codex_stream_read_limit" in line for line in result.diagnostics)
645
+ assert session.running is False
646
+ assert session.process is None
@@ -1,128 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- import asyncio
5
- from typing import Any
6
- from dataclasses import dataclass
7
-
8
- import pytest
9
-
10
- from nonebot_plugin_codex.native_client import NativeCodexClient
11
-
12
-
13
- class FakeStdout:
14
- def __init__(self, lines: list[str]) -> None:
15
- self._lines = [line.encode("utf-8") for line in lines]
16
-
17
- async def readline(self) -> bytes:
18
- if self._lines:
19
- return self._lines.pop(0)
20
- await asyncio.sleep(0)
21
- return b""
22
-
23
-
24
- class FakeStdin:
25
- def __init__(self) -> None:
26
- self.buffer: list[str] = []
27
-
28
- def write(self, data: bytes) -> None:
29
- self.buffer.append(data.decode("utf-8"))
30
-
31
- async def drain(self) -> None:
32
- return None
33
-
34
-
35
- @dataclass
36
- class FakeProcess:
37
- stdout: FakeStdout
38
- stdin: FakeStdin
39
- returncode: int | None = None
40
-
41
- def terminate(self) -> None:
42
- self.returncode = 0
43
-
44
- def kill(self) -> None:
45
- self.returncode = -9
46
-
47
- async def wait(self) -> int:
48
- self.returncode = self.returncode or 0
49
- return self.returncode
50
-
51
-
52
- @pytest.mark.asyncio
53
- async def test_native_client_start_resume_and_stream_text() -> None:
54
- requests: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
55
- process = FakeProcess(
56
- stdout=FakeStdout(
57
- [
58
- json.dumps({"jsonrpc": "2.0", "id": 1, "result": {}}) + "\n",
59
- json.dumps(
60
- {
61
- "jsonrpc": "2.0",
62
- "id": 2,
63
- "result": {
64
- "thread": {
65
- "id": "thread-1",
66
- "name": "Thread One",
67
- "updatedAt": "2025-03-01T00:00:00Z",
68
- "cwd": "/tmp/work",
69
- "source": "cli",
70
- }
71
- },
72
- }
73
- )
74
- + "\n",
75
- json.dumps({"jsonrpc": "2.0", "id": 3, "result": {}}) + "\n",
76
- json.dumps({"jsonrpc": "2.0", "method": "turn/started", "params": {}})
77
- + "\n",
78
- json.dumps(
79
- {
80
- "jsonrpc": "2.0",
81
- "method": "item/agentMessage/delta",
82
- "params": {"delta": "hello"},
83
- }
84
- )
85
- + "\n",
86
- json.dumps(
87
- {
88
- "jsonrpc": "2.0",
89
- "method": "turn/completed",
90
- "params": {
91
- "threadId": "thread-1",
92
- "turn": {"status": "completed", "error": None},
93
- },
94
- }
95
- )
96
- + "\n",
97
- ]
98
- ),
99
- stdin=FakeStdin(),
100
- )
101
-
102
- async def launcher(*args: Any, **kwargs: Any) -> FakeProcess:
103
- requests.append((args, kwargs))
104
- return process
105
-
106
- client = NativeCodexClient(binary="codex", launcher=launcher)
107
- progress: list[str] = []
108
- streamed: list[str] = []
109
-
110
- thread = await client.start_thread(
111
- workdir="/tmp/work",
112
- model="gpt-5",
113
- reasoning_effort="xhigh",
114
- permission_mode="safe",
115
- )
116
- result = await client.run_turn(
117
- thread.thread_id,
118
- "hello",
119
- on_progress=progress.append,
120
- on_stream_text=streamed.append,
121
- )
122
-
123
- assert requests[0][0][:3] == ("codex", "app-server", "--listen")
124
- assert thread.thread_id == "thread-1"
125
- assert progress == ["开始处理请求"]
126
- assert streamed == ["hello"]
127
- assert result.exit_code == 0
128
- assert result.final_text == ""