mirror of
https://github.com/unslothai/unsloth.git
synced 2026-07-09 15:58:41 +00:00
* feat: detect installed coding agent CLIs in Studio settings The API-keys panel only ever showed the "claude" flavor of the `unsloth start` command, so anyone using Codex, OpenCode, OpenClaw, Hermes, or Pi had to manually rewrite the copied command by hand. Add a backend check that looks for each agent's CLI binary on PATH (shutil.which, mirroring the pattern already used elsewhere in studio/backend/utils) and expose it as GET /api/settings/coding-agents. The API-keys panel now renders a picker for all six supported agents, marks the ones it finds installed, and defaults to one of those instead of always falling back to claude. Includes unit tests for the detection helper. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * address review feedback on coding-agent detection Three fixes from PR review: - detect_installed_coding_agents now treats a PATH lookup failure as "not installed" instead of letting it bubble up and break the settings endpoint; added a regression test for it. - CodingAgentsResponse.agents is now typed as an immutable tuple instead of a list built from one, matching CODING_AGENTS itself. - Fixed a race in the API-keys panel: picking an agent while the installed-CLI check is still in flight could get silently overwritten once that check resolved. A ref now tracks whether the user has made a manual choice, so the auto-detected default only applies before that happens. * Address Codex feedback: GGUF gating and remote-detection scope - codex refuses to launch against a non-GGUF (transformers-backed) model (unsloth_cli's _require_gguf_for_codex), so auto-defaulting to it produced a copy-pasteable command that fails immediately whenever the loaded model isn't GGUF. Add useActiveModelIsGguf() (looks up the active checkpoint in the chat runtime store) and a correction effect that steers the auto-pick away from codex unless the loaded model qualifies, without ever touching a choice the user made by hand. - Detection runs via shutil.which on the Studio backend host, which isn't the same machine as the browser in a tunnel/remote session. Reword the 'installed'/'detected' copy to say so explicitly when the tunnel URL is in use, instead of implying the check ran on the viewer's own device. * Rework auto-default per review: loopback gating + inline GGUF check Replaces the previous approach with the exact shape discussed on the PR: - Export isLoopbackHost/normalizeHost from agent-command.ts. The detection endpoint runs shutil.which on the Studio backend, which only describes the browser's own machine when the base this panel targets resolves to loopback. For a LAN or tunnel/remote base, gate the whole thing off -- don't mark anything as "detected" and don't let it drive the default -- instead of just relabeling the copy. - Drop the separate GGUF-correction effect and useActiveModelIsGguf hook. Read useChatRuntimeStore.getState().activeGgufVariant inline inside the existing detection effect's .then() (so it doesn't need to sit in the effect's deps), and pick the first detected agent that isn't codex unless the loaded model is GGUF, leaving the existing default untouched when no compatible agent is detected. Verified both branches (loopback vs LAN/tunnel base, gguf vs non-gguf, manual pick preserved, no-compatible-agent fallback) with a standalone port of the .then() logic. * Address latest Codex findings: stale detection, model swap, cache - Clear detectedAgents (and skip the network call entirely) when the panel leaves a loopback base, instead of leaving a previous loopback detection result marked 'installed' for a command that now targets a LAN/tunnel/ remote host. - Add a separate, network-free correction effect keyed on the live activeGgufVariant: if codex was auto-picked while a GGUF model was loaded and the user then switches to a transformers-backed model while this panel stays mounted, steer away from codex instead of leaving a command that unsloth_cli's _require_gguf_for_codex will now reject. Never touches a manual pick. - Drop coding-agents.ts's module-lifetime cache. Installed-CLI detection is environment state, not a persisted setting, so a stale positive/negative from before the user installed something (or reopened the tab) is worse than one extra cheap local API call per mount; keep only the in-flight de-dupe for concurrent callers. Verified the correction-effect logic (gguf->non-gguf swap with/without a fallback, still-gguf no-op, manual pick never overridden) with a standalone port of the effect. * Make the codex/GGUF auto-pick symmetric in both directions The correction effect only steered away from codex when the model stopped being GGUF; it never steered back toward codex if the model became GGUF *after* a non-GGUF-gated fallback had already picked something else (e.g. codex is the only detected CLI, a transformers model is loaded so the selection correctly falls back to the claude default, then the user loads a GGUF model while the panel stays mounted -- codex never gets reconsidered). Consolidate into one effect that re-derives the preferred detected agent from scratch whenever detectedAgents or activeGgufVariant changes, in either direction, instead of only reacting to the codex-specific downgrade case. The fetch effect now only populates detectedAgents/availableAgents; this effect is the single source of truth for what gets auto-picked from that list. Never overrides a manual choice. Verified both transition directions plus the manual-pick-survives and initial-detection cases with a standalone port of the derivation logic. * Reset the auto-pick to the default when it stops being trustworthy Two more real gaps from the latest Codex pass on d988f52: - The unified derivation effect only handled the case where a *different* detected agent could take over. If codex was the only detected agent and auto-picked while a GGUF model was loaded, then the model stopped being GGUF, 'preferred' came back undefined and the effect silently left the selection on codex -- exactly the command unsloth_cli's _require_gguf_for_codex now rejects. Fall back to DEFAULT_AGENT in that case instead of leaving it untouched. - Leaving a loopback base cleared detectedAgents (so the 'installed' badges correctly disappear) but left whatever agent had been auto-picked from that now-stale, server-side-only detection still selected. Reset to DEFAULT_AGENT there too, unless the user picked by hand. Introduces a shared DEFAULT_AGENT constant instead of repeating the "claude" literal at each reset site. Verified all five cases (both new resets, both manual-pick-survives variants, and the existing multi-detected-agent fallback still preferring another compatible agent over resetting) with a standalone port of the effects. * Derive GGUF-ness from the actual loaded state, not just the variant string activeGgufVariant only covers an HF-repo GGUF pick (a specific quant variant string). A direct local .gguf file -- custom folder, LM Studio, or drag-drop -- is just as much a GGUF the codex preflight (unsloth_cli's _require_gguf_for_codex) would accept, but it never has a "variant" to report, so it read as non-GGUF here even though /api/inference/status correctly reports is_gguf: true for it. That mismatch could leave a Codex-only install not auto-selected, or reset an auto-picked Codex, for a model that actually supports it. Combined activeGgufVariant with activeNativePathToken (covers the drag-drop/picked-file case) and ggufContextLength (only ever populated when the backend last reported is_gguf: true for the active model, see applyActiveModelStatusToStore) so all three paths a model can be GGUF through are covered, matching the same is_gguf-or-equivalent check hasGgufSource already applies to a staged pick elsewhere in this codebase. * Clear stale native-path token on a non-GGUF status refresh When a native (drag-dropped or picked) GGUF was loaded and the backend later switches to a transformers model outside the UI load path, refresh() adopts the new /api/inference/status via setCheckpoint and applyActiveModelStatusToStore. Those reset activeGgufVariant and ggufContextLength but never clear activeNativePathToken, so the isGguf OR stays true after the switch and a Codex-only detection auto-selects unsloth start codex for a non-GGUF model its preflight rejects. Drop activeNativePathToken in applyActiveModelStatusToStore whenever the status is non-GGUF. A real GGUF load reports is_gguf: true, so its token is preserved (the load path owns it); only a non-GGUF status clears it. * Add the AGPL-3.0 header to the new studio contract test * Fix/adjust agent detection for PR #6909 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <danielhanchen@gmail.com> Co-authored-by: wasimysaid <112766706+wasimysaid@users.noreply.github.com>
96 lines
4.1 KiB
Python
96 lines
4.1 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Static contract for the chat response-details action and metadata."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parents[2]
|
|
THREAD_TSX = REPO / "studio/frontend/src/components/assistant-ui/thread.tsx"
|
|
DETAILS_TSX = (
|
|
REPO / "studio/frontend/src/components/assistant-ui/message-response-details-sheet.tsx"
|
|
)
|
|
REASONING_TSX = REPO / "studio/frontend/src/components/assistant-ui/reasoning.tsx"
|
|
ADAPTER_TS = REPO / "studio/frontend/src/features/chat/api/chat-adapter.ts"
|
|
CHAT_PREFS_TS = REPO / "studio/frontend/src/features/chat/stores/chat-preferences-store.ts"
|
|
CHAT_TAB_TSX = REPO / "studio/frontend/src/features/settings/tabs/chat-tab.tsx"
|
|
|
|
|
|
def test_assistant_more_menu_exposes_response_details_action():
|
|
src = THREAD_TSX.read_text()
|
|
assert "MessageResponseDetailsSheet" in src
|
|
assert "See response details" in src
|
|
assert "setDetailsOpen(true)" in src
|
|
|
|
|
|
def test_response_details_sheet_uses_unsloth_sheet_and_key_sections():
|
|
src = DETAILS_TSX.read_text()
|
|
assert "SheetContent" in src
|
|
assert "Response details" in src
|
|
assert "MessageResponseModelBadge" in src
|
|
assert "showResponseModel" in src
|
|
assert "ChipIcon" not in src
|
|
assert "s.params.checkpoint" not in src
|
|
assert "Not recorded" in src
|
|
assert "min-w-0 break-words font-heading" in src
|
|
assert "toolCallsFromContent(message.content)" in src
|
|
assert 'label="Called"' in src
|
|
for section in ["Response", "Tokens", "Timing", "Tools"]:
|
|
assert f'title="{section}"' in src
|
|
for field in ["Model", "Provider", "Total", "Cache hits", "Enabled", "Called"]:
|
|
assert f'label="{field}"' in src
|
|
|
|
|
|
def test_response_model_chip_is_user_configurable_and_rendered_in_metadata_rows():
|
|
prefs_src = CHAT_PREFS_TS.read_text()
|
|
chat_tab_src = CHAT_TAB_TSX.read_text()
|
|
thread_src = THREAD_TSX.read_text()
|
|
reasoning_src = REASONING_TSX.read_text()
|
|
|
|
assert "showResponseModel: boolean" in prefs_src
|
|
assert "showResponseModel: false" in prefs_src
|
|
assert "showResponseModel: saved?.showResponseModel ?? false" in prefs_src
|
|
assert "Show response model" in chat_tab_src
|
|
assert "setShowResponseModel" in chat_tab_src
|
|
assert "aui-response-model-badge inline-flex min-h-5" in DETAILS_TSX.read_text()
|
|
assert "leading-5" in DETAILS_TSX.read_text()
|
|
assert "group-hover/assistant-message:opacity-100" in DETAILS_TSX.read_text()
|
|
assert "MessageResponseModelBadge" in thread_src
|
|
assert "hasReasoningParts" in thread_src
|
|
assert "group/assistant-message aui-assistant-message-root" in thread_src
|
|
assert "pointer-events-none relative h-0" in thread_src
|
|
assert "MessageResponseModelBadge" in reasoning_src
|
|
assert 'className="min-w-0 flex-none"' in reasoning_src
|
|
assert "hidden min-w-0 max-w-[12rem]" in reasoning_src
|
|
assert "group-hover/assistant-message:inline-flex" in reasoning_src
|
|
|
|
|
|
def test_response_details_metadata_is_persisted_without_backend_schema_change():
|
|
src = ADAPTER_TS.read_text()
|
|
assert "interface ResponseDetailsMetadata" in src
|
|
assert "buildResponseDetails" in src
|
|
assert "responseDetails: buildResponseDetails(finishedAt)" in src
|
|
assert "toolCalls: Array.from(" in src
|
|
assert "!isExternalRequest && supportsTools && toolsEnabled" in src
|
|
assert "!isExternalRequest && supportsTools && codeToolsEnabled" in src
|
|
assert re.search(r"selectedModelSummary\?\.name\s*\|\|\s*responseModelId", src)
|
|
assert "providerName" in src
|
|
assert "cancelId" in src
|
|
metadata_block = src[
|
|
src.find("interface ResponseDetailsMetadata") : src.find("type RunMessages")
|
|
]
|
|
builder_block = src[
|
|
src.find("const buildResponseDetails") : src.find("const externalCapabilities")
|
|
]
|
|
for forbidden in [
|
|
"encrypted_api_key",
|
|
"externalApiKey",
|
|
"apiKey",
|
|
"providerKey",
|
|
"secret",
|
|
]:
|
|
assert forbidden not in metadata_block
|
|
assert forbidden not in builder_block
|