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.
- {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/PKG-INFO +12 -4
- {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/README.md +11 -3
- {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/pyproject.toml +1 -1
- {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/config.py +1 -1
- {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/native_client.py +24 -6
- nonebot_plugin_codex-0.1.5/src/nonebot_plugin_codex/protocol_io.py +131 -0
- {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/service.py +43 -7
- {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/tests/test_config.py +6 -0
- nonebot_plugin_codex-0.1.5/tests/test_native_client.py +401 -0
- {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/tests/test_service.py +105 -0
- nonebot_plugin_codex-0.1.4/tests/test_native_client.py +0 -128
- {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/LICENSE +0 -0
- {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/__init__.py +0 -0
- {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/runtime.py +0 -0
- {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/telegram.py +0 -0
- {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/telegram_commands.py +0 -0
- {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/telegram_rendering.py +0 -0
- {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/tests/__init__.py +0 -0
- {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/tests/conftest.py +0 -0
- {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/tests/test_plugin_entry.py +0 -0
- {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/tests/test_plugin_meta.py +0 -0
- {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/tests/test_release_notes.py +0 -0
- {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/tests/test_runtime.py +0 -0
- {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/tests/test_telegram_commands.py +0 -0
- {nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/tests/test_telegram_handlers.py +0 -0
- {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.
|
|
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
|
-
#
|
|
184
|
-
codex_stream_read_limit =
|
|
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
|
-
-
|
|
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
|
-
#
|
|
171
|
-
codex_stream_read_limit =
|
|
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
|
-
-
|
|
186
|
+
- `codex_stream_read_limit`:限制单条 Codex 协议帧的最大字节数,不是 Telegram 消息分片长度。
|
|
187
|
+
- 其余项分别控制停止超时、进度保留条数、诊断输出条数和 Telegram 分片长度。
|
|
180
188
|
- 插件自己的配置数据由 `nonebot-plugin-localstore` 自动管理。
|
|
181
189
|
- 模型缓存、Codex CLI 配置和历史会话目录默认读取 `~/.codex/*`,属于插件内部实现路径。
|
|
182
190
|
|
{nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/native_client.py
RENAMED
|
@@ -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.
|
|
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
|
|
412
|
+
if self._process is None or self._reader is None:
|
|
402
413
|
raise RuntimeError("Codex app-server 尚未启动。")
|
|
403
|
-
|
|
404
|
-
|
|
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
|
{nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/service.py
RENAMED
|
@@ -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.
|
|
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
|
-
|
|
3030
|
+
exit_code: int | None = None
|
|
3031
|
+
cancelled = False
|
|
3016
3032
|
try:
|
|
3017
|
-
while
|
|
3018
|
-
|
|
3019
|
-
|
|
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 == ""
|
|
File without changes
|
{nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/__init__.py
RENAMED
|
File without changes
|
{nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/runtime.py
RENAMED
|
File without changes
|
{nonebot_plugin_codex-0.1.4 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/telegram.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|