mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
feat(code): allow dependency updates without requiring release (#4157)
`/update --deps` (and a prompt on `/update` when already current) refreshes dependencies to their newest in-range versions without waiting for a new release. --- Dependencies are range-pinned, so newer in-range releases (e.g. a fresh `langchain-openai`) are already permitted by a published `deepagents-code` — but `/update` only ever checked the dcode version on PyPI and dead-ended at "Already on the latest version", so users had to drop to raw `uv` to pull them in. This makes the refresh first-class in the TUI: - `/update --deps` re-resolves the tool environment via `uv tool install -U deepagents-code==<current>` (`perform_dependency_refresh`), so compatible dependency releases are picked up without crossing to a newer app version. If a dcode update also happens to be available, `/update --deps` first asks (via `UpdateBeforeDependenciesConfirmScreen`) whether to take the app update instead; declining continues with the deps-only refresh. - Plain `/update`, when dcode is already current, computes a preflight dry-run plan first and only then offers the refresh through a confirmation modal (`RefreshDependenciesConfirmScreen`, which previews the planned changes) instead of dead-ending. - Both paths parse uv's environment diff and report exactly which dependencies moved; a normal version bump also surfaces any dependency changes that rode along. Editable (and other non-uv) installs are still refused, and a dep refresh stays explicit/confirmed (it is not folded into silent background auto-update), consistent with the "announce before running shell" rule. `uv tool install`/`upgrade` has no `--dry-run`, so the preflight plan is computed against the running tool environment with `uv pip install --dry-run --python <sys.executable>` (`perform_dependency_refresh_dry_run`), using the same pinned requirement, extras, and preserved `--with` packages as the real refresh. Made by [Open SWE](https://openswe.vercel.app/agents/d64c7415-11cc-947f-f5f3-67aca0ec21ed) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
@@ -3873,8 +3873,10 @@ class DeepAgentsApp(App):
|
||||
async def _handle_update_command(self, command: str = "/update") -> None:
|
||||
"""Handle the `/update` slash command — check for and install updates.
|
||||
|
||||
Parses an optional `--prerelease` flag from the raw command line; any
|
||||
other option is rejected with a usage message.
|
||||
Parses optional `--prerelease` and `--deps` flags from the raw command
|
||||
line; any other option is rejected with a usage message. `--deps`
|
||||
re-resolves dependencies to their newest in-range versions even when
|
||||
`deepagents-code` itself is already current.
|
||||
|
||||
Args:
|
||||
command: The raw slash-command line as typed, including any options.
|
||||
@@ -3885,16 +3887,18 @@ class DeepAgentsApp(App):
|
||||
# loudly instead of silently downgrading to a stable update — mirroring
|
||||
# the headless path, which also refuses unknown options rather than
|
||||
# silently ignoring them.
|
||||
unknown = [opt for opt in parts[1:] if opt != "--prerelease"]
|
||||
allowed_options = {"--prerelease", "--deps"}
|
||||
unknown = [opt for opt in parts[1:] if opt not in allowed_options]
|
||||
if unknown:
|
||||
await self._mount_message(
|
||||
AppMessage(
|
||||
f"Unknown option(s) for /update: {' '.join(unknown)}. "
|
||||
"Usage: /update [--prerelease]",
|
||||
"Usage: /update [--deps] [--prerelease]",
|
||||
),
|
||||
)
|
||||
return
|
||||
prerelease_requested = "--prerelease" in parts[1:]
|
||||
deps_only = "--deps" in parts[1:]
|
||||
include_prereleases = True if prerelease_requested else None
|
||||
try:
|
||||
from deepagents_code._env_vars import DEBUG_UPDATE
|
||||
@@ -3902,10 +3906,14 @@ class DeepAgentsApp(App):
|
||||
from deepagents_code.config import _is_editable_install
|
||||
from deepagents_code.update_check import (
|
||||
_PRERELEASE_UNSUPPORTED_MESSAGE,
|
||||
dependency_refresh_supported,
|
||||
format_age_suffix,
|
||||
format_dependency_changes,
|
||||
format_installed_age_suffix,
|
||||
format_release_age_parenthetical,
|
||||
is_update_available,
|
||||
parse_dependency_changes,
|
||||
perform_dependency_refresh_dry_run,
|
||||
perform_upgrade,
|
||||
prerelease_upgrade_supported,
|
||||
upgrade_command,
|
||||
@@ -3948,14 +3956,72 @@ class DeepAgentsApp(App):
|
||||
)
|
||||
return
|
||||
if not available:
|
||||
if deps_only:
|
||||
await self._refresh_dependencies(
|
||||
include_prereleases=include_prereleases,
|
||||
)
|
||||
return
|
||||
|
||||
age_suffix = await asyncio.to_thread(format_age_suffix, cli_version)
|
||||
await self._mount_message(
|
||||
AppMessage(
|
||||
f"Already on the latest version (v{cli_version}{age_suffix}).",
|
||||
),
|
||||
)
|
||||
# dcode is current, but its dependencies may have newer in-range
|
||||
# releases. Compute a dry-run plan first so the confirmation only
|
||||
# appears when there are concrete updates to apply. Keep the support
|
||||
# gate before the check so brew/other users aren't asked about an
|
||||
# action that cannot run for their install.
|
||||
refresh_supported, _reason = await asyncio.to_thread(
|
||||
dependency_refresh_supported,
|
||||
)
|
||||
if not refresh_supported:
|
||||
return
|
||||
await self._mount_message(
|
||||
AppMessage("Checking for dependency updates...")
|
||||
)
|
||||
success, output = await perform_dependency_refresh_dry_run(
|
||||
include_prereleases=include_prereleases,
|
||||
)
|
||||
if not success:
|
||||
detail = f": {output[:200]}" if output else ""
|
||||
await self._mount_message(
|
||||
AppMessage(f"Could not check dependency updates{detail}"),
|
||||
)
|
||||
return
|
||||
dep_changes = parse_dependency_changes(output)
|
||||
if not dep_changes:
|
||||
await self._mount_message(
|
||||
AppMessage("Dependencies are already up to date."),
|
||||
)
|
||||
return
|
||||
planned = format_dependency_changes(dep_changes)
|
||||
if await self._confirm_refresh_dependencies(planned_changes=planned):
|
||||
await self._refresh_dependencies(
|
||||
include_prereleases=include_prereleases,
|
||||
)
|
||||
else:
|
||||
await self._mount_message(AppMessage("Dependency refresh skipped."))
|
||||
return
|
||||
|
||||
if deps_only:
|
||||
refresh_supported, _reason = await asyncio.to_thread(
|
||||
dependency_refresh_supported,
|
||||
)
|
||||
if (
|
||||
refresh_supported
|
||||
and not await self._confirm_update_before_dependency_refresh(
|
||||
current=cli_version,
|
||||
latest=latest,
|
||||
)
|
||||
):
|
||||
await self._refresh_dependencies(
|
||||
include_prereleases=include_prereleases,
|
||||
app_update_version=latest,
|
||||
)
|
||||
return
|
||||
|
||||
release_age = await asyncio.to_thread(
|
||||
format_release_age_parenthetical,
|
||||
latest,
|
||||
@@ -3988,6 +4054,20 @@ class DeepAgentsApp(App):
|
||||
"not the CLI)."
|
||||
),
|
||||
)
|
||||
# The upgrade re-resolves the whole environment, so surface any
|
||||
# dependency bumps that rode along with the dcode release.
|
||||
dep_changes = [
|
||||
change
|
||||
for change in parse_dependency_changes(output)
|
||||
if change.name != "deepagents-code"
|
||||
]
|
||||
if dep_changes:
|
||||
await self._mount_message(
|
||||
AppMessage(
|
||||
"Dependencies updated:\n"
|
||||
f"{format_dependency_changes(dep_changes)}",
|
||||
),
|
||||
)
|
||||
else:
|
||||
cmd = upgrade_command(include_prereleases=include_prereleases)
|
||||
detail = f": {output[:200]}" if output else ""
|
||||
@@ -4000,6 +4080,204 @@ class DeepAgentsApp(App):
|
||||
ErrorMessage(f"Update failed: {type(exc).__name__}: {exc}"),
|
||||
)
|
||||
|
||||
async def _refresh_dependencies(
|
||||
self,
|
||||
*,
|
||||
include_prereleases: bool | None,
|
||||
app_update_version: str | None = None,
|
||||
) -> None:
|
||||
"""Re-resolve dependencies to their newest in-range versions.
|
||||
|
||||
Reinstalls the current `deepagents-code` version with an upgraded
|
||||
dependency resolution, then reports which dependencies actually moved.
|
||||
Used by the `/update --deps` and already-current refresh flows. Editable
|
||||
installs are rejected by the caller before this runs; the refresh is
|
||||
uv-only by construction (other install methods are refused by
|
||||
`perform_dependency_refresh`), so pre-release resolution is always
|
||||
available here.
|
||||
|
||||
Args:
|
||||
include_prereleases: Whether to include alpha/beta/rc releases;
|
||||
`None` follows the installed version's channel.
|
||||
app_update_version: Newer `deepagents-code` version discovered by
|
||||
the caller, if dependency refresh is intentionally staying on
|
||||
the current app version.
|
||||
"""
|
||||
from deepagents_code._env_vars import DEBUG_UPDATE
|
||||
from deepagents_code.update_check import (
|
||||
format_dependency_changes,
|
||||
parse_dependency_changes,
|
||||
perform_dependency_refresh,
|
||||
)
|
||||
|
||||
await self._mount_message(AppMessage("Refreshing dependencies..."))
|
||||
if os.environ.get(DEBUG_UPDATE):
|
||||
await self._mount_message(
|
||||
AppMessage("Skipped dependency refresh (debug mode)."),
|
||||
)
|
||||
return
|
||||
success, output = await perform_dependency_refresh(
|
||||
include_prereleases=include_prereleases,
|
||||
)
|
||||
if not success:
|
||||
# Lead with the start of the output for parity with the upgrade
|
||||
# failure path; uv prints the actionable summary (e.g. "No solution
|
||||
# found") first. The full output is persisted to the update log.
|
||||
detail = f": {output[:200]}" if output else ""
|
||||
await self._mount_message(
|
||||
AppMessage(
|
||||
f"Dependency refresh failed{detail}",
|
||||
),
|
||||
)
|
||||
return
|
||||
changes = parse_dependency_changes(output)
|
||||
if output.strip() and not changes:
|
||||
# The refresh succeeded but nothing parsed out of uv's diff. Either
|
||||
# nothing moved, or uv's output format drifted past our parser. Leave
|
||||
# a breadcrumb so the latter doesn't masquerade as "up to date"
|
||||
# without a trace (the raw output is retained in the update log).
|
||||
logger.warning(
|
||||
"Dependency refresh produced no parseable changes; uv output "
|
||||
"format may have drifted.",
|
||||
)
|
||||
self_changes = [
|
||||
change for change in changes if change.name == "deepagents-code"
|
||||
]
|
||||
dep_changes = [change for change in changes if change.name != "deepagents-code"]
|
||||
if not dep_changes and not self_changes:
|
||||
if app_update_version is not None:
|
||||
await self._mount_message(
|
||||
AppMessage(
|
||||
"Dependencies are already up to date. "
|
||||
"A deepagents-code update is available: "
|
||||
f"v{app_update_version}.",
|
||||
),
|
||||
)
|
||||
return
|
||||
await self._mount_message(
|
||||
AppMessage("Dependencies are already up to date."),
|
||||
)
|
||||
return
|
||||
|
||||
message_parts: list[str] = []
|
||||
if self_changes:
|
||||
message_parts.append(
|
||||
f"Updated deepagents-code:\n{format_dependency_changes(self_changes)}"
|
||||
)
|
||||
if dep_changes:
|
||||
message_parts.append(
|
||||
f"Refreshed dependencies:\n{format_dependency_changes(dep_changes)}"
|
||||
)
|
||||
if app_update_version is not None:
|
||||
message_parts.append(
|
||||
f"A deepagents-code update is available: v{app_update_version}."
|
||||
)
|
||||
await self._mount_message(
|
||||
AppMessage(
|
||||
"\n".join(message_parts) + "\nQuit and relaunch dcode to use them.",
|
||||
),
|
||||
)
|
||||
|
||||
async def _confirm_update_before_dependency_refresh(
|
||||
self,
|
||||
*,
|
||||
current: str,
|
||||
latest: str,
|
||||
) -> bool:
|
||||
"""Ask whether `/update --deps` should take an app update first.
|
||||
|
||||
Args:
|
||||
current: Currently running `deepagents-code` version.
|
||||
latest: Latest available `deepagents-code` version.
|
||||
|
||||
Returns:
|
||||
`True` only when the user explicitly chooses the app update; `False`
|
||||
on cancel, timeout, or mount failure so `/update --deps` continues
|
||||
with the requested dependency refresh.
|
||||
"""
|
||||
from deepagents_code.widgets.update_confirm import (
|
||||
UpdateBeforeDependenciesConfirmScreen,
|
||||
)
|
||||
|
||||
try:
|
||||
confirmed = await asyncio.wait_for(
|
||||
self._push_screen_wait(
|
||||
UpdateBeforeDependenciesConfirmScreen(
|
||||
current=current,
|
||||
latest=latest,
|
||||
)
|
||||
),
|
||||
timeout=_MODAL_WATCHDOG_TIMEOUT_SECONDS,
|
||||
)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"App-update confirmation timed out; continuing dependency refresh",
|
||||
)
|
||||
await self._mount_message(
|
||||
AppMessage(
|
||||
"Update prompt timed out; refreshing dependencies for the "
|
||||
"current version instead.",
|
||||
),
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
logger.exception("Failed to mount app-update confirmation")
|
||||
await self._mount_message(
|
||||
AppMessage(
|
||||
"Couldn't show the update prompt; refreshing dependencies "
|
||||
"for the current version instead.",
|
||||
),
|
||||
)
|
||||
return False
|
||||
return confirmed is True
|
||||
|
||||
async def _confirm_refresh_dependencies(
|
||||
self,
|
||||
*,
|
||||
planned_changes: str | None = None,
|
||||
) -> bool:
|
||||
"""Ask the user to confirm a dependency refresh via a modal.
|
||||
|
||||
A watchdog bounds the wait so a modal that never resolves can't wedge
|
||||
command handling; a timeout or mount failure is treated as a cancel.
|
||||
|
||||
Args:
|
||||
planned_changes: Optional preflight summary to show before confirming.
|
||||
|
||||
Returns:
|
||||
`True` only when the user explicitly confirmed; `False` on cancel,
|
||||
timeout, or mount failure.
|
||||
"""
|
||||
from deepagents_code.widgets.update_confirm import (
|
||||
RefreshDependenciesConfirmScreen,
|
||||
)
|
||||
|
||||
try:
|
||||
confirmed = await asyncio.wait_for(
|
||||
self._push_screen_wait(
|
||||
RefreshDependenciesConfirmScreen(planned_changes=planned_changes),
|
||||
),
|
||||
timeout=_MODAL_WATCHDOG_TIMEOUT_SECONDS,
|
||||
)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"Dependency-refresh confirmation timed out; treating as cancel",
|
||||
)
|
||||
await self._mount_message(
|
||||
AppMessage("Dependency-refresh prompt timed out; skipping."),
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
logger.exception("Failed to mount dependency-refresh confirmation")
|
||||
await self._mount_message(
|
||||
AppMessage(
|
||||
"Couldn't show the dependency-refresh prompt; skipping. "
|
||||
"See logs for details.",
|
||||
),
|
||||
)
|
||||
return False
|
||||
return confirmed is True
|
||||
|
||||
async def _handle_install_command(self, command: str) -> None:
|
||||
"""Handle the `/install <extra>` slash command.
|
||||
|
||||
|
||||
@@ -189,7 +189,8 @@ COMMANDS: tuple[SlashCommand, ...] = (
|
||||
name="/update",
|
||||
description="Check for and install updates",
|
||||
bypass_tier=BypassTier.QUEUED,
|
||||
hidden_keywords="upgrade",
|
||||
hidden_keywords="upgrade dependencies deps refresh",
|
||||
argument_hint="[--deps] [--prerelease]",
|
||||
),
|
||||
SlashCommand(
|
||||
name="/install",
|
||||
|
||||
@@ -25,16 +25,15 @@ import tomllib
|
||||
from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
|
||||
from contextlib import suppress
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any, Literal, TextIO
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, NamedTuple, TextIO
|
||||
|
||||
from packaging.utils import canonicalize_name
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from deepagents_code._version import PYPI_URL, SDK_PYPI_URL, USER_AGENT, __version__
|
||||
from deepagents_code.model_config import DEFAULT_CONFIG_PATH, DEFAULT_STATE_DIR
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CACHE_FILE: Path = DEFAULT_STATE_DIR / "latest_version.json"
|
||||
@@ -843,6 +842,46 @@ def prerelease_upgrade_supported(
|
||||
return True, None
|
||||
|
||||
|
||||
_DEPENDENCY_REFRESH_UNSUPPORTED: dict[InstallMethod, str] = {
|
||||
"unknown": "Editable install detected — skipping dependency refresh.",
|
||||
"brew": (
|
||||
"Homebrew install detected — dependency-only refresh is not "
|
||||
"supported without upgrading deepagents-code."
|
||||
),
|
||||
"other": (
|
||||
"Unsupported install method detected — cannot refresh dependencies "
|
||||
"without knowing which environment provides `dcode`."
|
||||
),
|
||||
}
|
||||
"""Why each non-uv install method can't do a dependency-only refresh."""
|
||||
|
||||
|
||||
def dependency_refresh_supported(
|
||||
method: InstallMethod | None = None,
|
||||
) -> tuple[bool, str | None]:
|
||||
"""Return whether a dependency-only refresh is possible for the install.
|
||||
|
||||
A dependency refresh reinstalls the *current* `deepagents-code` version with
|
||||
upgraded dependency resolution (`uv tool install -U deepagents-code==<v>`).
|
||||
Only uv-managed installs can express that without crossing to a newer app
|
||||
version, so callers should refuse before prompting or shelling out. This is
|
||||
the single source of truth for both the gate in the TUI and the refusal in
|
||||
`perform_dependency_refresh`.
|
||||
|
||||
Args:
|
||||
method: Install method override. Auto-detected if `None`.
|
||||
|
||||
Returns:
|
||||
A `(supported, reason)` tuple. `reason` is `None` when supported, else a
|
||||
user-facing explanation of why the refresh is refused.
|
||||
"""
|
||||
if method is None:
|
||||
method = detect_install_method()
|
||||
if method == "uv":
|
||||
return True, None
|
||||
return False, _DEPENDENCY_REFRESH_UNSUPPORTED[method]
|
||||
|
||||
|
||||
def cleanup_update_logs(
|
||||
*,
|
||||
retention_days: int = UPDATE_LOG_RETENTION_DAYS,
|
||||
@@ -1062,6 +1101,214 @@ async def perform_upgrade(
|
||||
return await _run_install_subprocess(cmd, progress=progress, log_path=log_path)
|
||||
|
||||
|
||||
async def perform_dependency_refresh(
|
||||
*,
|
||||
progress: UpgradeProgressCallback | None = None,
|
||||
log_path: Path | None = None,
|
||||
include_prereleases: bool | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""Refresh dependencies while keeping `deepagents-code` on this version.
|
||||
|
||||
Runs `uv tool install -U deepagents-code==<current>` instead of
|
||||
`uv tool upgrade deepagents-code`, so compatible dependency releases can be
|
||||
picked up without crossing to a newer app version. Only uv-managed installs
|
||||
are supported; other install methods cannot safely express this operation.
|
||||
|
||||
Args:
|
||||
progress: Optional callback invoked for each output line.
|
||||
log_path: Optional path to persist command output.
|
||||
include_prereleases: Whether to include alpha/beta/rc releases. When
|
||||
`None`, follows the installed version's channel.
|
||||
|
||||
Returns:
|
||||
`(success, output)` — *output* is the combined stdout/stderr, or an
|
||||
explanatory message when the install method is unsupported, `uv` is
|
||||
unavailable, requirement introspection fails, or the subprocess
|
||||
fails or times out.
|
||||
"""
|
||||
supported, reason = dependency_refresh_supported()
|
||||
if not supported:
|
||||
return False, reason or ""
|
||||
if not shutil.which("uv"):
|
||||
return False, "`uv` not found on PATH."
|
||||
|
||||
from deepagents_code.extras_info import ExtrasIntrospectionError
|
||||
|
||||
try:
|
||||
cmd = dependency_refresh_command(
|
||||
include_prereleases=include_prereleases,
|
||||
)
|
||||
except (
|
||||
ExtrasIntrospectionError,
|
||||
ToolRequirementIntrospectionError,
|
||||
ValueError,
|
||||
) as exc:
|
||||
return False, f"{type(exc).__name__}: {exc}"
|
||||
return await _run_install_subprocess(cmd, progress=progress, log_path=log_path)
|
||||
|
||||
|
||||
async def perform_dependency_refresh_dry_run(
|
||||
*,
|
||||
progress: UpgradeProgressCallback | None = None,
|
||||
log_path: Path | None = None,
|
||||
include_prereleases: bool | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""Resolve a dependency refresh plan without mutating the tool environment.
|
||||
|
||||
`uv tool install` has no `--dry-run`, so this targets the running tool
|
||||
environment with `uv pip install --dry-run --python <sys.executable>`. It
|
||||
uses the same pinned `deepagents-code` requirement, installed extras, and
|
||||
preserved `--with` packages as the real refresh command.
|
||||
|
||||
Args:
|
||||
progress: Optional callback invoked for each output line.
|
||||
log_path: Optional path to persist command output.
|
||||
include_prereleases: Whether to include alpha/beta/rc releases. When
|
||||
`None`, follows the installed version's channel.
|
||||
|
||||
Returns:
|
||||
`(success, output)` — *output* is the combined stdout/stderr from uv, or
|
||||
an explanatory message when the plan cannot be computed safely.
|
||||
"""
|
||||
supported, reason = dependency_refresh_supported()
|
||||
if not supported:
|
||||
return False, reason or ""
|
||||
if not shutil.which("uv"):
|
||||
return False, "`uv` not found on PATH."
|
||||
|
||||
from deepagents_code.extras_info import ExtrasIntrospectionError
|
||||
|
||||
try:
|
||||
cmd = dependency_refresh_dry_run_command(
|
||||
include_prereleases=include_prereleases,
|
||||
)
|
||||
except (
|
||||
ExtrasIntrospectionError,
|
||||
ToolRequirementIntrospectionError,
|
||||
ValueError,
|
||||
) as exc:
|
||||
return False, f"{type(exc).__name__}: {exc}"
|
||||
return await _run_install_subprocess(cmd, progress=progress, log_path=log_path)
|
||||
|
||||
|
||||
class DependencyChange(NamedTuple):
|
||||
"""A single package version change parsed from uv's environment-diff output.
|
||||
|
||||
Emitted by both `uv tool upgrade` and `uv tool install -U` (the
|
||||
dependency-refresh path), so the wording stays command-agnostic. `old` is
|
||||
`None` for a newly added package and `new` is `None` for a removed one; both
|
||||
are set for an in-place version bump. The `(None, None)` state is invalid —
|
||||
see `kind`.
|
||||
"""
|
||||
|
||||
name: str
|
||||
"""Package name exactly as uv reported it in the diff line."""
|
||||
|
||||
old: str | None
|
||||
"""Version before the change; `None` when the package was newly added."""
|
||||
|
||||
new: str | None
|
||||
"""Version after the change; `None` when the package was removed."""
|
||||
|
||||
@property
|
||||
def kind(self) -> Literal["added", "removed", "bumped"]:
|
||||
"""Classify the change from which version sides are present.
|
||||
|
||||
Reading the case from here keeps consumers (e.g.
|
||||
`format_dependency_changes`) from re-deriving it via field truthiness,
|
||||
and turns the meaningless `(None, None)` shape into a hard error instead
|
||||
of silently rendering as a removal.
|
||||
|
||||
Returns:
|
||||
`"added"` when only `new` is set, `"removed"` when only `old` is set,
|
||||
and `"bumped"` when both are set.
|
||||
|
||||
Raises:
|
||||
ValueError: If neither `old` nor `new` is set.
|
||||
"""
|
||||
if self.old is None and self.new is None:
|
||||
msg = (
|
||||
f"DependencyChange {self.name!r} records neither an old nor new version"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
if self.old is None:
|
||||
return "added"
|
||||
if self.new is None:
|
||||
return "removed"
|
||||
return "bumped"
|
||||
|
||||
|
||||
_DEP_CHANGE_RE = re.compile(
|
||||
r"^\s*([+-])\s+([A-Za-z0-9._-]+)==([^\s(]+)(?:\s+\(.*\))?\s*$"
|
||||
)
|
||||
"""Matches uv's environment-diff lines, e.g. ` - langchain-openai==1.3.2`.
|
||||
|
||||
The optional trailing group tolerates uv's source annotations for non-PyPI
|
||||
packages, e.g. ` + example==0.1.0 (from file:///path)`.
|
||||
"""
|
||||
|
||||
|
||||
def parse_dependency_changes(output: str) -> list[DependencyChange]:
|
||||
"""Parse package version changes from uv's environment-diff output.
|
||||
|
||||
uv reports environment changes as paired ` - pkg==old` / ` + pkg==new`
|
||||
lines; this collapses them into one `DependencyChange` per package,
|
||||
preserving first-seen order.
|
||||
|
||||
Args:
|
||||
output: Combined stdout/stderr from a `uv tool install`/`upgrade`
|
||||
subprocess.
|
||||
|
||||
Returns:
|
||||
One entry per package whose version was added, removed, or bumped.
|
||||
"""
|
||||
removed: dict[str, str] = {}
|
||||
added: dict[str, str] = {}
|
||||
order: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for line in output.splitlines():
|
||||
match = _DEP_CHANGE_RE.match(line)
|
||||
if match is None:
|
||||
continue
|
||||
sign, name, version = match.group(1), match.group(2), match.group(3)
|
||||
(removed if sign == "-" else added)[name] = version
|
||||
if name not in seen:
|
||||
seen.add(name)
|
||||
order.append(name)
|
||||
return [
|
||||
DependencyChange(name=name, old=removed.get(name), new=added.get(name))
|
||||
for name in order
|
||||
]
|
||||
|
||||
|
||||
def format_dependency_changes(changes: Sequence[DependencyChange]) -> str:
|
||||
"""Render dependency changes as an aligned, human-readable block.
|
||||
|
||||
Args:
|
||||
changes: Parsed changes from `parse_dependency_changes`.
|
||||
|
||||
Returns:
|
||||
A newline-joined, column-aligned summary, or `""` when empty.
|
||||
"""
|
||||
if not changes:
|
||||
return ""
|
||||
width = max(len(change.name) for change in changes)
|
||||
lines: list[str] = []
|
||||
for change in changes:
|
||||
name = change.name.ljust(width)
|
||||
if change.kind == "bumped":
|
||||
lines.append(f" {name} {change.old} -> {change.new}")
|
||||
elif change.kind == "added":
|
||||
lines.append(f" {name} {change.new} (new)")
|
||||
else: # removed
|
||||
lines.append(f" {name} {change.old} (removed)")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class ToolRequirementIntrospectionError(RuntimeError):
|
||||
"""Raised when uv tool requested requirements cannot be preserved."""
|
||||
|
||||
|
||||
_EXTRA_NAME_RE = re.compile(r"^[A-Za-z0-9](?:[-_.A-Za-z0-9]*[A-Za-z0-9])?$")
|
||||
"""Conservative package-extra name pattern used before shell command display."""
|
||||
|
||||
@@ -1094,14 +1341,142 @@ def is_valid_package_name(package: str) -> bool:
|
||||
return bool(_PACKAGE_NAME_RE.fullmatch(package))
|
||||
|
||||
|
||||
def _dcode_extras_requirement(extras: Iterable[str]) -> str:
|
||||
def _uv_tool_receipt_path(tool_root: Path | None = None) -> Path:
|
||||
"""Return the uv receipt path for the current tool environment.
|
||||
|
||||
Args:
|
||||
tool_root: Optional uv tool environment root. Defaults to `sys.prefix`.
|
||||
|
||||
Returns:
|
||||
The expected `uv-receipt.toml` path.
|
||||
"""
|
||||
return (tool_root or Path(sys.prefix)) / "uv-receipt.toml"
|
||||
|
||||
|
||||
def _uv_tool_receipt_data(tool_root: Path | None = None) -> dict[str, Any]:
|
||||
"""Return parsed uv tool receipt data for the current tool environment.
|
||||
|
||||
Args:
|
||||
tool_root: Optional uv tool environment root. Defaults to `sys.prefix`.
|
||||
|
||||
Returns:
|
||||
Parsed TOML data from `uv-receipt.toml`.
|
||||
|
||||
Raises:
|
||||
ToolRequirementIntrospectionError: If the receipt cannot be read or
|
||||
parsed.
|
||||
"""
|
||||
receipt_path = _uv_tool_receipt_path(tool_root)
|
||||
try:
|
||||
return tomllib.loads(receipt_path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as exc:
|
||||
msg = f"uv tool receipt not found at {receipt_path}"
|
||||
raise ToolRequirementIntrospectionError(msg) from exc
|
||||
except (OSError, tomllib.TOMLDecodeError) as exc:
|
||||
msg = f"Could not read uv tool receipt at {receipt_path}: {exc}"
|
||||
raise ToolRequirementIntrospectionError(msg) from exc
|
||||
|
||||
|
||||
def _uv_tool_python(tool_root: Path | None = None) -> str | None:
|
||||
"""Return the Python interpreter recorded in the uv tool receipt.
|
||||
|
||||
Args:
|
||||
tool_root: Optional uv tool environment root. Defaults to `sys.prefix`.
|
||||
|
||||
Returns:
|
||||
The recorded `[tool].python` value, or `None` when the receipt does not
|
||||
pin an interpreter.
|
||||
|
||||
Raises:
|
||||
ToolRequirementIntrospectionError: If the receipt cannot be read, parsed,
|
||||
or safely re-expressed as a `--python` value.
|
||||
"""
|
||||
data = _uv_tool_receipt_data(tool_root)
|
||||
tool = data.get("tool")
|
||||
if not isinstance(tool, dict):
|
||||
msg = "uv tool receipt is missing `[tool]`"
|
||||
raise ToolRequirementIntrospectionError(msg)
|
||||
python = tool.get("python")
|
||||
if python is None:
|
||||
return None
|
||||
if not isinstance(python, str) or not python:
|
||||
msg = "uv tool receipt contains an invalid `[tool].python` value"
|
||||
raise ToolRequirementIntrospectionError(msg)
|
||||
return python
|
||||
|
||||
|
||||
def _uv_tool_with_packages(
|
||||
*,
|
||||
distribution_name: str = "deepagents-code",
|
||||
tool_root: Path | None = None,
|
||||
) -> tuple[str, ...]:
|
||||
"""Return package names recorded as uv tool `--with` requirements.
|
||||
|
||||
uv records the tool's requested requirements in `uv-receipt.toml`. Reading
|
||||
that receipt preserves only packages the user asked uv to keep, avoiding the
|
||||
over-broad fallback of promoting every installed transitive dependency to a
|
||||
top-level `--with` requirement.
|
||||
|
||||
Args:
|
||||
distribution_name: Main tool distribution to exclude from `--with`.
|
||||
tool_root: Optional uv tool environment root. Defaults to `sys.prefix`.
|
||||
|
||||
Returns:
|
||||
A sorted tuple of validated package names to pass as `--with` values.
|
||||
|
||||
Raises:
|
||||
ToolRequirementIntrospectionError: If the receipt cannot be read, parsed,
|
||||
or safely re-expressed as package-name `--with` requirements.
|
||||
"""
|
||||
data = _uv_tool_receipt_data(tool_root)
|
||||
tool = data.get("tool")
|
||||
requirements = tool.get("requirements") if isinstance(tool, dict) else None
|
||||
if not isinstance(requirements, list):
|
||||
msg = "uv tool receipt is missing `[tool].requirements`"
|
||||
raise ToolRequirementIntrospectionError(msg)
|
||||
|
||||
main = canonicalize_name(distribution_name)
|
||||
packages: set[str] = set()
|
||||
for entry in requirements:
|
||||
if not isinstance(entry, dict):
|
||||
msg = "uv tool receipt contains a non-table requirement entry"
|
||||
raise ToolRequirementIntrospectionError(msg)
|
||||
name = entry.get("name")
|
||||
if not isinstance(name, str) or not name:
|
||||
msg = "uv tool receipt contains a requirement without a package name"
|
||||
raise ToolRequirementIntrospectionError(msg)
|
||||
if canonicalize_name(name) == main:
|
||||
continue
|
||||
unsupported_keys = sorted(set(entry) - {"name"})
|
||||
if unsupported_keys:
|
||||
msg = (
|
||||
f"uv tool receipt requirement {name!r} cannot be preserved "
|
||||
"automatically; reinstall it manually after refreshing "
|
||||
"dependencies"
|
||||
)
|
||||
raise ToolRequirementIntrospectionError(msg)
|
||||
if not is_valid_package_name(name):
|
||||
msg = (
|
||||
f"Invalid uv tool receipt package name {name!r}: must match "
|
||||
f"PEP 508 ({_PACKAGE_NAME_RE.pattern})"
|
||||
)
|
||||
raise ToolRequirementIntrospectionError(msg)
|
||||
packages.add(name)
|
||||
return tuple(sorted(packages))
|
||||
|
||||
|
||||
def _dcode_extras_requirement(
|
||||
extras: Iterable[str],
|
||||
*,
|
||||
version: str | None = None,
|
||||
) -> str:
|
||||
"""Return the validated `deepagents-code[...]` requirement for a uv install.
|
||||
|
||||
Shared by the extra- and package-install commands so already-installed
|
||||
extras survive a `uv tool install` reinstall — a bare `deepagents-code`
|
||||
request would replace the tool and drop them. Returns plain
|
||||
`deepagents-code` when no extras are selected; otherwise the single-quoted
|
||||
bracket form, which keeps zsh from globbing the brackets.
|
||||
`deepagents-code` when no extras or version are selected; otherwise the
|
||||
shell-quoted requirement form, which keeps zsh from globbing brackets.
|
||||
|
||||
Args:
|
||||
extras: Extra names to encode. Each is validated against PEP 508
|
||||
@@ -1109,10 +1484,11 @@ def _dcode_extras_requirement(extras: Iterable[str]) -> str:
|
||||
caller-supplied extras (`install_extras_command`) and a
|
||||
redundant re-check for extras read from distribution metadata
|
||||
(`install_package_command`).
|
||||
version: Optional exact `deepagents-code` version pin.
|
||||
|
||||
Returns:
|
||||
Shell-safe requirement token, e.g. `deepagents-code` or
|
||||
`'deepagents-code[baseten,nvidia]'`.
|
||||
`'deepagents-code[baseten,nvidia]==1.0.0'`.
|
||||
|
||||
Raises:
|
||||
ValueError: If any extra fails PEP 508 validation.
|
||||
@@ -1125,10 +1501,109 @@ def _dcode_extras_requirement(extras: Iterable[str]) -> str:
|
||||
f"({_EXTRA_NAME_RE.pattern})"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
if not names:
|
||||
return "deepagents-code"
|
||||
extras_part = ",".join(names)
|
||||
return f"'deepagents-code[{extras_part}]'"
|
||||
version_suffix = ""
|
||||
if version is not None:
|
||||
try:
|
||||
parsed = Version(version)
|
||||
except InvalidVersion as exc:
|
||||
msg = f"Invalid deepagents-code version {version!r}"
|
||||
raise ValueError(msg) from exc
|
||||
version_suffix = f"=={parsed}"
|
||||
extras_part = f"[{','.join(names)}]" if names else ""
|
||||
requirement = f"deepagents-code{extras_part}{version_suffix}"
|
||||
if not names and version is None:
|
||||
return requirement
|
||||
return shlex.quote(requirement)
|
||||
|
||||
|
||||
def dependency_refresh_command(
|
||||
*,
|
||||
version: str = __version__,
|
||||
include_prereleases: bool | None = None,
|
||||
distribution_name: str = "deepagents-code",
|
||||
) -> str:
|
||||
"""Return the uv command that refreshes deps for the current dcode version.
|
||||
|
||||
Args:
|
||||
version: Exact `deepagents-code` version to keep installed.
|
||||
include_prereleases: Whether to include alpha/beta/rc releases. When
|
||||
`None`, follows the installed version's channel.
|
||||
distribution_name: Name of the installed distribution to inspect for
|
||||
already-installed extras.
|
||||
|
||||
Returns:
|
||||
Shell command string suitable for execution via the shell.
|
||||
|
||||
Propagates `ExtrasIntrospectionError` if installed extras cannot be
|
||||
determined safely from distribution metadata, and
|
||||
`ToolRequirementIntrospectionError` if the uv tool `--with` packages or
|
||||
interpreter cannot be determined safely from the tool receipt.
|
||||
`perform_dependency_refresh` converts both into a user-facing failure.
|
||||
"""
|
||||
from deepagents_code.extras_info import installed_extra_names
|
||||
|
||||
# `installed_extra_names` raises `ExtrasIntrospectionError`; `_uv_tool_python`
|
||||
# and `_uv_tool_with_packages` raise `ToolRequirementIntrospectionError`.
|
||||
# Both propagate to `perform_dependency_refresh`, which converts them into a
|
||||
# user-facing failure, so there's nothing to add by catching here.
|
||||
extras = installed_extra_names(distribution_name, strict=True)
|
||||
requirement = _dcode_extras_requirement(extras, version=version)
|
||||
cmd = "uv tool install -U"
|
||||
python = _uv_tool_python()
|
||||
if python is not None:
|
||||
cmd += f" --python {shlex.quote(python)}"
|
||||
cmd += f" {requirement}"
|
||||
with_packages = _uv_tool_with_packages(distribution_name=distribution_name)
|
||||
for package in with_packages:
|
||||
cmd += f" --with {shlex.quote(package)}"
|
||||
if _resolve_include_prereleases(include_prereleases):
|
||||
cmd += " --prerelease allow"
|
||||
return cmd
|
||||
|
||||
|
||||
def dependency_refresh_dry_run_command(
|
||||
*,
|
||||
version: str = __version__,
|
||||
include_prereleases: bool | None = None,
|
||||
distribution_name: str = "deepagents-code",
|
||||
python: str | None = None,
|
||||
) -> str:
|
||||
"""Return the uv command that plans a dependency refresh without installing.
|
||||
|
||||
Args:
|
||||
version: Exact `deepagents-code` version to keep installed.
|
||||
include_prereleases: Whether to include alpha/beta/rc releases. When
|
||||
`None`, follows the installed version's channel.
|
||||
distribution_name: Name of the installed distribution to inspect for
|
||||
already-installed extras and uv receipt requirements.
|
||||
python: Python executable for the target environment. Defaults to the
|
||||
running interpreter, which is the current dcode tool environment.
|
||||
|
||||
Returns:
|
||||
Shell command string suitable for execution via the shell.
|
||||
|
||||
Raises:
|
||||
ToolRequirementIntrospectionError: If the target Python or uv tool receipt
|
||||
requirements cannot be determined safely.
|
||||
"""
|
||||
from deepagents_code.extras_info import installed_extra_names
|
||||
|
||||
target_python = python or sys.executable
|
||||
if not target_python:
|
||||
msg = "Could not determine the running Python executable"
|
||||
raise ToolRequirementIntrospectionError(msg)
|
||||
extras = installed_extra_names(distribution_name, strict=True)
|
||||
requirement = _dcode_extras_requirement(extras, version=version)
|
||||
cmd = (
|
||||
"uv pip install --dry-run --python "
|
||||
f"{shlex.quote(target_python)} -U {requirement}"
|
||||
)
|
||||
with_packages = _uv_tool_with_packages(distribution_name=distribution_name)
|
||||
for package in with_packages:
|
||||
cmd += f" {shlex.quote(package)}"
|
||||
if _resolve_include_prereleases(include_prereleases):
|
||||
cmd += " --prerelease allow"
|
||||
return cmd
|
||||
|
||||
|
||||
def install_package_command(
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Confirmation modals for `/update` dependency-refresh flows in the TUI.
|
||||
|
||||
When `deepagents-code` itself is already on the latest release, `/update` can
|
||||
still re-resolve its dependencies to the newest versions allowed by the pinned
|
||||
ranges (e.g. a new `langchain-openai`). The already-current path dry-runs the
|
||||
resolution first, then asks for explicit confirmation only when dependencies can
|
||||
move. `/update --deps` skips that prompt, but asks before taking an available
|
||||
app update ahead of the dependency refresh for the current app version.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Vertical
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Static
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual.app import ComposeResult
|
||||
|
||||
|
||||
class _DependencyConfirmScreen(ModalScreen[bool]):
|
||||
"""Shared base for the `/update` dependency-refresh confirmation modals.
|
||||
|
||||
Subclasses supply only their bindings and the title/body/help text; the base
|
||||
owns the layout, styling, and the `True`/`False` dismissal contract. The
|
||||
`DEFAULT_CSS` type selector matches subclasses because Textual resolves type
|
||||
selectors against every class name in the MRO.
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
_DependencyConfirmScreen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
_DependencyConfirmScreen > Vertical {
|
||||
width: 66;
|
||||
max-width: 90%;
|
||||
height: auto;
|
||||
background: $surface;
|
||||
border: solid $primary;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
_DependencyConfirmScreen .dependency-confirm-title {
|
||||
text-style: bold;
|
||||
color: $primary;
|
||||
text-align: center;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
_DependencyConfirmScreen .dependency-confirm-body {
|
||||
height: auto;
|
||||
color: $text;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
_DependencyConfirmScreen .dependency-confirm-help {
|
||||
height: 1;
|
||||
color: $text-muted;
|
||||
text-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, *, title: str, body: str, help_text: str) -> None:
|
||||
"""Store the dialog copy for `compose`.
|
||||
|
||||
Args:
|
||||
title: Bold heading shown at the top of the dialog.
|
||||
body: Explanatory paragraph beneath the title.
|
||||
help_text: Muted key-hint row at the bottom.
|
||||
"""
|
||||
super().__init__()
|
||||
self._title = title
|
||||
self._body = body
|
||||
self._help = help_text
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the confirmation dialog.
|
||||
|
||||
Yields:
|
||||
Title, body, and help-row widgets parented inside a `Vertical`.
|
||||
"""
|
||||
with Vertical():
|
||||
yield Static(
|
||||
self._title,
|
||||
classes="dependency-confirm-title",
|
||||
markup=False,
|
||||
)
|
||||
yield Static(
|
||||
self._body,
|
||||
classes="dependency-confirm-body",
|
||||
markup=False,
|
||||
)
|
||||
yield Static(
|
||||
self._help,
|
||||
classes="dependency-confirm-help",
|
||||
markup=False,
|
||||
)
|
||||
|
||||
def action_confirm(self) -> None:
|
||||
"""Dismiss with `True`."""
|
||||
self.dismiss(True)
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
"""Dismiss with `False`.
|
||||
|
||||
The method name must stay `cancel`: the app owns a priority `escape`
|
||||
binding that, for an active `ModalScreen`, dispatches to `action_cancel`
|
||||
if present and otherwise falls through to `dismiss(None)`. Renaming this
|
||||
would silently regress Esc to a `None` dismiss instead of an explicit
|
||||
cancel.
|
||||
"""
|
||||
self.dismiss(False)
|
||||
|
||||
|
||||
class UpdateBeforeDependenciesConfirmScreen(_DependencyConfirmScreen):
|
||||
"""Confirmation overlay before `/update --deps` upgrades dcode itself.
|
||||
|
||||
Dismisses with `True` when the user chooses the app update first and `False`
|
||||
when they prefer to refresh dependencies for the current app version.
|
||||
"""
|
||||
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("enter", "confirm", "Update", show=False, priority=True),
|
||||
Binding("escape", "cancel", "Refresh deps", show=False, priority=True),
|
||||
]
|
||||
|
||||
def __init__(self, *, current: str, latest: str) -> None:
|
||||
"""Create the app-update confirmation dialog.
|
||||
|
||||
Args:
|
||||
current: Currently running `deepagents-code` version.
|
||||
latest: Latest available `deepagents-code` version.
|
||||
"""
|
||||
super().__init__(
|
||||
title="Update dcode first?",
|
||||
body=(
|
||||
f"A newer deepagents-code version is available ({current} -> "
|
||||
f"{latest}). Update dcode now, or refresh dependencies for the "
|
||||
"current version you already have."
|
||||
),
|
||||
help_text="Enter to update dcode, Esc to refresh current dependencies",
|
||||
)
|
||||
|
||||
|
||||
class RefreshDependenciesConfirmScreen(_DependencyConfirmScreen):
|
||||
"""Confirmation overlay for a dependency refresh.
|
||||
|
||||
Dismisses with `True` when the user confirms and `False` when the user
|
||||
cancels. Esc is treated as cancel so the user is never forced into a refresh
|
||||
they did not explicitly choose.
|
||||
"""
|
||||
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("enter", "confirm", "Refresh", show=False, priority=True),
|
||||
Binding("escape", "cancel", "Cancel", show=False, priority=True),
|
||||
]
|
||||
|
||||
def __init__(self, *, planned_changes: str | None = None) -> None:
|
||||
"""Create the dependency-refresh confirmation dialog.
|
||||
|
||||
Args:
|
||||
planned_changes: Optional dry-run summary of dependency updates.
|
||||
"""
|
||||
body = (
|
||||
"deepagents-code is already up to date, but compatible dependency "
|
||||
"updates are available. Refresh to apply these changes:\n\n"
|
||||
f"{planned_changes}"
|
||||
if planned_changes
|
||||
else (
|
||||
"deepagents-code is already up to date, but its dependencies "
|
||||
"can be re-resolved to the newest compatible versions. This may "
|
||||
"pull in newer minor releases of packages like langchain-openai."
|
||||
)
|
||||
)
|
||||
super().__init__(
|
||||
title="Refresh dependencies?",
|
||||
body=body,
|
||||
help_text="Enter to refresh, Esc to cancel",
|
||||
)
|
||||
@@ -5,6 +5,8 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
import time
|
||||
import tomllib
|
||||
from collections.abc import Mapping, Sequence # noqa: TC003
|
||||
@@ -21,17 +23,23 @@ from deepagents_code._version import __version__
|
||||
from deepagents_code.extras_info import ExtrasIntrospectionError, installed_extra_names
|
||||
from deepagents_code.update_check import (
|
||||
CACHE_TTL,
|
||||
DependencyChange,
|
||||
InstallMethod,
|
||||
ToolRequirementIntrospectionError,
|
||||
_extract_release_times,
|
||||
_latest_from_releases,
|
||||
_parse_version,
|
||||
cleanup_update_logs,
|
||||
clear_update_notified,
|
||||
create_update_log_path,
|
||||
dependency_refresh_command,
|
||||
dependency_refresh_dry_run_command,
|
||||
dependency_refresh_supported,
|
||||
detect_install_method,
|
||||
editable_extra_hint,
|
||||
editable_package_hint,
|
||||
format_age_suffix,
|
||||
format_dependency_changes,
|
||||
format_installed_age_suffix,
|
||||
format_release_age,
|
||||
format_release_age_parenthetical,
|
||||
@@ -54,6 +62,9 @@ from deepagents_code.update_check import (
|
||||
mark_auto_update_default_acknowledged,
|
||||
mark_update_notified,
|
||||
mark_version_seen,
|
||||
parse_dependency_changes,
|
||||
perform_dependency_refresh,
|
||||
perform_dependency_refresh_dry_run,
|
||||
perform_install_extra,
|
||||
perform_install_package,
|
||||
perform_upgrade,
|
||||
@@ -122,6 +133,19 @@ def _write_dist_info(
|
||||
dist_info.joinpath("METADATA").write_text("\n".join(metadata), encoding="utf-8")
|
||||
|
||||
|
||||
def _write_uv_receipt(
|
||||
root: Path,
|
||||
requirements: str,
|
||||
*,
|
||||
python: str | None = None,
|
||||
) -> None:
|
||||
python_line = f'python = "{python}"\n' if python is not None else ""
|
||||
root.joinpath("uv-receipt.toml").write_text(
|
||||
f"[tool]\n{python_line}requirements = [{requirements}]\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
class TestParseVersion:
|
||||
def test_basic(self) -> None:
|
||||
assert _parse_version("1.2.3") == Version("1.2.3")
|
||||
@@ -1106,6 +1130,335 @@ class TestUpdateLogs:
|
||||
== "uv tool upgrade deepagents-code --prerelease allow"
|
||||
)
|
||||
|
||||
def test_dependency_refresh_command_pins_current_version(
|
||||
self, tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""Dependency refresh keeps dcode on the running version."""
|
||||
_write_uv_receipt(tmp_path, '{ name = "deepagents-code" }')
|
||||
monkeypatch.setattr("sys.prefix", str(tmp_path))
|
||||
with patch(
|
||||
"deepagents_code.extras_info.installed_extra_names",
|
||||
return_value=frozenset(),
|
||||
):
|
||||
assert (
|
||||
dependency_refresh_command(version="1.2.3")
|
||||
== "uv tool install -U deepagents-code==1.2.3"
|
||||
)
|
||||
|
||||
def test_dependency_refresh_command_preserves_extras(
|
||||
self, tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""Dependency refresh must not drop already-installed extras."""
|
||||
_write_uv_receipt(tmp_path, '{ name = "deepagents-code" }')
|
||||
monkeypatch.setattr("sys.prefix", str(tmp_path))
|
||||
with patch(
|
||||
"deepagents_code.extras_info.installed_extra_names",
|
||||
return_value=frozenset({"quickjs", "nvidia"}),
|
||||
):
|
||||
assert (
|
||||
dependency_refresh_command(
|
||||
version="1.2.3",
|
||||
include_prereleases=True,
|
||||
)
|
||||
== "uv tool install -U "
|
||||
"'deepagents-code[nvidia,quickjs]==1.2.3' --prerelease allow"
|
||||
)
|
||||
|
||||
def test_dependency_refresh_command_preserves_with_packages(
|
||||
self, tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""Dependency refresh must not drop packages installed via `--with`."""
|
||||
_write_uv_receipt(
|
||||
tmp_path,
|
||||
(
|
||||
'{ name = "deepagents-code" }, '
|
||||
'{ name = "langchain-custom" }, '
|
||||
'{ name = "langchain.another_provider" }'
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("sys.prefix", str(tmp_path))
|
||||
|
||||
with patch(
|
||||
"deepagents_code.extras_info.installed_extra_names",
|
||||
return_value=frozenset(),
|
||||
):
|
||||
assert dependency_refresh_command(version="1.2.3") == (
|
||||
"uv tool install -U deepagents-code==1.2.3 "
|
||||
"--with langchain-custom --with langchain.another_provider"
|
||||
)
|
||||
|
||||
def test_dependency_refresh_command_preserves_uv_python(
|
||||
self, tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""Dependency refresh must keep uv's recorded interpreter selection."""
|
||||
_write_uv_receipt(
|
||||
tmp_path,
|
||||
'{ name = "deepagents-code" }',
|
||||
python="3.13",
|
||||
)
|
||||
monkeypatch.setattr("sys.prefix", str(tmp_path))
|
||||
|
||||
with patch(
|
||||
"deepagents_code.extras_info.installed_extra_names",
|
||||
return_value=frozenset(),
|
||||
):
|
||||
assert dependency_refresh_command(version="1.2.3") == (
|
||||
"uv tool install -U --python 3.13 deepagents-code==1.2.3"
|
||||
)
|
||||
|
||||
def test_dependency_refresh_command_quotes_uv_python(
|
||||
self, tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""Recorded interpreter paths are shell-quoted before execution."""
|
||||
_write_uv_receipt(
|
||||
tmp_path,
|
||||
'{ name = "deepagents-code" }, { name = "langchain-custom" }',
|
||||
python="/opt/Python 3.13/bin/python",
|
||||
)
|
||||
monkeypatch.setattr("sys.prefix", str(tmp_path))
|
||||
|
||||
with patch(
|
||||
"deepagents_code.extras_info.installed_extra_names",
|
||||
return_value=frozenset(),
|
||||
):
|
||||
assert dependency_refresh_command(version="1.2.3") == (
|
||||
"uv tool install -U --python '/opt/Python 3.13/bin/python' "
|
||||
"deepagents-code==1.2.3 --with langchain-custom"
|
||||
)
|
||||
|
||||
def test_dependency_refresh_command_refuses_malformed_receipt(
|
||||
self, tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""Malformed uv receipts must not silently drop `--with` packages."""
|
||||
tmp_path.joinpath("uv-receipt.toml").write_text(
|
||||
"[tool\nrequirements = []\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr("sys.prefix", str(tmp_path))
|
||||
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.extras_info.installed_extra_names",
|
||||
return_value=frozenset(),
|
||||
),
|
||||
pytest.raises(ToolRequirementIntrospectionError, match="Could not read"),
|
||||
):
|
||||
dependency_refresh_command(version="1.2.3")
|
||||
|
||||
def test_dependency_refresh_command_refuses_unpreservable_with_requirement(
|
||||
self, tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""Unsupported receipt entries are refused instead of rewritten lossy."""
|
||||
_write_uv_receipt(
|
||||
tmp_path,
|
||||
(
|
||||
'{ name = "deepagents-code" }, '
|
||||
'{ name = "langchain-custom", editable = "/tmp/pkg" }'
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("sys.prefix", str(tmp_path))
|
||||
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.extras_info.installed_extra_names",
|
||||
return_value=frozenset(),
|
||||
),
|
||||
pytest.raises(
|
||||
ToolRequirementIntrospectionError,
|
||||
match="cannot be preserved automatically",
|
||||
),
|
||||
):
|
||||
dependency_refresh_command(version="1.2.3")
|
||||
|
||||
def test_dependency_refresh_dry_run_command_targets_current_python(
|
||||
self, tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""Dry-run planning resolves against the running tool environment."""
|
||||
_write_uv_receipt(
|
||||
tmp_path,
|
||||
'{ name = "deepagents-code" }, { name = "langchain-custom" }',
|
||||
)
|
||||
monkeypatch.setattr("sys.prefix", str(tmp_path))
|
||||
with patch(
|
||||
"deepagents_code.extras_info.installed_extra_names",
|
||||
return_value=frozenset({"quickjs"}),
|
||||
):
|
||||
assert dependency_refresh_dry_run_command(
|
||||
version="1.2.3",
|
||||
include_prereleases=True,
|
||||
python="/opt/Dcode Python/bin/python",
|
||||
) == (
|
||||
"uv pip install --dry-run --python "
|
||||
"'/opt/Dcode Python/bin/python' -U "
|
||||
"'deepagents-code[quickjs]==1.2.3' langchain-custom "
|
||||
"--prerelease allow"
|
||||
)
|
||||
|
||||
async def test_perform_dependency_refresh_dry_run_uses_pinned_uv_pip_command(
|
||||
self,
|
||||
) -> None:
|
||||
"""Dependency dry run shells out without mutating the tool environment."""
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.update_check.detect_install_method",
|
||||
return_value="uv",
|
||||
),
|
||||
patch("shutil.which", return_value="/usr/bin/uv"),
|
||||
patch(
|
||||
"deepagents_code.extras_info.installed_extra_names",
|
||||
return_value=frozenset(),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check._uv_tool_with_packages",
|
||||
return_value=(),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check._run_install_subprocess",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(True, ""),
|
||||
) as run_mock,
|
||||
):
|
||||
success, _output = await perform_dependency_refresh_dry_run()
|
||||
|
||||
assert success is True
|
||||
run_mock.assert_awaited_once()
|
||||
await_args = run_mock.await_args
|
||||
assert await_args is not None
|
||||
assert await_args.args[0] == (
|
||||
f"uv pip install --dry-run --python {shlex.quote(sys.executable)} "
|
||||
f"-U deepagents-code=={__version__}"
|
||||
)
|
||||
|
||||
async def test_perform_dependency_refresh_uses_pinned_uv_command(self) -> None:
|
||||
"""Dependency refresh shells out without allowing a dcode version bump."""
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.update_check.detect_install_method",
|
||||
return_value="uv",
|
||||
),
|
||||
patch("shutil.which", return_value="/usr/bin/uv"),
|
||||
patch(
|
||||
"deepagents_code.extras_info.installed_extra_names",
|
||||
return_value=frozenset(),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check._uv_tool_with_packages",
|
||||
return_value=(),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check._uv_tool_python",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check._run_install_subprocess",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(True, ""),
|
||||
) as run_mock,
|
||||
):
|
||||
success, _output = await perform_dependency_refresh()
|
||||
|
||||
assert success is True
|
||||
run_mock.assert_awaited_once()
|
||||
await_args = run_mock.await_args
|
||||
assert await_args is not None
|
||||
assert await_args.args[0] == (
|
||||
f"uv tool install -U deepagents-code=={__version__}"
|
||||
)
|
||||
|
||||
async def test_perform_dependency_refresh_reports_with_package_errors(
|
||||
self,
|
||||
) -> None:
|
||||
"""Refresh refuses rather than dropping unknown `--with` packages."""
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.update_check.detect_install_method",
|
||||
return_value="uv",
|
||||
),
|
||||
patch("shutil.which", return_value="/usr/bin/uv"),
|
||||
patch(
|
||||
"deepagents_code.update_check.dependency_refresh_command",
|
||||
side_effect=ToolRequirementIntrospectionError("receipt broken"),
|
||||
),
|
||||
):
|
||||
success, output = await perform_dependency_refresh()
|
||||
|
||||
assert success is False
|
||||
assert "ToolRequirementIntrospectionError" in output
|
||||
assert "receipt broken" in output
|
||||
|
||||
async def test_perform_dependency_refresh_refuses_brew(self) -> None:
|
||||
"""Brew cannot refresh deps without taking the app formula update."""
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.update_check.detect_install_method",
|
||||
return_value="brew",
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check._run_install_subprocess",
|
||||
new_callable=AsyncMock,
|
||||
) as run_mock,
|
||||
):
|
||||
success, output = await perform_dependency_refresh()
|
||||
|
||||
assert success is False
|
||||
assert "dependency-only refresh is not supported" in output
|
||||
run_mock.assert_not_awaited()
|
||||
|
||||
async def test_perform_dependency_refresh_refuses_editable(self) -> None:
|
||||
"""Editable installs can't be re-resolved as a tool environment."""
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.update_check.detect_install_method",
|
||||
return_value="unknown",
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check._run_install_subprocess",
|
||||
new_callable=AsyncMock,
|
||||
) as run_mock,
|
||||
):
|
||||
success, output = await perform_dependency_refresh()
|
||||
|
||||
assert success is False
|
||||
assert "Editable install detected" in output
|
||||
run_mock.assert_not_awaited()
|
||||
|
||||
async def test_perform_dependency_refresh_refuses_other(self) -> None:
|
||||
"""An unrecognized install method is refused, not guessed at."""
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.update_check.detect_install_method",
|
||||
return_value="other",
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check._run_install_subprocess",
|
||||
new_callable=AsyncMock,
|
||||
) as run_mock,
|
||||
):
|
||||
success, output = await perform_dependency_refresh()
|
||||
|
||||
assert success is False
|
||||
assert "Unsupported install method detected" in output
|
||||
run_mock.assert_not_awaited()
|
||||
|
||||
async def test_perform_dependency_refresh_refuses_when_uv_missing(self) -> None:
|
||||
"""A uv-managed install still needs `uv` on PATH to refresh."""
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.update_check.detect_install_method",
|
||||
return_value="uv",
|
||||
),
|
||||
patch("shutil.which", return_value=None),
|
||||
patch(
|
||||
"deepagents_code.update_check._run_install_subprocess",
|
||||
new_callable=AsyncMock,
|
||||
) as run_mock,
|
||||
):
|
||||
success, output = await perform_dependency_refresh()
|
||||
|
||||
assert success is False
|
||||
assert "`uv` not found on PATH." in output
|
||||
run_mock.assert_not_awaited()
|
||||
|
||||
def test_prerelease_upgrade_supported_for_uv(self) -> None:
|
||||
"""The uv install method can be steered onto the pre-release channel."""
|
||||
supported, reason = prerelease_upgrade_supported("uv")
|
||||
@@ -1126,6 +1479,131 @@ class TestUpdateLogs:
|
||||
assert "aren't supported for this install" in reason
|
||||
|
||||
|
||||
class TestParseDependencyChanges:
|
||||
"""`parse_dependency_changes` collapses uv's env diff into changes."""
|
||||
|
||||
def test_version_bump_pairs_removed_and_added(self) -> None:
|
||||
"""A `- old` / `+ new` pair for one package becomes one bump entry."""
|
||||
output = (
|
||||
"Resolved 120 packages in 12ms\n"
|
||||
" - langchain-openai==1.3.2\n"
|
||||
" + langchain-openai==1.5.0\n"
|
||||
"Installed 1 executable: dcode\n"
|
||||
)
|
||||
assert parse_dependency_changes(output) == [
|
||||
DependencyChange(name="langchain-openai", old="1.3.2", new="1.5.0"),
|
||||
]
|
||||
|
||||
def test_new_package_has_no_old(self) -> None:
|
||||
"""A lone `+` line is reported as a new package."""
|
||||
assert parse_dependency_changes(" + httpx==0.28.1\n") == [
|
||||
DependencyChange(name="httpx", old=None, new="0.28.1"),
|
||||
]
|
||||
|
||||
def test_removed_package_has_no_new(self) -> None:
|
||||
"""A lone `-` line is reported as a removed package."""
|
||||
assert parse_dependency_changes(" - httpx==0.28.1\n") == [
|
||||
DependencyChange(name="httpx", old="0.28.1", new=None),
|
||||
]
|
||||
|
||||
def test_preserves_first_seen_order(self) -> None:
|
||||
"""Packages keep the order uv first mentioned them in."""
|
||||
output = " - b-pkg==1.0\n + b-pkg==2.0\n - a-pkg==1.0\n + a-pkg==2.0\n"
|
||||
names = [change.name for change in parse_dependency_changes(output)]
|
||||
assert names == ["b-pkg", "a-pkg"]
|
||||
|
||||
def test_ignores_non_diff_lines(self) -> None:
|
||||
"""Resolver chatter without `+`/`-` markers is skipped."""
|
||||
assert parse_dependency_changes("Resolved 3 packages\nAudited 3\n") == []
|
||||
|
||||
|
||||
class TestFormatDependencyChanges:
|
||||
"""`format_dependency_changes` renders an aligned summary."""
|
||||
|
||||
def test_empty_returns_empty_string(self) -> None:
|
||||
"""No changes renders to an empty string."""
|
||||
assert format_dependency_changes([]) == ""
|
||||
|
||||
def test_renders_bump_new_and_removed(self) -> None:
|
||||
"""Each change kind gets its own rendering, column-aligned."""
|
||||
changes = [
|
||||
DependencyChange(name="langchain-openai", old="1.3.2", new="1.5.0"),
|
||||
DependencyChange(name="httpx", old=None, new="0.28.1"),
|
||||
DependencyChange(name="old-pkg", old="1.0", new=None),
|
||||
]
|
||||
rendered = format_dependency_changes(changes)
|
||||
assert "langchain-openai 1.3.2 -> 1.5.0" in rendered
|
||||
assert "0.28.1 (new)" in rendered
|
||||
assert "1.0 (removed)" in rendered
|
||||
assert "httpx 0.28.1 (new)" in rendered
|
||||
|
||||
|
||||
class TestDependencyChangeKind:
|
||||
"""`DependencyChange.kind` classifies the three legal shapes."""
|
||||
|
||||
def test_bumped_when_both_sides_present(self) -> None:
|
||||
"""Both `old` and `new` set is an in-place bump."""
|
||||
assert DependencyChange(name="a", old="1.0", new="2.0").kind == "bumped"
|
||||
|
||||
def test_added_when_only_new(self) -> None:
|
||||
"""Only `new` set is a newly added package."""
|
||||
assert DependencyChange(name="a", old=None, new="1.0").kind == "added"
|
||||
|
||||
def test_removed_when_only_old(self) -> None:
|
||||
"""Only `old` set is a removed package."""
|
||||
assert DependencyChange(name="a", old="1.0", new=None).kind == "removed"
|
||||
|
||||
def test_empty_shape_is_rejected(self) -> None:
|
||||
"""`(None, None)` is meaningless and must raise rather than mis-render."""
|
||||
with pytest.raises(ValueError, match="neither an old nor new version"):
|
||||
_ = DependencyChange(name="a", old=None, new=None).kind
|
||||
|
||||
|
||||
class TestDependencyChangeAnnotations:
|
||||
"""`parse_dependency_changes` tolerates uv's source annotations."""
|
||||
|
||||
def test_source_annotation_suffix_is_parsed(self) -> None:
|
||||
"""A non-PyPI source suffix doesn't hide the version change."""
|
||||
output = (
|
||||
" - example==0.1.0 (from file:///old)\n"
|
||||
" + example==0.2.0 (from file:///new)\n"
|
||||
)
|
||||
assert parse_dependency_changes(output) == [
|
||||
DependencyChange(name="example", old="0.1.0", new="0.2.0"),
|
||||
]
|
||||
|
||||
|
||||
class TestDependencyRefreshSupported:
|
||||
"""`dependency_refresh_supported` gates the dependency-only refresh."""
|
||||
|
||||
def test_uv_is_supported(self) -> None:
|
||||
"""uv-managed installs can re-resolve dependencies in place."""
|
||||
supported, reason = dependency_refresh_supported("uv")
|
||||
|
||||
assert supported is True
|
||||
assert reason is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "needle"),
|
||||
[
|
||||
("unknown", "Editable install detected"),
|
||||
("brew", "Homebrew install detected"),
|
||||
("other", "Unsupported install method detected"),
|
||||
],
|
||||
)
|
||||
def test_non_uv_methods_are_refused_with_reason(
|
||||
self,
|
||||
method: InstallMethod,
|
||||
needle: str,
|
||||
) -> None:
|
||||
"""Each non-uv method is refused with a distinct, user-facing reason."""
|
||||
supported, reason = dependency_refresh_supported(method)
|
||||
|
||||
assert supported is False
|
||||
assert reason is not None
|
||||
assert needle in reason
|
||||
|
||||
|
||||
class TestInstallExtraCommand:
|
||||
"""`install_extra_command` builds the uv tool install string."""
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Tests for the `/update` dependency-refresh confirmation modal."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Static
|
||||
|
||||
from deepagents_code.widgets.update_confirm import (
|
||||
RefreshDependenciesConfirmScreen,
|
||||
UpdateBeforeDependenciesConfirmScreen,
|
||||
)
|
||||
|
||||
|
||||
class _RefreshConfirmTestApp(App[None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static("base")
|
||||
|
||||
|
||||
class TestRefreshDependenciesConfirmScreen:
|
||||
"""Behavior tests for `RefreshDependenciesConfirmScreen`."""
|
||||
|
||||
async def test_enter_dismisses_with_true(self) -> None:
|
||||
"""Pressing Enter confirms the refresh."""
|
||||
app = _RefreshConfirmTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
outcomes: list[bool | None] = []
|
||||
app.push_screen(RefreshDependenciesConfirmScreen(), outcomes.append)
|
||||
await pilot.pause()
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
assert outcomes == [True]
|
||||
|
||||
async def test_escape_dismisses_with_false(self) -> None:
|
||||
"""Pressing Esc cancels (no implicit refresh)."""
|
||||
app = _RefreshConfirmTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
outcomes: list[bool | None] = []
|
||||
app.push_screen(RefreshDependenciesConfirmScreen(), outcomes.append)
|
||||
await pilot.pause()
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
assert outcomes == [False]
|
||||
|
||||
async def test_action_cancel_dismisses_with_false(self) -> None:
|
||||
"""`action_cancel` cancels — the path taken by the app's Esc handler."""
|
||||
app = _RefreshConfirmTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
outcomes: list[bool | None] = []
|
||||
screen = RefreshDependenciesConfirmScreen()
|
||||
app.push_screen(screen, outcomes.append)
|
||||
await pilot.pause()
|
||||
screen.action_cancel()
|
||||
await pilot.pause()
|
||||
assert outcomes == [False]
|
||||
|
||||
async def test_planned_changes_are_displayed(self) -> None:
|
||||
"""Dry-run dependency changes are shown before confirmation."""
|
||||
app = _RefreshConfirmTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
app.push_screen(
|
||||
RefreshDependenciesConfirmScreen(
|
||||
planned_changes=" langchain-openai 1.3.2 -> 1.5.0",
|
||||
),
|
||||
)
|
||||
await pilot.pause()
|
||||
text = "\n".join(str(widget.content) for widget in app.screen.query(Static))
|
||||
assert "compatible dependency updates are available" in text
|
||||
assert "langchain-openai 1.3.2 -> 1.5.0" in text
|
||||
|
||||
|
||||
class TestUpdateBeforeDependenciesConfirmScreen:
|
||||
"""Behavior tests for `UpdateBeforeDependenciesConfirmScreen`."""
|
||||
|
||||
async def test_enter_dismisses_with_true(self) -> None:
|
||||
"""Pressing Enter chooses the app update first."""
|
||||
app = _RefreshConfirmTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
outcomes: list[bool | None] = []
|
||||
app.push_screen(
|
||||
UpdateBeforeDependenciesConfirmScreen(
|
||||
current="1.0.0",
|
||||
latest="1.1.0",
|
||||
),
|
||||
outcomes.append,
|
||||
)
|
||||
await pilot.pause()
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
assert outcomes == [True]
|
||||
|
||||
async def test_escape_dismisses_with_false(self) -> None:
|
||||
"""Pressing Esc keeps dcode current and refreshes dependencies."""
|
||||
app = _RefreshConfirmTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
outcomes: list[bool | None] = []
|
||||
app.push_screen(
|
||||
UpdateBeforeDependenciesConfirmScreen(
|
||||
current="1.0.0",
|
||||
latest="1.1.0",
|
||||
),
|
||||
outcomes.append,
|
||||
)
|
||||
await pilot.pause()
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
assert outcomes == [False]
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import sys
|
||||
import tomllib
|
||||
from importlib.metadata import version as pkg_version
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -601,6 +602,655 @@ async def test_update_slash_command_rejects_unknown_option() -> None:
|
||||
)
|
||||
|
||||
|
||||
async def test_update_deps_refreshes_when_dcode_current() -> None:
|
||||
"""`/update --deps` re-resolves deps after confirming dcode is current."""
|
||||
from deepagents_code.app import DeepAgentsApp
|
||||
from deepagents_code.widgets.messages import AppMessage
|
||||
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.config._is_editable_install",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.is_update_available",
|
||||
return_value=(False, "1.0.0"),
|
||||
) as is_update_mock,
|
||||
patch(
|
||||
"deepagents_code.update_check.perform_dependency_refresh",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(
|
||||
True,
|
||||
" - langchain-openai==1.3.2\n + langchain-openai==1.5.0\n",
|
||||
),
|
||||
) as refresh_mock,
|
||||
):
|
||||
await app._handle_command("/update --deps")
|
||||
await pilot.pause()
|
||||
|
||||
is_update_mock.assert_called_once_with(
|
||||
bypass_cache=True,
|
||||
include_prereleases=None,
|
||||
)
|
||||
refresh_mock.assert_awaited_once_with(include_prereleases=None)
|
||||
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
|
||||
content = str(app_msgs[-1]._content)
|
||||
assert "Refreshed dependencies:" in content
|
||||
assert "langchain-openai 1.3.2 -> 1.5.0" in content
|
||||
|
||||
|
||||
async def test_update_deps_reports_when_already_current() -> None:
|
||||
"""`/update --deps` reports when nothing changed."""
|
||||
from deepagents_code.app import DeepAgentsApp
|
||||
from deepagents_code.widgets.messages import AppMessage
|
||||
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.config._is_editable_install",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.is_update_available",
|
||||
return_value=(False, "1.0.0"),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.perform_dependency_refresh",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(True, "Resolved 120 packages in 12ms\n"),
|
||||
),
|
||||
):
|
||||
await app._handle_command("/update --deps")
|
||||
await pilot.pause()
|
||||
|
||||
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
|
||||
assert "Dependencies are already up to date." in str(app_msgs[-1]._content)
|
||||
|
||||
|
||||
async def test_update_deps_routes_outdated_dcode_through_regular_update() -> None:
|
||||
"""`/update --deps` runs the normal update flow when dcode is outdated."""
|
||||
from deepagents_code.app import DeepAgentsApp
|
||||
from deepagents_code.widgets.messages import AppMessage
|
||||
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.config._is_editable_install",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.is_update_available",
|
||||
return_value=(True, "1.1.0"),
|
||||
),
|
||||
patch.object(
|
||||
DeepAgentsApp,
|
||||
"_confirm_update_before_dependency_refresh",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.perform_upgrade",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(
|
||||
True,
|
||||
" - deepagents-code==1.0.0\n + deepagents-code==1.1.0\n",
|
||||
),
|
||||
),
|
||||
):
|
||||
await app._handle_command("/update --deps")
|
||||
await pilot.pause()
|
||||
|
||||
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
|
||||
content = str(app_msgs[-1]._content)
|
||||
assert "Updated to v1.1.0" in content
|
||||
assert "Quit and relaunch dcode to use the new version" in content
|
||||
assert "Updated deepagents-code:" not in content
|
||||
assert "Dependencies are already up to date." not in content
|
||||
|
||||
|
||||
async def test_update_deps_skips_refresh_prompt_when_refresh_unsupported() -> None:
|
||||
"""Unsupported refresh installs take the normal outdated dcode update path."""
|
||||
from deepagents_code.app import DeepAgentsApp
|
||||
from deepagents_code.widgets.messages import AppMessage
|
||||
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.config._is_editable_install",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.is_update_available",
|
||||
return_value=(True, "1.1.0"),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.dependency_refresh_supported",
|
||||
return_value=(False, "Homebrew install detected — ..."),
|
||||
),
|
||||
patch.object(
|
||||
DeepAgentsApp,
|
||||
"_confirm_update_before_dependency_refresh",
|
||||
new_callable=AsyncMock,
|
||||
) as confirm_mock,
|
||||
patch(
|
||||
"deepagents_code.update_check.perform_dependency_refresh",
|
||||
new_callable=AsyncMock,
|
||||
) as refresh_mock,
|
||||
patch(
|
||||
"deepagents_code.update_check.perform_upgrade",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(
|
||||
True,
|
||||
" - deepagents-code==1.0.0\n + deepagents-code==1.1.0\n",
|
||||
),
|
||||
) as perform_upgrade_mock,
|
||||
):
|
||||
await app._handle_command("/update --deps")
|
||||
await pilot.pause()
|
||||
|
||||
confirm_mock.assert_not_awaited()
|
||||
refresh_mock.assert_not_awaited()
|
||||
perform_upgrade_mock.assert_awaited_once_with(include_prereleases=None)
|
||||
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
|
||||
content = str(app_msgs[-1]._content)
|
||||
assert "Updated to v1.1.0" in content
|
||||
assert "Updated deepagents-code:" not in content
|
||||
assert "Dependency refresh failed" not in content
|
||||
|
||||
|
||||
async def test_update_deps_decline_app_update_refreshes_current_deps() -> None:
|
||||
"""Declining the app update refreshes deps without upgrading dcode."""
|
||||
from deepagents_code.app import DeepAgentsApp
|
||||
from deepagents_code.widgets.messages import AppMessage
|
||||
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.config._is_editable_install",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.is_update_available",
|
||||
return_value=(True, "1.1.0"),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.dependency_refresh_supported",
|
||||
return_value=(True, None),
|
||||
),
|
||||
patch.object(
|
||||
DeepAgentsApp,
|
||||
"_confirm_update_before_dependency_refresh",
|
||||
new_callable=AsyncMock,
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.perform_dependency_refresh",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(
|
||||
True,
|
||||
" - langchain-openai==1.3.2\n + langchain-openai==1.5.0\n",
|
||||
),
|
||||
) as refresh_mock,
|
||||
patch(
|
||||
"deepagents_code.update_check.perform_upgrade",
|
||||
new_callable=AsyncMock,
|
||||
) as perform_upgrade_mock,
|
||||
):
|
||||
await app._handle_command("/update --deps")
|
||||
await pilot.pause()
|
||||
|
||||
refresh_mock.assert_awaited_once_with(include_prereleases=None)
|
||||
perform_upgrade_mock.assert_not_awaited()
|
||||
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
|
||||
content = str(app_msgs[-1]._content)
|
||||
assert "Refreshed dependencies:" in content
|
||||
assert "langchain-openai 1.3.2 -> 1.5.0" in content
|
||||
assert "A deepagents-code update is available: v1.1.0." in content
|
||||
assert "Updated to v1.1.0" not in content
|
||||
|
||||
|
||||
async def test_update_deps_decline_app_update_reports_no_new_deps() -> None:
|
||||
"""`/update --deps` reports current deps even when dcode has an update."""
|
||||
from deepagents_code.app import DeepAgentsApp
|
||||
from deepagents_code.widgets.messages import AppMessage
|
||||
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.config._is_editable_install",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.is_update_available",
|
||||
return_value=(True, "1.1.0"),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.dependency_refresh_supported",
|
||||
return_value=(True, None),
|
||||
),
|
||||
patch.object(
|
||||
DeepAgentsApp,
|
||||
"_confirm_update_before_dependency_refresh",
|
||||
new_callable=AsyncMock,
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.perform_dependency_refresh",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(True, "Resolved 120 packages in 12ms\n"),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.perform_upgrade",
|
||||
new_callable=AsyncMock,
|
||||
) as perform_upgrade_mock,
|
||||
):
|
||||
await app._handle_command("/update --deps")
|
||||
await pilot.pause()
|
||||
|
||||
perform_upgrade_mock.assert_not_awaited()
|
||||
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
|
||||
content = str(app_msgs[-1]._content)
|
||||
assert "Dependencies are already up to date." in content
|
||||
assert "A deepagents-code update is available: v1.1.0." in content
|
||||
assert "Updated to v1.1.0" not in content
|
||||
|
||||
|
||||
async def test_update_already_current_prompts_and_refreshes_on_confirm() -> None:
|
||||
"""Plain `/update` offers a dep refresh when dcode is already current."""
|
||||
from deepagents_code.app import DeepAgentsApp
|
||||
from deepagents_code.widgets.messages import AppMessage
|
||||
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.config._is_editable_install",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.is_update_available",
|
||||
return_value=(False, "1.0.0"),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.dependency_refresh_supported",
|
||||
return_value=(True, None),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.perform_dependency_refresh_dry_run",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(
|
||||
True,
|
||||
" - langchain-openai==1.3.2\n + langchain-openai==1.5.0\n",
|
||||
),
|
||||
) as dry_run_mock,
|
||||
patch.object(
|
||||
DeepAgentsApp,
|
||||
"_confirm_refresh_dependencies",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
) as confirm_mock,
|
||||
patch(
|
||||
"deepagents_code.update_check.perform_dependency_refresh",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(
|
||||
True,
|
||||
" - langchain-openai==1.3.2\n + langchain-openai==1.5.0\n",
|
||||
),
|
||||
) as refresh_mock,
|
||||
):
|
||||
await app._handle_command("/update")
|
||||
await pilot.pause()
|
||||
|
||||
dry_run_mock.assert_awaited_once_with(include_prereleases=None)
|
||||
confirm_mock.assert_awaited_once_with(
|
||||
planned_changes=" langchain-openai 1.3.2 -> 1.5.0",
|
||||
)
|
||||
refresh_mock.assert_awaited_once_with(include_prereleases=None)
|
||||
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
|
||||
assert "Refreshed dependencies:" in str(app_msgs[-1]._content)
|
||||
|
||||
|
||||
async def test_update_already_current_skips_refresh_on_decline() -> None:
|
||||
"""Declining the prompt leaves the install untouched."""
|
||||
from deepagents_code.app import DeepAgentsApp
|
||||
from deepagents_code.widgets.messages import AppMessage
|
||||
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.config._is_editable_install",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.is_update_available",
|
||||
return_value=(False, "1.0.0"),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.dependency_refresh_supported",
|
||||
return_value=(True, None),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.perform_dependency_refresh_dry_run",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(
|
||||
True,
|
||||
" - langchain-openai==1.3.2\n + langchain-openai==1.5.0\n",
|
||||
),
|
||||
) as dry_run_mock,
|
||||
patch.object(
|
||||
DeepAgentsApp,
|
||||
"_confirm_refresh_dependencies",
|
||||
new_callable=AsyncMock,
|
||||
return_value=False,
|
||||
) as confirm_mock,
|
||||
patch(
|
||||
"deepagents_code.update_check.perform_dependency_refresh",
|
||||
new_callable=AsyncMock,
|
||||
) as refresh_mock,
|
||||
):
|
||||
await app._handle_command("/update")
|
||||
await pilot.pause()
|
||||
|
||||
dry_run_mock.assert_awaited_once_with(include_prereleases=None)
|
||||
confirm_mock.assert_awaited_once_with(
|
||||
planned_changes=" langchain-openai 1.3.2 -> 1.5.0",
|
||||
)
|
||||
refresh_mock.assert_not_awaited()
|
||||
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
|
||||
assert "Dependency refresh skipped." in str(app_msgs[-1]._content)
|
||||
|
||||
|
||||
async def test_update_already_current_reports_no_dependency_changes() -> None:
|
||||
"""Plain `/update` skips the prompt when the dry run finds no changes."""
|
||||
from deepagents_code.app import DeepAgentsApp
|
||||
from deepagents_code.widgets.messages import AppMessage
|
||||
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.config._is_editable_install",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.is_update_available",
|
||||
return_value=(False, "1.0.0"),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.dependency_refresh_supported",
|
||||
return_value=(True, None),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.perform_dependency_refresh_dry_run",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(True, "Resolved 120 packages in 12ms\n"),
|
||||
) as dry_run_mock,
|
||||
patch.object(
|
||||
DeepAgentsApp,
|
||||
"_confirm_refresh_dependencies",
|
||||
new_callable=AsyncMock,
|
||||
) as confirm_mock,
|
||||
patch(
|
||||
"deepagents_code.update_check.perform_dependency_refresh",
|
||||
new_callable=AsyncMock,
|
||||
) as refresh_mock,
|
||||
):
|
||||
await app._handle_command("/update")
|
||||
await pilot.pause()
|
||||
|
||||
dry_run_mock.assert_awaited_once_with(include_prereleases=None)
|
||||
confirm_mock.assert_not_awaited()
|
||||
refresh_mock.assert_not_awaited()
|
||||
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
|
||||
assert "Dependencies are already up to date." in str(app_msgs[-1]._content)
|
||||
|
||||
|
||||
async def test_update_already_current_reports_dependency_check_failure() -> None:
|
||||
"""A failed dry-run check reports the failure without refreshing."""
|
||||
from deepagents_code.app import DeepAgentsApp
|
||||
from deepagents_code.widgets.messages import AppMessage
|
||||
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.config._is_editable_install",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.is_update_available",
|
||||
return_value=(False, "1.0.0"),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.dependency_refresh_supported",
|
||||
return_value=(True, None),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.perform_dependency_refresh_dry_run",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(False, "No solution found"),
|
||||
),
|
||||
patch.object(
|
||||
DeepAgentsApp,
|
||||
"_confirm_refresh_dependencies",
|
||||
new_callable=AsyncMock,
|
||||
) as confirm_mock,
|
||||
patch(
|
||||
"deepagents_code.update_check.perform_dependency_refresh",
|
||||
new_callable=AsyncMock,
|
||||
) as refresh_mock,
|
||||
):
|
||||
await app._handle_command("/update")
|
||||
await pilot.pause()
|
||||
|
||||
confirm_mock.assert_not_awaited()
|
||||
refresh_mock.assert_not_awaited()
|
||||
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
|
||||
content = str(app_msgs[-1]._content)
|
||||
assert "Could not check dependency updates" in content
|
||||
assert "No solution found" in content
|
||||
|
||||
|
||||
async def test_update_already_current_skips_prompt_when_refresh_unsupported() -> None:
|
||||
"""brew/other installs aren't prompted for a refresh that can't run."""
|
||||
from deepagents_code.app import DeepAgentsApp
|
||||
from deepagents_code.widgets.messages import AppMessage
|
||||
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.config._is_editable_install",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.is_update_available",
|
||||
return_value=(False, "1.0.0"),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.dependency_refresh_supported",
|
||||
return_value=(False, "Homebrew install detected — ..."),
|
||||
),
|
||||
patch.object(
|
||||
DeepAgentsApp,
|
||||
"_confirm_refresh_dependencies",
|
||||
new_callable=AsyncMock,
|
||||
) as confirm_mock,
|
||||
patch(
|
||||
"deepagents_code.update_check.perform_dependency_refresh",
|
||||
new_callable=AsyncMock,
|
||||
) as refresh_mock,
|
||||
):
|
||||
await app._handle_command("/update")
|
||||
await pilot.pause()
|
||||
|
||||
confirm_mock.assert_not_awaited()
|
||||
refresh_mock.assert_not_awaited()
|
||||
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
|
||||
assert "Already on the latest version" in str(app_msgs[-1]._content)
|
||||
|
||||
|
||||
async def test_refresh_dependencies_surfaces_failure() -> None:
|
||||
"""A failed refresh reports the start of uv's output, not silence."""
|
||||
from deepagents_code.app import DeepAgentsApp
|
||||
from deepagents_code.widgets.messages import AppMessage
|
||||
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
with patch(
|
||||
"deepagents_code.update_check.perform_dependency_refresh",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(False, "No solution found for langchain-openai" + "x" * 400),
|
||||
):
|
||||
await app._refresh_dependencies(include_prereleases=None)
|
||||
await pilot.pause()
|
||||
|
||||
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
|
||||
content = str(app_msgs[-1]._content)
|
||||
assert "Dependency refresh failed" in content
|
||||
assert "No solution found" in content
|
||||
|
||||
|
||||
async def test_refresh_dependencies_renders_self_changes() -> None:
|
||||
"""A `deepagents-code` line in the diff renders under its own heading."""
|
||||
from deepagents_code.app import DeepAgentsApp
|
||||
from deepagents_code.widgets.messages import AppMessage
|
||||
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
with patch(
|
||||
"deepagents_code.update_check.perform_dependency_refresh",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(
|
||||
True,
|
||||
(
|
||||
" - deepagents-code==1.0.0\n + deepagents-code==1.0.1\n"
|
||||
" - langchain-openai==1.3.2\n + langchain-openai==1.5.0\n"
|
||||
),
|
||||
),
|
||||
):
|
||||
await app._refresh_dependencies(include_prereleases=None)
|
||||
await pilot.pause()
|
||||
|
||||
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
|
||||
content = str(app_msgs[-1]._content)
|
||||
assert "Updated deepagents-code:" in content
|
||||
assert "Refreshed dependencies:" in content
|
||||
assert "langchain-openai 1.3.2 -> 1.5.0" in content
|
||||
|
||||
|
||||
async def test_refresh_dependencies_skips_in_debug_mode(monkeypatch) -> None:
|
||||
"""`DEBUG_UPDATE` short-circuits before shelling out to uv."""
|
||||
from deepagents_code._env_vars import DEBUG_UPDATE
|
||||
from deepagents_code.app import DeepAgentsApp
|
||||
from deepagents_code.widgets.messages import AppMessage
|
||||
|
||||
monkeypatch.setenv(DEBUG_UPDATE, "1")
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
with patch(
|
||||
"deepagents_code.update_check.perform_dependency_refresh",
|
||||
new_callable=AsyncMock,
|
||||
) as refresh_mock:
|
||||
await app._refresh_dependencies(include_prereleases=None)
|
||||
await pilot.pause()
|
||||
|
||||
refresh_mock.assert_not_awaited()
|
||||
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
|
||||
assert "Skipped dependency refresh (debug mode)." in str(app_msgs[-1]._content)
|
||||
|
||||
|
||||
async def test_confirm_refresh_dependencies_reports_mount_failure() -> None:
|
||||
"""A modal that fails to mount is surfaced, not silently swallowed."""
|
||||
from deepagents_code.app import DeepAgentsApp
|
||||
from deepagents_code.widgets.messages import AppMessage
|
||||
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
with patch.object(
|
||||
DeepAgentsApp,
|
||||
"_push_screen_wait",
|
||||
Mock(side_effect=RuntimeError("boom")),
|
||||
):
|
||||
result = await app._confirm_refresh_dependencies()
|
||||
await pilot.pause()
|
||||
|
||||
assert result is False
|
||||
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
|
||||
assert "Couldn't show the dependency-refresh prompt" in str(
|
||||
app_msgs[-1]._content
|
||||
)
|
||||
|
||||
|
||||
async def test_confirm_refresh_dependencies_reports_timeout(monkeypatch) -> None:
|
||||
"""A modal that never resolves is bounded by the watchdog and surfaced."""
|
||||
from deepagents_code import app as app_module
|
||||
from deepagents_code.app import DeepAgentsApp
|
||||
from deepagents_code.widgets.messages import AppMessage
|
||||
|
||||
async def _never(_self: object, _screen: object) -> None:
|
||||
await asyncio.sleep(10)
|
||||
|
||||
monkeypatch.setattr(app_module, "_MODAL_WATCHDOG_TIMEOUT_SECONDS", 0.01)
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
with patch.object(DeepAgentsApp, "_push_screen_wait", _never):
|
||||
result = await app._confirm_refresh_dependencies()
|
||||
await pilot.pause()
|
||||
|
||||
assert result is False
|
||||
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
|
||||
assert "timed out" in str(app_msgs[-1]._content)
|
||||
|
||||
|
||||
async def test_confirm_update_before_dependency_refresh_reports_mount_failure() -> None:
|
||||
"""A failed app-update prompt falls back to refreshing current deps."""
|
||||
from deepagents_code.app import DeepAgentsApp
|
||||
from deepagents_code.widgets.messages import AppMessage
|
||||
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
with patch.object(
|
||||
DeepAgentsApp,
|
||||
"_push_screen_wait",
|
||||
Mock(side_effect=RuntimeError("boom")),
|
||||
):
|
||||
result = await app._confirm_update_before_dependency_refresh(
|
||||
current="1.0.0",
|
||||
latest="1.1.0",
|
||||
)
|
||||
await pilot.pause()
|
||||
|
||||
assert result is False
|
||||
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
|
||||
assert "Couldn't show the update prompt" in str(app_msgs[-1]._content)
|
||||
|
||||
|
||||
def test_help_mentions_version_flag() -> None:
|
||||
"""Verify that the CLI help text mentions `--version` and SDK."""
|
||||
result = subprocess.run(
|
||||
|
||||
Reference in New Issue
Block a user