feat(v1.8.0): 稳定性修复 + AI Agent 体验增强

稳定性修复:

- 修复 cache.py SQLite 连接泄漏(contextlib.closing 包装)

- 修复 fetch.py requests stream=True 连接泄漏(try/finally resp.close())

- RETRYABLE_STATUS 新增 403,激活 UA fallback 切换逻辑

- --cache-stats 移至实例解析前,无需实例即可查询

- classify_error 从错误消息提取 HTTP 状态码,正确分类 E_AUTH/E_RATE_LIMIT

- --stream 与 --queries-file 互斥检查,违规报 E_INPUT

- batch 退出码语义统一(0=有结果 / 1=全部错误 / 2=全部空结果)

AI Agent 体验增强:

- 错误码体系完善:E_CONFIG/E_AUTH/E_NETWORK/E_RATE_LIMIT/E_PARSE/E_EMPTY/E_INPUT/E_INTERNAL

- recovery_hint 恢复提示字段,AI Agent 可程序化决策恢复策略

- stream 模式新增 error 事件类型(含 error_code + recovery_hint)

- 进度事件扩展:instance_try/instance_ok/instance_fail

- batch 模式统一 schema(status 字段区分 success/failed)

- JSON 输出含 schema_version 字段确保版本兼容

测试与文档:

- 测试覆盖:330 -> 352

- SKILL.md / README.md 同步更新
This commit is contained in:
2026-08-01 19:02:44 +08:00
parent dea899143d
commit fb9b2af45f
12 changed files with 871 additions and 131 deletions
+114 -1
View File
@@ -6,8 +6,9 @@ disabled by default, stream only works with --format json.
"""
import json
import sys
import urllib.error
from types import SimpleNamespace
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
import common
@@ -205,3 +206,115 @@ def test_stream_result_event_shape():
assert parsed[0]["result"]["url"] == "https://x.com"
assert parsed[-1]["type"] == "done"
assert parsed[-1]["count"] == 1
# ----- stream done/error: schema_version & recovery_hint (v1.8.0) -----
def test_stream_done_includes_schema_version(capsys):
"""stream done event includes the schema_version field."""
mock_results = {"results": [{"title": "t", "url": "https://x.com"}]}
with patch.object(search_mod, "search_multi", return_value=mock_results):
with pytest.raises(SystemExit) as exc_info:
with patch.object(sys, "argv", ["search.py", "-q", "test",
"-i", "https://x.example.com",
"--stream", "--format", "json",
"--retry", "0"]):
with patch.object(search_mod, "setup_logging"):
main()
assert exc_info.value.code == 0
out, _ = capsys.readouterr()
lines = [json.loads(l) for l in out.strip().split("\n") if l]
done_events = [e for e in lines if e["type"] == "done"]
assert len(done_events) == 1
assert done_events[0]["schema_version"] == "1.0"
def test_stream_error_includes_recovery_hint(capsys):
"""stream error event includes recovery_hint for classified errors.
HTTP 403 -> E_AUTH, which has a recovery_hint in RECOVERY_HINTS.
--retry 0 avoids backoff sleeps on the (mocked) 403.
"""
err = urllib.error.HTTPError("url", 403, "Forbidden", {}, None)
with patch.object(search_mod, "search_multi", side_effect=err):
with pytest.raises(SystemExit) as exc_info:
with patch.object(sys, "argv", ["search.py", "-q", "test",
"-i", "https://x.example.com",
"--stream", "--format", "json",
"--retry", "0"]):
with patch.object(search_mod, "setup_logging"):
main()
assert exc_info.value.code == 1
out, _ = capsys.readouterr()
lines = [json.loads(l) for l in out.strip().split("\n") if l]
error_events = [e for e in lines if e["type"] == "error"]
assert len(error_events) == 1
assert error_events[0]["error_code"] == "E_AUTH"
assert "recovery_hint" in error_events[0]
# ----- instance_try / instance_ok / instance_fail progress events (v1.8.0) -----
#
# These events are emitted inside search_multi (not _run_single_query), so
# we must let the real search_multi run and only mock urllib.request.urlopen.
def _mock_urlopen_resp(data: bytes, content_type="application/json"):
"""Build a MagicMock that quacks like an urlopen context manager."""
resp = MagicMock()
resp.read.return_value = data
resp.headers = {"Content-Type": content_type}
resp.__enter__.return_value = resp
resp.__exit__.return_value = None
return resp
def _url_router(routing):
"""Build a side_effect that dispatches urlopen by request URL substring."""
def _side_effect(req, *args, **kwargs):
url = getattr(req, "full_url", str(req))
for key, resp in routing.items():
if key in url:
if isinstance(resp, BaseException):
raise resp
return resp
raise AssertionError(f"unexpected urlopen for {url!r}")
return _side_effect
def test_progress_emits_instance_try_ok_fail(capsys):
"""instance_try/instance_ok/instance_fail events are emitted to stderr.
Uses serial mode (parallel=False) so the events come out in a
deterministic order: try a -> fail a -> try b -> ok b.
"""
set_progress_enabled(True)
try:
payload = json.dumps(
{"results": [{"title": "b", "url": "https://b.com"}]}
).encode()
router = _url_router({
"a.example.com": urllib.error.URLError("down"),
"b.example.com": _mock_urlopen_resp(payload),
})
with patch("urllib.request.urlopen", side_effect=router):
search_mod.search_multi(
["https://a.example.com", "https://b.example.com"],
{"q": "test", "format": "json"},
parallel=False, retry_per=0,
)
_, err = capsys.readouterr()
lines = [json.loads(l) for l in err.strip().split("\n") if l]
events = [e["event"] for e in lines]
assert "instance_try" in events
assert "instance_fail" in events
assert "instance_ok" in events
# instance_fail carries error_code
fail_events = [e for e in lines if e["event"] == "instance_fail"]
assert len(fail_events) >= 1
assert "error_code" in fail_events[0]
# instance_ok carries results count
ok_events = [e for e in lines if e["event"] == "instance_ok"]
assert len(ok_events) >= 1
assert "results" in ok_events[0]
finally:
set_progress_enabled(False)