wechatbridge-cli 1.6.0__tar.gz → 1.6.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {wechatbridge_cli-1.6.0 → wechatbridge_cli-1.6.1}/PKG-INFO +1 -1
  2. {wechatbridge_cli-1.6.0 → wechatbridge_cli-1.6.1}/tests/test_hardening.py +186 -0
  3. {wechatbridge_cli-1.6.0 → wechatbridge_cli-1.6.1}/wechatbridge/__init__.py +1 -1
  4. {wechatbridge_cli-1.6.0 → wechatbridge_cli-1.6.1}/wechatbridge/agy.py +38 -11
  5. {wechatbridge_cli-1.6.0 → wechatbridge_cli-1.6.1}/wechatbridge/runner_common.py +24 -6
  6. {wechatbridge_cli-1.6.0 → wechatbridge_cli-1.6.1}/wechatbridge_cli.egg-info/PKG-INFO +1 -1
  7. {wechatbridge_cli-1.6.0 → wechatbridge_cli-1.6.1}/LICENSE +0 -0
  8. {wechatbridge_cli-1.6.0 → wechatbridge_cli-1.6.1}/README.md +0 -0
  9. {wechatbridge_cli-1.6.0 → wechatbridge_cli-1.6.1}/pyproject.toml +0 -0
  10. {wechatbridge_cli-1.6.0 → wechatbridge_cli-1.6.1}/setup.cfg +0 -0
  11. {wechatbridge_cli-1.6.0 → wechatbridge_cli-1.6.1}/tests/test_codex.py +0 -0
  12. {wechatbridge_cli-1.6.0 → wechatbridge_cli-1.6.1}/tests/test_codex_auth.py +0 -0
  13. {wechatbridge_cli-1.6.0 → wechatbridge_cli-1.6.1}/tests/test_dsh.py +0 -0
  14. {wechatbridge_cli-1.6.0 → wechatbridge_cli-1.6.1}/tests/test_grok_auth.py +0 -0
  15. {wechatbridge_cli-1.6.0 → wechatbridge_cli-1.6.1}/wechatbridge/__main__.py +0 -0
  16. {wechatbridge_cli-1.6.0 → wechatbridge_cli-1.6.1}/wechatbridge/codex.py +0 -0
  17. {wechatbridge_cli-1.6.0 → wechatbridge_cli-1.6.1}/wechatbridge/config.py +0 -0
  18. {wechatbridge_cli-1.6.0 → wechatbridge_cli-1.6.1}/wechatbridge/dsh.py +0 -0
  19. {wechatbridge_cli-1.6.0 → wechatbridge_cli-1.6.1}/wechatbridge/grok.py +0 -0
  20. {wechatbridge_cli-1.6.0 → wechatbridge_cli-1.6.1}/wechatbridge/ilink.py +0 -0
  21. {wechatbridge_cli-1.6.0 → wechatbridge_cli-1.6.1}/wechatbridge/main.py +0 -0
  22. {wechatbridge_cli-1.6.0 → wechatbridge_cli-1.6.1}/wechatbridge/update_check.py +0 -0
  23. {wechatbridge_cli-1.6.0 → wechatbridge_cli-1.6.1}/wechatbridge_cli.egg-info/SOURCES.txt +0 -0
  24. {wechatbridge_cli-1.6.0 → wechatbridge_cli-1.6.1}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
  25. {wechatbridge_cli-1.6.0 → wechatbridge_cli-1.6.1}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
  26. {wechatbridge_cli-1.6.0 → wechatbridge_cli-1.6.1}/wechatbridge_cli.egg-info/requires.txt +0 -0
  27. {wechatbridge_cli-1.6.0 → wechatbridge_cli-1.6.1}/wechatbridge_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wechatbridge-cli
3
- Version: 1.6.0
3
+ Version: 1.6.1
4
4
  Summary: Bridge WeChat messages to agy, Grok Build, or Codex CLIs — text/image/file/voice in, CLI replies and generated files back.
5
5
  Author: WeChatBridge contributors
6
6
  License: MIT
@@ -2302,6 +2302,28 @@ class TestClassifyCliError(unittest.TestCase):
2302
2302
  def test_network_unreachable(self):
2303
2303
  self.assertEqual(self._cat("network is unreachable"), "network")
2304
2304
 
2305
+ # --- agent_stream_interrupted -------------------------------------------------
2306
+
2307
+ def test_agent_stream_interrupted_real_error(self):
2308
+ err = "Error: the connection to the agent was interrupted before the response finished: subscriber fell behind updates, stalled for 8s"
2309
+ self.assertEqual(self._cat(err), "agent_stream_interrupted")
2310
+
2311
+ def test_agent_stream_interrupted_stalled_variants(self):
2312
+ self.assertEqual(self._cat("subscriber fell behind updates, stalled for 5s"), "agent_stream_interrupted")
2313
+ self.assertEqual(self._cat("subscriber fell behind updates, stalled for 13s"), "agent_stream_interrupted")
2314
+ self.assertEqual(self._cat("subscriber fell behind updates, stalled for 13 s"), "agent_stream_interrupted")
2315
+ self.assertEqual(self._cat("stalled for 8s"), "agent_stream_interrupted")
2316
+
2317
+ def test_agent_stream_interrupted_conversation_stream_failed(self):
2318
+ self.assertEqual(self._cat("conversation update stream failed"), "agent_stream_interrupted")
2319
+
2320
+ def test_agent_stream_interrupted_connection_interrupted(self):
2321
+ self.assertEqual(self._cat("connection to the agent was interrupted"), "agent_stream_interrupted")
2322
+
2323
+ def test_agent_stream_interrupted_negative_installed(self):
2324
+ err = "package wechatbridge-cli 1.6.0, installed using Python 3.13.5"
2325
+ self.assertNotEqual(self._cat(err), "agent_stream_interrupted")
2326
+
2305
2327
  # --- cascade_timeout ----------------------------------------------------------
2306
2328
 
2307
2329
  def test_cascade_timeout(self):
@@ -2404,6 +2426,12 @@ class TestFormatCliErrorCategories(unittest.TestCase):
2404
2426
  out = self._fmt("connection refused")
2405
2427
  self.assertIn("网络错误", out)
2406
2428
 
2429
+ def test_agent_stream_interrupted_copy(self):
2430
+ out = self._fmt("Error: the connection to the agent was interrupted before the response finished: subscriber fell behind updates, stalled for 8s")
2431
+ self.assertIn("助手连接中断", out)
2432
+ self.assertIn("❌", out)
2433
+ self.assertIn("/new", out)
2434
+
2407
2435
  def test_timeout_still_error(self):
2408
2436
  out = self._fmt("timeout")
2409
2437
  self.assertIn("超时", out)
@@ -2439,6 +2467,12 @@ class TestFormatCliErrorNoRawInReply(unittest.TestCase):
2439
2467
  self.assertNotIn("rate", out.lower())
2440
2468
  self.assertNotIn("limit", out.lower())
2441
2469
 
2470
+ def test_no_raw_agent_stream_interrupted(self):
2471
+ out = self._fmt("Error: the connection to the agent was interrupted before the response finished: subscriber fell behind updates, stalled for 8s")
2472
+ self.assertNotIn("subscriber", out.lower())
2473
+ self.assertNotIn("interrupted", out.lower())
2474
+ self.assertNotIn("stalled", out.lower())
2475
+
2442
2476
 
2443
2477
  class TestFormatCliErrorLogging(unittest.TestCase):
2444
2478
  """format_cli_error logs category, not raw text."""
@@ -2797,5 +2831,157 @@ class TestUpdateScriptPackageMatch(unittest.TestCase):
2797
2831
  shutil.rmtree(td, ignore_errors=True)
2798
2832
 
2799
2833
 
2834
+ class _FakeAgyProc:
2835
+ def __init__(self, stdout: str, stderr: str, rc: int = 0, pid: int = 4242):
2836
+ self._stdout = stdout.encode("utf-8")
2837
+ self._stderr = stderr.encode("utf-8")
2838
+ self.returncode = rc
2839
+ self.pid = pid
2840
+
2841
+ async def communicate(self):
2842
+ return self._stdout, self._stderr
2843
+
2844
+ def kill(self):
2845
+ pass
2846
+
2847
+
2848
+ class TestAgyStreamRetry(unittest.IsolatedAsyncioTestCase):
2849
+ """Test agy runner retry behavior for stream interruption and cascade timeouts."""
2850
+
2851
+ def setUp(self):
2852
+ from wechatbridge.config import config
2853
+
2854
+ self.td = tempfile.mkdtemp(prefix="wb-agy-retry-")
2855
+ self._patchers = [
2856
+ mock.patch.object(config, "session_base_dir", self.td),
2857
+ ]
2858
+ for p in self._patchers:
2859
+ p.start()
2860
+
2861
+ def tearDown(self):
2862
+ for p in self._patchers:
2863
+ p.stop()
2864
+ shutil.rmtree(self.td, ignore_errors=True)
2865
+
2866
+ def test_is_transient_stream_error_helper(self):
2867
+ from wechatbridge.agy import _is_transient_stream_error
2868
+
2869
+ self.assertIsNone(_is_transient_stream_error(""))
2870
+ self.assertEqual(_is_transient_stream_error("timeout waiting for cascade"), "cascade")
2871
+ self.assertEqual(_is_transient_stream_error("timeout waiting for response"), "cascade")
2872
+ self.assertEqual(
2873
+ _is_transient_stream_error("subscriber fell behind updates, stalled for 8s"),
2874
+ "stream",
2875
+ )
2876
+ self.assertEqual(_is_transient_stream_error("conversation update stream failed"), "stream")
2877
+ self.assertEqual(_is_transient_stream_error("connection to the agent was interrupted"), "stream")
2878
+ self.assertIsNone(_is_transient_stream_error("package wechatbridge-cli 1.6.0, installed using Python 3.13.5"))
2879
+ self.assertIsNone(_is_transient_stream_error("some random error"))
2880
+
2881
+ async def test_stream_interrupted_retries_and_recovers(self):
2882
+ from wechatbridge import agy as agy_mod
2883
+
2884
+ calls = 0
2885
+ stream_err = (
2886
+ "Error: the connection to the agent was interrupted before the response finished: "
2887
+ "subscriber fell behind updates, stalled for 8s"
2888
+ )
2889
+
2890
+ async def spawn(*_a, **_k):
2891
+ nonlocal calls
2892
+ calls += 1
2893
+ if calls == 1:
2894
+ return _FakeAgyProc("", stream_err, rc=1)
2895
+ return _FakeAgyProc("hello from retry", "", rc=0)
2896
+
2897
+ with mock.patch("asyncio.create_subprocess_exec", side_effect=spawn), \
2898
+ mock.patch.object(agy_mod, "terminate_process", new=AsyncMock()), \
2899
+ mock.patch.object(agy_mod.asyncio, "sleep", new=AsyncMock()) as mock_sleep:
2900
+ display, arts = await agy_mod.run_agy("hi", "u-stream-ok")
2901
+
2902
+ self.assertEqual(calls, 2)
2903
+ self.assertEqual(display, "hello from retry")
2904
+ self.assertEqual(arts, [])
2905
+ mock_sleep.assert_awaited_once_with(3)
2906
+
2907
+ async def test_stream_interrupted_retry_fails_returns_stream_interrupted_copy(self):
2908
+ from wechatbridge import agy as agy_mod
2909
+
2910
+ calls = 0
2911
+ stream_err = (
2912
+ "Error: the connection to the agent was interrupted before the response finished: "
2913
+ "subscriber fell behind updates, stalled for 8s"
2914
+ )
2915
+
2916
+ async def spawn(*_a, **_k):
2917
+ nonlocal calls
2918
+ calls += 1
2919
+ return _FakeAgyProc("", stream_err, rc=1)
2920
+
2921
+ with mock.patch("asyncio.create_subprocess_exec", side_effect=spawn), \
2922
+ mock.patch.object(agy_mod, "terminate_process", new=AsyncMock()), \
2923
+ mock.patch.object(agy_mod.asyncio, "sleep", new=AsyncMock()) as mock_sleep:
2924
+ display, arts = await agy_mod.run_agy("hi", "u-stream-fail")
2925
+
2926
+ self.assertEqual(calls, 2)
2927
+ self.assertEqual(arts, [])
2928
+ self.assertIn("助手连接中断", display)
2929
+ self.assertIn("❌", display)
2930
+ self.assertIn("/new", display)
2931
+ mock_sleep.assert_awaited_once_with(3)
2932
+
2933
+ async def test_stream_interrupted_retry_timeout_returns_stream_interrupted_copy(self):
2934
+ from wechatbridge import agy as agy_mod
2935
+
2936
+ calls = 0
2937
+ stream_err = (
2938
+ "Error: the connection to the agent was interrupted before the response finished: "
2939
+ "subscriber fell behind updates, stalled for 8s"
2940
+ )
2941
+
2942
+ class _TimeoutProc(_FakeAgyProc):
2943
+ async def communicate(self):
2944
+ raise asyncio.TimeoutError()
2945
+
2946
+ async def spawn(*_a, **_k):
2947
+ nonlocal calls
2948
+ calls += 1
2949
+ if calls == 1:
2950
+ return _FakeAgyProc("", stream_err, rc=1)
2951
+ return _TimeoutProc("", "", rc=0)
2952
+
2953
+ mock_term = AsyncMock()
2954
+ with mock.patch("asyncio.create_subprocess_exec", side_effect=spawn), \
2955
+ mock.patch.object(agy_mod, "terminate_process", new=mock_term), \
2956
+ mock.patch.object(agy_mod.asyncio, "sleep", new=AsyncMock()):
2957
+ display, arts = await agy_mod.run_agy("hi", "u-stream-timeout")
2958
+
2959
+ self.assertEqual(calls, 2)
2960
+ self.assertEqual(arts, [])
2961
+ self.assertIn("助手连接中断", display)
2962
+ mock_term.assert_awaited_once()
2963
+
2964
+ async def test_cascade_timeout_retries_and_fails_with_cascade_copy(self):
2965
+ from wechatbridge import agy as agy_mod
2966
+
2967
+ calls = 0
2968
+ cascade_err = "Timeout waiting for cascade response"
2969
+
2970
+ async def spawn(*_a, **_k):
2971
+ nonlocal calls
2972
+ calls += 1
2973
+ return _FakeAgyProc("", cascade_err, rc=1)
2974
+
2975
+ with mock.patch("asyncio.create_subprocess_exec", side_effect=spawn), \
2976
+ mock.patch.object(agy_mod, "terminate_process", new=AsyncMock()), \
2977
+ mock.patch.object(agy_mod.asyncio, "sleep", new=AsyncMock()) as mock_sleep:
2978
+ display, arts = await agy_mod.run_agy("hi", "u-cascade-fail")
2979
+
2980
+ self.assertEqual(calls, 2)
2981
+ self.assertEqual(arts, [])
2982
+ self.assertIn("模型响应超时", display)
2983
+ mock_sleep.assert_not_awaited()
2984
+
2985
+
2800
2986
  if __name__ == "__main__":
2801
2987
  unittest.main()
@@ -1,2 +1,2 @@
1
1
  """WeChatBridge — bridge WeChat messages to agy, Grok Build, or Codex CLIs."""
2
- __version__ = "1.6.0"
2
+ __version__ = "1.6.1"
@@ -18,7 +18,7 @@ from .runner_common import (
18
18
  sanitize_user_id, get_session_dir, ensure_session_dir, is_first_message, mark_initialized, clear_initialized,
19
19
  clean_output, load_prefs, save_prefs, is_dangerous, parse_model_effort,
20
20
  sanitize_env, terminate_process, update_active_prefs,
21
- format_error, format_cli_error, is_bridge_formatted_reply, EMPTY_REPLY,
21
+ format_error, format_cli_error, _classify_cli_error, is_bridge_formatted_reply, EMPTY_REPLY,
22
22
  ANSI_RE, HTML_TAG_RE, validate_add_dir,
23
23
  )
24
24
 
@@ -55,6 +55,22 @@ def extract_artifacts(text: str) -> list[tuple[str, str]]:
55
55
  return result
56
56
 
57
57
 
58
+ def _is_transient_stream_error(stderr_text: str) -> str | None:
59
+ """Check if stderr indicates a transient stream error eligible for retry.
60
+
61
+ Returns "cascade" for cascade/response timeouts, "stream" for agent stream
62
+ interruption, or None if not retriable.
63
+ """
64
+ if not stderr_text:
65
+ return None
66
+ cat = _classify_cli_error(stderr_text, backend="agy")
67
+ if cat == "cascade_timeout":
68
+ return "cascade"
69
+ if cat == "agent_stream_interrupted":
70
+ return "stream"
71
+ return None
72
+
73
+
58
74
  def ensure_user_gemini(user_id: str) -> str:
59
75
  """Ensure per-user .gemini directory with auth token and default persona.
60
76
 
@@ -235,8 +251,15 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
235
251
  stderr_text,
236
252
  )
237
253
  if not stdout_text and stderr_text:
238
- if "timeout waiting for cascade" in stderr_text.lower() or "timeout waiting for response" in stderr_text.lower():
239
- logger.warning("Cascade/API timeout detected for user %s, retrying once automatically", user_id)
254
+ err_kind = _is_transient_stream_error(stderr_text)
255
+ if err_kind:
256
+ logger.warning(
257
+ "Transient stream error (%s) detected for user %s, retrying once automatically",
258
+ err_kind,
259
+ user_id,
260
+ )
261
+ if err_kind == "stream":
262
+ await asyncio.sleep(3)
240
263
  retry_process = await asyncio.create_subprocess_exec(
241
264
  *cmd,
242
265
  stdout=asyncio.subprocess.PIPE,
@@ -257,10 +280,12 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
257
280
  timeout, user_id,
258
281
  )
259
282
  await terminate_process(retry_process, graceful=True)
260
- return format_error(
261
- "模型响应超时",
262
- "模型响应超时,自动重试仍超时。请稍后重试或简化指令。",
263
- ), []
283
+ if err_kind == "cascade":
284
+ return format_error(
285
+ "模型响应超时",
286
+ "模型响应超时,自动重试仍超时。请稍后重试或简化指令。",
287
+ ), []
288
+ return format_cli_error(stderr_text, backend="agy"), []
264
289
  except (asyncio.CancelledError, Exception):
265
290
  await terminate_process(retry_process, graceful=False)
266
291
  raise
@@ -278,10 +303,12 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
278
303
  ):
279
304
  mark_initialized(session_dir, backend="agy")
280
305
  return r_display, r_artifacts
281
- return format_error(
282
- "模型响应超时",
283
- "模型响应超时,自动重试仍失败。请稍后重试或简化指令。",
284
- ), []
306
+ if err_kind == "cascade":
307
+ return format_error(
308
+ "模型响应超时",
309
+ "模型响应超时,自动重试仍失败。请稍后重试或简化指令。",
310
+ ), []
311
+ return format_cli_error(r_stderr_text or stderr_text, backend="agy"), []
285
312
  return format_cli_error(stderr_text, backend="agy"), []
286
313
  # Non-zero exit: never treat raw stdout as a normal success reply
287
314
  raw = stderr_text or stdout_text or display or "process exited abnormally"
@@ -298,9 +298,9 @@ def _classify_cli_error(raw_message: str, *, backend: str = "") -> str:
298
298
  Returns one of:
299
299
  payload_too_large, context_too_large, invalid_argument,
300
300
  auth, resource_exhausted, rate_limit, bare_429, quota,
301
- network, timeout, cascade_timeout, permission,
302
- session_not_found, model_invalid, command_not_found, not_found,
303
- unknown
301
+ agent_stream_interrupted, network, timeout, cascade_timeout,
302
+ permission, session_not_found, model_invalid, command_not_found,
303
+ not_found, unknown
304
304
 
305
305
  This is a pure function (no I/O, no logging). ``format_cli_error``
306
306
  drives user-facing copy from the category; callers that need the
@@ -434,7 +434,16 @@ def _classify_cli_error(raw_message: str, *, backend: str = "") -> str:
434
434
  ):
435
435
  return "bare_429"
436
436
 
437
- # --- 6. Network ---------------------------------------------------------------
437
+ # --- 6. Agent stream interrupted ----------------------------------------------
438
+ if (
439
+ "subscriber fell behind updates" in lower
440
+ or "connection to the agent was interrupted" in lower
441
+ or "conversation update stream failed" in lower
442
+ or bool(re.search(r"\bstalled for \d+\s*s", lower))
443
+ ):
444
+ return "agent_stream_interrupted"
445
+
446
+ # --- 7. Network ---------------------------------------------------------------
438
447
  if (
439
448
  "connection refused" in lower
440
449
  or "connection reset" in lower
@@ -449,14 +458,14 @@ def _classify_cli_error(raw_message: str, *, backend: str = "") -> str:
449
458
  ):
450
459
  return "network"
451
460
 
452
- # --- 7. Timeout / cascade -----------------------------------------------------
461
+ # --- 8. Timeout / cascade -----------------------------------------------------
453
462
  if "timeout waiting for cascade" in lower or "timeout waiting for response" in lower:
454
463
  return "cascade_timeout"
455
464
 
456
465
  if "timeout" in lower or "timed out" in lower or "deadline exceeded" in lower:
457
466
  return "timeout"
458
467
 
459
- # --- 8. Permission / misc specific --------------------------------------------
468
+ # --- 9. Permission / misc specific --------------------------------------------
460
469
  if "permission" in lower and ("denied" in lower or "refuse" in lower or "rejected" in lower):
461
470
  return "permission"
462
471
 
@@ -557,6 +566,15 @@ def format_cli_error(raw_message: str, *, backend: str = "") -> str:
557
566
  if category == "network":
558
567
  return format_error("网络错误", "连不上服务,请检查网络后重试。")
559
568
 
569
+ if category == "agent_stream_interrupted":
570
+ return format_error(
571
+ "助手连接中断",
572
+ (
573
+ "助手响应流意外中断。\n"
574
+ "请发 /new 开始新会话或稍后再试。"
575
+ ),
576
+ )
577
+
560
578
  if category == "cascade_timeout":
561
579
  return format_error(
562
580
  "模型响应超时",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wechatbridge-cli
3
- Version: 1.6.0
3
+ Version: 1.6.1
4
4
  Summary: Bridge WeChat messages to agy, Grok Build, or Codex CLIs — text/image/file/voice in, CLI replies and generated files back.
5
5
  Author: WeChatBridge contributors
6
6
  License: MIT