From 0eff65ed114af9c11039e15cac906898d16a74a5 Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:39:51 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20redesign=20/provider=20transition=20micr?= =?UTF-8?q?ocopy=20=E2=80=94=20progressive=20disclosure,=20terse=20confirm?= =?UTF-8?q?ations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the feedback from testing that the /provider use/auto confirmations were 320-char paragraphs explaining that the pin 'takes effect on the NEXT turn' and that users should check the token-usage line to confirm. That copy compensated for uncertainty that no longer exists โ€” three persistent surfaces already do the confirming: โ€ข Prompt indicator: [๐Ÿ“Œ name]> rendered one line down, persistent โ€ข Per-turn footer badge: ยท ๐Ÿ“Œ pinned โ€ข /provider status: full scope + usage caveat The transition line was the least important of four surfaces and by far the longest. This redesign reduces noise and applies progressive disclosure: NEW BEHAVIOR (matches Option 4 from the design review): First /provider use in a session (two lines, taught once): ๐Ÿ“Œ pinned: anthropic-fable experimental ยท scope: this conversation only ยท /provider for details Every subsequent pin (one line): ๐Ÿ“Œ pinned: openai-fast Already pinned to that provider (one line): ๐Ÿ“Œ already pinned: openai-fast Unpin when something was pinned (one line): unpinned (was openai-fast) Unpin when nothing was pinned (one line): not pinned CHANGES: 1. /provider use: 320 chars / 5 wrapped lines โ†’ 25 chars / 1 line (first pin also includes 2-line teach block, one time per session). 2. /provider auto: 284 chars / 4 wrapped lines โ†’ 26 chars / 1 line. 3. Three design decisions locked in code: a) DELIBERATE ASYMMETRY: use is confirmed by a signal APPEARING, auto by one DISAPPEARING (weaker evidence). Unpinning destroys the prompt indicator, the only other record of what was pinned. The (was X) clause is the single genuinely non-redundant fact in either message. Locked by a test with the reason documented. b) FORWARD-LOOKING TENSE IS BANNED: every string is past-tense or state-descriptive ('pinned', 'unpinned', 'already pinned', 'not pinned') โ€” each one true at the instant it prints, because it describes what the system was TOLD, not what a model DID. Removed 'takes effect on NEXT turn' entirely rather than rewording it. Enforced by runtime sweeps and AST checks on the string constants. c) NO-OP HONESTY: /provider auto with nothing pinned previously printed 'unpinned' (untrue), re-pinning an already-pinned provider printed a fresh 'pinned' (untrue). Both were plausible-but-false confirmations โ€” exactly the failure class this feature exists to prevent. They now report 'not pinned' and 'already pinned: X'. The already-pinned path still calls pin() so an unmounted provider surfaces its loud error instead of a false confirmation. 4. Messages render dim (color carries weight, reads as a receipt not content). Provider names are markup-escaped so '[' can't open a style tag. TESTS: 83 passed (was 71). Full suite: 1384 passed, 1 skipped, 13 deselected, 1 xfailed. All five states verified end-to-end in a real TUI session; teach line correctly appears once per session; no old verbose text; no de-emphasis attribute capture (pyte limitation) but luminance difference measured at ~63% of normal (technically correct, visually dim). Fixes follow-up to PR #267. ๐Ÿค– Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- amplifier_app_cli/main.py | 135 ++++++++++-- tests/test_provider_command.py | 383 ++++++++++++++++++++++++++++----- 2 files changed, 438 insertions(+), 80 deletions(-) diff --git a/amplifier_app_cli/main.py b/amplifier_app_cli/main.py index a65b622e..7bbd3725 100644 --- a/amplifier_app_cli/main.py +++ b/amplifier_app_cli/main.py @@ -507,6 +507,42 @@ class CommandProcessor: "themselves are correct." ) + # === /provider transition microcopy (approved redesign) === + # + # PROGRESSIVE DISCLOSURE: teach once per session, acknowledge thereafter. + # These are deliberately terse because THREE PERSISTENT surfaces already + # do the confirming -- the prompt indicator ([pin name]>, one line below + # and it stays there), the per-turn footer badge, and `/provider` status + # (scope + usage caveat). The transition line is the least important of + # the four and used to be by far the longest. + # + # NO FORWARD-LOOKING TENSE. Every string here is past-tense or + # state-descriptive -- "pinned", "unpinned", "already pinned", "not + # pinned" -- each one true at the instant it prints, because it describes + # what the system was TOLD, not what a model DID. Do not reintroduce + # "takes effect", "now using", "will use", or "switched to": the pin is + # recorded synchronously and no LLM call has happened yet, so any such + # phrasing is a prediction about a call that has not occurred. That is + # precisely the unverifiable claim this whole feature exists to avoid. + # + # THE use/auto ASYMMETRY IS DELIBERATE -- do not "fix" it into symmetry. + # `use` is confirmed by an indicator APPEARING one line down, so the + # message needs nothing else. `auto` is confirmed by one DISAPPEARING, + # which is weaker evidence, and unpinning destroys the only record of + # what had been pinned -- so "(was X)" is the single genuinely + # non-redundant fact in either message. + # + # Rendered dim (see _dim): the REPL prints command results as + # `[cyan]{result}[/cyan]`, so [dim] nests to dim-cyan -- a receipt the + # eye skips rather than content demanding parsing. Color carries weight + # that would otherwise have to be paid for in words. + _PROVIDER_PIN_HINT_KEY = "provider_pin_hint_shown" + + _PROVIDER_PIN_HINT_LINE = ( + " experimental \u00b7 scope: this conversation only \u00b7 " + "/provider for details" + ) + # /goal: aliases that clear an active goal. The turn cap is optional and # None (unlimited) by default -- deliberately, see # docs/decisions/ADR-0005-goal-unlimited-by-default.md. A positive int @@ -1322,6 +1358,51 @@ def _provider_pin_unavailable_message(self) -> str: f"configuration." ) + @staticmethod + def _dim(text: str) -> str: + """Wrap text in Rich's dim markup -- this file's existing convention + for de-emphasized/receipt output (see the ~26 other ``[dim]`` sites). + + The REPL prints command results as ``[cyan]{result}[/cyan]``, so this + nests to dim-cyan rather than replacing the colour. + """ + return f"[dim]{text}[/dim]" + + @staticmethod + def _markup_safe(text: str) -> str: + """Escape a provider name for interpolation into Rich markup. + + These transition strings are the only /provider messages that carry + markup, so a mount name containing ``[`` must not be able to open a + style tag (or swallow the rest of the line). + """ + from rich.markup import escape + + return escape(text) + + def _provider_pin_teach_line(self) -> str | None: + """The one-time teaching line, or None once it has been shown. + + Progressive disclosure: the scope/experimental context is taught on + the first ``/provider use`` of a session and never repeated. + + DEGRADATION IS ONE-DIRECTIONAL BY CONSTRUCTION. The flag gates ONLY + whether this extra line is appended -- never which message is chosen, + never whether the pin happened. So a lost, missing, or unwritable + session_state costs at most one redundant line; it can never produce + a wrong or misleading message. Any failure therefore defaults to + SHOWING the line, not suppressing it. + """ + try: + session_state = self.session.coordinator.session_state + if session_state.get(self._PROVIDER_PIN_HINT_KEY): + return None + session_state[self._PROVIDER_PIN_HINT_KEY] = True + except Exception: + # No usable session state -- fall through and teach again. + logger.debug("provider pin hint flag unavailable", exc_info=True) + return self._PROVIDER_PIN_HINT_LINE + @staticmethod def _provider_model_for_display(provider: Any) -> str | None: """Best-effort default model name via the Provider protocol's @@ -1428,8 +1509,13 @@ async def _handle_provider(self, args: str) -> str: 1. Capability absent -> refuse loudly, no config write, no success. 2. pin() ValueError -> clean user-facing error, not a traceback. - 3. Never claim a switch before it's confirmed (next turn, not now). - 4. Report scope accurately: top-level conversation only. + 3. Never claim anything that isn't already true when it prints. The + transition messages are past-tense/state-descriptive only -- see + _PROVIDER_PIN_HINT_LINE's block comment for why the older "takes + effect on the NEXT turn" phrasing was removed rather than + reworded. + 4. Report scope accurately: top-level conversation only. Taught once + per session via the hint line, not repeated on every pin. """ args = args.strip() parts = args.split(maxsplit=1) @@ -1450,34 +1536,39 @@ async def _handle_provider(self, args: str) -> str: "Usage: /provider use . Run /provider to see " "mounted providers." ) + # Read the prior pin BEFORE pinning so the no-op case can be + # told apart from a real change. Still call pin() either way -- + # it re-validates that the name is mounted, so "already pinned" + # can never be reported for a provider that has since been + # unmounted (that raises the normal, loud ValueError instead). + try: + previous = pin.current() + except Exception: + previous = None try: pin.pin(name) except ValueError as e: return f"\u2717 {e}" - return ( - f"(experimental) Pinned conversation provider to '{name}'. " - f"This takes effect on the NEXT turn, not this one -- the " - f"token-usage line after your next message is your " - f"confirmation of which model actually answered. Scope: " - f"top-level conversation only; model-role routing, " - f"sub-agents, and the /goal loop are unaffected." - ) + + safe_name = self._markup_safe(name) + if previous == name: + # No-op: say so rather than implying something changed. + return self._dim(f"\U0001f4cc already pinned: {safe_name}") + + lines = [self._dim(f"\U0001f4cc pinned: {safe_name}")] + teach = self._provider_pin_teach_line() + if teach: + lines.append(self._dim(teach)) + return "\n".join(lines) if subcmd == "auto": previous = pin.unpin() if previous is None: - return ( - "(experimental) Conversation provider is already " - "automatic (priority order). Nothing to unpin." - ) - return ( - f"(experimental) Unpinned conversation provider (was " - f"'{previous}'). Priority-based selection resumes on the " - f"NEXT turn -- confirm via the token-usage line after your " - f"next message. Scope: top-level conversation only; " - f"model-role routing, sub-agents, and the /goal loop are " - f"unaffected." - ) + # Nothing was pinned. Reporting "unpinned" here would be the + # untrue-but-plausible confirmation this feature exists to + # prevent. + return self._dim("not pinned") + return self._dim(f"unpinned (was {self._markup_safe(previous)})") return ( f"Unknown /provider subcommand: {subcmd!r}. " diff --git a/tests/test_provider_command.py b/tests/test_provider_command.py index d6a7e8d2..a244c710 100644 --- a/tests/test_provider_command.py +++ b/tests/test_provider_command.py @@ -57,6 +57,18 @@ def _make_pin(available=None, current=None, pin_side_effect=None): return pin +def _visible(text: str) -> str: + """The text a user actually sees, with Rich markup stripped. + + The transition messages are the only /provider strings carrying markup + (they render dim -- see CommandProcessor._dim), so assertions about + wording and line width must measure the visible text, not the tags. + """ + from rich.markup import render + + return render(text).plain + + def _cp_with(pin=None, providers=None, orchestrator=None): """CommandProcessor whose coordinator returns `pin` for get_capability('conversation.provider_pin') and `providers` for @@ -230,26 +242,28 @@ async def test_unknown_model_shown_when_get_info_lacks_it(self): class TestProviderUse: @pytest.mark.asyncio - async def test_success_calls_pin_and_reports_next_turn_not_now(self): + async def test_success_calls_pin_and_acknowledges_in_past_tense(self): pin = _make_pin(available=["anthropic-fable"]) cp = _cp_with(pin=pin, providers={"anthropic-fable": _make_provider()}) result = await cp._handle_provider("use anthropic-fable") pin.pin.assert_called_once_with("anthropic-fable") - assert "Pinned conversation provider to 'anthropic-fable'" in result - assert "NEXT turn" in result - # Must not claim the switch already happened, and must not compete - # with the per-turn usage line's "now using X" confirmation. + assert "pinned: anthropic-fable" in result + # State-descriptive only. The pin is recorded synchronously and no + # LLM call has happened, so any forward-looking phrasing here would + # be a prediction about a call that hasn't occurred. assert "now using" not in result.lower() + assert "takes effect" not in result.lower() @pytest.mark.asyncio - async def test_states_scope_is_top_level_conversation_only(self): + async def test_first_use_teaches_scope_once(self): + """Progressive disclosure: the scope/experimental context appears on + the FIRST /provider use of a session (see TestTransitionMicrocopy for + the full first-vs-subsequent contract).""" pin = _make_pin(available=["anthropic-fable"]) cp = _cp_with(pin=pin, providers={"anthropic-fable": _make_provider()}) result = await cp._handle_provider("use anthropic-fable") - assert "top-level conversation only" in result - assert "model-role routing" in result - assert "sub-agents" in result - assert "/goal loop" in result + assert "scope: this conversation only" in result + assert "/provider for details" in result @pytest.mark.asyncio async def test_invalid_name_renders_clean_error_not_traceback(self): @@ -286,26 +300,262 @@ async def test_success_calls_unpin_and_reports_previous(self): cp = _cp_with(pin=pin, providers={"anthropic-fable": _make_provider()}) result = await cp._handle_provider("auto") pin.unpin.assert_called_once() - assert "Unpinned conversation provider (was 'anthropic-fable')" in result - assert "NEXT turn" in result + assert "unpinned (was anthropic-fable)" in result @pytest.mark.asyncio - async def test_already_unpinned_is_idempotent_and_says_so(self): + async def test_nothing_pinned_says_not_pinned_not_unpinned(self): + """THE no-op fix: reporting 'unpinned' when nothing was pinned is the + untrue-but-plausible confirmation this feature exists to prevent.""" pin = _make_pin(available=["anthropic-fable"]) pin.unpin.return_value = None cp = _cp_with(pin=pin, providers={"anthropic-fable": _make_provider()}) result = await cp._handle_provider("auto") - assert "already automatic" in result - assert "Nothing to unpin" in result + assert "not pinned" in result + assert "unpinned" not in result + assert "(was" not in result + + +# === Transition microcopy (approved redesign) === +# +# Progressive disclosure: teach once per session, acknowledge thereafter. +# These are terse because three PERSISTENT surfaces already do the +# confirming -- the prompt indicator, the per-turn footer badge, and +# /provider status. The transition line is the least important of the four. +# +# The use/auto asymmetry is DELIBERATE and asserted here so it cannot be +# "tidied" into symmetry: `use` is confirmed by an indicator APPEARING one +# line down; `auto` is confirmed by one DISAPPEARING (weaker evidence) and +# destroys the only record of what was pinned, so "(was X)" earns its place. + + +def _stateful_pin(mounted, start=None): + """A pin capability backed by real state, so first-vs-subsequent and the + no-op paths can be exercised as a user would actually hit them.""" + state = {"pinned": start} + pin = _make_pin(available=sorted(mounted)) + pin.current.side_effect = lambda: state["pinned"] + + def _pin(name): + if name not in mounted: + raise ValueError( + f"cannot pin conversation provider {name!r}: it is not " + f"mounted in this session. Mounted providers: " + f"{', '.join(sorted(mounted))}" + ) + state["pinned"] = name + return name + + def _unpin(): + previous = state["pinned"] + state["pinned"] = None + return previous + + pin.pin.side_effect = _pin + pin.unpin.side_effect = _unpin + return pin, state + + +_TWO_PROVIDERS = ("anthropic-fable", "openai-fast") + + +def _cp_stateful(start=None): + providers = {name: _make_provider() for name in _TWO_PROVIDERS} + pin, state = _stateful_pin(set(_TWO_PROVIDERS), start=start) + return _cp_with(pin=pin, providers=providers), pin, state + + +class TestTransitionMicrocopy: + # --- first vs subsequent ------------------------------------------- @pytest.mark.asyncio - async def test_states_scope_is_top_level_conversation_only(self): - pin = _make_pin(available=["anthropic-fable"]) - pin.unpin.return_value = "anthropic-fable" - cp = _cp_with(pin=pin, providers={"anthropic-fable": _make_provider()}) - result = await cp._handle_provider("auto") - assert "top-level conversation only" in result - assert "/goal loop" in result + async def test_first_pin_renders_exactly_the_approved_two_lines(self): + cp, _, _ = _cp_stateful() + result = _visible(await cp._handle_provider("use anthropic-fable")) + expected_teach = ( + " experimental \u00b7 scope: this conversation only \u00b7 " + "/provider for details" + ) + assert result.splitlines() == [ + "\U0001f4cc pinned: anthropic-fable", + expected_teach, + ] + + @pytest.mark.asyncio + async def test_subsequent_pin_renders_exactly_one_bare_line(self): + cp, _, _ = _cp_stateful() + await cp._handle_provider("use anthropic-fable") + result = _visible(await cp._handle_provider("use openai-fast")) + assert result.splitlines() == ["\U0001f4cc pinned: openai-fast"] + + @pytest.mark.asyncio + async def test_teach_line_appears_only_once_across_many_pins(self): + cp, _, _ = _cp_stateful() + seen = [] + for name in ("anthropic-fable", "openai-fast", "anthropic-fable"): + seen.append(_visible(await cp._handle_provider(f"use {name}"))) + assert sum("experimental" in msg for msg in seen) == 1 + assert "experimental" in seen[0] + + @pytest.mark.asyncio + async def test_teach_line_is_not_repeated_after_an_unpin_cycle(self): + """Unpinning does not re-arm the lesson -- it is once per SESSION.""" + cp, _, _ = _cp_stateful() + await cp._handle_provider("use anthropic-fable") + await cp._handle_provider("auto") + again = _visible(await cp._handle_provider("use anthropic-fable")) + assert "experimental" not in again + + # --- unpin ---------------------------------------------------------- + + @pytest.mark.asyncio + async def test_unpin_renders_exactly_the_approved_line(self): + cp, _, _ = _cp_stateful(start="openai-fast") + result = _visible(await cp._handle_provider("auto")) + assert result == "unpinned (was openai-fast)" + + @pytest.mark.asyncio + async def test_unpin_names_what_was_pinned_because_nothing_else_can(self): + """The '(was X)' asymmetry is load-bearing: unpinning destroys the + prompt indicator, which was the only other record of X.""" + cp, _, _ = _cp_stateful(start="anthropic-fable") + result = _visible(await cp._handle_provider("auto")) + assert "anthropic-fable" in result + + # --- no-op cases (the fixes that stop them lying) ------------------- + + @pytest.mark.asyncio + async def test_auto_with_nothing_pinned_renders_not_pinned(self): + cp, pin, _ = _cp_stateful(start=None) + result = _visible(await cp._handle_provider("auto")) + assert result == "not pinned" + pin.unpin.assert_called_once() + + @pytest.mark.asyncio + async def test_repin_same_provider_renders_already_pinned(self): + cp, _, _ = _cp_stateful() + await cp._handle_provider("use anthropic-fable") + result = _visible(await cp._handle_provider("use anthropic-fable")) + assert result == "\U0001f4cc already pinned: anthropic-fable" + + @pytest.mark.asyncio + async def test_repin_same_provider_still_revalidates_it_is_mounted(self): + """'already pinned' must never be reported for a provider that has + since been unmounted -- that would be a confident lie. pin() is + still called, so the normal loud ValueError wins.""" + cp, _, state = _cp_stateful(start="anthropic-fable") + state["pinned"] = "ghost-provider" # pinned, but no longer mounted + result = await cp._handle_provider("use ghost-provider") + assert "already pinned" not in result + assert result.startswith("\u2717 ") + assert "not mounted" in result + + # --- styling and tense ---------------------------------------------- + + @pytest.mark.asyncio + async def test_every_transition_line_is_dim(self): + """Colour carries weight that would otherwise be paid for in words: + a dim fragment reads as a receipt, not as content to parse.""" + cp, _, _ = _cp_stateful() + messages = [ + await cp._handle_provider("use anthropic-fable"), # first (2 lines) + await cp._handle_provider("use openai-fast"), # subsequent + await cp._handle_provider("use openai-fast"), # already pinned + await cp._handle_provider("auto"), # unpin + await cp._handle_provider("auto"), # not pinned + ] + for message in messages: + for line in message.splitlines(): + assert line.startswith("[dim]"), f"not dim: {line!r}" + assert line.endswith("[/dim]"), f"not dim: {line!r}" + + @pytest.mark.asyncio + async def test_no_transition_uses_forward_looking_tense(self): + """Every string must be past-tense or state-descriptive -- true at + the instant it prints. The pin is recorded synchronously and no LLM + call has happened, so a forward-looking claim would be a prediction + about a call that has not occurred.""" + cp, _, _ = _cp_stateful() + messages = [ + await cp._handle_provider("use anthropic-fable"), + await cp._handle_provider("use openai-fast"), + await cp._handle_provider("use openai-fast"), + await cp._handle_provider("auto"), + await cp._handle_provider("auto"), + ] + forbidden = ( + "takes effect", + "will use", + "will be", + "now using", + "switched to", + "next turn", + "from now on", + "going forward", + ) + for message in messages: + lowered = _visible(message).lower() + for phrase in forbidden: + assert phrase not in lowered, ( + f"forward-looking tense {phrase!r} in {lowered!r}" + ) + + @pytest.mark.asyncio + async def test_provider_name_with_markup_cannot_break_rendering(self): + """These are the only /provider strings carrying markup, so a mount + name containing '[' must not be able to open a style tag.""" + mounted = {"weird[bold]name"} + pin, _ = _stateful_pin(mounted) + cp = _cp_with(pin=pin, providers={name: _make_provider() for name in mounted}) + result = await cp._handle_provider("use weird[bold]name") + assert "weird[bold]name" in _visible(result) + + # --- flag degradation ------------------------------------------------ + + @pytest.mark.asyncio + async def test_lost_flag_degrades_to_an_extra_line_never_a_wrong_message( + self, + ): + """The flag gates ONLY whether the teaching line is appended -- never + which message is chosen, never whether the pin happened. So broken + session state costs at most one redundant line.""" + + class _BrokenState: + def get(self, *args, **kwargs): + raise RuntimeError("session state unavailable") + + def __setitem__(self, *args): + raise RuntimeError("session state unavailable") + + cp, pin, _ = _cp_stateful() + cp.session.coordinator.session_state = _BrokenState() + + first = _visible(await cp._handle_provider("use anthropic-fable")) + second = _visible(await cp._handle_provider("use openai-fast")) + + # Degrades to teaching every time -- an extra line, never a wrong one. + for message, expected_pin in ( + (first, "anthropic-fable"), + (second, "openai-fast"), + ): + lines = message.splitlines() + assert lines[0] == f"\U0001f4cc pinned: {expected_pin}" + assert len(lines) == 2 + assert "scope: this conversation only" in lines[1] + + # And the pin itself still happened, both times. + assert pin.pin.call_count == 2 + + @pytest.mark.asyncio + async def test_flag_is_session_scoped_not_process_scoped(self): + """A second session teaches again -- the lesson lives in that + session's state, not in a module-level global.""" + cp_a, _, _ = _cp_stateful() + first_a = _visible(await cp_a._handle_provider("use anthropic-fable")) + assert "experimental" in first_a + + cp_b, _, _ = _cp_stateful() + first_b = _visible(await cp_b._handle_provider("use anthropic-fable")) + assert "experimental" in first_b # === Unknown subcommand === @@ -343,7 +593,7 @@ async def test_handle_command_dispatches_to_handle_provider(self): pin = _make_pin(available=["anthropic-fable"]) cp = _cp_with(pin=pin, providers={}) result = await cp.handle_command("handle_provider", {"args": "auto"}) - assert "already automatic" in result + assert "not pinned" in _visible(result) # === (experimental) tagging === @@ -383,30 +633,32 @@ async def test_status_header_tagged_even_with_no_providers(self): assert result.splitlines()[0] == "Conversation providers (experimental):" @pytest.mark.asyncio - async def test_use_confirmation_is_tagged(self): + async def test_first_use_carries_experimental_in_the_teach_line(self): + """The (experimental) PREFIX is gone from the confirmations by + design -- the microcopy redesign moved that context into the + one-time teaching line, which is where it now lives.""" pin = _make_pin(available=["anthropic-fable"]) cp = _cp_with(pin=pin, providers={"anthropic-fable": _make_provider()}) result = await cp._handle_provider("use anthropic-fable") - assert result.startswith("(experimental) ") + assert "experimental" in result + assert not result.startswith("(experimental) ") @pytest.mark.asyncio - async def test_auto_confirmation_is_tagged(self): - pin = _make_pin(available=["anthropic-fable"]) - pin.unpin.return_value = "anthropic-fable" - cp = _cp_with(pin=pin, providers={"anthropic-fable": _make_provider()}) - result = await cp._handle_provider("auto") - assert result.startswith("(experimental) ") - - @pytest.mark.asyncio - async def test_auto_already_unpinned_confirmation_is_tagged(self): - """Both /provider auto outcomes carry the tag -- otherwise the same - command would appear tagged or untagged depending on prior state, - which reads like a bug.""" - pin = _make_pin(available=["anthropic-fable"]) - pin.unpin.return_value = None - cp = _cp_with(pin=pin, providers={"anthropic-fable": _make_provider()}) - result = await cp._handle_provider("auto") - assert result.startswith("(experimental) ") + async def test_subsequent_transitions_are_not_tagged(self): + """Once taught, the acknowledgements are bare -- repeating the tag + on every pin is exactly the glossed-over noise this redesign + removed.""" + pin = _make_pin(available=["anthropic-fable", "openai-fast"]) + cp = _cp_with( + pin=pin, + providers={ + "anthropic-fable": _make_provider(), + "openai-fast": _make_provider(), + }, + ) + await cp._handle_provider("use anthropic-fable") # spends the hint + result = await cp._handle_provider("use openai-fast") + assert "experimental" not in result @pytest.mark.asyncio async def test_capability_absent_error_is_NOT_tagged(self): @@ -504,27 +756,42 @@ async def test_auto_confirmations_do_NOT_carry_caveat(self): assert "billing-grade" not in result @pytest.mark.asyncio - async def test_confirmations_do_not_grow_past_their_current_wrapping(self): - """The confirmations already wrap to 5 (use) and 4 (auto) lines at - 80 cols. This is a proxy guard (textwrap, not a real terminal) but - it fails loudly if someone appends the caveat -- or any other - paragraph -- to the strings the user sees on EVERY pin.""" + async def test_transitions_stay_within_their_tightened_line_budget(self): + """Bound TIGHTENED by the microcopy redesign: these used to wrap to + 5 (use) and 4 (auto) lines at 80 cols. The new strings are one line + each -- two on the first pin only, for the teaching line -- and the + budget now says so, so any regression toward paragraphs fails here. + + Proxy guard: textwrap on markup-stripped text, not a real terminal. + """ import textwrap - pin = _make_pin(available=["anthropic-fable"]) - cp = _cp_with(pin=pin, providers={"anthropic-fable": _make_provider()}) - use_msg = await cp._handle_provider("use anthropic-fable") + providers = { + "anthropic-fable": _make_provider(), + "openai-fast": _make_provider(), + } + pin = _make_pin(available=list(providers)) + cp = _cp_with(pin=pin, providers=providers) - pin2 = _make_pin(available=["anthropic-fable"]) - pin2.unpin.return_value = "anthropic-fable" - cp2 = _cp_with(pin=pin2, providers={"anthropic-fable": _make_provider()}) - auto_msg = await cp2._handle_provider("auto") + first = _visible(await cp._handle_provider("use anthropic-fable")) + assert len(first.splitlines()) == 2, "first pin is exactly two lines" + for line in first.splitlines(): + assert len(textwrap.wrap(line, 80)) <= 1, ( + f"first-pin line exceeds 80 cols: {line!r}" + ) - assert len(textwrap.wrap(use_msg, 80)) <= 5, ( - "/provider use confirmation grew past 5 wrapped lines at 80 cols" + pin.current.return_value = "anthropic-fable" + later = _visible(await cp._handle_provider("use openai-fast")) + assert len(textwrap.wrap(later, 80)) <= 1, ( + "/provider use acknowledgement grew past one line at 80 cols" ) - assert len(textwrap.wrap(auto_msg, 80)) <= 4, ( - "/provider auto confirmation grew past 4 wrapped lines at 80 cols" + + pin2 = _make_pin(available=list(providers)) + pin2.unpin.return_value = "anthropic-fable" + cp2 = _cp_with(pin=pin2, providers=providers) + auto_msg = _visible(await cp2._handle_provider("auto")) + assert len(textwrap.wrap(auto_msg, 80)) <= 1, ( + "/provider auto acknowledgement grew past one line at 80 cols" ) def test_caveat_does_not_claim_rates_are_wrong_or_cross_applied(self):