mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-08-27 10:51:26 -04:00
fix(code): show contextual auth footer actions (#5690)
The `/auth` manager footer now names the action for the highlighted row instead of listing every action the screen supports. Before, one line had to cover every row: ``` ↑/↓ or Tab/Shift+Tab navigate • Enter add/replace/delete/install • Esc close ``` Now the action follows the cursor: | Highlighted row | Footer | | --- | --- | | `anthropic [env set: ANTHROPIC_API_KEY]` | `Enter replace` | | `openai [stored]` | `Enter replace/delete` | | `tavily` | `Enter add` | | `groq [not installed]` | `Enter install` | | `OpenAI Codex (ChatGPT login) [chatgpt: plus]` | `Enter manage` | --- **An expired ChatGPT access token counts as signed in.** Expiry is the normal state for a returning user: the saved refresh token renews the access token when a model is constructed, which is why `_get_codex_auth_status` reports the row as `CONFIGURED` and badges it `[chatgpt]`. The footer reads that same status, so the badge, the footer, and Enter agree. Treating expiry as signed-out would have told the user to re-authorize a session that never lapsed, and would have hidden sign-out — which only the signed-in overlay offers — behind that unnecessary OAuth round trip. Reading the cached status also means highlighting a row never touches the token file. Arrowing over the ChatGPT row used to read `chatgpt-auth.json` on every keypress. **A stored key with no known env var offers replace/delete.** The set of providers with a credential in `auth.json` is snapshotted when the option list is built. Without it, a provider whose env var is absent from `PROVIDER_API_KEY_ENV` — a custom `class_path` provider, or a well-known one renamed across an upgrade — resolved to `UNKNOWN` and the footer said `add`, while the prompt behind Enter offered `Ctrl+D delete` for the key it had just denied existed. **The footer follows the row, not the cursor index.** `_refresh_options` preserves the highlighted index while configured rows float to the top, so saving or deleting a key changes which provider sits under an unmoved cursor. The rebuild recomputes the footer from the row that ended up there. Both footer writers share one helper that tolerates a dismissed manager. A queued `OptionHighlighted` can arrive after the screen is gone, where an unguarded `query_one` raises `NoMatches` and Textual escalates it into a callback error that tears down the REPL. Made by [Open SWE](https://openswe.vercel.app/agents/d9dd1ec2-f3c6-5ff0-aaa2-da82e97fcbc7) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
@@ -1610,6 +1610,13 @@ class AuthManagerScreen(ModalScreen[None]):
|
||||
# populated each time the option list is built. Selecting one routes
|
||||
# to the install confirmation instead of the key prompt.
|
||||
self._install_extras: dict[str, str] = {}
|
||||
# Resolved auth status per provider/service key, and the set of keys
|
||||
# with a credential in `auth_store`. Both are side effects of
|
||||
# building the option list, so both are empty until then — hence the
|
||||
# `.get()` fallback in `_action_for_provider`, and the reason
|
||||
# `compose` builds the options before the footer.
|
||||
self._auth_statuses: dict[str, ProviderAuthStatus] = {}
|
||||
self._stored_providers: set[str] = set()
|
||||
# Set when the user confirms installing a provider's extra; the app
|
||||
# reads these off the screen after dismissal to install then reopen
|
||||
# the manager with the just-installed provider highlighted.
|
||||
@@ -1623,7 +1630,6 @@ class AuthManagerScreen(ModalScreen[None]):
|
||||
Yields:
|
||||
Widgets for the manager listing.
|
||||
"""
|
||||
glyphs = get_glyphs()
|
||||
options, store_warning = self._build_options_with_warning()
|
||||
with Vertical():
|
||||
yield Static("Manage API keys", classes="auth-manager-title")
|
||||
@@ -1637,13 +1643,78 @@ class AuthManagerScreen(ModalScreen[None]):
|
||||
classes="auth-manager-warning",
|
||||
)
|
||||
yield OptionList(*options, id="auth-manager-options")
|
||||
# `OptionList` highlights its first row on construction, so the
|
||||
# mount-time `OptionHighlighted` recomputes this footer before the
|
||||
# first frame. Seeding it with the same provider keeps that frame
|
||||
# from flashing a generic label.
|
||||
first_provider = options[0].id if options else None
|
||||
yield Static(
|
||||
f"{glyphs.arrow_up}/{glyphs.arrow_down} or Tab/Shift+Tab "
|
||||
f"navigate {glyphs.bullet} Enter add/replace/delete/install "
|
||||
f"{glyphs.bullet} Esc close",
|
||||
self._build_manager_help(first_provider),
|
||||
classes="auth-manager-help",
|
||||
id="auth-manager-help",
|
||||
)
|
||||
|
||||
def _build_manager_help(self, provider: str | None) -> str:
|
||||
"""Build the manager footer for the highlighted provider.
|
||||
|
||||
Args:
|
||||
provider: Highlighted provider or service config key, if any.
|
||||
|
||||
Returns:
|
||||
Navigation help naming the highlighted row's Enter action.
|
||||
"""
|
||||
glyphs = get_glyphs()
|
||||
action = self._action_for_provider(provider)
|
||||
return (
|
||||
f"{glyphs.arrow_up}/{glyphs.arrow_down} or Tab/Shift+Tab navigate "
|
||||
f"{glyphs.bullet} Enter {action} {glyphs.bullet} Esc close"
|
||||
)
|
||||
|
||||
def _action_for_provider(self, provider: str | None) -> str:
|
||||
"""Return the action exposed by selecting a provider row.
|
||||
|
||||
Args:
|
||||
provider: Highlighted provider or service config key, if any.
|
||||
|
||||
Returns:
|
||||
Short action label for the manager footer.
|
||||
"""
|
||||
if provider is None:
|
||||
return "select"
|
||||
if provider in self._install_extras:
|
||||
return "install"
|
||||
|
||||
if provider == CODEX_PROVIDER:
|
||||
return "manage" if self._codex_session_is_active() else "sign in"
|
||||
|
||||
status = self._auth_statuses.get(provider)
|
||||
if provider in self._stored_providers or (
|
||||
status is not None and status.source is ProviderAuthSource.STORED
|
||||
):
|
||||
# Enter opens the key prompt, which is where the delete action
|
||||
# lives (`Ctrl+D`); the manager itself never deletes a key.
|
||||
return "replace/delete"
|
||||
if status is not None and status.state is ProviderAuthState.CONFIGURED:
|
||||
return "replace"
|
||||
return "add"
|
||||
|
||||
def _codex_session_is_active(self) -> bool:
|
||||
"""Return whether a usable ChatGPT token is stored on disk.
|
||||
|
||||
An expired *access* token still counts. `_get_codex_auth_status`
|
||||
reports it as `CONFIGURED` because the saved refresh token renews it
|
||||
when a model is constructed, and the row's badge says so. Treating
|
||||
expiry as signed-out would contradict that badge and hide sign-out
|
||||
behind a re-authorization the user does not need. Reads the status
|
||||
resolved when the option list was built, so highlighting a row never
|
||||
touches the token file.
|
||||
|
||||
Returns:
|
||||
`True` when a ChatGPT token is stored and refreshable.
|
||||
"""
|
||||
status = self._auth_statuses.get(CODEX_PROVIDER)
|
||||
return status is not None and status.state is ProviderAuthState.CONFIGURED
|
||||
|
||||
def _build_description(self) -> Content:
|
||||
"""Build the description line with an inline docs hyperlink.
|
||||
|
||||
@@ -1707,6 +1778,31 @@ class AuthManagerScreen(ModalScreen[None]):
|
||||
"""Reset the pointer shape when the mouse leaves the manager."""
|
||||
self.styles.pointer = "default"
|
||||
|
||||
def on_option_list_option_highlighted(
|
||||
self, event: OptionList.OptionHighlighted
|
||||
) -> None:
|
||||
"""Update the footer for the highlighted provider row."""
|
||||
self._update_manager_help(event.option.id)
|
||||
|
||||
def _update_manager_help(self, provider: str | None) -> None:
|
||||
"""Rewrite the footer for `provider`, if the manager is still mounted.
|
||||
|
||||
Guarded against the user having dismissed the manager before a
|
||||
queued `OptionHighlighted` was dispatched; without the guard,
|
||||
`query_one` would raise `NoMatches` and Textual would surface it as
|
||||
a callback error.
|
||||
|
||||
Args:
|
||||
provider: Highlighted provider or service config key, if any.
|
||||
"""
|
||||
from textual.css.query import NoMatches
|
||||
|
||||
try:
|
||||
help_widget = self.query_one("#auth-manager-help", Static)
|
||||
except NoMatches:
|
||||
return
|
||||
help_widget.update(self._build_manager_help(provider))
|
||||
|
||||
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
||||
"""Open the prompt for the selected provider.
|
||||
|
||||
@@ -1777,18 +1873,16 @@ class AuthManagerScreen(ModalScreen[None]):
|
||||
def _open_codex_screen(self) -> None:
|
||||
"""Push the ChatGPT OAuth flow modal and refresh on close.
|
||||
|
||||
When `openai_codex` is already signed in, give the user a chance to
|
||||
sign out before launching a fresh sign-in flow. Otherwise just run
|
||||
the sign-in worker.
|
||||
When a ChatGPT token is already stored, give the user a chance to
|
||||
sign out (or switch account) before launching a fresh sign-in flow.
|
||||
Otherwise just run the sign-in worker.
|
||||
"""
|
||||
from deepagents_code.integrations import openai_codex
|
||||
from deepagents_code.tui.widgets.codex_auth import (
|
||||
CodexAuthScreen,
|
||||
CodexSignedInScreen,
|
||||
)
|
||||
|
||||
status = openai_codex.get_status()
|
||||
if status.logged_in and not status.is_expired:
|
||||
if self._codex_session_is_active():
|
||||
self.app.push_screen(
|
||||
CodexSignedInScreen(),
|
||||
self._on_codex_signed_in_closed,
|
||||
@@ -1851,7 +1945,7 @@ class AuthManagerScreen(ModalScreen[None]):
|
||||
self.post_message(self.CredentialDeleted(provider))
|
||||
|
||||
def _refresh_options(self) -> None:
|
||||
"""Rebuild option labels from current store state."""
|
||||
"""Rebuild option labels, and the footer, from current store state."""
|
||||
option_list = self.query_one("#auth-manager-options", OptionList)
|
||||
highlighted = option_list.highlighted
|
||||
option_list.clear_options()
|
||||
@@ -1860,6 +1954,17 @@ class AuthManagerScreen(ModalScreen[None]):
|
||||
option_list.add_option(option)
|
||||
if highlighted is not None and option_list.option_count:
|
||||
option_list.highlighted = min(highlighted, option_list.option_count - 1)
|
||||
# Restoring the index re-posts `OptionHighlighted`, but only on a
|
||||
# non-empty list, and only a frame later. Rewrite the footer here so
|
||||
# an emptied list can't keep a stale action, and so a saved key can't
|
||||
# leave the old label on screen for a frame — the rows resort, so the
|
||||
# provider under an unmoved cursor may have changed.
|
||||
restored = option_list.highlighted
|
||||
self._update_manager_help(
|
||||
option_list.get_option_at_index(restored).id
|
||||
if restored is not None
|
||||
else None
|
||||
)
|
||||
|
||||
def _build_options_with_warning(self) -> tuple[list[Option], str | None]:
|
||||
"""Render the option list, returning a corruption warning if any.
|
||||
@@ -1880,6 +1985,7 @@ class AuthManagerScreen(ModalScreen[None]):
|
||||
f"Credential file is unreadable ({exc}). "
|
||||
"Saving a key here will overwrite it."
|
||||
)
|
||||
self._stored_providers = stored
|
||||
|
||||
config = ModelConfig.load()
|
||||
config_providers = {
|
||||
@@ -1917,12 +2023,14 @@ class AuthManagerScreen(ModalScreen[None]):
|
||||
self._install_extras = self._uninstalled_known_providers(config, shown)
|
||||
|
||||
# Resolve each manageable entry's auth status once and reuse it for
|
||||
# both ordering and badge rendering. `_auth_status_for` reads the
|
||||
# credential file, so resolving it separately in the sort key and in
|
||||
# `_format_label` would read `auth.json` twice per row (and, on a
|
||||
# corrupt store, log the same warning twice). A single pass halves both.
|
||||
# ordering, badge rendering, and the footer's Enter action.
|
||||
# `_auth_status_for` reads the credential file, so resolving it
|
||||
# separately in the sort key and in `_format_label` would read
|
||||
# `auth.json` twice per row (and, on a corrupt store, log the same
|
||||
# warning twice). A single pass halves both.
|
||||
services = set(SERVICE_API_KEY_ENV) - shown - set(self._install_extras)
|
||||
status_by_key = {key: _auth_status_for(key) for key in shown | services}
|
||||
self._auth_statuses = status_by_key
|
||||
|
||||
# Float entries that already have a credential configured to the top so
|
||||
# the keys a user is actively using are easiest to find; everything else
|
||||
|
||||
@@ -2176,6 +2176,55 @@ api_key_env = "MY_GATEWAY_API_KEY"
|
||||
after = current_ids()
|
||||
assert after.index("openai") < after.index("anthropic")
|
||||
|
||||
async def test_refresh_options_updates_footer_for_resorted_row(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A rebuild rewrites the footer for whatever row the cursor now sits on.
|
||||
|
||||
`_refresh_options` preserves the highlighted *index*, and saving a key
|
||||
re-floats its provider, so the provider under an unmoved cursor
|
||||
changes. The footer has to follow the row rather than the index, or it
|
||||
promises an action Enter will not perform. Asserted before any
|
||||
`pilot.pause()` so the rebuild's own footer write is what satisfies
|
||||
it, not the re-posted `OptionHighlighted` a frame later.
|
||||
"""
|
||||
for var in (
|
||||
"OPENAI_API_KEY",
|
||||
"DEEPAGENTS_CODE_OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"DEEPAGENTS_CODE_ANTHROPIC_API_KEY",
|
||||
"LANGSMITH_API_KEY",
|
||||
"DEEPAGENTS_CODE_LANGSMITH_API_KEY",
|
||||
"TAVILY_API_KEY",
|
||||
"DEEPAGENTS_CODE_TAVILY_API_KEY",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
# Only these two count as installed, so the manageable rows are
|
||||
# `anthropic`, `openai`, and the two services — a short enough list
|
||||
# that the alphabetical head is predictable.
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.config_manifest.is_provider_package_installed",
|
||||
lambda provider: provider in {"openai", "anthropic"},
|
||||
)
|
||||
app = _AuthHostApp()
|
||||
async with app.run_test() as pilot:
|
||||
app.show_manager()
|
||||
await pilot.pause()
|
||||
options = app.screen.query_one("#auth-manager-options", OptionList)
|
||||
help_text = app.screen.query_one("#auth-manager-help", Static)
|
||||
# Nothing is configured yet, so rows are alphabetical and
|
||||
# `anthropic` leads.
|
||||
assert options.highlighted == 0
|
||||
assert options.get_option_at_index(0).id == "anthropic"
|
||||
assert "Enter add " in str(help_text.content)
|
||||
|
||||
auth_store.set_stored_key("openai", "k")
|
||||
screen = cast("AuthManagerScreen", app.screen)
|
||||
screen._refresh_options()
|
||||
# Index 0 is now `openai`, which has a stored key to replace.
|
||||
assert options.get_option_at_index(0).id == "openai"
|
||||
assert "Enter replace/delete " in str(help_text.content)
|
||||
|
||||
async def test_configured_group_preserves_alphabetical_order(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
@@ -2638,14 +2687,97 @@ enabled = false
|
||||
# surfaces in the rendered span representation.
|
||||
assert "providers" in repr(copy.content) or "providers" in content
|
||||
|
||||
async def test_footer_lists_full_action_set(self) -> None:
|
||||
"""Footer mentions add/replace/delete (delete happens via the prompt)."""
|
||||
async def test_footer_tracks_highlighted_action(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Footer names the action exposed by the highlighted provider row.
|
||||
|
||||
Pins all four non-codex branches of `_action_for_provider`: a stored
|
||||
key offers replace/delete, an env-only key offers replace, a known
|
||||
service with neither offers add, and an uninstalled provider offers
|
||||
install. The `set_stored_key`/`setenv` split is what separates the
|
||||
first two — scrubbing the env vars first keeps an ambient key from
|
||||
turning `tavily` or `openai` into a false pass.
|
||||
"""
|
||||
for var in (
|
||||
"OPENAI_API_KEY",
|
||||
"DEEPAGENTS_CODE_OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"DEEPAGENTS_CODE_ANTHROPIC_API_KEY",
|
||||
"TAVILY_API_KEY",
|
||||
"DEEPAGENTS_CODE_TAVILY_API_KEY",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "from-env")
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.config_manifest.is_provider_package_installed",
|
||||
lambda provider: provider in {"openai", "anthropic"},
|
||||
)
|
||||
auth_store.set_stored_key("openai", "stored-key")
|
||||
|
||||
app = _AuthHostApp()
|
||||
async with app.run_test() as pilot:
|
||||
app.show_manager()
|
||||
await pilot.pause()
|
||||
help_text = app.screen.query_one(".auth-manager-help", Static)
|
||||
assert "add/replace/delete" in str(help_text.content)
|
||||
options = app.screen.query_one("#auth-manager-options", OptionList)
|
||||
help_text = app.screen.query_one("#auth-manager-help", Static)
|
||||
# Assert the mount-time footer before navigating, so deleting the
|
||||
# highlight handler can't be masked by dict ordering below.
|
||||
initial = str(help_text.content)
|
||||
assert "Enter replace " in initial, initial
|
||||
assert "Esc close" in initial, initial
|
||||
assert "navigate" in initial, initial
|
||||
expected_actions = {
|
||||
"openai": "replace/delete",
|
||||
"anthropic": "replace",
|
||||
"tavily": "add",
|
||||
"groq": "install",
|
||||
}
|
||||
for provider, action in expected_actions.items():
|
||||
index = next(
|
||||
i
|
||||
for i in range(options.option_count)
|
||||
if options.get_option_at_index(i).id == provider
|
||||
)
|
||||
options.highlighted = index
|
||||
assert options.highlighted == index
|
||||
await pilot.pause()
|
||||
assert f"Enter {action} " in str(help_text.content)
|
||||
|
||||
async def test_footer_uses_stored_snapshot_for_custom_providers(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Stored custom keys expose replace/delete regardless of auth status."""
|
||||
config_path = tmp_path / "config.toml"
|
||||
config_path.write_text("""
|
||||
[models.providers.managed_provider]
|
||||
class_path = "example.models:ManagedChat"
|
||||
models = ["managed-model"]
|
||||
""")
|
||||
monkeypatch.setattr(model_config, "DEFAULT_CONFIG_PATH", config_path)
|
||||
model_config.clear_caches()
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.config_manifest.is_provider_package_installed",
|
||||
lambda provider: provider == "openai",
|
||||
)
|
||||
auth_store.set_stored_key("unknown_provider", "test-key")
|
||||
auth_store.set_stored_key("managed_provider", "test-key")
|
||||
|
||||
app = _AuthHostApp()
|
||||
async with app.run_test() as pilot:
|
||||
app.show_manager()
|
||||
await pilot.pause()
|
||||
options = app.screen.query_one("#auth-manager-options", OptionList)
|
||||
help_text = app.screen.query_one("#auth-manager-help", Static)
|
||||
for provider in ("unknown_provider", "managed_provider"):
|
||||
index = next(
|
||||
i
|
||||
for i in range(options.option_count)
|
||||
if options.get_option_at_index(i).id == provider
|
||||
)
|
||||
options.highlighted = index
|
||||
await pilot.pause()
|
||||
assert "Enter replace/delete " in str(help_text.content)
|
||||
|
||||
async def test_corrupt_store_surfaces_warning_banner(
|
||||
self, fake_state_dir: Path
|
||||
@@ -2773,6 +2905,9 @@ enabled = false
|
||||
break
|
||||
assert target_index is not None
|
||||
options.highlighted = target_index
|
||||
await pilot.pause()
|
||||
help_text = app.screen.query_one("#auth-manager-help", Static)
|
||||
assert "Enter sign in " in str(help_text.content)
|
||||
# We just need to observe that the screen is pushed *before* the
|
||||
# fake worker finishes; capture the screen class via the
|
||||
# `screen_stack` instead of asserting on `app.screen` (which the
|
||||
@@ -2859,6 +2994,9 @@ enabled = false
|
||||
break
|
||||
assert target_index is not None
|
||||
options.highlighted = target_index
|
||||
await pilot.pause()
|
||||
help_text = app.screen.query_one("#auth-manager-help", Static)
|
||||
assert "Enter manage " in str(help_text.content)
|
||||
pushed: list[type] = []
|
||||
original = app.push_screen
|
||||
|
||||
@@ -2871,9 +3009,67 @@ enabled = false
|
||||
await pilot.pause()
|
||||
assert CodexSignedInScreen in pushed
|
||||
|
||||
async def test_codex_expired_token_shows_manage_and_pushes_signed_in(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""An expired access token is still a session: manage, not sign in.
|
||||
|
||||
The row badges an expired token as `[chatgpt]` because the saved
|
||||
refresh token renews it on use, so the footer has to agree — and
|
||||
sign-out has to stay reachable, which only the signed-in overlay
|
||||
offers.
|
||||
"""
|
||||
from deepagents_code.integrations import openai_codex as codex_integration
|
||||
from deepagents_code.model_config import clear_caches
|
||||
from deepagents_code.tui.widgets.codex_auth import (
|
||||
CodexAuthScreen,
|
||||
CodexSignedInScreen,
|
||||
)
|
||||
|
||||
path = tmp_path / "auth.json"
|
||||
self._write_token(path, expired=True)
|
||||
monkeypatch.setattr(codex_integration, "default_store_path", lambda: path)
|
||||
|
||||
async def _fake_run( # noqa: RUF029 # async signature dictated by protocol
|
||||
*_args: object, **_kwargs: object
|
||||
) -> codex_integration.CodexAuthStatus:
|
||||
return codex_integration.CodexAuthStatus(logged_in=False, store_path=path)
|
||||
|
||||
monkeypatch.setattr(codex_integration, "run_browser_login", _fake_run)
|
||||
clear_caches()
|
||||
app = _AuthHostApp()
|
||||
async with app.run_test() as pilot:
|
||||
app.show_manager()
|
||||
await pilot.pause()
|
||||
options = app.screen.query_one("#auth-manager-options", OptionList)
|
||||
target_index = next(
|
||||
i
|
||||
for i in range(options.option_count)
|
||||
if options.get_option_at_index(i).id == "openai_codex"
|
||||
)
|
||||
options.highlighted = target_index
|
||||
await pilot.pause()
|
||||
help_text = app.screen.query_one("#auth-manager-help", Static)
|
||||
assert "Enter manage " in str(help_text.content)
|
||||
pushed: list[type] = []
|
||||
original = app.push_screen
|
||||
|
||||
def _capture(screen, *args, **kwargs): # noqa: ANN002, ANN003, ANN202
|
||||
pushed.append(type(screen))
|
||||
return original(screen, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(app, "push_screen", _capture)
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
assert CodexSignedInScreen in pushed
|
||||
assert CodexAuthScreen not in pushed
|
||||
|
||||
@staticmethod
|
||||
def _write_token(path: Path) -> None:
|
||||
"""Plant a valid (unexpired) token bundle at `path` with 0600 perms."""
|
||||
def _write_token(path: Path, *, expired: bool = False) -> None:
|
||||
"""Write a ChatGPT token bundle at `path` with 0600 permissions.
|
||||
|
||||
The token is unexpired unless `expired` is true.
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
@@ -2883,7 +3079,9 @@ enabled = false
|
||||
{
|
||||
"access_token": "fake",
|
||||
"refresh_token": "fake",
|
||||
"expires_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat(),
|
||||
"expires_at": (
|
||||
datetime.now(UTC) + timedelta(hours=-1 if expired else 1)
|
||||
).isoformat(),
|
||||
"account_id": "acct",
|
||||
"plan_type": "plus",
|
||||
"user_id": "u",
|
||||
@@ -2897,7 +3095,12 @@ enabled = false
|
||||
async def test_signout_dispatch_deletes_token(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""`SIGN_OUT` from the overlay deletes the stored token on disk."""
|
||||
"""`SIGN_OUT` from the overlay deletes the token and re-labels the row.
|
||||
|
||||
The footer reads the status cached when the option list was built, so
|
||||
signing out only re-labels the row if the dispatch invalidates that
|
||||
cache — this fails if either `clear_caches` or the refresh is dropped.
|
||||
"""
|
||||
from deepagents_code.integrations import openai_codex as codex_integration
|
||||
from deepagents_code.model_config import clear_caches
|
||||
from deepagents_code.tui.widgets.codex_auth import CodexSignedInAction
|
||||
@@ -2910,9 +3113,29 @@ enabled = false
|
||||
async with app.run_test() as pilot:
|
||||
app.show_manager()
|
||||
await pilot.pause()
|
||||
options = app.screen.query_one("#auth-manager-options", OptionList)
|
||||
help_text = app.screen.query_one("#auth-manager-help", Static)
|
||||
options.highlighted = next(
|
||||
i
|
||||
for i in range(options.option_count)
|
||||
if options.get_option_at_index(i).id == "openai_codex"
|
||||
)
|
||||
await pilot.pause()
|
||||
assert "Enter manage " in str(help_text.content)
|
||||
|
||||
manager = cast("AuthManagerScreen", app.screen)
|
||||
manager._on_codex_signed_in_closed(CodexSignedInAction.SIGN_OUT)
|
||||
await pilot.pause()
|
||||
# Signing out drops the row out of the configured block, so the
|
||||
# cursor's index now points at a different provider; re-find the
|
||||
# ChatGPT row rather than assuming the cursor followed it.
|
||||
options.highlighted = next(
|
||||
i
|
||||
for i in range(options.option_count)
|
||||
if options.get_option_at_index(i).id == "openai_codex"
|
||||
)
|
||||
await pilot.pause()
|
||||
assert "Enter sign in " in str(help_text.content)
|
||||
assert not path.exists()
|
||||
|
||||
async def test_reauth_dispatch_pushes_oauth_screen(
|
||||
|
||||
Reference in New Issue
Block a user