diff --git a/AGENTS.md b/AGENTS.md index d565ddd8ea..423db758f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -225,6 +225,8 @@ These apply repo-wide; module guides own the module-specific detail. - **Test-driven development** — features and bug fixes ship with tests. Backend tests live in `backend/tests/` (TDD is mandatory there; see [backend/AGENTS.md](backend/AGENTS.md)); frontend tests live in `frontend/tests/`. + Public skill script regressions live in `tests/skills/`; exercise dependency fallbacks + with the optional import unavailable and mock HTTP transport to keep tests offline. - **Format before pushing** — run `make format` (backend) / `pnpm check` (frontend). Backend CI enforces `ruff format --check`, so formatting must be clean before a push. - **Skill text encoding** — treat `SKILL.md` and other textual skill resources as UTF-8; diff --git a/README.md b/README.md index 601e2a261a..f2f125d859 100644 --- a/README.md +++ b/README.md @@ -1299,6 +1299,10 @@ Skill Markdown and bundled text resources use UTF-8. Skill-creator CLI and revie Custom skill history preserves Unicode line separators inside saved content and metadata, keeping those revisions readable for history inspection and rollback. Malformed JSON history records are still rejected. +The bundled `github-deep-research` API client also works without `requests`, using +Python's standard-library HTTP client. Its fallback preserves spaces, Unicode, and +reserved characters in search queries and label filters. + Users can explicitly activate an enabled skill for a single turn by starting the request with `/skill-name`, for example `/data-analysis analyze uploads/foo.csv`. DeerFlow loads that skill's `SKILL.md` as hidden current-turn context while leaving the base prompt limited to skill metadata. Slash activation respects disabled skills, custom-agent skill whitelists, and existing channel commands such as `/new` and `/help`. After an answer loads skills, its toolbar includes **Skills used**. Hover over or click the icon to see the skills and their sources; hovering a skill name underlines it. Select a skill to inspect its `SKILL.md` in the resizable side panel (a drawer on mobile). The view uses snapshots captured during successful configured read-tool loads or explicit slash activation, so later edits or removal of a skill do not rewrite its history. Repeated loads appear once per run, in first-load order. Range reads and bounded snapshots are labeled as partial; older conversations without captured evidence do not show this menu. Copy returns the captured Markdown, including YAML frontmatter. Package-relative links and images remain readable references rather than navigating away from the conversation. Skill loads performed through other tools, such as shell commands, are not inferred from answer text. diff --git a/skills/public/github-deep-research/scripts/github_api.py b/skills/public/github-deep-research/scripts/github_api.py index 978f88647b..61f2501f34 100644 --- a/skills/public/github-deep-research/scripts/github_api.py +++ b/skills/public/github-deep-research/scripts/github_api.py @@ -14,6 +14,7 @@ try: except ImportError: # Fallback to urllib if requests not available import urllib.error + import urllib.parse import urllib.request class RequestsFallback: @@ -35,7 +36,7 @@ except ImportError: @staticmethod def get(url: str, headers: dict = None, params: dict = None, timeout: int = 30): if params: - query = "&".join(f"{k}={v}" for k, v in params.items()) + query = urllib.parse.urlencode(params) url = f"{url}?{query}" req = urllib.request.Request(url, headers=headers or {}) diff --git a/tests/skills/test_github_deep_research.py b/tests/skills/test_github_deep_research.py new file mode 100644 index 0000000000..d175f3d9e8 --- /dev/null +++ b/tests/skills/test_github_deep_research.py @@ -0,0 +1,84 @@ +"""Exercise the GitHub research client's dependency-free HTTP fallback.""" + +import http.client +import io +import runpy +import sys +import urllib.request +from pathlib import Path +from urllib.parse import parse_qs, urlsplit + +import pytest + + +@pytest.fixture +def fallback_client(monkeypatch): + monkeypatch.setitem(sys.modules, "requests", None) + script = ( + Path(__file__).resolve().parents[2] + / "skills/public/github-deep-research/scripts/github_api.py" + ) + module = runpy.run_path(str(script)) + captured = {} + + def fake_urlopen(request, timeout): + # Validate the request target as the real HTTP transport does, without IO. + connection = http.client.HTTPSConnection("api.github.com") + try: + connection.putrequest("GET", request.selector) + finally: + connection.close() + captured.update(url=request.full_url, timeout=timeout, headers=request.headers) + response = io.BytesIO(b'{"ok": true}') + response.status = 200 + return response + + monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) + return module["GitHubAPI"](), captured + + +@pytest.mark.parametrize("query", ["bug fix", "C++ & retry#fragment", "模型请求"]) +def test_fallback_search_preserves_query(fallback_client, query): + client, captured = fallback_client + + assert client.search_issues("owner", "repo", query) == {"ok": True} + + url = urlsplit(captured["url"]) + assert url.path == "/search/issues" + assert url.fragment == "" + assert parse_qs(url.query) == { + "q": [f"repo:owner/repo {query}"], + "per_page": ["30"], + } + + +def test_fallback_issues_preserves_filters(fallback_client): + client, captured = fallback_client + + assert client.get_issues( + "owner", "repo", state="open", limit=10, labels="bug,help wanted" + ) == {"ok": True} + + assert parse_qs(urlsplit(captured["url"]).query) == { + "state": ["open"], + "per_page": ["10"], + "labels": ["bug,help wanted"], + } + + +def test_fallback_ordinary_parameters(fallback_client): + client, captured = fallback_client + + assert client.get_contributors("owner", "repo", limit=10) == {"ok": True} + + assert parse_qs(urlsplit(captured["url"]).query) == {"per_page": ["10"]} + assert captured["timeout"] == 30 + assert captured["headers"]["User-agent"] == "Deep-Research-Bot/1.0" + + +def test_fallback_without_parameters(fallback_client): + client, captured = fallback_client + + assert client.get_repo_info("owner", "repo") == {"ok": True} + + assert captured["url"] == "https://api.github.com/repos/owner/repo"