diff --git a/docs/agent.md b/docs/agent.md index 487e42d1..dc2ae468 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -159,6 +159,18 @@ API — use the Anthropic API entry above for Claude models. Without configuration, `Ctrl-A` shows a setup hint pointing at `:ai`. +### Turning the agent off and on + +`Ctrl-A` only toggles the panel's *visibility* — the runtime stays +connected. To actually disconnect for the session, use `:ai off`: the +provider connection is released, the status bar flips to `AI off`, and +prompt submission is disabled while the transcript stays visible. The +configured provider, model, profile, and credentials are all kept (nothing +is rewritten in `config.yaml`), so a bare `:ai` reopens the wizard with the +current settings and reconnects when applied. `:ai off` is refused while a +turn is running — stop the turn first (`Ctrl-X`) — and is a no-op when the +agent is already off. + ## Capability profiles Small local models (3B–14B) handle the agent's default surface — up to 15 diff --git a/src/korvid/__main__.py b/src/korvid/__main__.py index c75d3ebb..eb3ed918 100644 --- a/src/korvid/__main__.py +++ b/src/korvid/__main__.py @@ -411,6 +411,7 @@ def _agent_unavailable_wiring( None, None, Callable[[AgentRuntime | None, bool, str | None], None], + Callable[[], None], list[LLMProvider | None], _UIBridgeProxy, ]: @@ -432,7 +433,7 @@ def _retarget_noop( ) -> None: return None - return None, None, None, _retarget_noop, provider_box, ui_proxy + return None, None, None, _retarget_noop, lambda: None, provider_box, ui_proxy def _build_agent_wiring( @@ -448,6 +449,7 @@ def _build_agent_wiring( AgentConfigurator | None, Callable[[AgentSettings], AgentRuntime | None] | None, Callable[[AgentRuntime | None, bool, str | None], None], + Callable[[], None], list[LLMProvider | None], _UIBridgeProxy, ]: @@ -621,7 +623,33 @@ def retarget_agent( ) runtime.retarget(tools=retarget_profile.tools, cluster_context=cluster_context) - return agent_runtime, configurator, rebuild_agent, retarget_agent, provider_box, ui_proxy + return ( + agent_runtime, + configurator, + rebuild_agent, + retarget_agent, + _make_disconnect_agent(provider_box, close_tasks), + provider_box, + ui_proxy, + ) + + +def _make_disconnect_agent( + provider_box: list[LLMProvider | None], close_tasks: set[asyncio.Task[None]] +) -> Callable[[], None]: + """`:ai off` (issue #167): release the live provider for the session. + + Persisted configuration is untouched, so a later wizard rebuild + reconnects with the kept settings. Idempotent when already off. + """ + + def disconnect_agent() -> None: + old = provider_box[0] + provider_box[0] = None + if old is not None: + _close_provider_in_background(old, close_tasks) + + return disconnect_agent def _load_startup_config( @@ -910,15 +938,17 @@ async def fetch( # the agent system prompt and the Service/Ingress describe footer. provider_info = await _probe_cloud_provider(kube) - agent_runtime, configurator, rebuild_agent, retarget_agent, _, ui_proxy = _build_agent_wiring( - config, - kube, - aliases, - pod_resize_supported=pod_resize_supported, - cluster_context=cluster_context_note(provider_info), - # Ownership lands in the teardown guard's box the moment the - # provider exists, so partial agent wiring is also cleaned up. - provider_box=state.provider_box, + agent_runtime, configurator, rebuild_agent, retarget_agent, disconnect_agent, _, ui_proxy = ( + _build_agent_wiring( + config, + kube, + aliases, + pod_resize_supported=pod_resize_supported, + cluster_context=cluster_context_note(provider_info), + # Ownership lands in the teardown guard's box the moment the + # provider exists, so partial agent wiring is also cleaned up. + provider_box=state.provider_box, + ) ) mcp_hooks = _MCPAppHooks() @@ -949,6 +979,7 @@ async def fetch( agent_model_name=config.agent_model, agent_configurator=configurator, rebuild_agent=rebuild_agent, + disconnect_agent=disconnect_agent, # The wiring returns no configurator only when the [agent] extra is # absent — the app then hides the agent panel and its commands. agent_available=configurator is not None, diff --git a/src/korvid/ui/app.py b/src/korvid/ui/app.py index 561d22b1..ac895a7e 100644 --- a/src/korvid/ui/app.py +++ b/src/korvid/ui/app.py @@ -698,6 +698,7 @@ def __init__( agent_model_name: str | None = None, agent_configurator: AgentConfigurator | None = None, rebuild_agent: Callable[[AgentSettings], AgentRuntime | None] | None = None, + disconnect_agent: Callable[[], None] | None = None, agent_available: bool = True, write_ops: WriteOps | None = None, audit: AuditLog | None = None, @@ -899,6 +900,9 @@ def __init__( self._agent_model_name = agent_model_name self._agent_configurator = agent_configurator self._rebuild_agent = rebuild_agent + #: Releases the live provider on `:ai off` (issue #167) — session + #: state only; persisted configuration is untouched. + self._disconnect_agent = disconnect_agent #: False when the [agent] extra is absent (issue #73): the agent #: panel is not mounted and :ai/:model/Ctrl-A are not offered. self._agent_available = agent_available @@ -921,6 +925,9 @@ def __init__( profile=config.agent_profile or "full", ) self._agent_task: asyncio.Task[None] | None = None + #: True after :ai off (issue #167): the agent was configured and + #: explicitly disconnected — reconnect hint, not the setup wipe. + self._agent_disconnected = False # Interrupt-and-submit (issue #170): the latest correction typed # while a turn runs; started once the cancelled turn is finalized. self._agent_replacement: str | None = None @@ -2929,6 +2936,9 @@ def on_unknown_command(self, message: UnknownCommand) -> None: if len(parts) > 1 and parts[1].lower() == "follow": self._handle_agent_follow_command(parts[2:]) return + if len(parts) > 1 and parts[1].lower() == "off": + self._handle_agent_off() + return self._open_agent_setup() return if head == "model" and self._agent_available: @@ -2968,6 +2978,33 @@ def on_unknown_command(self, message: UnknownCommand) -> None: severity="warning", ) + def _handle_agent_off(self) -> None: + """`:ai off` (issue #167): disconnect the runtime for this session. + + Keeps the configured provider/model/profile/credentials so bare + `:ai` reconnects without re-entry; never rewrites `agent.enabled` + or the persisted config. Refused while a turn runs — cancelling + midway is the interrupt key's job, not a state command's. + """ + if self._agent_runtime is None: + self.notify("Agent is already off") + return + if self._agent_task is not None and not self._agent_task.done(): + self.notify( + "Agent is busy — wait for the turn to finish (or stop it) before :ai off", + severity="warning", + ) + return + if self._disconnect_agent is not None: + self._disconnect_agent() + self._agent_runtime = None + # Disconnected-but-configured (vs never-configured): visibility + # toggles must show the reconnect hint, never the setup wipe. + self._agent_disconnected = True + self._refresh_status() + self._agent_panel.show_reconnect_hint() + self.notify("Agent disconnected — run :ai to reconnect") + def _open_agent_setup(self) -> None: if self._agent_configurator is None: self.notify( @@ -2982,6 +3019,7 @@ def _open_agent_setup(self) -> None: self._agent_configurator, apply_settings=self._apply_agent_settings, current_profile=self._configured_agent_profile, + current_settings=self._agent_settings, ) ) @@ -3275,6 +3313,7 @@ def _apply_agent_settings(self, settings: AgentSettings) -> bool: ) return False self._agent_runtime = runtime + self._agent_disconnected = False # reconnected (issue #167) self._agent_model_name = settings.model self._agent_settings = settings self._agent_profile = settings.profile @@ -8366,7 +8405,13 @@ def action_toggle_agent(self) -> None: return panel.display = True if self._agent_runtime is None: - panel.show_setup_hint() + if self._agent_disconnected: + # Disconnected-but-configured (:ai off, issue #167): the + # transcript must survive visibility toggles — never the + # setup wipe meant for a never-configured agent. + panel.show_reconnect_hint() + else: + panel.show_setup_hint() return if self._agent_model_name: runtime = self._agent_runtime diff --git a/src/korvid/ui/widgets/agent_panel.py b/src/korvid/ui/widgets/agent_panel.py index a94e9a37..b86f3e2f 100644 --- a/src/korvid/ui/widgets/agent_panel.py +++ b/src/korvid/ui/widgets/agent_panel.py @@ -234,6 +234,16 @@ def show_setup_hint(self) -> None: chat.mount(ChatEntry(_SETUP_HINT, raw=_SETUP_HINT, classes="agent-msg")) self.query_one("#agent-input", Input).disabled = True + def show_reconnect_hint(self) -> None: + """Disconnected state (issue #167): the transcript stays, prompt + submission is disabled, and the way back is named. Idempotent — + repeated panel toggles must not stack hint entries.""" + hint = "agent off — run :ai to reconnect" + entries = list(self.query(ChatEntry)) + if not entries or entries[-1].raw != hint: + self._mount_entry(ChatEntry(Text(hint, style="dim"), raw=hint, classes="agent-msg")) + self.query_one("#agent-input", Input).disabled = True + def echo_user(self, text: str) -> None: """Show a user message immediately, before its turn starts. diff --git a/src/korvid/ui/widgets/agent_setup_screen.py b/src/korvid/ui/widgets/agent_setup_screen.py index 2ef58adf..535cc49e 100644 --- a/src/korvid/ui/widgets/agent_setup_screen.py +++ b/src/korvid/ui/widgets/agent_setup_screen.py @@ -38,6 +38,23 @@ "ollama": "ollama — local models, native API (no auth)", } +# Registry aliases (providers/registry.py) that all resolve to the wizard's +# openai-compat entry: settings configured under an alias must still +# pre-highlight and prefill the wizard on reconnect (issue #167). +_OPENAI_COMPAT_ALIASES = frozenset({"openai", "vllm", "github", "anthropic", "claude"}) + + +def _canonical_provider(name: str | None) -> str | None: + """The wizard entry a configured provider name maps onto, or None.""" + if not name: + return None + lowered = name.strip().lower() + if lowered in _DEFAULTS: + return lowered + if lowered in _OPENAI_COMPAT_ALIASES: + return "openai-compat" + return None + class AgentSetupScreen(ModalScreen["AgentSettings | None"]): """Conversational wizard: one question at a time + completed-step checklist.""" @@ -76,6 +93,7 @@ def __init__( configurator: AgentConfigurator, apply_settings: Callable[[AgentSettings], bool] | None = None, current_profile: str | None = None, + current_settings: AgentSettings | None = None, ) -> None: super().__init__() self._configurator = configurator @@ -84,6 +102,14 @@ def __init__( # explicit choice is preserved; only an unset profile receives the # Ollama `small` suggestion (issue #71). self._current_profile = current_profile + # Kept settings from a configured (possibly :ai off'd) agent + # (issue #167): the wizard starts from them so reconnecting is + # confirm-through, not re-entry. Registry aliases normalize onto + # the wizard's canonical entries for highlighting/prefilling. + self._current_settings = current_settings + self._current_canonical = _canonical_provider( + current_settings.provider if current_settings is not None else None + ) self._provider = "" self._auth_method = "" self._base_url: str | None = None @@ -122,6 +148,8 @@ def on_mount(self) -> None: self.query_one(widget_id).display = False provider_list = self.query_one("#setup-provider", OptionList) provider_list.highlighted = 0 + if self._current_canonical is not None: + provider_list.highlighted = list(_DEFAULTS).index(self._current_canonical) provider_list.focus() # ------------------------------------------------------------------ @@ -154,9 +182,28 @@ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> No auth_list = self.query_one("#setup-auth", OptionList) auth_list.display = True auth_list.highlighted = 0 + current = self._current_settings + if ( + self._current_canonical == "azure" + and current is not None + and current.auth_method == "entra" + ): + # Confirm-through reconnect must not silently switch + # the retained Entra flow to api_key (issue #167). + auth_list.highlighted = 1 auth_list.focus() return self._auth_method = _DEFAULTS[self._provider][0] + current = self._current_settings + if ( + current is not None + and self._current_canonical == self._provider + and current.auth_method + ): + # Confirm-through reconnect keeps the retained auth method: + # a no-auth endpoint (local vLLM) must not be reset to + # api_key and prompted for a nonexistent key env. + self._auth_method = current.auth_method self._after_auth_method() elif event.option_list.id == "setup-auth": self._auth_method = str(event.option.prompt) @@ -171,6 +218,11 @@ def _after_auth_method(self) -> None: self.run_worker(self._copilot_connect(), exclusive=True) return _, base_url, _ = _DEFAULTS[self._provider] + current = self._current_settings + if current is not None and self._current_canonical == self._provider and current.base_url: + # Same provider as the kept settings: start from the kept + # endpoint, not the provider default (issue #167 reconnect). + base_url = current.base_url self._ask(f"Where is your {self._provider} endpoint?") base_input = self.query_one("#setup-base-url", Input) base_input.value = base_url @@ -185,6 +237,13 @@ def on_input_submitted(self, event: Input.Submitted) -> None: if self._auth_method == "api_key": self._ask("Which environment variable holds your API key?") env_input = self.query_one("#setup-api-key-env", Input) + current = self._current_settings + if ( + current is not None + and self._current_canonical == self._provider + and current.api_key_env + ): + env_input.value = current.api_key_env env_input.display = True env_input.focus() else: @@ -249,6 +308,10 @@ def _draft_settings(self, model: str) -> AgentSettings: def _show_model_step(self, models: list[str]) -> None: self._models = models default_model = _DEFAULTS[self._provider][2] + current = self._current_settings + if current is not None and self._current_canonical == self._provider and current.model: + # Reconnect flow (issue #167): the kept model beats the default. + default_model = current.model if models: self._ask(f"Choose a model ({len(models)} available)") self.query_one("#setup-model-filter", Input).display = True diff --git a/tests/test_main_wiring.py b/tests/test_main_wiring.py index 4f25232e..b734aecd 100644 --- a/tests/test_main_wiring.py +++ b/tests/test_main_wiring.py @@ -176,7 +176,7 @@ def test_agent_wiring_includes_ui_tools(monkeypatch: object) -> None: agent_api_key_env="KORVID_TEST_KEY", ) kube_stub = cast("Any", object()) # wiring never touches kube before a tool call - runtime, _, _, _, _, proxy = _build_agent_wiring(config, kube_stub, {}) + runtime, _, _, _, _, _, proxy = _build_agent_wiring(config, kube_stub, {}) assert runtime is not None names = [t["function"]["name"] for t in runtime._tools] assert "navigate" in names @@ -186,7 +186,7 @@ def test_agent_wiring_includes_ui_tools(monkeypatch: object) -> None: assert executor._ui is proxy # readonly strips every write tool: the model is never told they exist. - ro_runtime, _, _, _, _, _ = _build_agent_wiring( + ro_runtime, _, _, _, _, _, _ = _build_agent_wiring( dataclasses.replace(config, readonly=True), kube_stub, {} ) assert ro_runtime is not None @@ -218,15 +218,17 @@ def test_agent_wiring_gates_resize_tool_on_discovery(monkeypatch: object) -> Non ) kube_stub = cast("Any", object()) - runtime, _, _, _, _, _ = _build_agent_wiring(config, kube_stub, {}, pod_resize_supported=True) + runtime, _, _, _, _, _, _ = _build_agent_wiring( + config, kube_stub, {}, pod_resize_supported=True + ) assert runtime is not None assert "resize_pod" in [t["function"]["name"] for t in runtime._tools] - gated, _, _, _, _, _ = _build_agent_wiring(config, kube_stub, {}, pod_resize_supported=False) + gated, _, _, _, _, _, _ = _build_agent_wiring(config, kube_stub, {}, pod_resize_supported=False) assert gated is not None assert "resize_pod" not in [t["function"]["name"] for t in gated._tools] - ro, _, _, _, _, _ = _build_agent_wiring( + ro, _, _, _, _, _, _ = _build_agent_wiring( dataclasses.replace(config, readonly=True), kube_stub, {}, pod_resize_supported=True ) assert ro is not None @@ -518,7 +520,7 @@ async def test_agent_wiring_injects_cluster_context(monkeypatch: object) -> None ) kube_stub = cast("Any", object()) note = "This cluster runs on Azure (AKS managed)." - runtime, _, rebuild, retarget, _, _ = _build_agent_wiring( + runtime, _, rebuild, retarget, _, _, _ = _build_agent_wiring( config, kube_stub, {}, cluster_context=note ) assert runtime is not None @@ -800,7 +802,7 @@ async def test_agent_wiring_applies_the_small_profile(monkeypatch: object) -> No agent_profile="small", ) kube_stub = cast("Any", object()) - runtime, _, rebuild, _, _, _ = _build_agent_wiring( + runtime, _, rebuild, _, _, _, _ = _build_agent_wiring( config, kube_stub, {}, pod_resize_supported=True ) assert runtime is not None @@ -859,7 +861,7 @@ async def test_ctx_retarget_keeps_the_small_profile_surface(monkeypatch: object) agent_profile="small", ) kube_stub = cast("Any", object()) - runtime, _, _, retarget, _, _ = _build_agent_wiring( + runtime, _, _, retarget, _, _, _ = _build_agent_wiring( config, kube_stub, {}, pod_resize_supported=False ) assert runtime is not None @@ -1056,7 +1058,7 @@ def test_missing_agent_extra_degrades_when_not_enabled( from korvid.k8s.client import KubeClient _uninstall_packages(monkeypatch, *_AGENT_ROOTS) - runtime, configurator, rebuild, retarget, provider_box, _ = _build_agent_wiring( + runtime, configurator, rebuild, retarget, _, provider_box, _ = _build_agent_wiring( KorvidConfig(), cast("KubeClient", object()), {} ) assert runtime is None @@ -1131,7 +1133,7 @@ def test_mcp_only_install_does_not_compose_the_agent( if cached in ("korvid.agent.runtime", "korvid.agent.profiles"): monkeypatch.delitem(sys.modules, cached) - runtime, configurator, rebuild, _, provider_box, _ = _build_agent_wiring( + runtime, configurator, rebuild, _, _, provider_box, _ = _build_agent_wiring( KorvidConfig(), cast("KubeClient", object()), {} ) assert runtime is None @@ -1212,3 +1214,46 @@ def test_telepresence_wiring_respects_detection_and_kill_switch() -> None: with mock.patch("korvid.__main__.find_telepresence", return_value="/x/telepresence"): assert isinstance(_build_telepresence(KorvidConfig()), TelepresenceCLI) assert _build_telepresence(KorvidConfig(telepresence_enabled=False)) is None + + +async def test_disconnect_agent_releases_the_provider(monkeypatch: object) -> None: + """`:ai off` (issue #167): the disconnect closure empties the provider + box (so teardown/rebuild never touch the dead provider) and closes the + old provider in the background.""" + import pytest + + mp = monkeypatch + assert isinstance(mp, pytest.MonkeyPatch) + mp.setenv("KORVID_TEST_KEY", "k") + + from korvid.__main__ import _build_agent_wiring + from korvid.core.config import KorvidConfig + + config = KorvidConfig( + agent_enabled=True, + agent_provider="openai", + agent_auth_method="api_key", + agent_base_url="http://localhost:9999/v1", + agent_model="m", + agent_api_key_env="KORVID_TEST_KEY", + ) + kube_stub = cast("Any", object()) + runtime, _, _, _, disconnect, provider_box, _ = _build_agent_wiring(config, kube_stub, {}) + assert runtime is not None + provider = provider_box[0] + assert provider is not None + closed: list[bool] = [] + + async def fake_aclose() -> None: + closed.append(True) + + mp.setattr(provider, "aclose", fake_aclose) + disconnect() + assert provider_box[0] is None # the box never points at a dead provider + for _ in range(10): + if closed: + break + await asyncio.sleep(0.01) + assert closed == [True] # released in the background, not leaked + disconnect() # idempotent when already off + assert provider_box[0] is None diff --git a/tests/ui/test_agent_off.py b/tests/ui/test_agent_off.py new file mode 100644 index 00000000..647d675a --- /dev/null +++ b/tests/ui/test_agent_off.py @@ -0,0 +1,206 @@ +"""`:ai off` disconnects the agent runtime for the session; bare `:ai` +reconnects with the kept settings (issue #167). Ctrl+A stays a pure panel +visibility toggle.""" + +from __future__ import annotations + +from typing import Any, cast + +from textual.widgets import Input + +from korvid.agent.events import TextDelta, TurnComplete +from korvid.ui.messages import UnknownCommand +from korvid.ui.widgets.agent_panel import AgentPanel, ChatEntry +from korvid.ui.widgets.status_bar import StatusBar +from tests.ui.test_agent_wiring import StubRuntime, make_app + +from .waits import until + + +def _panel_text(app: Any) -> str: + return "\n".join(entry.raw for entry in app.query_one(AgentPanel).query(ChatEntry)) + + +def _status(app: Any) -> str: + return str(app.query_one(StatusBar).render()) + + +async def test_ai_off_disconnects_the_runtime_and_updates_the_status() -> None: + closed: list[bool] = [] + runtime = StubRuntime([TurnComplete(input_tokens=0, output_tokens=0, estimated=False)]) + app = make_app(runtime, disconnect_agent=lambda: closed.append(True)) + async with app.run_test() as pilot: + assert "AI on" in _status(app) + app.on_unknown_command(UnknownCommand("ai off")) + await pilot.pause() + assert app._agent_runtime is None + assert "AI off" in _status(app) + assert closed == [True] # the provider was released, not leaked + + +async def test_ai_off_disables_prompt_submission_and_shows_the_hint() -> None: + runtime = StubRuntime([TurnComplete(input_tokens=0, output_tokens=0, estimated=False)]) + app = make_app(runtime) + async with app.run_test() as pilot: + await pilot.press("ctrl+a") + app.on_unknown_command(UnknownCommand("ai off")) + await pilot.pause() + inp = app.query_one(AgentPanel).query_one("#agent-input", Input) + assert inp.disabled is True + assert ":ai" in _panel_text(app) # reconnect hint names the command + assert not runtime.calls + + +async def test_ai_off_keeps_the_conversation_transcript() -> None: + runtime = StubRuntime( + [TextDelta(text="all good"), TurnComplete(input_tokens=1, output_tokens=1, estimated=False)] + ) + app = make_app(runtime) + async with app.run_test() as pilot: + await pilot.press("ctrl+a") + inp = app.query_one(AgentPanel).query_one("#agent-input", Input) + inp.value = "how are my pods?" + await pilot.press("enter") + await until(pilot, lambda: "all good" in _panel_text(app), label="turn done") + app.on_unknown_command(UnknownCommand("ai off")) + await pilot.pause() + assert "all good" in _panel_text(app) # disconnect never erases history + + +async def test_ai_off_is_idempotent_when_already_off() -> None: + app = make_app(runtime=None, model=None) + async with app.run_test() as pilot: + app.on_unknown_command(UnknownCommand("ai off")) + await pilot.pause() + assert app._agent_runtime is None # no crash, still off + assert any("off" in n.message for n in app._notifications) + + +async def test_ai_off_refuses_while_a_turn_is_running() -> None: + runtime = StubRuntime([TextDelta(text="thinking")], block=True) + app = make_app(runtime) + async with app.run_test() as pilot: + await pilot.press("ctrl+a") + inp = app.query_one(AgentPanel).query_one("#agent-input", Input) + inp.value = "q" + await pilot.press("enter") + await until(pilot, lambda: bool(runtime.calls), label="turn running") + app.on_unknown_command(UnknownCommand("ai off")) + await pilot.pause() + assert app._agent_runtime is not None # unchanged: never cancels midway + assert any("busy" in n.message.lower() for n in app._notifications) + + +async def test_reconnect_after_off_restores_the_agent() -> None: + from korvid.agent.setup import AgentSettings + + runtime = StubRuntime([TurnComplete(input_tokens=0, output_tokens=0, estimated=False)]) + fresh = cast("Any", StubRuntime([])) + app = make_app(runtime, rebuild_agent=lambda s: fresh) + async with app.run_test() as pilot: + await pilot.press("ctrl+a") + app.on_unknown_command(UnknownCommand("ai off")) + await pilot.pause() + assert "AI off" in _status(app) + settings = AgentSettings( + provider="ollama", + auth_method="none", + base_url="http://localhost:11434/v1", + model="llama3", + ) + assert app._apply_agent_settings(settings) is True + await pilot.pause() + assert app._agent_runtime is fresh + assert "AI on" in _status(app) + inp = app.query_one(AgentPanel).query_one("#agent-input", Input) + assert inp.disabled is False + + +async def test_ctrl_a_stays_a_pure_visibility_toggle() -> None: + runtime = StubRuntime([TurnComplete(input_tokens=0, output_tokens=0, estimated=False)]) + app = make_app(runtime) + async with app.run_test() as pilot: + panel = app.query_one(AgentPanel) + await pilot.press("ctrl+a") + assert panel.display is True + await pilot.press("ctrl+a") + assert panel.display is False + assert app._agent_runtime is runtime # visibility never touches state + + +async def test_ctrl_a_after_off_keeps_the_transcript() -> None: + """Ctrl+A must stay a pure visibility toggle after :ai off: reopening + the panel shows the reconnect hint without erasing the conversation + (review on #180).""" + runtime = StubRuntime( + [TextDelta(text="all good"), TurnComplete(input_tokens=1, output_tokens=1, estimated=False)] + ) + app = make_app(runtime) + async with app.run_test() as pilot: + await pilot.press("ctrl+a") + inp = app.query_one(AgentPanel).query_one("#agent-input", Input) + inp.value = "how are my pods?" + await pilot.press("enter") + await until(pilot, lambda: "all good" in _panel_text(app), label="turn done") + app.on_unknown_command(UnknownCommand("ai off")) + await pilot.pause() + await pilot.press("ctrl+a") # hide + await pilot.press("ctrl+a") # …and reopen + assert "all good" in _panel_text(app) # transcript survived the toggle + assert ":ai" in _panel_text(app) # reconnect hint, not the setup wipe + await pilot.press("ctrl+a") + await pilot.press("ctrl+a") + assert _panel_text(app).count("run :ai to reconnect") == 1 # no hint spam + + +async def test_bare_ai_after_off_prefills_the_wizard() -> None: + """The wizard opened after :ai off starts from the kept settings — + the user reconnects by confirming, not re-entering (review on #180).""" + from typing import Any as _Any + + from korvid.agent.setup import AgentSettings + from korvid.ui.widgets.agent_setup_screen import AgentSetupScreen + + class NoopConfigurator: + async def begin_device_login(self) -> _Any: + raise NotImplementedError + + async def finish_device_login(self) -> None: + raise NotImplementedError + + async def test(self, settings: _Any) -> str: + return "ok" + + async def list_models(self, settings: _Any) -> list[str]: + return [] + + async def save(self, settings: _Any) -> None: + pass + + runtime = StubRuntime([TurnComplete(input_tokens=0, output_tokens=0, estimated=False)]) + app = make_app(runtime, agent_configurator=NoopConfigurator()) + settings = AgentSettings( + provider="ollama", + auth_method="none", + base_url="http://my-ollama:11434/v1", + model="qwen3:8b", + ) + app._agent_settings = settings + async with app.run_test() as pilot: + app.on_unknown_command(UnknownCommand("ai off")) + await pilot.pause() + app.on_unknown_command(UnknownCommand("ai")) + await pilot.pause() + assert isinstance(app.screen, AgentSetupScreen) + screen = app.screen + from textual.widgets import OptionList + + provider_list = screen.query_one("#setup-provider", OptionList) + highlighted = provider_list.highlighted + assert highlighted is not None + assert provider_list.get_option_at_index(highlighted).id == "ollama" + # accept the highlighted provider: the endpoint step starts from the + # kept base URL, not the provider default + await pilot.press("enter") + base = screen.query_one("#setup-base-url", Input) + assert base.value == "http://my-ollama:11434/v1" diff --git a/tests/ui/test_agent_setup_screen.py b/tests/ui/test_agent_setup_screen.py index b2e32812..4514363d 100644 --- a/tests/ui/test_agent_setup_screen.py +++ b/tests/ui/test_agent_setup_screen.py @@ -480,3 +480,83 @@ async def test_explicit_small_profile_survives_a_cloud_provider() -> None: screen._auth_method = "api_key" settings = screen._draft_settings("gpt-4o-mini") assert settings.profile == "small" + + +class _HostWithSettings(App[None]): + def __init__(self, configurator: FakeConfigurator, current_settings: AgentSettings) -> None: + super().__init__() + self.configurator = configurator + self.current_settings = current_settings + + def on_mount(self) -> None: + self.push_screen( + AgentSetupScreen(self.configurator, current_settings=self.current_settings) + ) + + +async def test_reconnect_prefills_azure_auth_method() -> None: + """Azure + Entra kept settings must pre-highlight the auth choice: a + confirm-through reconnect must not silently switch to api_key + (review on #180).""" + settings = AgentSettings( + provider="azure", + auth_method="entra", + base_url="https://my.openai.azure.com", + model="gpt-4o", + ) + app = _HostWithSettings(FakeConfigurator(), settings) + async with app.run_test() as pilot: + await pilot.pause() + provider_list = app.screen.query_one("#setup-provider", OptionList) + assert provider_list.highlighted is not None + assert provider_list.get_option_at_index(provider_list.highlighted).id == "azure" + await pilot.press("enter") # accept azure → auth step + auth_list = app.screen.query_one("#setup-auth", OptionList) + assert auth_list.display is True + assert auth_list.highlighted is not None + assert str(auth_list.get_option_at_index(auth_list.highlighted).prompt) == "entra" + + +async def test_reconnect_normalizes_registry_provider_aliases() -> None: + """Settings configured with a registry alias (openai, vllm, github, + anthropic, claude) must map onto the wizard's openai-compat entry and + still prefill the endpoint (review on #180).""" + settings = AgentSettings( + provider="openai", + auth_method="api_key", + base_url="https://api.my-proxy.example/v1", + model="gpt-4o-mini", + api_key_env="MY_KEY", + ) + app = _HostWithSettings(FakeConfigurator(), settings) + async with app.run_test() as pilot: + await pilot.pause() + provider_list = app.screen.query_one("#setup-provider", OptionList) + assert provider_list.highlighted is not None + assert provider_list.get_option_at_index(provider_list.highlighted).id == "openai-compat" + await pilot.press("enter") # accept openai-compat → endpoint step + base = app.screen.query_one("#setup-base-url", Input) + assert base.value == "https://api.my-proxy.example/v1" + + +async def test_reconnect_preserves_a_no_auth_method() -> None: + """A no-auth OpenAI-compatible endpoint (e.g. local vLLM) must keep + auth_method='none' on confirm-through — never reset to api_key and + prompt for a nonexistent key env (review on #180).""" + settings = AgentSettings( + provider="vllm", + auth_method="none", + base_url="http://localhost:8000/v1", + model="qwen", + ) + app = _HostWithSettings(FakeConfigurator(), settings) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("enter") # accept openai-compat (alias-normalized) + await pilot.press("enter") # accept the kept endpoint + await _pump(pilot) + screen = app.screen + assert isinstance(screen, AgentSetupScreen) + assert screen._auth_method == "none" + env_input = screen.query_one("#setup-api-key-env", Input) + assert env_input.display is False # never asked for a key env