diff --git a/raven/cli/deep_research_commands.py b/raven/cli/deep_research_commands.py index a6e796b20..d2cb64033 100644 --- a/raven/cli/deep_research_commands.py +++ b/raven/cli/deep_research_commands.py @@ -92,7 +92,7 @@ def configure_deep_research(*, non_interactive: bool = False, warnings: Optional """ warnings = warnings if warnings is not None else [] from raven.cli._styles import RAVEN_STYLE - from raven.cli.onboard_commands import _QMARK, _prompt_api_key, _require_questionary, _t, console + from raven.cli.onboard_commands import _BACK, _QMARK, _prompt_api_key, _require_questionary, _t, console if non_interactive: warnings.append("deep_research: skipped (non-interactive; pass --key to configure)") @@ -142,7 +142,13 @@ def configure_deep_research(*, non_interactive: bool = False, warnings: Optional ) while True: - key = _prompt_api_key("deep_research") + key = _prompt_api_key( + "deep_research", + allow_back=True, + back_label=_t("empty ↵ to cancel", "留空回车取消"), + ) + if key is _BACK: + return False # empty submit cancels configuration res = _validate_key(key, current["api_base"]) if res["ok"]: break diff --git a/raven/cli/onboard_commands.py b/raven/cli/onboard_commands.py index f92bc9c8d..39052d44c 100644 --- a/raven/cli/onboard_commands.py +++ b/raven/cli/onboard_commands.py @@ -373,16 +373,18 @@ def _validate_provider_name(name: str) -> str: return candidate -def _back_placeholder(allow_back: bool) -> Any: - """A faint in-field placeholder telling the user an empty submit rewinds. +def _back_placeholder(allow_back: bool, label: Optional[str] = None) -> Any: + """A faint in-field placeholder telling the user what an empty submit does. Rendered greyed inside the input (via prompt_toolkit's ``placeholder``), it disappears the moment they type and leaves nothing behind once the - prompt is answered. Returns ``None`` when back isn't offered. + prompt is answered. Returns ``None`` when back isn't offered. ``label`` + overrides the default "go back" wording for prompts where an empty submit + means something else (e.g. cancelling rather than rewinding a step). """ if not allow_back: return None - return [("fg:#6c6c6c italic", _t("empty ↵ to go back", "留空回车返回上一步"))] + return [("fg:#6c6c6c italic", label or _t("empty ↵ to go back", "留空回车返回上一步"))] def _field_placeholder(allow_back: bool, required: bool) -> Any: @@ -453,18 +455,19 @@ def _select_provider() -> Optional[str]: return picked # None on Ctrl+C -def _prompt_api_key(provider: str, *, allow_back: bool = False) -> Any: +def _prompt_api_key(provider: str, *, allow_back: bool = False, back_label: Optional[str] = None) -> Any: """Ask for an API key (hidden input). Returns ``_BACK`` on empty submit - when ``allow_back`` is set, else the key string.""" + when ``allow_back`` is set, else the key string. ``back_label`` overrides + the empty-submit hint for callers where it cancels rather than rewinds.""" questionary = _require_questionary() from raven.cli._styles import RAVEN_STYLE def _validate(v: str) -> Any: if allow_back and v == "": - return True # empty is the back signal, not an error + return True # a truly-empty submit is the back/cancel signal return ( True - if len(v) >= 8 + if len(v.strip()) >= 8 else _t( "API key looks off (empty or too short) — please re-enter (≥ 8 chars).", "API Key 看起来不对(过短或为空),请重新输入(至少 8 位)。", @@ -474,7 +477,7 @@ def _validate(v: str) -> Any: key = questionary.password( _t("Paste your API key:", "粘贴你的 API Key:"), validate=_validate, - placeholder=_back_placeholder(allow_back), + placeholder=_back_placeholder(allow_back, back_label), style=RAVEN_STYLE, qmark=_QMARK, ).ask() diff --git a/tests/test_cli_deep_research_commands.py b/tests/test_cli_deep_research_commands.py index f3446f036..ece518d46 100644 --- a/tests/test_cli_deep_research_commands.py +++ b/tests/test_cli_deep_research_commands.py @@ -183,6 +183,47 @@ def test_configure_validation_fail_then_cancel(tmp_path: Path, monkeypatch): assert configure_deep_research(non_interactive=False, warnings=[]) is False # cancelled, nothing written +def test_configure_key_empty_submit_cancels(tmp_path: Path, monkeypatch): + """Empty submit at the key prompt (``_prompt_api_key`` returns ``_BACK``) + cancels configuration: returns False and writes nothing -- the missing + pre-key cancel path that used to force a Ctrl+C / whole-process exit.""" + import raven.cli.onboard_commands as ob + + p = tmp_path / "config.json" + monkeypatch.setattr(ut, "get_config_path", lambda: p) + monkeypatch.setattr(ob, "_require_questionary", lambda: _FakeQuestionary(["configure"])) + + captured: dict = {} + + def _fake_prompt(*a, **k): + captured.update(k) + return ob._BACK + + monkeypatch.setattr(ob, "_prompt_api_key", _fake_prompt) + + assert configure_deep_research(non_interactive=False, warnings=[]) is False + assert not p.exists() or "deepResearch" not in json.loads(p.read_text()).get("tools", {}) + # the prompt is asked with a cancel-worded hint, not the default "go back" + assert captured.get("allow_back") is True + assert captured.get("back_label") + + +def test_configure_reconfigure_key_empty_submit_cancels(tmp_path: Path, monkeypatch): + """Reconfigure path (already configured): empty submit at the key prompt + cancels and leaves the existing key untouched, instead of exiting raven -- + the ``raven deep-research enable`` -> Reconfigure repro.""" + import raven.cli.onboard_commands as ob + + p = tmp_path / "config.json" + monkeypatch.setattr(ut, "get_config_path", lambda: p) + ut.set_deep_research({"api_key": "sk-old", "model": "m"}, config_path=p) + monkeypatch.setattr(ob, "_require_questionary", lambda: _FakeQuestionary(["reconfigure"])) + monkeypatch.setattr(ob, "_prompt_api_key", lambda *a, **k: ob._BACK) + + assert configure_deep_research(non_interactive=False, warnings=[]) is False + assert json.loads(p.read_text())["tools"]["deepResearch"]["apiKey"] == "sk-old" # unchanged + + def test_configure_validation_retry_then_ok(tmp_path: Path, monkeypatch): calls = {"n": 0} diff --git a/tests/test_cli_onboard_commands.py b/tests/test_cli_onboard_commands.py index da689e8b7..82f6dbbc1 100644 --- a/tests/test_cli_onboard_commands.py +++ b/tests/test_cli_onboard_commands.py @@ -666,6 +666,55 @@ def _fake_autocomplete(message, choices, default=None, **kwargs): assert data["agents"]["defaults"]["model"] == "claude-haiku-4-5" +def _capture_password_validate(monkeypatch: pytest.MonkeyPatch, answer: str) -> dict[str, Any]: + """Stub ``questionary.password`` to record the ``validate`` callable the + prompt installs and return ``answer`` from ``.ask()``.""" + import questionary + + captured: dict[str, Any] = {} + + class _FakeQuestion: + def ask(self) -> Any: + return answer + + def _fake_password(message: Any, *, validate: Any = None, **kwargs: Any) -> Any: + captured["validate"] = validate + return _FakeQuestion() + + monkeypatch.setattr(questionary, "password", _fake_password) + return captured + + +def test_prompt_api_key_validator_rejects_whitespace_only(monkeypatch: pytest.MonkeyPatch) -> None: + """An all-whitespace (or empty) key must fail the field validator (which + re-prompts) rather than pass the raw length check, strip to empty, and hit + ``typer.Exit`` (which quit raven with no message).""" + captured = _capture_password_validate(monkeypatch, "sk-realkey123") + + key = onboard_commands._prompt_api_key("deep_research") + assert key == "sk-realkey123" + + validate = captured["validate"] + assert validate(" ") is not True + assert validate("") is not True + assert validate("sk-12345678") is True + + +def test_prompt_api_key_empty_is_back_but_whitespace_rejected(monkeypatch: pytest.MonkeyPatch) -> None: + """With ``allow_back``, only a truly-empty submit is the back/cancel signal; + a whitespace-only entry is still rejected as an invalid key (re-prompt), not + silently treated as back.""" + captured = _capture_password_validate(monkeypatch, "") + + result = onboard_commands._prompt_api_key("deep_research", allow_back=True) + assert result is onboard_commands._BACK + + validate = captured["validate"] + assert validate("") is True # truly-empty submit rewinds/cancels + assert validate(" ") is not True # whitespace rejected, not back + assert validate("sk-12345678") is True + + def test_format_model_for_provider_prefix_rules() -> None: """Provider's ``litellm_prefix`` is applied unless model_id already has one.""" from raven.providers.registry import find_by_name