wechatbridge-cli 1.4.4__tar.gz → 1.4.6__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.
- {wechatbridge_cli-1.4.4/wechatbridge_cli.egg-info → wechatbridge_cli-1.4.6}/PKG-INFO +1 -1
- {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.6}/tests/test_codex.py +256 -0
- {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.6}/tests/test_hardening.py +447 -0
- {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.6}/wechatbridge/__init__.py +1 -1
- {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.6}/wechatbridge/codex.py +113 -2
- {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.6}/wechatbridge/runner_common.py +180 -66
- {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.6/wechatbridge_cli.egg-info}/PKG-INFO +1 -1
- {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.6}/LICENSE +0 -0
- {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.6}/README.md +0 -0
- {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.6}/pyproject.toml +0 -0
- {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.6}/setup.cfg +0 -0
- {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.6}/wechatbridge/__main__.py +0 -0
- {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.6}/wechatbridge/agy.py +0 -0
- {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.6}/wechatbridge/config.py +0 -0
- {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.6}/wechatbridge/grok.py +0 -0
- {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.6}/wechatbridge/ilink.py +0 -0
- {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.6}/wechatbridge/main.py +0 -0
- {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.6}/wechatbridge/update_check.py +0 -0
- {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.6}/wechatbridge_cli.egg-info/SOURCES.txt +0 -0
- {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.6}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
- {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.6}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
- {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.6}/wechatbridge_cli.egg-info/requires.txt +0 -0
- {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.6}/wechatbridge_cli.egg-info/top_level.txt +0 -0
|
@@ -908,6 +908,262 @@ class TestCodexRunFakeCli(unittest.IsolatedAsyncioTestCase):
|
|
|
908
908
|
self.assertFalse(os.path.isfile(os.path.join(sd, ".initialized.codex")))
|
|
909
909
|
|
|
910
910
|
|
|
911
|
+
# --- Fallback scan tests for shell-created artifacts ---
|
|
912
|
+
|
|
913
|
+
def test_shell_file_fallback_collects_new_files(self):
|
|
914
|
+
"""ok_shell_files: new file created during execution is collected by fallback."""
|
|
915
|
+
display, artifacts = self._run("hi", "u-shell-new", mode="ok_shell_files")
|
|
916
|
+
self.assertIn("first(", display)
|
|
917
|
+
names = {n for n, _ in artifacts}
|
|
918
|
+
self.assertIn("result.txt", names, "fallback should collect new file")
|
|
919
|
+
self.assertNotIn("out.md", names, "old file should NOT be collected")
|
|
920
|
+
# All artifact paths must be absolute and under session_dir
|
|
921
|
+
for _n, ap in artifacts:
|
|
922
|
+
self.assertTrue(os.path.isabs(ap))
|
|
923
|
+
sd = os.path.join(self.td, _sanitize("u-shell-new"))
|
|
924
|
+
self.assertTrue(ap.startswith(sd))
|
|
925
|
+
|
|
926
|
+
def test_shell_file_fallback_dedup_with_file_change(self):
|
|
927
|
+
"""ok_shell_and_file_change: file_change is authoritative, no duplicate."""
|
|
928
|
+
display, artifacts = self._run("hi", "u-shell-dedup", mode="ok_shell_and_file_change")
|
|
929
|
+
names = {n for n, _ in artifacts}
|
|
930
|
+
# file_change collected result.txt; fallback should NOT add a duplicate
|
|
931
|
+
self.assertEqual(names.count("result.txt") if hasattr(names, 'count')
|
|
932
|
+
else len([n for n in artifacts if n[0] == "result.txt"]),
|
|
933
|
+
len([n for n in artifacts if n[0] == "result.txt"]))
|
|
934
|
+
# Exactly one result.txt in artifacts (from file_change, not fallback)
|
|
935
|
+
result_entries = [a for a in artifacts if a[0] == "result.txt"]
|
|
936
|
+
self.assertEqual(len(result_entries), 1, "no duplicate from fallback")
|
|
937
|
+
|
|
938
|
+
def test_shell_file_fallback_old_files_not_collected(self):
|
|
939
|
+
"""ok_shell_old_files: files with old mtimes are NOT collected."""
|
|
940
|
+
display, artifacts = self._run("hi", "u-shell-old", mode="ok_shell_old_files")
|
|
941
|
+
self.assertIn("first(", display)
|
|
942
|
+
names = {n for n, _ in artifacts}
|
|
943
|
+
self.assertNotIn("old_result.txt", names, "old file should not be collected")
|
|
944
|
+
self.assertNotIn("old_output.md", names, "old file should not be collected")
|
|
945
|
+
|
|
946
|
+
def test_shell_file_fallback_internal_metadata_excluded(self):
|
|
947
|
+
"""ok_shell_internal_metadata: .codex/ and .git/ files are excluded."""
|
|
948
|
+
display, artifacts = self._run("hi", "u-shell-meta", mode="ok_shell_internal_metadata")
|
|
949
|
+
self.assertIn("first(", display)
|
|
950
|
+
names = {n for n, _ in artifacts}
|
|
951
|
+
self.assertIn("real_output.txt", names, "real file should be collected")
|
|
952
|
+
self.assertNotIn("meta.json", names, ".codex/ metadata should be excluded")
|
|
953
|
+
self.assertNotIn("config", names, ".git/ file should be excluded")
|
|
954
|
+
# No path should contain .codex or .git as a directory component
|
|
955
|
+
for _n, ap in artifacts:
|
|
956
|
+
parts = ap.split(os.sep)
|
|
957
|
+
self.assertNotIn(".codex", parts)
|
|
958
|
+
self.assertNotIn(".git", parts)
|
|
959
|
+
|
|
960
|
+
def test_shell_file_fallback_symlink_escape_excluded(self):
|
|
961
|
+
"""ok_shell_escape: symlink pointing outside allowed roots is NOT collected."""
|
|
962
|
+
display, artifacts = self._run("hi", "u-shell-escape", mode="ok_shell_escape")
|
|
963
|
+
self.assertIn("first(", display)
|
|
964
|
+
names = {n for n, _ in artifacts}
|
|
965
|
+
self.assertNotIn("escaped.txt", names, "symlink escape should be excluded")
|
|
966
|
+
# Cleanup target
|
|
967
|
+
try:
|
|
968
|
+
os.unlink("/tmp/_codex_escape_test_target.txt")
|
|
969
|
+
except OSError:
|
|
970
|
+
pass
|
|
971
|
+
|
|
972
|
+
def test_fallback_realpath_alias_dedup(self):
|
|
973
|
+
"""Symlink alias to a file collected via file_change: same realpath -> no duplicate."""
|
|
974
|
+
import os, sys, json, uuid, datetime
|
|
975
|
+
target = os.path.join(self.td, "_dedup_target.txt")
|
|
976
|
+
with open(target, "w") as f:
|
|
977
|
+
f.write("content")
|
|
978
|
+
link = os.path.join(self.td, "_dedup_alias.txt")
|
|
979
|
+
os.symlink(target, link)
|
|
980
|
+
|
|
981
|
+
# Build a custom shim that: emits file_change for the alias path AND writes the real file.
|
|
982
|
+
fake = os.path.join(self.td, "_fake_dedup.py")
|
|
983
|
+
with open(fake, "w") as f:
|
|
984
|
+
f.write(
|
|
985
|
+
'#!/usr/bin/env python3\n'
|
|
986
|
+
'import json, os, sys, uuid, datetime\n'
|
|
987
|
+
'session_dir = os.getcwd()\n'
|
|
988
|
+
'new_id = str(uuid.uuid4())\n'
|
|
989
|
+
'now = datetime.datetime.now()\n'
|
|
990
|
+
'ts = now.strftime("%Y-%m-%dT%H-%M-%S")\n'
|
|
991
|
+
'rollout_dir = os.path.join(session_dir, ".codex", "sessions",\n'
|
|
992
|
+
' now.strftime("%Y"), now.strftime("%m"), now.strftime("%d"))\n'
|
|
993
|
+
'os.makedirs(rollout_dir, exist_ok=True)\n'
|
|
994
|
+
'with open(os.path.join(rollout_dir, f"rollout-{ts}-{new_id}.jsonl"), "w") as fo:\n'
|
|
995
|
+
' fo.write(json.dumps({"type": "session_meta", "payload": {"id": new_id}}) + "\\n")\n'
|
|
996
|
+
' fo.write(json.dumps({"type": "thread.started", "thread_id": new_id}) + "\\n")\n'
|
|
997
|
+
'lines = [\n'
|
|
998
|
+
' json.dumps({"type": "thread.started", "thread_id": new_id}),\n'
|
|
999
|
+
' json.dumps({"type": "turn.started"}),\n'
|
|
1000
|
+
' json.dumps({"type": "item.completed", "item": {"id": "i1", "type": "agent_message", "text": "done"}}),\n'
|
|
1001
|
+
' json.dumps({"type": "item.completed", "item": {\n'
|
|
1002
|
+
' "id": "i2", "type": "file_change", "status": "completed",\n'
|
|
1003
|
+
' "changes": [{"kind": "add", "path": "_dedup_alias.txt"}]\n'
|
|
1004
|
+
' }}),\n'
|
|
1005
|
+
' json.dumps({"type": "turn.completed", "usage": {}}),\n'
|
|
1006
|
+
']\n'
|
|
1007
|
+
'sys.stdout.write("\\n".join(lines) + "\\n")\n'
|
|
1008
|
+
'sys.stdout.flush()\n'
|
|
1009
|
+
'with open(os.path.join(session_dir, "_dedup_target.txt"), "w") as fo:\n'
|
|
1010
|
+
' fo.write("content")\n'
|
|
1011
|
+
'sys.exit(0)\n'
|
|
1012
|
+
)
|
|
1013
|
+
os.chmod(fake, 0o755)
|
|
1014
|
+
shim = os.path.join(self.td, "_shim_dedup.py")
|
|
1015
|
+
with open(shim, "w") as f:
|
|
1016
|
+
f.write(
|
|
1017
|
+
"#!/usr/bin/env python3\n"
|
|
1018
|
+
"import sys, os\n"
|
|
1019
|
+
f"sys.argv[0] = {fake!r}\n"
|
|
1020
|
+
"sys.argv.insert(0, sys.executable)\n"
|
|
1021
|
+
"os.execv(sys.executable, sys.argv)\n"
|
|
1022
|
+
)
|
|
1023
|
+
os.chmod(shim, 0o755)
|
|
1024
|
+
from wechatbridge.config import config
|
|
1025
|
+
orig = config.codex_binary_path
|
|
1026
|
+
config.codex_binary_path = shim
|
|
1027
|
+
try:
|
|
1028
|
+
display, artifacts = self._run("hi", "u-dedup-realpath")
|
|
1029
|
+
result_entries = [a for a in artifacts if a[0] == "_dedup_target.txt"]
|
|
1030
|
+
self.assertEqual(len(result_entries), 1, "no duplicate from fallback for realpath alias")
|
|
1031
|
+
finally:
|
|
1032
|
+
config.codex_binary_path = orig
|
|
1033
|
+
|
|
1034
|
+
def test_add_dirs_retry_argv_consistent(self):
|
|
1035
|
+
"""Retry argv uses same validated add_dirs list as first run."""
|
|
1036
|
+
import os, datetime
|
|
1037
|
+
sd = os.path.join(self.td, _sanitize("u-retry-add"))
|
|
1038
|
+
os.makedirs(sd, exist_ok=True)
|
|
1039
|
+
from wechatbridge.codex import (
|
|
1040
|
+
ensure_user_codex, mark_initialized, _write_codex_thread_id,
|
|
1041
|
+
_has_codex_session,
|
|
1042
|
+
)
|
|
1043
|
+
mark_initialized(sd, backend="codex")
|
|
1044
|
+
tid = "aabbccdd-0000-0000-0000-000000000000"
|
|
1045
|
+
_write_codex_thread_id(sd, tid)
|
|
1046
|
+
now = datetime.datetime.now()
|
|
1047
|
+
rollout_dir = os.path.join(
|
|
1048
|
+
sd, ".codex", "sessions", now.strftime("%Y"),
|
|
1049
|
+
now.strftime("%m"), now.strftime("%d"),
|
|
1050
|
+
)
|
|
1051
|
+
os.makedirs(rollout_dir, exist_ok=True)
|
|
1052
|
+
with open(os.path.join(rollout_dir, f"rollout-x-{tid}.jsonl"), "w") as f:
|
|
1053
|
+
f.write("x")
|
|
1054
|
+
self.assertTrue(_has_codex_session(sd, tid))
|
|
1055
|
+
|
|
1056
|
+
from wechatbridge.runner_common import save_prefs
|
|
1057
|
+
save_prefs("u-retry-add", {"add_dirs": [sd]})
|
|
1058
|
+
|
|
1059
|
+
captured = []
|
|
1060
|
+
from wechatbridge import codex as codex_mod
|
|
1061
|
+
|
|
1062
|
+
async def spawn(*args, **kwargs):
|
|
1063
|
+
captured.append(list(args))
|
|
1064
|
+
cmd_str = str(args)
|
|
1065
|
+
if "resume" in cmd_str:
|
|
1066
|
+
from tests.test_codex import _FakeProc
|
|
1067
|
+
return _FakeProc("", "error: session not found", 1)
|
|
1068
|
+
from tests.test_codex import _FakeProc
|
|
1069
|
+
return _FakeProc(
|
|
1070
|
+
'{"type":"thread.started","thread_id":"new-tid"}\n'
|
|
1071
|
+
'{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}\n',
|
|
1072
|
+
"", 0,
|
|
1073
|
+
)
|
|
1074
|
+
|
|
1075
|
+
with mock.patch("asyncio.create_subprocess_exec", side_effect=spawn):
|
|
1076
|
+
display, _ = asyncio.run(codex_mod.run_codex("hi", "u-retry-add"))
|
|
1077
|
+
self.assertEqual(len(captured), 2)
|
|
1078
|
+
self.assertIn("--add-dir", captured[0])
|
|
1079
|
+
self.assertIn(sd, captured[0])
|
|
1080
|
+
self.assertIn("--add-dir", captured[1])
|
|
1081
|
+
self.assertIn(sd, captured[1])
|
|
1082
|
+
|
|
1083
|
+
def test_fallback_scan_file_limit(self):
|
|
1084
|
+
"""When session_dir has >200 regular files, fallback scan returns empty."""
|
|
1085
|
+
sd = os.path.join(self.td, _sanitize("u-file-limit"))
|
|
1086
|
+
os.makedirs(sd, exist_ok=True)
|
|
1087
|
+
for i in range(205):
|
|
1088
|
+
with open(os.path.join(sd, f"f_{i}.txt"), "w") as f:
|
|
1089
|
+
f.write("x")
|
|
1090
|
+
from wechatbridge.codex import _snapshot_regular_files
|
|
1091
|
+
snap = _snapshot_regular_files([sd])
|
|
1092
|
+
self.assertEqual(snap, {}, ">200 files -> empty")
|
|
1093
|
+
|
|
1094
|
+
def test_fallback_scan_dir_limit(self):
|
|
1095
|
+
"""When session_dir has >50 subdirectories, fallback scan returns empty."""
|
|
1096
|
+
sd = os.path.join(self.td, _sanitize("u-dir-limit"))
|
|
1097
|
+
os.makedirs(sd, exist_ok=True)
|
|
1098
|
+
for i in range(55):
|
|
1099
|
+
d = os.path.join(sd, f"dir_{i}")
|
|
1100
|
+
os.makedirs(d, exist_ok=True)
|
|
1101
|
+
with open(os.path.join(d, "file.txt"), "w") as f:
|
|
1102
|
+
f.write("x")
|
|
1103
|
+
from wechatbridge.codex import _snapshot_regular_files
|
|
1104
|
+
snap = _snapshot_regular_files([sd])
|
|
1105
|
+
self.assertEqual(snap, {}, ">50 dirs -> empty")
|
|
1106
|
+
|
|
1107
|
+
def test_fallback_scan_timeout(self):
|
|
1108
|
+
"""When snapshot takes >2s, fallback scan returns empty."""
|
|
1109
|
+
from wechatbridge import codex as codex_mod
|
|
1110
|
+
original = codex_mod._snapshot_regular_files
|
|
1111
|
+
|
|
1112
|
+
def slow_snapshot(roots):
|
|
1113
|
+
import time
|
|
1114
|
+
time.sleep(2.5)
|
|
1115
|
+
return {"/fake/file.txt": 1.0}
|
|
1116
|
+
|
|
1117
|
+
codex_mod._snapshot_regular_files = slow_snapshot
|
|
1118
|
+
try:
|
|
1119
|
+
display, artifacts = self._run("hi", "u-scan-timeout", mode="ok")
|
|
1120
|
+
self.assertEqual(artifacts, [], "slow snapshot -> empty")
|
|
1121
|
+
finally:
|
|
1122
|
+
codex_mod._snapshot_regular_files = original
|
|
1123
|
+
|
|
1124
|
+
def test_fallback_scan_not_on_failure(self):
|
|
1125
|
+
"""Fallback scan must NOT run when process fails."""
|
|
1126
|
+
display, artifacts = self._run("hi", "u-fail-noscan", mode="fail_first")
|
|
1127
|
+
self.assertTrue(display.startswith("❌"))
|
|
1128
|
+
self.assertEqual(artifacts, [])
|
|
1129
|
+
|
|
1130
|
+
def test_fallback_scan_not_on_timeout(self):
|
|
1131
|
+
"""Fallback scan must NOT run when process times out."""
|
|
1132
|
+
from wechatbridge import codex as codex_mod
|
|
1133
|
+
display, artifacts = self._run("hi", "u-to-noscan", timeout=1, mode="timeout")
|
|
1134
|
+
self.assertIn("超时", display)
|
|
1135
|
+
self.assertEqual(artifacts, [])
|
|
1136
|
+
|
|
1137
|
+
def test_add_dirs_consistency_between_build_and_scan(self):
|
|
1138
|
+
"""add_dirs in command builder and fallback scan use same validated paths."""
|
|
1139
|
+
from wechatbridge import codex as codex_mod
|
|
1140
|
+
|
|
1141
|
+
# Create a real directory that validate_add_dir would accept
|
|
1142
|
+
add_dir = os.path.join(self.td, "validated_workdir")
|
|
1143
|
+
os.makedirs(add_dir, exist_ok=True)
|
|
1144
|
+
# Write a file in add_dir with old mtime (should NOT be collected)
|
|
1145
|
+
old_file = os.path.join(add_dir, "old_in_add.txt")
|
|
1146
|
+
with open(old_file, "w") as f:
|
|
1147
|
+
f.write("old")
|
|
1148
|
+
os.utime(old_file, (1000000, 1000000))
|
|
1149
|
+
|
|
1150
|
+
# Mock validate_add_dir to return the resolved path (simulates valid dir)
|
|
1151
|
+
with mock.patch("wechatbridge.codex.validate_add_dir", return_value=(True, add_dir)):
|
|
1152
|
+
# Manually set prefs with raw add_dir to test resolution
|
|
1153
|
+
from wechatbridge.runner_common import save_prefs, get_session_dir
|
|
1154
|
+
save_prefs("u-add-consistency", {"add_dirs": [add_dir]})
|
|
1155
|
+
|
|
1156
|
+
display, artifacts = self._run("hi", "u-add-consistency")
|
|
1157
|
+
self.assertIn("first(", display)
|
|
1158
|
+
# old_in_add.txt should NOT be in artifacts (old mtime)
|
|
1159
|
+
names = {n for n, _ in artifacts}
|
|
1160
|
+
self.assertNotIn("old_in_add.txt", names)
|
|
1161
|
+
|
|
1162
|
+
# Verify the command used the resolved path
|
|
1163
|
+
sd = os.path.join(self.td, _sanitize("u-add-consistency"))
|
|
1164
|
+
self.assertTrue(os.path.isdir(sd))
|
|
1165
|
+
|
|
1166
|
+
|
|
911
1167
|
class TestCodexEnvOverride(unittest.IsolatedAsyncioTestCase):
|
|
912
1168
|
"""CODEX_HOME must be overridden to session_dir/.codex, beating any global
|
|
913
1169
|
CODEX_HOME from the service environment. The retry reuses the same env.
|
|
@@ -1890,3 +1890,450 @@ class TestPreflightBeforeSlot(unittest.IsolatedAsyncioTestCase):
|
|
|
1890
1890
|
|
|
1891
1891
|
if __name__ == "__main__":
|
|
1892
1892
|
unittest.main()
|
|
1893
|
+
|
|
1894
|
+
|
|
1895
|
+
|
|
1896
|
+
class TestClassifyCliError(unittest.TestCase):
|
|
1897
|
+
"""_classify_cli_error returns correct category for each error pattern."""
|
|
1898
|
+
|
|
1899
|
+
def _cat(self, raw: str, backend: str = "agy") -> str:
|
|
1900
|
+
from wechatbridge.runner_common import _classify_cli_error
|
|
1901
|
+
return _classify_cli_error(raw, backend=backend)
|
|
1902
|
+
|
|
1903
|
+
def _fmt(self, raw: str, backend: str = "agy") -> str:
|
|
1904
|
+
from wechatbridge.runner_common import format_cli_error
|
|
1905
|
+
return format_cli_error(raw, backend=backend)
|
|
1906
|
+
|
|
1907
|
+
# --- payload_too_large --------------------------------------------------------
|
|
1908
|
+
|
|
1909
|
+
def test_request_payload_size_exceeds(self):
|
|
1910
|
+
self.assertEqual(self._cat("request payload size exceeds the limit"), "payload_too_large")
|
|
1911
|
+
|
|
1912
|
+
def test_payload_too_large(self):
|
|
1913
|
+
self.assertEqual(self._cat("payload too large"), "payload_too_large")
|
|
1914
|
+
|
|
1915
|
+
def test_request_entity_too_large(self):
|
|
1916
|
+
self.assertEqual(self._cat("Request Entity Too Large"), "payload_too_large")
|
|
1917
|
+
|
|
1918
|
+
def test_content_length_exceeds(self):
|
|
1919
|
+
self.assertEqual(self._cat("Content-Length exceeds the limit of 8MB"), "payload_too_large")
|
|
1920
|
+
|
|
1921
|
+
def test_content_length_is_too_large(self):
|
|
1922
|
+
self.assertEqual(self._cat("content length is too large"), "payload_too_large")
|
|
1923
|
+
|
|
1924
|
+
def test_http_status_413_explicit(self):
|
|
1925
|
+
self.assertEqual(self._cat("HTTP status 413"), "payload_too_large")
|
|
1926
|
+
self.assertEqual(self._cat("status code 413"), "payload_too_large")
|
|
1927
|
+
self.assertEqual(self._cat("http code 413"), "payload_too_large")
|
|
1928
|
+
self.assertEqual(self._cat("code=413"), "payload_too_large")
|
|
1929
|
+
|
|
1930
|
+
def test_json_code_413(self):
|
|
1931
|
+
self.assertEqual(self._cat('{"code": 413, "message": "too big"}'), "payload_too_large")
|
|
1932
|
+
|
|
1933
|
+
def test_413_in_date_not_matched(self):
|
|
1934
|
+
"""A bare 413 in a date string must NOT be classified as payload_too_large."""
|
|
1935
|
+
self.assertNotEqual(self._cat("Session expired on 2024-04-13 at 10:00"), "payload_too_large")
|
|
1936
|
+
|
|
1937
|
+
def test_413_in_path_not_matched(self):
|
|
1938
|
+
"""A bare 413 in a file path or directory name must NOT match."""
|
|
1939
|
+
self.assertNotEqual(self._cat("file not found: /tmp/record-413.txt"), "payload_too_large")
|
|
1940
|
+
|
|
1941
|
+
def test_413_in_version_not_matched(self):
|
|
1942
|
+
"""Version numbers containing 413 must NOT match."""
|
|
1943
|
+
self.assertNotEqual(self._cat("library version 1.413.0"), "payload_too_large")
|
|
1944
|
+
|
|
1945
|
+
# --- context_too_large --------------------------------------------------------
|
|
1946
|
+
|
|
1947
|
+
def test_input_token_count_exceeds(self):
|
|
1948
|
+
self.assertEqual(self._cat("input token count exceeds the maximum"), "context_too_large")
|
|
1949
|
+
|
|
1950
|
+
def test_context_length_exceeded(self):
|
|
1951
|
+
self.assertEqual(self._cat("context length exceeded"), "context_too_large")
|
|
1952
|
+
|
|
1953
|
+
def test_maximum_context_length(self):
|
|
1954
|
+
self.assertEqual(self._cat("maximum context length is 128k tokens"), "context_too_large")
|
|
1955
|
+
|
|
1956
|
+
def test_context_window_exceeded(self):
|
|
1957
|
+
self.assertEqual(self._cat("context window exceeded"), "context_too_large")
|
|
1958
|
+
|
|
1959
|
+
def test_too_many_tokens(self):
|
|
1960
|
+
self.assertEqual(self._cat("too many tokens in this conversation"), "context_too_large")
|
|
1961
|
+
|
|
1962
|
+
def test_your_input_context_is_too_long(self):
|
|
1963
|
+
self.assertEqual(self._cat("your input context is too long for this model"), "context_too_large")
|
|
1964
|
+
|
|
1965
|
+
def test_invalid_argument_with_context(self):
|
|
1966
|
+
self.assertEqual(self._cat("INVALID_ARGUMENT: context is too long"), "context_too_large")
|
|
1967
|
+
|
|
1968
|
+
def test_resource_exhausted_with_token(self):
|
|
1969
|
+
self.assertEqual(self._cat("RESOURCE_EXHAUSTED: token limit exceeded"), "context_too_large")
|
|
1970
|
+
|
|
1971
|
+
def test_resource_exhausted_with_maximum(self):
|
|
1972
|
+
self.assertEqual(self._cat("resource_exhausted: maximum context size reached"), "context_too_large")
|
|
1973
|
+
|
|
1974
|
+
# --- invalid_argument (generic) -----------------------------------------------
|
|
1975
|
+
|
|
1976
|
+
def test_generic_invalid_argument(self):
|
|
1977
|
+
self.assertEqual(self._cat("INVALID_ARGUMENT: unknown field xyz"), "invalid_argument")
|
|
1978
|
+
|
|
1979
|
+
def test_invalid_argument_no_context_token(self):
|
|
1980
|
+
self.assertEqual(self._cat("invalid_argument: bad request format"), "invalid_argument")
|
|
1981
|
+
|
|
1982
|
+
# --- auth ---------------------------------------------------------------------
|
|
1983
|
+
|
|
1984
|
+
def test_auth_generic(self):
|
|
1985
|
+
self.assertEqual(self._cat("Not signed in. Please run login --device"), "auth")
|
|
1986
|
+
|
|
1987
|
+
def test_auth_unauthorized(self):
|
|
1988
|
+
self.assertEqual(self._cat("unauthorized access"), "auth")
|
|
1989
|
+
|
|
1990
|
+
def test_auth_401_with_token(self):
|
|
1991
|
+
self.assertEqual(self._cat("401 error: token expired"), "auth")
|
|
1992
|
+
|
|
1993
|
+
def test_auth_codex_specific(self):
|
|
1994
|
+
self.assertEqual(self._cat("codex login required", backend="codex"), "auth")
|
|
1995
|
+
|
|
1996
|
+
def test_auth_codex_api_key(self):
|
|
1997
|
+
self.assertEqual(self._cat("codex_api_key missing", backend="codex"), "auth")
|
|
1998
|
+
|
|
1999
|
+
# --- rate_limit ---------------------------------------------------------------
|
|
2000
|
+
|
|
2001
|
+
def test_eligibility_resource_exhausted(self):
|
|
2002
|
+
self.assertEqual(self._cat("Eligibility check failed: RESOURCE_EXHAUSTED"), "resource_exhausted")
|
|
2003
|
+
|
|
2004
|
+
def test_resource_exhausted_generic(self):
|
|
2005
|
+
self.assertEqual(self._cat("RESOURCE_EXHAUSTED: resource has been exhausted"), "resource_exhausted")
|
|
2006
|
+
|
|
2007
|
+
def test_rate_limit_exceeded(self):
|
|
2008
|
+
self.assertEqual(self._cat("rate limit exceeded, please slow down"), "rate_limit")
|
|
2009
|
+
|
|
2010
|
+
def test_too_many_requests(self):
|
|
2011
|
+
self.assertEqual(self._cat("HTTP 429 Too Many Requests"), "rate_limit")
|
|
2012
|
+
|
|
2013
|
+
def test_bare_429(self):
|
|
2014
|
+
self.assertEqual(self._cat("upstream returned status 429"), "bare_429")
|
|
2015
|
+
|
|
2016
|
+
# --- quota --------------------------------------------------------------------
|
|
2017
|
+
|
|
2018
|
+
def test_quota_exceeded(self):
|
|
2019
|
+
self.assertEqual(self._cat("You exceeded your current quota"), "quota")
|
|
2020
|
+
|
|
2021
|
+
def test_daily_quota(self):
|
|
2022
|
+
self.assertEqual(self._cat("daily quota exceeded"), "quota")
|
|
2023
|
+
|
|
2024
|
+
def test_usage_limit(self):
|
|
2025
|
+
self.assertEqual(self._cat("usage limit reached"), "quota")
|
|
2026
|
+
|
|
2027
|
+
# --- network ------------------------------------------------------------------
|
|
2028
|
+
|
|
2029
|
+
def test_connection_refused(self):
|
|
2030
|
+
self.assertEqual(self._cat("connection refused"), "network")
|
|
2031
|
+
|
|
2032
|
+
def test_connection_reset(self):
|
|
2033
|
+
self.assertEqual(self._cat("connection reset by peer"), "network")
|
|
2034
|
+
|
|
2035
|
+
def test_network_unreachable(self):
|
|
2036
|
+
self.assertEqual(self._cat("network is unreachable"), "network")
|
|
2037
|
+
|
|
2038
|
+
# --- cascade_timeout ----------------------------------------------------------
|
|
2039
|
+
|
|
2040
|
+
def test_cascade_timeout(self):
|
|
2041
|
+
self.assertEqual(self._cat("timeout waiting for cascade"), "cascade_timeout")
|
|
2042
|
+
|
|
2043
|
+
def test_cascade_response_timeout(self):
|
|
2044
|
+
self.assertEqual(self._cat("timeout waiting for response"), "cascade_timeout")
|
|
2045
|
+
|
|
2046
|
+
# --- timeout ------------------------------------------------------------------
|
|
2047
|
+
|
|
2048
|
+
def test_generic_timeout(self):
|
|
2049
|
+
self.assertEqual(self._cat("timeout occurred"), "timeout")
|
|
2050
|
+
|
|
2051
|
+
def test_timed_out(self):
|
|
2052
|
+
self.assertEqual(self._cat("operation timed out"), "timeout")
|
|
2053
|
+
|
|
2054
|
+
def test_deadline_exceeded(self):
|
|
2055
|
+
self.assertEqual(self._cat("deadline exceeded"), "timeout")
|
|
2056
|
+
|
|
2057
|
+
# --- permission ---------------------------------------------------------------
|
|
2058
|
+
|
|
2059
|
+
def test_permission_denied(self):
|
|
2060
|
+
self.assertEqual(self._cat("permission denied"), "permission")
|
|
2061
|
+
|
|
2062
|
+
# --- session_not_found --------------------------------------------------------
|
|
2063
|
+
|
|
2064
|
+
def test_no_session_found(self):
|
|
2065
|
+
self.assertEqual(self._cat("no session found"), "session_not_found")
|
|
2066
|
+
|
|
2067
|
+
# --- model_invalid ------------------------------------------------------------
|
|
2068
|
+
|
|
2069
|
+
def test_model_not_found(self):
|
|
2070
|
+
self.assertEqual(self._cat("model not found"), "model_invalid")
|
|
2071
|
+
|
|
2072
|
+
def test_model_unknown(self):
|
|
2073
|
+
self.assertEqual(self._cat("unknown model xyz"), "model_invalid")
|
|
2074
|
+
|
|
2075
|
+
# --- command_not_found --------------------------------------------------------
|
|
2076
|
+
|
|
2077
|
+
def test_command_not_found(self):
|
|
2078
|
+
self.assertEqual(self._cat("command not found: xyz"), "command_not_found")
|
|
2079
|
+
|
|
2080
|
+
# --- not_found ----------------------------------------------------------------
|
|
2081
|
+
|
|
2082
|
+
def test_not_found_generic(self):
|
|
2083
|
+
self.assertEqual(self._cat("not found"), "not_found")
|
|
2084
|
+
|
|
2085
|
+
def test_enoent(self):
|
|
2086
|
+
self.assertEqual(self._cat("enoent: no such file"), "not_found")
|
|
2087
|
+
|
|
2088
|
+
# --- unknown ------------------------------------------------------------------
|
|
2089
|
+
|
|
2090
|
+
def test_empty_is_unknown(self):
|
|
2091
|
+
self.assertEqual(self._cat(""), "unknown")
|
|
2092
|
+
|
|
2093
|
+
def test_garbage_is_unknown(self):
|
|
2094
|
+
self.assertEqual(self._cat("abc123 random noise"), "unknown")
|
|
2095
|
+
|
|
2096
|
+
|
|
2097
|
+
class TestFormatCliErrorCategories(unittest.TestCase):
|
|
2098
|
+
"""format_cli_error produces correct user-facing copy for each category."""
|
|
2099
|
+
|
|
2100
|
+
def _fmt(self, raw: str, backend: str = "agy") -> str:
|
|
2101
|
+
from wechatbridge.runner_common import format_cli_error
|
|
2102
|
+
return format_cli_error(raw, backend=backend)
|
|
2103
|
+
|
|
2104
|
+
def test_payload_too_large_title(self):
|
|
2105
|
+
out = self._fmt("request payload size exceeds the limit")
|
|
2106
|
+
self.assertIn("请求内容过大", out)
|
|
2107
|
+
self.assertIn("❌", out)
|
|
2108
|
+
self.assertIn("/new", out)
|
|
2109
|
+
|
|
2110
|
+
def test_context_too_large_title(self):
|
|
2111
|
+
out = self._fmt("context length exceeded")
|
|
2112
|
+
self.assertIn("会话内容过长", out)
|
|
2113
|
+
self.assertIn("❌", out)
|
|
2114
|
+
self.assertIn("/new", out)
|
|
2115
|
+
|
|
2116
|
+
def test_invalid_argument_title(self):
|
|
2117
|
+
out = self._fmt("INVALID_ARGUMENT: bad field")
|
|
2118
|
+
self.assertIn("请求参数无效", out)
|
|
2119
|
+
self.assertIn("❌", out)
|
|
2120
|
+
|
|
2121
|
+
def test_rate_limit_still_notice(self):
|
|
2122
|
+
out = self._fmt("rate limit exceeded")
|
|
2123
|
+
self.assertIn("🔔", out)
|
|
2124
|
+
self.assertIn("请求较多", out)
|
|
2125
|
+
|
|
2126
|
+
def test_quota_still_notice(self):
|
|
2127
|
+
out = self._fmt("quota exceeded")
|
|
2128
|
+
self.assertIn("🔔", out)
|
|
2129
|
+
self.assertIn("额度相关", out)
|
|
2130
|
+
|
|
2131
|
+
def test_auth_still_error(self):
|
|
2132
|
+
out = self._fmt("not signed in")
|
|
2133
|
+
self.assertIn("❌", out)
|
|
2134
|
+
self.assertIn("未登录", out)
|
|
2135
|
+
|
|
2136
|
+
def test_network_still_error(self):
|
|
2137
|
+
out = self._fmt("connection refused")
|
|
2138
|
+
self.assertIn("网络错误", out)
|
|
2139
|
+
|
|
2140
|
+
def test_timeout_still_error(self):
|
|
2141
|
+
out = self._fmt("timeout")
|
|
2142
|
+
self.assertIn("超时", out)
|
|
2143
|
+
|
|
2144
|
+
def test_unknown_still_generic(self):
|
|
2145
|
+
out = self._fmt("something weird happened")
|
|
2146
|
+
self.assertIn("执行失败", out)
|
|
2147
|
+
|
|
2148
|
+
|
|
2149
|
+
class TestFormatCliErrorNoRawInReply(unittest.TestCase):
|
|
2150
|
+
"""format_cli_error must never echo raw English text to WeChat users."""
|
|
2151
|
+
|
|
2152
|
+
def _fmt(self, raw: str) -> str:
|
|
2153
|
+
from wechatbridge.runner_common import format_cli_error
|
|
2154
|
+
return format_cli_error(raw, backend="agy")
|
|
2155
|
+
|
|
2156
|
+
def test_no_raw_payload_error(self):
|
|
2157
|
+
out = self._fmt("request payload size exceeds the limit")
|
|
2158
|
+
self.assertNotIn("payload", out.lower())
|
|
2159
|
+
self.assertNotIn("request", out.lower())
|
|
2160
|
+
|
|
2161
|
+
def test_no_raw_context_error(self):
|
|
2162
|
+
out = self._fmt("context length exceeded")
|
|
2163
|
+
self.assertNotIn("context", out.lower())
|
|
2164
|
+
self.assertNotIn("exceeded", out.lower())
|
|
2165
|
+
|
|
2166
|
+
def test_no_raw_invalid_argument(self):
|
|
2167
|
+
out = self._fmt("INVALID_ARGUMENT: bad field")
|
|
2168
|
+
self.assertNotIn("INVALID_ARGUMENT", out)
|
|
2169
|
+
|
|
2170
|
+
def test_no_raw_rate_limit(self):
|
|
2171
|
+
out = self._fmt("rate limit exceeded")
|
|
2172
|
+
self.assertNotIn("rate", out.lower())
|
|
2173
|
+
self.assertNotIn("limit", out.lower())
|
|
2174
|
+
|
|
2175
|
+
|
|
2176
|
+
class TestFormatCliErrorLogging(unittest.TestCase):
|
|
2177
|
+
"""format_cli_error logs category, not raw text."""
|
|
2178
|
+
|
|
2179
|
+
def test_log_contains_category_not_raw(self):
|
|
2180
|
+
from wechatbridge.runner_common import format_cli_error, _classify_cli_error
|
|
2181
|
+
import logging
|
|
2182
|
+
import io
|
|
2183
|
+
|
|
2184
|
+
# First, verify the category is correct
|
|
2185
|
+
category = _classify_cli_error("request payload size exceeds the limit")
|
|
2186
|
+
self.assertEqual(category, "payload_too_large")
|
|
2187
|
+
|
|
2188
|
+
# Capture log output — set logger level to INFO so our info() call propagates
|
|
2189
|
+
log_capture = io.StringIO()
|
|
2190
|
+
handler = logging.StreamHandler(log_capture)
|
|
2191
|
+
handler.setLevel(logging.DEBUG)
|
|
2192
|
+
logger = logging.getLogger("wechatbridge.runner")
|
|
2193
|
+
old_level = logger.level
|
|
2194
|
+
logger.setLevel(logging.DEBUG)
|
|
2195
|
+
logger.addHandler(handler)
|
|
2196
|
+
try:
|
|
2197
|
+
format_cli_error("request payload size exceeds the limit", backend="agy")
|
|
2198
|
+
finally:
|
|
2199
|
+
logger.removeHandler(handler)
|
|
2200
|
+
logger.setLevel(old_level)
|
|
2201
|
+
|
|
2202
|
+
log_output = log_capture.getvalue()
|
|
2203
|
+
self.assertIn("category=payload_too_large", log_output)
|
|
2204
|
+
# Verify raw text is NOT in the log
|
|
2205
|
+
self.assertNotIn("request payload", log_output)
|
|
2206
|
+
|
|
2207
|
+
def test_category_independent_of_raw(self):
|
|
2208
|
+
"""_classify_cli_error is a pure function; verify it directly."""
|
|
2209
|
+
from wechatbridge.runner_common import _classify_cli_error
|
|
2210
|
+
|
|
2211
|
+
# payload_too_large
|
|
2212
|
+
self.assertEqual(_classify_cli_error("request payload size exceeds the limit"), "payload_too_large")
|
|
2213
|
+
self.assertEqual(_classify_cli_error("payload too large"), "payload_too_large")
|
|
2214
|
+
# context_too_large
|
|
2215
|
+
self.assertEqual(_classify_cli_error("context length exceeded"), "context_too_large")
|
|
2216
|
+
self.assertEqual(_classify_cli_error("too many tokens"), "context_too_large")
|
|
2217
|
+
# invalid_argument
|
|
2218
|
+
self.assertEqual(_classify_cli_error("INVALID_ARGUMENT: bad field"), "invalid_argument")
|
|
2219
|
+
# rate_limit (resource_exhausted without context/token keyword)
|
|
2220
|
+
self.assertEqual(_classify_cli_error("RESOURCE_EXHAUSTED: quota"), "resource_exhausted")
|
|
2221
|
+
# rate_limit (explicit)
|
|
2222
|
+
self.assertEqual(_classify_cli_error("rate limit exceeded"), "rate_limit")
|
|
2223
|
+
# bare 429
|
|
2224
|
+
self.assertEqual(_classify_cli_error("status 429"), "bare_429")
|
|
2225
|
+
# quota
|
|
2226
|
+
self.assertEqual(_classify_cli_error("quota exceeded"), "quota")
|
|
2227
|
+
|
|
2228
|
+
|
|
2229
|
+
class TestFormatCliError413Boundary(unittest.TestCase):
|
|
2230
|
+
"""413 must not be misclassified when appearing in dates, paths, or versions."""
|
|
2231
|
+
|
|
2232
|
+
def _cat(self, raw: str) -> str:
|
|
2233
|
+
from wechatbridge.runner_common import _classify_cli_error
|
|
2234
|
+
return _classify_cli_error(raw)
|
|
2235
|
+
|
|
2236
|
+
def test_date_with_413(self):
|
|
2237
|
+
self.assertNotEqual(self._cat("2024-04-13T10:00:00"), "payload_too_large")
|
|
2238
|
+
|
|
2239
|
+
def test_path_with_413(self):
|
|
2240
|
+
self.assertNotEqual(self._cat("/tmp/file-413.txt"), "payload_too_large")
|
|
2241
|
+
|
|
2242
|
+
def test_version_with_413(self):
|
|
2243
|
+
self.assertNotEqual(self._cat("version 1.413.2"), "payload_too_large")
|
|
2244
|
+
|
|
2245
|
+
def test_error_code_413_without_prefix(self):
|
|
2246
|
+
"""A bare '413' without status/code/http prefix should NOT match."""
|
|
2247
|
+
self.assertNotEqual(self._cat("error 413 occurred"), "payload_too_large")
|
|
2248
|
+
|
|
2249
|
+
def test_explicit_status_413(self):
|
|
2250
|
+
self.assertEqual(self._cat("status 413"), "payload_too_large")
|
|
2251
|
+
|
|
2252
|
+
def test_explicit_code_413(self):
|
|
2253
|
+
self.assertEqual(self._cat("code: 413"), "payload_too_large")
|
|
2254
|
+
|
|
2255
|
+
|
|
2256
|
+
class TestFormatCliErrorContextResourceExhausted(unittest.TestCase):
|
|
2257
|
+
"""RESOURCE_EXHAUSTED with context/token keywords must classify as context_too_large."""
|
|
2258
|
+
|
|
2259
|
+
def _cat(self, raw: str) -> str:
|
|
2260
|
+
from wechatbridge.runner_common import _classify_cli_error
|
|
2261
|
+
return _classify_cli_error(raw)
|
|
2262
|
+
|
|
2263
|
+
def test_resource_exhausted_with_context(self):
|
|
2264
|
+
self.assertEqual(self._cat("RESOURCE_EXHAUSTED: context length exceeded"), "context_too_large")
|
|
2265
|
+
|
|
2266
|
+
def test_resource_exhausted_with_token(self):
|
|
2267
|
+
self.assertEqual(self._cat("RESOURCE_EXHAUSTED: token limit reached"), "context_too_large")
|
|
2268
|
+
|
|
2269
|
+
def test_resource_exhausted_with_maximum(self):
|
|
2270
|
+
self.assertEqual(self._cat("RESOURCE_EXHAUSTED: maximum context size"), "context_too_large")
|
|
2271
|
+
|
|
2272
|
+
def test_invalid_argument_with_context(self):
|
|
2273
|
+
self.assertEqual(self._cat("INVALID_ARGUMENT: context too long"), "context_too_large")
|
|
2274
|
+
|
|
2275
|
+
|
|
2276
|
+
class TestFormatCliErrorPreservesExistingThrottle(unittest.TestCase):
|
|
2277
|
+
"""Existing throttle/quota behavior must not be regressed."""
|
|
2278
|
+
|
|
2279
|
+
def _fmt(self, raw: str) -> str:
|
|
2280
|
+
from wechatbridge.runner_common import format_cli_error
|
|
2281
|
+
return format_cli_error(raw, backend="agy")
|
|
2282
|
+
|
|
2283
|
+
def test_eligibility_still_busy(self):
|
|
2284
|
+
out = self._fmt("Eligibility check failed: RESOURCE_EXHAUSTED (code 429)")
|
|
2285
|
+
self.assertIn("助手通道繁忙", out)
|
|
2286
|
+
self.assertIn("🔔", out)
|
|
2287
|
+
|
|
2288
|
+
def test_quota_exceeded_still_quota(self):
|
|
2289
|
+
out = self._fmt("You exceeded your current quota")
|
|
2290
|
+
self.assertIn("额度相关", out)
|
|
2291
|
+
self.assertIn("🔔", out)
|
|
2292
|
+
|
|
2293
|
+
def test_rate_limit_still_busy(self):
|
|
2294
|
+
out = self._fmt("rate limit exceeded")
|
|
2295
|
+
self.assertIn("请求较多", out)
|
|
2296
|
+
self.assertIn("🔔", out)
|
|
2297
|
+
|
|
2298
|
+
def test_bare_429_still_busy(self):
|
|
2299
|
+
out = self._fmt("upstream returned status 429")
|
|
2300
|
+
self.assertIn("助手通道繁忙", out)
|
|
2301
|
+
self.assertIn("🔔", out)
|
|
2302
|
+
|
|
2303
|
+
|
|
2304
|
+
class TestClearInitializedPreservesHistory(unittest.TestCase):
|
|
2305
|
+
"""clear_initialized must only remove .initialized flags, not conversation history."""
|
|
2306
|
+
|
|
2307
|
+
def test_clear_initialized_only_removes_flags(self):
|
|
2308
|
+
from wechatbridge.runner_common import clear_initialized, ensure_session_dir
|
|
2309
|
+
|
|
2310
|
+
with tempfile.TemporaryDirectory() as td:
|
|
2311
|
+
sd = os.path.join(td, "user_test")
|
|
2312
|
+
os.makedirs(sd, exist_ok=True)
|
|
2313
|
+
# Create .initialized flag
|
|
2314
|
+
flag = os.path.join(sd, ".initialized.agy")
|
|
2315
|
+
with open(flag, "w") as f:
|
|
2316
|
+
f.write("1")
|
|
2317
|
+
# Create some history files
|
|
2318
|
+
history_dir = os.path.join(sd, ".gemini", "antigravity-cli", "conversations")
|
|
2319
|
+
os.makedirs(history_dir, exist_ok=True)
|
|
2320
|
+
history_file = os.path.join(history_dir, "conv.db")
|
|
2321
|
+
with open(history_file, "w") as f:
|
|
2322
|
+
f.write("history data")
|
|
2323
|
+
# Create prefs
|
|
2324
|
+
prefs_file = os.path.join(sd, "prefs.json")
|
|
2325
|
+
with open(prefs_file, "w") as f:
|
|
2326
|
+
f.write("{}")
|
|
2327
|
+
|
|
2328
|
+
clear_initialized(sd, backend="agy")
|
|
2329
|
+
|
|
2330
|
+
# Flag is removed
|
|
2331
|
+
self.assertFalse(os.path.exists(flag))
|
|
2332
|
+
# History is preserved
|
|
2333
|
+
self.assertTrue(os.path.exists(history_file))
|
|
2334
|
+
# Prefs are preserved
|
|
2335
|
+
self.assertTrue(os.path.exists(prefs_file))
|
|
2336
|
+
|
|
2337
|
+
|
|
2338
|
+
if __name__ == "__main__":
|
|
2339
|
+
unittest.main()
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""WeChatBridge — bridge WeChat messages to agy, Grok Build, or Codex CLIs."""
|
|
2
|
-
__version__ = "1.4.
|
|
2
|
+
__version__ = "1.4.6"
|
|
@@ -22,7 +22,7 @@ from .runner_common import (
|
|
|
22
22
|
sanitize_user_id, get_session_dir, ensure_session_dir, is_first_message, mark_initialized, clear_initialized,
|
|
23
23
|
clean_output, load_prefs, save_prefs, is_dangerous, parse_model_effort,
|
|
24
24
|
sanitize_env, terminate_process, update_active_prefs, is_bridge_formatted_reply,
|
|
25
|
-
format_error, format_cli_error, format_model_label, EMPTY_REPLY, validate_add_dir,
|
|
25
|
+
format_error, format_cli_error, format_model_label, EMPTY_REPLY, validate_add_dir, path_is_under,
|
|
26
26
|
)
|
|
27
27
|
|
|
28
28
|
logger = logging.getLogger("codex_runner")
|
|
@@ -388,6 +388,7 @@ def _build_codex_command(prompt: str, prefs: dict, first: bool, thread_id: str =
|
|
|
388
388
|
cmd += ["-c", f"model_reasoning_effort={effort}"]
|
|
389
389
|
|
|
390
390
|
# add_dirs -> each --add-dir (codex 原生支持,比 grok 的"仅记录"强)
|
|
391
|
+
# add_dirs 已由 _resolve_add_dirs 通过 validate_add_dir 规范化
|
|
391
392
|
for d in prefs.get("add_dirs", []) or []:
|
|
392
393
|
if d:
|
|
393
394
|
cmd += ["--add-dir", d]
|
|
@@ -411,7 +412,7 @@ def _collect_codex_artifact(path: str, session_dir: str, since: float, seen: set
|
|
|
411
412
|
if not os.path.isabs(path):
|
|
412
413
|
path = os.path.join(session_dir, path)
|
|
413
414
|
try:
|
|
414
|
-
path = os.path.
|
|
415
|
+
path = os.path.realpath(path)
|
|
415
416
|
except (OSError, ValueError):
|
|
416
417
|
return
|
|
417
418
|
try:
|
|
@@ -428,6 +429,75 @@ def _collect_codex_artifact(path: str, session_dir: str, since: float, seen: set
|
|
|
428
429
|
artifacts.append(key)
|
|
429
430
|
|
|
430
431
|
|
|
432
|
+
def _resolve_add_dirs(raw_dirs: list, session_dir: str, user_id: str = "") -> list:
|
|
433
|
+
"""Validate add_dirs via validate_add_dir for command building + fallback scan.
|
|
434
|
+
|
|
435
|
+
Calls validate_add_dir once per directory to get resolved realpaths.
|
|
436
|
+
Ensures consistency between the --add-dir flags passed to Codex and the
|
|
437
|
+
allowed roots used by the artifact fallback scan.
|
|
438
|
+
"""
|
|
439
|
+
validated = []
|
|
440
|
+
for d in raw_dirs or []:
|
|
441
|
+
if not d:
|
|
442
|
+
continue
|
|
443
|
+
try:
|
|
444
|
+
ok, result = validate_add_dir(d, user_id)
|
|
445
|
+
except Exception as e:
|
|
446
|
+
logger.warning("validate_add_dir failed for %s: %s", d, e)
|
|
447
|
+
continue
|
|
448
|
+
if ok and result:
|
|
449
|
+
validated.append(result)
|
|
450
|
+
return validated
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
_EXCLUDED_SCAN_NAMES = frozenset({".codex", ".git", "__pycache__", "node_modules", "venv", ".venv", "cache", ".cache"})
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _snapshot_regular_files(roots: list) -> dict:
|
|
457
|
+
"""Snapshot regular files (path, mtime) under allowed roots for fallback scan.
|
|
458
|
+
|
|
459
|
+
Enforces limits: <=200 files, <=50 directories, <=2 seconds. Returns empty
|
|
460
|
+
dict if any limit is exceeded (never partial results).
|
|
461
|
+
|
|
462
|
+
``os.walk(followlinks=False)`` naturally excludes symlink traversal;
|
|
463
|
+
``_EXCLUDED_SCAN_NAMES`` skips internal metadata dirs; only regular files
|
|
464
|
+
(not dirs/symlinks/special) are collected.
|
|
465
|
+
"""
|
|
466
|
+
snap = {}
|
|
467
|
+
t_start = time.monotonic()
|
|
468
|
+
file_count = 0
|
|
469
|
+
dir_count = 0
|
|
470
|
+
_MAX_FILES = 200
|
|
471
|
+
_MAX_DIRS = 50
|
|
472
|
+
_MAX_SCAN_TIME = 2.0
|
|
473
|
+
for root in roots:
|
|
474
|
+
if not os.path.isdir(root):
|
|
475
|
+
continue
|
|
476
|
+
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
|
477
|
+
dir_count += 1
|
|
478
|
+
if dir_count > _MAX_DIRS:
|
|
479
|
+
return {}
|
|
480
|
+
if time.monotonic() - t_start > _MAX_SCAN_TIME:
|
|
481
|
+
return {}
|
|
482
|
+
# Exclude internal metadata directories in-place
|
|
483
|
+
dirnames[:] = [d for d in dirnames if d not in _EXCLUDED_SCAN_NAMES]
|
|
484
|
+
for fn in filenames:
|
|
485
|
+
file_count += 1
|
|
486
|
+
if file_count > _MAX_FILES:
|
|
487
|
+
return {}
|
|
488
|
+
fp = os.path.join(dirpath, fn)
|
|
489
|
+
try:
|
|
490
|
+
if not os.path.isfile(fp) or os.path.islink(fp):
|
|
491
|
+
continue
|
|
492
|
+
# Verify candidate is under one of the allowed roots
|
|
493
|
+
if not any(path_is_under(fp, r) for r in roots):
|
|
494
|
+
continue
|
|
495
|
+
snap[fp] = os.path.getmtime(fp)
|
|
496
|
+
except OSError:
|
|
497
|
+
continue
|
|
498
|
+
return snap
|
|
499
|
+
|
|
500
|
+
|
|
431
501
|
def _parse_codex_output(stdout_text: str, session_dir: str, since: float = 0.0) -> tuple:
|
|
432
502
|
"""Parse codex JSONL output into (display_text, artifacts, thread_id, parse_failed).
|
|
433
503
|
|
|
@@ -561,6 +631,28 @@ def _extract_codex_error_messages(stdout_text: str) -> list:
|
|
|
561
631
|
# codex CLI execution
|
|
562
632
|
# ---------------------------------------------------------------------------
|
|
563
633
|
|
|
634
|
+
|
|
635
|
+
def _collect_fallback_artifacts(session_dir: str, snapshot_before: dict, t0: float) -> list:
|
|
636
|
+
"""Scan session_dir for new files created during codex execution.
|
|
637
|
+
|
|
638
|
+
Only runs when: returncode==0, parse_failed==False, no structured artifacts.
|
|
639
|
+
Scans only session_dir (never add_dirs). Excludes internal metadata dirs.
|
|
640
|
+
Each candidate realpath is verified to be under session_dir.
|
|
641
|
+
"""
|
|
642
|
+
_internal_names = {".codex_thread_id"}
|
|
643
|
+
fallback = []
|
|
644
|
+
fallback_seen = set()
|
|
645
|
+
for fp, fmtime in _snapshot_regular_files([session_dir]).items():
|
|
646
|
+
if fmtime >= t0 and fp not in snapshot_before:
|
|
647
|
+
name = os.path.basename(fp)
|
|
648
|
+
if name in _internal_names or name.startswith(".initialized."):
|
|
649
|
+
continue
|
|
650
|
+
key = (name, fp)
|
|
651
|
+
if key not in fallback_seen:
|
|
652
|
+
fallback_seen.add(key)
|
|
653
|
+
fallback.append(key)
|
|
654
|
+
return fallback
|
|
655
|
+
|
|
564
656
|
async def run_codex(prompt: str, user_id: str, timeout: int = None) -> tuple:
|
|
565
657
|
"""Execute codex CLI for a given user message.
|
|
566
658
|
|
|
@@ -610,9 +702,15 @@ async def run_codex(prompt: str, user_id: str, timeout: int = None) -> tuple:
|
|
|
610
702
|
first = True
|
|
611
703
|
|
|
612
704
|
prefs = load_prefs(user_id)
|
|
705
|
+
# 验证并规范化 add_dirs,确保命令构建与 fallback 扫描使用同一批目录
|
|
706
|
+
validated_add_dirs = _resolve_add_dirs(prefs.get("add_dirs"), session_dir, user_id)
|
|
707
|
+
prefs["add_dirs"] = validated_add_dirs
|
|
613
708
|
# persona 经 AGENTS.md 由 codex 自动读取,这里无需注入 argv
|
|
614
709
|
cmd = _build_codex_command(prompt, prefs, first, thread_id)
|
|
615
710
|
|
|
711
|
+
# 执行前快照现有文件,用于 shell 产物 fallback 扫描(仅 session_dir)
|
|
712
|
+
snapshot_before = _snapshot_regular_files([session_dir])
|
|
713
|
+
|
|
616
714
|
if first:
|
|
617
715
|
logger.info("First message for user %s, running: codex exec ...", user_id)
|
|
618
716
|
else:
|
|
@@ -733,6 +831,12 @@ async def run_codex(prompt: str, user_id: str, timeout: int = None) -> tuple:
|
|
|
733
831
|
mark_initialized(session_dir, backend="codex")
|
|
734
832
|
if r_thread_id:
|
|
735
833
|
_write_codex_thread_id(session_dir, r_thread_id)
|
|
834
|
+
# Fallback: shell 产物扫描(仅重试成功且无结构化 artifacts 时触发)
|
|
835
|
+
if not r_failed and not r_artifacts:
|
|
836
|
+
fallback = _collect_fallback_artifacts(session_dir, snapshot_before, t0)
|
|
837
|
+
if fallback:
|
|
838
|
+
r_artifacts = fallback
|
|
839
|
+
logger.info("Fallback scan collected %d additional files on retry", len(r_artifacts))
|
|
736
840
|
elapsed = time.time() - t0
|
|
737
841
|
logger.info(
|
|
738
842
|
"codex retry done: user=%s elapsed=%.1fs artifacts=%d output=%d chars failed=%s",
|
|
@@ -746,6 +850,13 @@ async def run_codex(prompt: str, user_id: str, timeout: int = None) -> tuple:
|
|
|
746
850
|
if parsed_thread_id:
|
|
747
851
|
_write_codex_thread_id(session_dir, parsed_thread_id)
|
|
748
852
|
|
|
853
|
+
# Fallback: shell 产物扫描(仅在成功且无结构化 artifacts 时触发)
|
|
854
|
+
if not failed and not artifacts:
|
|
855
|
+
fallback = _collect_fallback_artifacts(session_dir, snapshot_before, t0)
|
|
856
|
+
if fallback:
|
|
857
|
+
artifacts = fallback
|
|
858
|
+
logger.info("Fallback scan collected %d additional files", len(artifacts))
|
|
859
|
+
|
|
749
860
|
elapsed = time.time() - t0
|
|
750
861
|
logger.info(
|
|
751
862
|
"codex done: user=%s elapsed=%.1fs artifacts=%d output=%d chars failed=%s",
|
|
@@ -283,25 +283,65 @@ def format_artifact_send_failure_notice(art_name: str, reason: str) -> str:
|
|
|
283
283
|
)
|
|
284
284
|
|
|
285
285
|
|
|
286
|
-
def
|
|
287
|
-
"""
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
286
|
+
def _classify_cli_error(raw_message: str, *, backend: str = "") -> str:
|
|
287
|
+
"""Classify a backend error string into a category label.
|
|
288
|
+
|
|
289
|
+
Returns one of:
|
|
290
|
+
payload_too_large, context_too_large, invalid_argument,
|
|
291
|
+
auth, resource_exhausted, rate_limit, bare_429, quota,
|
|
292
|
+
network, timeout, cascade_timeout, permission,
|
|
293
|
+
session_not_found, model_invalid, command_not_found, not_found,
|
|
294
|
+
unknown
|
|
295
|
+
|
|
296
|
+
This is a pure function (no I/O, no logging). ``format_cli_error``
|
|
297
|
+
drives user-facing copy from the category; callers that need the
|
|
298
|
+
category directly (e.g. structured logging) can use this too.
|
|
291
299
|
"""
|
|
292
|
-
raw = clean_output(raw_message or "") or "
|
|
300
|
+
raw = clean_output(raw_message or "") or ""
|
|
293
301
|
lower = raw.lower()
|
|
294
302
|
backend = (backend or "").strip().lower()
|
|
295
|
-
logger.info(
|
|
296
|
-
"format_cli_error backend=%s raw=%.300s",
|
|
297
|
-
backend or "?",
|
|
298
|
-
raw,
|
|
299
|
-
)
|
|
300
303
|
|
|
301
|
-
#
|
|
302
|
-
#
|
|
303
|
-
#
|
|
304
|
-
|
|
304
|
+
# --- 1. Payload / request body too large (413) --------------------------------
|
|
305
|
+
# Match explicit payload/body-size phrases first to avoid
|
|
306
|
+
# catching a bare 413 that appears in dates or path segments.
|
|
307
|
+
_PAYLOAD_SIZE_RE = re.compile(
|
|
308
|
+
r"request\s+payload\s+size\s+exceeds\s+the\s+limit"
|
|
309
|
+
r"|payload\s+too\s+large"
|
|
310
|
+
r"|request\s+entity\s+too\s+large"
|
|
311
|
+
r"|content[-\s]?length\s+(?:exceeds|exceeded|is\s+too\s+large)",
|
|
312
|
+
re.IGNORECASE,
|
|
313
|
+
)
|
|
314
|
+
if _PAYLOAD_SIZE_RE.search(lower):
|
|
315
|
+
return "payload_too_large"
|
|
316
|
+
# HTTP 413 with surrounding context (status/code/http prefix or JSON \"code\":413)
|
|
317
|
+
if re.search(
|
|
318
|
+
r"(?:status|code|http)\s*[:=]?\s*413\b", lower
|
|
319
|
+
) or re.search(r'\"code\"\s*:\s*413', lower):
|
|
320
|
+
return "payload_too_large"
|
|
321
|
+
|
|
322
|
+
# --- 2. Context / token limit -------------------------------------------------
|
|
323
|
+
_CONTEXT_TOKEN_RE = re.compile(
|
|
324
|
+
r"input\s+token\s+count\s+exceeds\s+the\s+maximum"
|
|
325
|
+
r"|context\s+length\s+exceeded"
|
|
326
|
+
r"|maximum\s+context\s+length"
|
|
327
|
+
r"|context\s+window\s+exceeded"
|
|
328
|
+
r"|too\s+many\s+tokens"
|
|
329
|
+
r"|your\s+input\s+context\s+is\s+too\s+long",
|
|
330
|
+
re.IGNORECASE,
|
|
331
|
+
)
|
|
332
|
+
if _CONTEXT_TOKEN_RE.search(lower):
|
|
333
|
+
return "context_too_large"
|
|
334
|
+
# INVALID_ARGUMENT or RESOURCE_EXHAUSTED combined with context/token max signals
|
|
335
|
+
_ISAE = re.compile(r"invalid_argument|resource_exhausted", re.IGNORECASE)
|
|
336
|
+
_CTX_KW = re.compile(r"context|token|maximum", re.IGNORECASE)
|
|
337
|
+
if _ISAE.search(lower) and _CTX_KW.search(lower):
|
|
338
|
+
return "context_too_large"
|
|
339
|
+
|
|
340
|
+
# --- 3. Generic INVALID_ARGUMENT (not payload/context) ------------------------
|
|
341
|
+
if "invalid_argument" in lower:
|
|
342
|
+
return "invalid_argument"
|
|
343
|
+
|
|
344
|
+
# --- 4. Auth / login ----------------------------------------------------------
|
|
305
345
|
if backend == "codex":
|
|
306
346
|
if (
|
|
307
347
|
"codex login" in lower
|
|
@@ -326,12 +366,8 @@ def format_cli_error(raw_message: str, *, backend: str = "") -> str:
|
|
|
326
366
|
or "unauthorized" in lower
|
|
327
367
|
or "401" in lower and ("auth" in lower or "token" in lower or "login" in lower)
|
|
328
368
|
):
|
|
329
|
-
return
|
|
330
|
-
"未登录",
|
|
331
|
-
"助手尚未登录或凭证失效,请联系管理员处理。",
|
|
332
|
-
)
|
|
369
|
+
return "auth"
|
|
333
370
|
|
|
334
|
-
# Auth / login — ops details stay in logs; users contact admin
|
|
335
371
|
if (
|
|
336
372
|
"not signed in" in lower
|
|
337
373
|
or "authenticate" in lower
|
|
@@ -345,28 +381,18 @@ def format_cli_error(raw_message: str, *, backend: str = "") -> str:
|
|
|
345
381
|
or ("xai_api_key" in lower and ("sign" in lower or "login" in lower or "auth" in lower))
|
|
346
382
|
or "api_key" in lower and ("missing" in lower or "invalid" in lower or "required" in lower)
|
|
347
383
|
):
|
|
348
|
-
return
|
|
349
|
-
"未登录",
|
|
350
|
-
"助手尚未登录或凭证失效,请联系管理员处理。",
|
|
351
|
-
)
|
|
384
|
+
return "auth"
|
|
352
385
|
|
|
353
|
-
#
|
|
354
|
-
#
|
|
355
|
-
#
|
|
356
|
-
# not bridge concurrency, not user spam. More specific patterns first.
|
|
386
|
+
# --- 5. Rate limit / quota (RESOURCE_EXHAUSTED without context/token) ---------
|
|
387
|
+
# Must come AFTER context_too_large so "context/token + RESOURCE_EXHAUSTED"
|
|
388
|
+
# does not land here.
|
|
357
389
|
if (
|
|
358
390
|
"eligibility" in lower
|
|
359
391
|
or "resource_exhausted" in lower
|
|
360
392
|
or "resource exhausted" in lower
|
|
361
393
|
):
|
|
362
|
-
return
|
|
363
|
-
"助手通道繁忙",
|
|
364
|
-
"上游助手通道暂时限流或繁忙,请稍等片刻再试。",
|
|
365
|
-
)
|
|
394
|
+
return "resource_exhausted"
|
|
366
395
|
|
|
367
|
-
# Explicit account / daily quota (after resource-exhausted so "check quota"
|
|
368
|
-
# in that message does not steal this branch).
|
|
369
|
-
# Require exceed/limit/daily/… semantics — bare "quota usage report" must not match.
|
|
370
396
|
if (
|
|
371
397
|
"quota exceeded" in lower
|
|
372
398
|
or "quota_exceeded" in lower
|
|
@@ -384,33 +410,22 @@ def format_cli_error(raw_message: str, *, backend: str = "") -> str:
|
|
|
384
410
|
)
|
|
385
411
|
)
|
|
386
412
|
):
|
|
387
|
-
return
|
|
388
|
-
"额度相关",
|
|
389
|
-
"当前额度或配额可能受限,请稍后再试或联系管理员。",
|
|
390
|
-
)
|
|
413
|
+
return "quota"
|
|
391
414
|
|
|
392
|
-
# Explicit rate limit / too many requests (no resource-exhausted wording).
|
|
393
415
|
if (
|
|
394
416
|
"rate limit" in lower
|
|
395
417
|
or "rate_limit" in lower
|
|
396
418
|
or "too many requests" in lower
|
|
397
419
|
):
|
|
398
|
-
return
|
|
399
|
-
"请求较多",
|
|
400
|
-
"当前请求较多,请稍后再试。",
|
|
401
|
-
)
|
|
420
|
+
return "rate_limit"
|
|
402
421
|
|
|
403
422
|
# Bare 429 fallback — word-boundary so "1429" / "x4290" do not match.
|
|
404
|
-
# Also accept explicit "status 429" / "code 429" forms.
|
|
405
423
|
if re.search(r"\b429\b", lower) or re.search(
|
|
406
424
|
r"(?:status|code|http)\s*[:=]?\s*429\b", lower
|
|
407
425
|
):
|
|
408
|
-
return
|
|
409
|
-
"助手通道繁忙",
|
|
410
|
-
"上游暂时限流或繁忙,请稍等片刻再试。",
|
|
411
|
-
)
|
|
426
|
+
return "bare_429"
|
|
412
427
|
|
|
413
|
-
# Network
|
|
428
|
+
# --- 6. Network ---------------------------------------------------------------
|
|
414
429
|
if (
|
|
415
430
|
"connection refused" in lower
|
|
416
431
|
or "connection reset" in lower
|
|
@@ -423,26 +438,21 @@ def format_cli_error(raw_message: str, *, backend: str = "") -> str:
|
|
|
423
438
|
or "fetch failed" in lower
|
|
424
439
|
or "socket hang up" in lower
|
|
425
440
|
):
|
|
426
|
-
return
|
|
441
|
+
return "network"
|
|
427
442
|
|
|
428
|
-
#
|
|
443
|
+
# --- 7. Timeout / cascade -----------------------------------------------------
|
|
429
444
|
if "timeout waiting for cascade" in lower or "timeout waiting for response" in lower:
|
|
430
|
-
return
|
|
431
|
-
"模型响应超时",
|
|
432
|
-
"模型响应超时,请稍后重试或简化指令。",
|
|
433
|
-
)
|
|
434
|
-
|
|
435
|
-
if "permission" in lower and ("denied" in lower or "refuse" in lower or "rejected" in lower):
|
|
436
|
-
return format_error("权限不足", "没有执行该操作的权限。")
|
|
445
|
+
return "cascade_timeout"
|
|
437
446
|
|
|
438
447
|
if "timeout" in lower or "timed out" in lower or "deadline exceeded" in lower:
|
|
439
|
-
return
|
|
448
|
+
return "timeout"
|
|
449
|
+
|
|
450
|
+
# --- 8. Permission / misc specific --------------------------------------------
|
|
451
|
+
if "permission" in lower and ("denied" in lower or "refuse" in lower or "rejected" in lower):
|
|
452
|
+
return "permission"
|
|
440
453
|
|
|
441
454
|
if "no session found" in lower:
|
|
442
|
-
return
|
|
443
|
-
"会话不存在",
|
|
444
|
-
"上一轮对话记录已过期,请重新发一次消息即可。",
|
|
445
|
-
)
|
|
455
|
+
return "session_not_found"
|
|
446
456
|
|
|
447
457
|
if "model" in lower and (
|
|
448
458
|
"not found" in lower
|
|
@@ -453,15 +463,119 @@ def format_cli_error(raw_message: str, *, backend: str = "") -> str:
|
|
|
453
463
|
or "not supported" in lower
|
|
454
464
|
or "no such" in lower
|
|
455
465
|
):
|
|
456
|
-
return
|
|
466
|
+
return "model_invalid"
|
|
457
467
|
|
|
458
468
|
if "command not found" in lower or "not a command" in lower:
|
|
469
|
+
return "command_not_found"
|
|
470
|
+
|
|
471
|
+
if "not found" in lower or "no such file" in lower or "enoent" in lower:
|
|
472
|
+
return "not_found"
|
|
473
|
+
|
|
474
|
+
return "unknown"
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def format_cli_error(raw_message: str, *, backend: str = "") -> str:
|
|
478
|
+
"""Map backend stderr/JSON error text into a short Chinese user reply.
|
|
479
|
+
|
|
480
|
+
Never put English raw blobs or internal path/env names into user text.
|
|
481
|
+
Details stay in the server log only.
|
|
482
|
+
"""
|
|
483
|
+
raw = clean_output(raw_message or "") or "未知错误"
|
|
484
|
+
category = _classify_cli_error(raw, backend=backend)
|
|
485
|
+
logger.info(
|
|
486
|
+
"format_cli_error backend=%s category=%s",
|
|
487
|
+
backend or "?",
|
|
488
|
+
category,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
# --- category → user-facing copy -----------------------------------------------
|
|
492
|
+
if category == "auth":
|
|
493
|
+
return format_error(
|
|
494
|
+
"未登录",
|
|
495
|
+
"助手尚未登录或凭证失效,请联系管理员处理。",
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
if category == "payload_too_large":
|
|
499
|
+
return format_error(
|
|
500
|
+
"请求内容过大",
|
|
501
|
+
(
|
|
502
|
+
"本次发送的内容超出服务端限制。\n"
|
|
503
|
+
"如果已续聊很久,请发 /new 开始新会话;\n"
|
|
504
|
+
"若新会话仍失败,请减少本次文字、图片或文件。"
|
|
505
|
+
),
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
if category == "context_too_large":
|
|
509
|
+
return format_error(
|
|
510
|
+
"会话内容过长",
|
|
511
|
+
(
|
|
512
|
+
"会话累积内容超出模型上下文限制。\n"
|
|
513
|
+
"请发 /new 开始新会话后重试。"
|
|
514
|
+
),
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
if category == "invalid_argument":
|
|
518
|
+
return format_error(
|
|
519
|
+
"请求参数无效",
|
|
520
|
+
"本次输入参数有误,请检查内容后重试。",
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
# --- throttle / quota (🔔 notice style — existing behaviour preserved) ---------
|
|
524
|
+
if category == "resource_exhausted":
|
|
525
|
+
return format_notice(
|
|
526
|
+
"助手通道繁忙",
|
|
527
|
+
"上游助手通道暂时限流或繁忙,请稍等片刻再试。",
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
if category == "rate_limit":
|
|
531
|
+
return format_notice(
|
|
532
|
+
"请求较多",
|
|
533
|
+
"当前请求较多,请稍后再试。",
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
if category == "bare_429":
|
|
537
|
+
return format_notice(
|
|
538
|
+
"助手通道繁忙",
|
|
539
|
+
"上游暂时限流或繁忙,请稍等片刻再试。",
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
if category == "quota":
|
|
543
|
+
return format_notice(
|
|
544
|
+
"额度相关",
|
|
545
|
+
"当前额度或配额可能受限,请稍后再试或联系管理员。",
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
if category == "network":
|
|
549
|
+
return format_error("网络错误", "连不上服务,请检查网络后重试。")
|
|
550
|
+
|
|
551
|
+
if category == "cascade_timeout":
|
|
552
|
+
return format_error(
|
|
553
|
+
"模型响应超时",
|
|
554
|
+
"模型响应超时,请稍后重试或简化指令。",
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
if category == "permission":
|
|
558
|
+
return format_error("权限不足", "没有执行该操作的权限。")
|
|
559
|
+
|
|
560
|
+
if category == "timeout":
|
|
561
|
+
return format_error("超时", "等待响应超时,请稍后重试。")
|
|
562
|
+
|
|
563
|
+
if category == "session_not_found":
|
|
564
|
+
return format_error(
|
|
565
|
+
"会话不存在",
|
|
566
|
+
"上一轮对话记录已过期,请重新发一次消息即可。",
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
if category == "model_invalid":
|
|
570
|
+
return format_error("模型无效", "指定的模型不可用,请用 `/models` 查看后重选。")
|
|
571
|
+
|
|
572
|
+
if category == "command_not_found":
|
|
459
573
|
return format_error(
|
|
460
574
|
"助手不可用",
|
|
461
575
|
"助手程序未正确安装或配置,请联系管理员。",
|
|
462
576
|
)
|
|
463
577
|
|
|
464
|
-
if
|
|
578
|
+
if category == "not_found":
|
|
465
579
|
return format_error("未找到", "请求的资源或文件不存在。")
|
|
466
580
|
|
|
467
581
|
# Unknown: fixed Chinese only — never echo English raw to WeChat users
|
|
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
|
{wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.6}/wechatbridge_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.6}/wechatbridge_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|