mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-07 16:19:09 +00:00
Compare commits
2 commits
2211c1f2c9
...
5350b63c85
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5350b63c85 | ||
|
|
bc59c05642 |
24 changed files with 795 additions and 105 deletions
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
|
|
@ -80,6 +80,7 @@ jobs:
|
|||
working-directory: tests/e2e
|
||||
run: |
|
||||
npm ci
|
||||
sudo rm -f /etc/apt/sources.list.d/azure-cli.list /etc/apt/sources.list.d/microsoft-prod.list
|
||||
npx playwright install --with-deps chromium
|
||||
# Playwright's webServer boots `langgraph dev`; globalSetup builds the real
|
||||
# ui/ SPA. The fake LLM/GitHub/Slack boundaries need no secrets.
|
||||
|
|
|
|||
|
|
@ -28,8 +28,16 @@ warnings.filterwarnings("ignore", message=".*Pydantic V1.*", category=UserWarnin
|
|||
from deepagents import create_deep_agent
|
||||
from langchain.agents.middleware import ModelCallLimitMiddleware
|
||||
|
||||
from .dashboard.options import SUPPORTED_MODEL_IDS, model_supports_effort
|
||||
from .dashboard.team_settings import get_effective_gateway_enabled, get_team_default_model
|
||||
from .dashboard.options import (
|
||||
SUPPORTED_MODEL_IDS,
|
||||
gate_fable_model,
|
||||
model_supports_effort,
|
||||
)
|
||||
from .dashboard.team_settings import (
|
||||
get_effective_gateway_enabled,
|
||||
get_team_default_model,
|
||||
get_team_fable_enabled,
|
||||
)
|
||||
from .middleware import (
|
||||
ExcludeToolsMiddleware,
|
||||
SanitizeFireworksMessagesMiddleware,
|
||||
|
|
@ -125,6 +133,9 @@ async def get_chat_agent(config: RunnableConfig) -> Pregel:
|
|||
configurable["chat_github_token"] = token
|
||||
|
||||
model_id, effort = await _resolve_chat_model(configurable)
|
||||
model_id, effort = gate_fable_model(
|
||||
model_id, effort, fable_enabled=await get_team_fable_enabled()
|
||||
)
|
||||
use_gateway = await get_effective_gateway_enabled()
|
||||
model_kwargs = provider_model_kwargs(
|
||||
model_id,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,13 @@ SUPPORTED_MODELS: list[ModelOption] = [
|
|||
"default_effort": "high",
|
||||
"supports_images": True,
|
||||
},
|
||||
{
|
||||
"id": "bedrock_converse:us.anthropic.claude-fable-5",
|
||||
"label": "Fable 5",
|
||||
"efforts": ["low", "medium", "high", "xhigh", "max"],
|
||||
"default_effort": "high",
|
||||
"supports_images": True,
|
||||
},
|
||||
{
|
||||
"id": "fireworks:accounts/fireworks/models/kimi-k2p7-code",
|
||||
"label": "Kimi K2.7",
|
||||
|
|
@ -74,6 +81,38 @@ SUPPORTED_MODELS: list[ModelOption] = [
|
|||
|
||||
SUPPORTED_MODEL_IDS: frozenset[str] = frozenset(m["id"] for m in SUPPORTED_MODELS)
|
||||
|
||||
_FABLE_ID_PREFIX = "bedrock_converse:us.anthropic.claude-fable"
|
||||
|
||||
FABLE_MODEL_IDS: frozenset[str] = frozenset(
|
||||
m["id"] for m in SUPPORTED_MODELS if m["id"].startswith(_FABLE_ID_PREFIX)
|
||||
)
|
||||
|
||||
|
||||
def fable_disabled_fallback(effort: object = None) -> tuple[str, str]:
|
||||
"""Newest supported non-Fable Claude model (keeps the Claude family),
|
||||
else the global default. Substitutes a Fable selection when Fable is
|
||||
disabled workspace-wide, preserving ``effort`` when the fallback supports it."""
|
||||
for m in SUPPORTED_MODELS:
|
||||
if m["id"].startswith("bedrock_converse:us.anthropic.") and m["id"] not in FABLE_MODEL_IDS:
|
||||
return m["id"], _fallback_effort_for(m, effort) or m["default_effort"]
|
||||
return default_model_pair()
|
||||
|
||||
|
||||
def gate_fable_model(
|
||||
model_id: str, effort: str | None, *, fable_enabled: bool
|
||||
) -> tuple[str, str | None]:
|
||||
"""Provider-data-share gate: if Fable is disabled but a Fable id was
|
||||
resolved, swap in a safe non-Fable model. Fable requires the account to opt
|
||||
into Bedrock ``provider_data_share`` (prompts/completions shared with the
|
||||
provider), so it is gated off by default. Non-Fable selections pass through
|
||||
unchanged. Applied at every model-construction entrypoint so a disabled
|
||||
Fable model can never reach ``make_model``, no matter which layer selected
|
||||
it."""
|
||||
if not fable_enabled and isinstance(model_id, str) and model_id in FABLE_MODEL_IDS:
|
||||
return fable_disabled_fallback(effort)
|
||||
return model_id, effort
|
||||
|
||||
|
||||
DEFAULT_MODEL_ID: str = "bedrock_converse:us.anthropic.claude-opus-4-8"
|
||||
DEFAULT_MODEL_EFFORT: str = "medium"
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ from .oauth import (
|
|||
require_session,
|
||||
sanitize_redirect_to,
|
||||
)
|
||||
from .options import SUPPORTED_MODELS
|
||||
from .options import FABLE_MODEL_IDS, SUPPORTED_MODELS, gate_fable_model
|
||||
from .profiles import (
|
||||
ProfileUpdate,
|
||||
get_profile,
|
||||
|
|
@ -145,6 +145,7 @@ from .team_settings import (
|
|||
TeamSettingsUpdate,
|
||||
get_team_default_model,
|
||||
get_team_default_subagent_model,
|
||||
get_team_fable_enabled,
|
||||
get_team_settings,
|
||||
upsert_team_settings,
|
||||
)
|
||||
|
|
@ -445,8 +446,23 @@ async def me(session: dict[str, Any] = _SESSION_DEP) -> dict[str, Any]:
|
|||
async def options() -> dict[str, Any]:
|
||||
agent_model, agent_effort = await get_team_default_model("agent")
|
||||
subagent_model, subagent_effort = await get_team_default_subagent_model("agent")
|
||||
fable_enabled = await get_team_fable_enabled()
|
||||
# Never advertise a default that isn't in the selectable list: when Fable is
|
||||
# off, gate a stale Fable default down to its non-Fable fallback so the Cloud
|
||||
# Agents page (and the PUT /profile it drives) don't choke on it.
|
||||
agent_model, agent_effort = gate_fable_model(
|
||||
agent_model, agent_effort, fable_enabled=fable_enabled
|
||||
)
|
||||
subagent_model, subagent_effort = gate_fable_model(
|
||||
subagent_model, subagent_effort, fable_enabled=fable_enabled
|
||||
)
|
||||
models = (
|
||||
SUPPORTED_MODELS
|
||||
if fable_enabled
|
||||
else [m for m in SUPPORTED_MODELS if m["id"] not in FABLE_MODEL_IDS]
|
||||
)
|
||||
return {
|
||||
"models": SUPPORTED_MODELS,
|
||||
"models": models,
|
||||
"default_agent_model": agent_model,
|
||||
"default_agent_reasoning_effort": agent_effort,
|
||||
"default_agent_subagent_model": subagent_model,
|
||||
|
|
@ -468,6 +484,12 @@ async def put_my_profile(
|
|||
session: dict[str, Any] = _SESSION_DEP,
|
||||
) -> dict[str, Any]:
|
||||
update.validate_pairing()
|
||||
if not await get_team_fable_enabled():
|
||||
if (
|
||||
update.default_model in FABLE_MODEL_IDS
|
||||
or update.default_subagent_model in FABLE_MODEL_IDS
|
||||
):
|
||||
raise HTTPException(400, "Fable is disabled for this workspace")
|
||||
return await upsert_profile(session["sub"], session.get("email") or "", update)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -12,9 +12,10 @@ from fastapi import HTTPException
|
|||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from ..utils.thread_ops import langgraph_client
|
||||
from .options import SUPPORTED_MODEL_IDS, model_supports_effort
|
||||
from .options import SUPPORTED_MODEL_IDS, gate_fable_model, model_supports_effort
|
||||
from .profiles import get_profile, get_valid_access_token
|
||||
from .repo_access import repo_config_for_user, require_repo_access_for_user
|
||||
from .team_settings import get_team_fable_enabled
|
||||
from .thread_api import _agent_version_metadata, _now_ms, _resolve_run_email
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -405,7 +406,7 @@ def _agent_run_metadata(record: dict[str, Any], thread_id: str) -> dict[str, Any
|
|||
return metadata
|
||||
|
||||
|
||||
def _agent_run_config(record: dict[str, Any], thread_id: str) -> dict[str, Any]:
|
||||
async def _agent_run_config(record: dict[str, Any], thread_id: str) -> dict[str, Any]:
|
||||
configurable: dict[str, Any] = {
|
||||
"thread_id": thread_id,
|
||||
"source": "schedule",
|
||||
|
|
@ -418,6 +419,9 @@ def _agent_run_config(record: dict[str, Any], thread_id: str) -> dict[str, Any]:
|
|||
configurable["repo"] = repo
|
||||
model, effort = _normalize_model_choice(record.get("model"), record.get("effort"))
|
||||
if model and effort:
|
||||
model, effort = gate_fable_model(
|
||||
model, effort, fable_enabled=await get_team_fable_enabled()
|
||||
)
|
||||
configurable["agent_model_id"] = model
|
||||
configurable["agent_effort"] = effort
|
||||
report_channel = record.get("slack_report_channel")
|
||||
|
|
@ -471,7 +475,7 @@ async def launch_scheduled_agent_run(schedule_id: str) -> dict[str, Any]:
|
|||
thread_id,
|
||||
_AGENT_ASSISTANT_ID,
|
||||
input={"messages": [{"role": "user", "content": record["prompt"]}]},
|
||||
config=_agent_run_config(record, thread_id),
|
||||
config=await _agent_run_config(record, thread_id),
|
||||
if_not_exists="create",
|
||||
stream_mode=["values", "updates", "messages-tuple"],
|
||||
stream_resumable=True,
|
||||
|
|
|
|||
|
|
@ -17,8 +17,10 @@ from pydantic import BaseModel, field_validator, model_validator
|
|||
|
||||
from ..utils.gateway import resolve_gateway_enabled
|
||||
from .options import (
|
||||
FABLE_MODEL_IDS,
|
||||
SUPPORTED_MODEL_IDS,
|
||||
default_model_pair,
|
||||
gate_fable_model,
|
||||
model_supports_effort,
|
||||
provider_fallback_pair,
|
||||
)
|
||||
|
|
@ -56,6 +58,7 @@ class TeamSettingsUpdate(BaseModel):
|
|||
# Tri-state LLM Gateway toggle: True/False is authoritative, None inherits the
|
||||
# LANGSMITH_GATEWAY_ENABLED deployment default.
|
||||
gateway_enabled: bool | None = None
|
||||
fable_enabled: bool = False
|
||||
review_tracing_project: str | None = None
|
||||
org_guidelines: str | None = None
|
||||
default_agent_model: str | None = None
|
||||
|
|
@ -131,6 +134,26 @@ class TeamSettingsUpdate(BaseModel):
|
|||
_validate_model_effort_pair(
|
||||
self.default_chat_model, self.default_chat_reasoning_effort, "review chat"
|
||||
)
|
||||
if not self.fable_enabled:
|
||||
# Disabling Fable is the provider-data-share kill switch and must always succeed: rather
|
||||
# than reject a payload that still carries a Fable default, swap each
|
||||
# Fable default to its safe non-Fable fallback (mirrors the runtime
|
||||
# gate_fable_model guard) so the stored record can't advertise Fable.
|
||||
for model_field, effort_field in (
|
||||
("default_agent_model", "default_agent_reasoning_effort"),
|
||||
("default_agent_subagent_model", "default_agent_subagent_reasoning_effort"),
|
||||
("default_reviewer_model", "default_reviewer_reasoning_effort"),
|
||||
("default_reviewer_subagent_model", "default_reviewer_subagent_reasoning_effort"),
|
||||
("default_grouping_model", "default_grouping_reasoning_effort"),
|
||||
("default_chat_model", "default_chat_reasoning_effort"),
|
||||
):
|
||||
model = getattr(self, model_field)
|
||||
if model in FABLE_MODEL_IDS:
|
||||
new_model, new_effort = gate_fable_model(
|
||||
model, getattr(self, effort_field), fable_enabled=False
|
||||
)
|
||||
setattr(self, model_field, new_model)
|
||||
setattr(self, effort_field, new_effort)
|
||||
return self
|
||||
|
||||
|
||||
|
|
@ -171,6 +194,7 @@ def _default_settings() -> dict[str, Any]:
|
|||
"pr_summaries": True,
|
||||
"review_trace_links": True,
|
||||
"gateway_enabled": None,
|
||||
"fable_enabled": False,
|
||||
"review_tracing_project": None,
|
||||
"org_guidelines": DEFAULT_ORG_REVIEW_GUIDELINES,
|
||||
"default_agent_model": fallback_model,
|
||||
|
|
@ -226,6 +250,7 @@ async def upsert_team_settings(update: TeamSettingsUpdate) -> dict[str, Any]:
|
|||
"pr_summaries": update.pr_summaries,
|
||||
"review_trace_links": update.review_trace_links,
|
||||
"gateway_enabled": update.gateway_enabled,
|
||||
"fable_enabled": update.fable_enabled,
|
||||
"review_tracing_project": update.review_tracing_project,
|
||||
"org_guidelines": update.org_guidelines,
|
||||
"default_agent_model": update.default_agent_model,
|
||||
|
|
@ -353,6 +378,13 @@ async def get_team_gateway_enabled() -> bool | None:
|
|||
return value if isinstance(value, bool) else None
|
||||
|
||||
|
||||
async def get_team_fable_enabled() -> bool:
|
||||
"""Return whether Fable models are enabled for the team."""
|
||||
settings = await get_team_settings()
|
||||
value = settings.get("fable_enabled")
|
||||
return bool(value) if isinstance(value, bool) else False
|
||||
|
||||
|
||||
async def get_effective_gateway_enabled() -> bool:
|
||||
"""Resolve whether LLM Gateway routing is on: team setting, else env default."""
|
||||
return resolve_gateway_enabled(await get_team_gateway_enabled())
|
||||
|
|
|
|||
|
|
@ -31,12 +31,13 @@ from .agent_overrides import normalize_profile_overrides
|
|||
from .options import (
|
||||
SUPPORTED_MODEL_IDS,
|
||||
default_vision_model_pair,
|
||||
gate_fable_model,
|
||||
model_supports_effort,
|
||||
model_supports_images,
|
||||
)
|
||||
from .pr_diff import build_pr_diff_files
|
||||
from .profiles import get_profile, get_valid_access_token
|
||||
from .team_settings import get_team_default_model
|
||||
from .team_settings import get_team_default_model, get_team_fable_enabled
|
||||
from .user_mappings import email_for_login
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -153,6 +154,9 @@ async def _resolve_agent_model_choice(
|
|||
chosen_model, chosen_effort = _normalize_model_choice(model_id, effort)
|
||||
if chosen_model and chosen_effort:
|
||||
resolved_model, resolved_effort = chosen_model, chosen_effort
|
||||
resolved_model, resolved_effort = gate_fable_model(
|
||||
resolved_model, resolved_effort, fable_enabled=await get_team_fable_enabled()
|
||||
)
|
||||
return resolved_model, resolved_effort
|
||||
|
||||
|
||||
|
|
@ -374,6 +378,14 @@ def _thread_summary(
|
|||
thread_id = thread.get("thread_id") or thread.get("id")
|
||||
trace_url = get_langsmith_trace_url(thread_id) if isinstance(thread_id, str) else None
|
||||
|
||||
raw_sandbox_id = metadata.get("sandbox_id")
|
||||
# "__creating__" is the in-flight sentinel written before the real id lands.
|
||||
sandbox_id = (
|
||||
raw_sandbox_id
|
||||
if isinstance(raw_sandbox_id, str) and raw_sandbox_id and raw_sandbox_id != "__creating__"
|
||||
else None
|
||||
)
|
||||
|
||||
summary: dict[str, Any] = {
|
||||
"id": thread_id,
|
||||
"title": title,
|
||||
|
|
@ -402,6 +414,7 @@ def _thread_summary(
|
|||
"updatedAt": int(updated_at) if isinstance(updated_at, (int, float)) else _now_ms(),
|
||||
"isOwner": (_user_owns_thread(metadata, owner_login, owner_email) if owner_login else True),
|
||||
"traceUrl": trace_url,
|
||||
"sandboxId": sandbox_id,
|
||||
}
|
||||
if isinstance(pr_number, int) and isinstance(pr_url, str):
|
||||
summary["pr"] = {
|
||||
|
|
|
|||
|
|
@ -33,11 +33,13 @@ from deepagents import create_deep_agent
|
|||
from langchain.agents.middleware import ModelCallLimitMiddleware
|
||||
from langchain_core.language_models.chat_models import BaseChatModel
|
||||
|
||||
from .dashboard.options import gate_fable_model
|
||||
from .dashboard.team_settings import (
|
||||
get_effective_gateway_enabled,
|
||||
get_org_review_guidelines,
|
||||
get_team_default_grouping_model,
|
||||
get_team_default_model_pair,
|
||||
get_team_fable_enabled,
|
||||
)
|
||||
from .middleware import (
|
||||
RepairOrphanedToolCallsMiddleware,
|
||||
|
|
@ -822,6 +824,9 @@ async def _resolve_grouping_model(
|
|||
effort = configured_effort if isinstance(configured_effort, str) else None
|
||||
else:
|
||||
model_id, effort = await get_team_default_grouping_model()
|
||||
model_id, effort = gate_fable_model(
|
||||
model_id, effort, fable_enabled=await get_team_fable_enabled()
|
||||
)
|
||||
model_kwargs = provider_model_kwargs(
|
||||
model_id,
|
||||
effort,
|
||||
|
|
@ -1124,6 +1129,13 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
|
|||
subagent_effort = (
|
||||
configured_subagent_effort if isinstance(configured_subagent_effort, str) else None
|
||||
)
|
||||
fable_enabled = await get_team_fable_enabled()
|
||||
model_id, reasoning_effort = gate_fable_model(
|
||||
model_id, reasoning_effort, fable_enabled=fable_enabled
|
||||
)
|
||||
subagent_model_id, subagent_effort = gate_fable_model(
|
||||
subagent_model_id, subagent_effort, fable_enabled=fable_enabled
|
||||
)
|
||||
model_kwargs = provider_model_kwargs(
|
||||
model_id,
|
||||
reasoning_effort,
|
||||
|
|
|
|||
|
|
@ -41,12 +41,18 @@ from .dashboard.agent_overrides import (
|
|||
resolve_github_login,
|
||||
)
|
||||
from .dashboard.agent_usage import record_agent_thread_usage
|
||||
from .dashboard.options import DEFAULT_MODEL_ID, SUPPORTED_MODEL_IDS, model_supports_effort
|
||||
from .dashboard.options import (
|
||||
DEFAULT_MODEL_ID,
|
||||
SUPPORTED_MODEL_IDS,
|
||||
gate_fable_model,
|
||||
model_supports_effort,
|
||||
)
|
||||
from .dashboard.repo_snapshots import resolve_repo_snapshot_id
|
||||
from .dashboard.team_settings import (
|
||||
get_effective_gateway_enabled,
|
||||
get_team_default_model_pair,
|
||||
get_team_default_repo,
|
||||
get_team_fable_enabled,
|
||||
)
|
||||
from .dashboard.user_mappings import email_for_login
|
||||
from .integrations.corridor_mcp import load_corridor_tools
|
||||
|
|
@ -765,6 +771,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
)
|
||||
team_defaults_task = asyncio.create_task(get_team_default_model_pair("agent"))
|
||||
gateway_task = asyncio.create_task(get_effective_gateway_enabled())
|
||||
fable_task = asyncio.create_task(get_team_fable_enabled())
|
||||
profile_task = asyncio.create_task(load_profile(profile_login)) if profile_login else None
|
||||
try:
|
||||
(
|
||||
|
|
@ -772,11 +779,13 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
sandbox_backend,
|
||||
team_defaults,
|
||||
use_gateway,
|
||||
fable_enabled,
|
||||
) = await asyncio.gather(
|
||||
triggering_user_identity_task,
|
||||
sandbox_task,
|
||||
team_defaults_task,
|
||||
gateway_task,
|
||||
fable_task,
|
||||
)
|
||||
except SandboxRepoMismatchError as exc:
|
||||
# Repo-binding refusal at the run boundary: log for alarming and surface the
|
||||
|
|
@ -787,6 +796,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
triggering_user_identity_task,
|
||||
team_defaults_task,
|
||||
gateway_task,
|
||||
fable_task,
|
||||
profile_task,
|
||||
):
|
||||
if pending is not None and not pending.done():
|
||||
|
|
@ -860,6 +870,13 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
if always_create_prs:
|
||||
logger.info("Always Create PRs enabled by profile for %s", profile_login)
|
||||
|
||||
model_id, profile_effort = gate_fable_model(
|
||||
model_id, profile_effort, fable_enabled=fable_enabled
|
||||
)
|
||||
subagent_model_id, subagent_effort = gate_fable_model(
|
||||
subagent_model_id, subagent_effort, fable_enabled=fable_enabled
|
||||
)
|
||||
|
||||
model_kwargs = provider_model_kwargs(
|
||||
model_id,
|
||||
profile_effort,
|
||||
|
|
|
|||
|
|
@ -70,11 +70,11 @@
|
|||
{"sha": "216cf181", "pr": 1699, "subject": "fix: keep workflow HITL without token downscoping (#1699)", "disposition": "landed", "reason": "DIVERGES-FROM-UPSTREAM: fork deliberately does NOT adopt #1699's standing-token workflows:write broadening. Security review (#159) BLOCKed it — the standing ALWAYS-ON proxy token carrying workflows:write turns the HITL guard's git-push-parser gaps (obfuscated-expansion push, `gh api` REST contents PUT, cross-branch refspecs) into live unapproved-workflow-push exploits. Fork keeps BASE without workflows:write and restores the transient per-approval elevation (_run_with_workflow_token mints WORKFLOW_RUNTIME_PROXY_TOKEN_PERMISSIONS around the approved, guard-normalized fixed_command, then downscopes to RUNTIME then BASE): the token scope is the backstop the parser relies on, so a bypass hits GitHub 403. HITL diff-preview/approval-URL/Slack-card additions from #159 retained; token-model divergence only.", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-09T20:00:00Z"}
|
||||
{"sha": "67abf5b0", "pr": 1659, "subject": "fix: surface attributed PR creation failures (#1659)", "disposition": "deferred", "reason": "PR-attribution-failure guard (new mw, safe imports); heavy conflict on diverged open_pull_request.py", "branch": "pr-attribution", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "3dbc0282", "pr": 1676, "subject": "fix: preserve plan redirects after login (#1676)", "disposition": "landed", "reason": "FLAG-HUMAN: follow-on to landed #1668 refining sanitize_redirect_to (open-redirect auth surface); not a dup", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-09T17:10:22Z"}
|
||||
{"sha": "c75cbb1f", "pr": 1677, "subject": "feat: re-add Fable 5 with an admin toggle to disable it (#1677)", "disposition": "deferred", "reason": "FLAG-HUMAN: re-adds Fable 5 via anthropic: — contradicts dev's deliberate hide (#1483) + Bedrock migration (#62); wont-merge candidate", "branch": "fable-admin-toggle", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "c75cbb1f", "pr": 1677, "subject": "feat: re-add Fable 5 with an admin toggle to disable it (#1677)", "disposition": "landed", "reason": "Ported + Bedrock-converted onto dev via feat/readd-fable5-bedrock; anthropic: Fable ID mapped to bedrock_converse:us.anthropic.claude-fable-5.", "branch": "fable-admin-toggle", "local_sha": null, "updated": "2026-07-10T17:07:19Z"}
|
||||
{"sha": "bb104d93", "pr": 1679, "subject": "fix: submit plan comments with cmd enter (#1679)", "disposition": "landed", "reason": "applies clean but edits fork-diverged PlanReview.tsx (#130); needs UI/e2e validation — separate PR", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-09T17:10:22Z"}
|
||||
{"sha": "304032fa", "pr": 1680, "subject": "chore: clarify question answering prompt (#1680)", "disposition": "deferred", "reason": "reword Slack info-only answer guidance; conflicts w/ fork's customized Slack prompt", "branch": "prompt-tweaks", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "5003c953", "pr": 1683, "subject": "feat: open Linear-triggered PRs as the triggering user (#1683)", "disposition": "deferred", "reason": "FLAG-HUMAN: adds linear to resolve_github_token per-user OAuth branch (auth surface); depends on #1626 linear.py", "branch": "linear-pr-as-user", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "feb7ac98", "pr": 1689, "subject": "feat(web): surface thread sandbox ID with touch-friendly menu (#1689)", "disposition": "deferred", "reason": "applies clean but frontend<->backend contract (thread_api->queries->types->sidebar); needs UI build + e2e validation — separate PR", "branch": "dashboard-ui", "local_sha": null, "updated": "2026-07-08T23:17:35Z"}
|
||||
{"sha": "feb7ac98", "pr": 1689, "subject": "feat(web): surface thread sandbox ID with touch-friendly menu (#1689)", "disposition": "landed", "reason": "Ported to dev via feat/thread-sandbox-id-sidebar.", "branch": "dashboard-ui", "local_sha": null, "updated": "2026-07-10T16:48:54Z"}
|
||||
{"sha": "7f7af715", "pr": 1684, "subject": "feat: auto-load scoped AGENTS on reads (#1684)", "disposition": "landed", "reason": "ported in #129 (SubdirAgentsReadMiddleware)", "branch": "subdir-agents", "local_sha": null, "updated": "2026-07-08T22:58:22Z"}
|
||||
{"sha": "88b62322", "pr": 1685, "subject": "feat: add platform issue reporting tool (#1685)", "disposition": "landed", "reason": "ported in #129 (report_platform_issue tool)", "branch": "small-tools", "local_sha": null, "updated": "2026-07-08T22:58:22Z"}
|
||||
{"sha": "90cb6caa", "pr": 1681, "subject": "feat: terse Slack replies, share long content via plan-review page (#1681)", "disposition": "landed", "reason": "terse Slack + long-content-via-plan-page; conflicts w/ fork prompt + diverged plan stack", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-09T17:10:22Z"}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,9 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro
|
|||
| `5f7f5fbd` | #1697 | fix: Reduce graph import and loader startup latency (#1697) | Landed | import-hygiene refactor; cross-cutting, references many deferred upstream-only modules | durable-dispatch |
|
||||
| `216cf181` | #1699 | fix: keep workflow HITL without token downscoping (#1699) | Landed | DIVERGES-FROM-UPSTREAM: fork deliberately does NOT adopt #1699's standing-token workflows:write broadening. Security review (#159) BLOCKed it — the standing ALWAYS-ON proxy token carrying workflows:write turns the HITL guard's git-push-parser gaps (obfuscated-expansion push, `gh api` REST contents PUT, cross-branch refspecs) into live unapproved-workflow-push exploits. Fork keeps BASE without workflows:write and restores the transient per-approval elevation (_run_with_workflow_token mints WORKFLOW_RUNTIME_PROXY_TOKEN_PERMISSIONS around the approved, guard-normalized fixed_command, then downscopes to RUNTIME then BASE): the token scope is the backstop the parser relies on, so a bypass hits GitHub 403. HITL diff-preview/approval-URL/Slack-card additions from #159 retained; token-model divergence only. | plan-approval |
|
||||
| `3dbc0282` | #1676 | fix: preserve plan redirects after login (#1676) | Landed | FLAG-HUMAN: follow-on to landed #1668 refining sanitize_redirect_to (open-redirect auth surface); not a dup | plan-approval |
|
||||
| `c75cbb1f` | #1677 | feat: re-add Fable 5 with an admin toggle to disable it (#1677) | Landed | Ported + Bedrock-converted onto dev via feat/readd-fable5-bedrock; anthropic: Fable ID mapped to bedrock_converse:us.anthropic.claude-fable-5. | fable-admin-toggle |
|
||||
| `bb104d93` | #1679 | fix: submit plan comments with cmd enter (#1679) | Landed | applies clean but edits fork-diverged PlanReview.tsx (#130); needs UI/e2e validation — separate PR | plan-approval |
|
||||
| `feb7ac98` | #1689 | feat(web): surface thread sandbox ID with touch-friendly menu (#1689) | Landed | Ported to dev via feat/thread-sandbox-id-sidebar. | dashboard-ui |
|
||||
| `7f7af715` | #1684 | feat: auto-load scoped AGENTS on reads (#1684) | Landed | ported in #129 (SubdirAgentsReadMiddleware) | subdir-agents |
|
||||
| `88b62322` | #1685 | feat: add platform issue reporting tool (#1685) | Landed | ported in #129 (report_platform_issue tool) | small-tools |
|
||||
| `90cb6caa` | #1681 | feat: terse Slack replies, share long content via plan-review page (#1681) | Landed | terse Slack + long-content-via-plan-page; conflicts w/ fork prompt + diverged plan stack | plan-approval |
|
||||
|
|
@ -88,10 +90,8 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro
|
|||
| `4f8bc2dd` | #1692 | refactor: simplify open-swe agent sandbox lifecycle (#1692) | Deferred | FLAG-HUMAN: structural rewrite of ensure_sandbox_for_thread (drops __creating__ 4-case sentinel) | sandbox-refactor |
|
||||
| `48217b68` | #1489 | feat(open-swe): add E2B sandbox provider (#1489) | Deferred | additive E2B provider; separable but ships on the async sandbox.py base | sandbox-refactor |
|
||||
| `67abf5b0` | #1659 | fix: surface attributed PR creation failures (#1659) | Deferred | PR-attribution-failure guard (new mw, safe imports); heavy conflict on diverged open_pull_request.py | pr-attribution |
|
||||
| `c75cbb1f` | #1677 | feat: re-add Fable 5 with an admin toggle to disable it (#1677) | Deferred | FLAG-HUMAN: re-adds Fable 5 via anthropic: — contradicts dev's deliberate hide (#1483) + Bedrock migration (#62); wont-merge candidate | fable-admin-toggle |
|
||||
| `304032fa` | #1680 | chore: clarify question answering prompt (#1680) | Deferred | reword Slack info-only answer guidance; conflicts w/ fork's customized Slack prompt | prompt-tweaks |
|
||||
| `5003c953` | #1683 | feat: open Linear-triggered PRs as the triggering user (#1683) | Deferred | FLAG-HUMAN: adds linear to resolve_github_token per-user OAuth branch (auth surface); depends on #1626 linear.py | linear-pr-as-user |
|
||||
| `feb7ac98` | #1689 | feat(web): surface thread sandbox ID with touch-friendly menu (#1689) | Deferred | applies clean but frontend<->backend contract (thread_api->queries->types->sidebar); needs UI build + e2e validation — separate PR | dashboard-ui |
|
||||
| `f53caff1` | #1701 | fix: fall back to core GitHub App scope when optional grants missing (#1701) | Deferred | FLAG-HUMAN: GitHub-App permission-ladder degrade (auth surface); heavy conflict on diverged github_app.py/_resolve_proxy_token | github-app-scope |
|
||||
| `22e024cb` | #1704 | fix: link issue PRs and prompt repo conventions (#1704) | Deferred | issue/PR linking + repo-convention prompt; clean but prompt-conflict risk vs #113 | webhook-issue-linking |
|
||||
| `27b0ddeb` | #1708 | feat: add GPT-5.6 OpenAI models (#1708) | Deferred | FLAG-HUMAN: adds OpenAI GPT-5.6 to the model picker; fork's picker is Bedrock/Fireworks-only — needs a product decision before adopting OpenAI models. Gateway (#155) can route OpenAI if adopted. | model-picker |
|
||||
|
|
|
|||
115
tests/e2e/tests/sandbox_id.spec.ts
Normal file
115
tests/e2e/tests/sandbox_id.spec.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { test, expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
// Exercises the sidebar's "Copy sandbox ID" action against the REAL dashboard
|
||||
// UI and a REAL (local-provider) sandbox: the Slack flow runs the agent, which
|
||||
// creates a sandbox and stamps its id into the thread metadata, and the UI
|
||||
// copies that same id. Only the LLM/GitHub/Slack boundaries are faked.
|
||||
const SAME_USER = { login: "alice", email: "alice@example.com" };
|
||||
|
||||
async function loginAs(page: Page, user: { login: string; email: string }) {
|
||||
const res = await page.request.post("/control/login", { data: user });
|
||||
expect(res.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
// Drive the Slack flow so the real agent creates a thread + sandbox, then follow
|
||||
// the bot's "Open in Web" link. Returns the created thread id.
|
||||
async function createThreadWithSandbox(page: Page): Promise<string> {
|
||||
await page.goto("/mock/slack");
|
||||
await page.locator("#reset").click();
|
||||
await expect(page.locator("#thread")).toContainText("No messages yet");
|
||||
await page
|
||||
.locator("#text")
|
||||
.fill("<@U0BOT> please add a greet() helper and open a PR");
|
||||
await page.locator("#send").click();
|
||||
await expect(
|
||||
page.locator(".msg.bot").filter({ hasText: "Add greet() helper" }),
|
||||
).toBeVisible();
|
||||
|
||||
const webLink = page.locator('.msg.bot a[href*="/agents/"]').first();
|
||||
await expect(webLink).toBeVisible();
|
||||
await webLink.click();
|
||||
await expect(page).toHaveURL(/\/agents\//);
|
||||
|
||||
const id = new URL(page.url()).pathname.split("/").filter(Boolean).pop();
|
||||
expect(id).toBeTruthy();
|
||||
return id as string;
|
||||
}
|
||||
|
||||
const copyItem = (page: Page) =>
|
||||
page.getByRole("menuitem", { name: "Copy sandbox ID" });
|
||||
|
||||
// The kebab sits beside the row Link (not inside the anchor), so reach it via
|
||||
// their shared wrapper — the Link's parent.
|
||||
const kebabFor = (row: Locator) =>
|
||||
row.locator("..").getByRole("button", { name: "Thread actions" });
|
||||
|
||||
test.describe("thread sandbox id (real dashboard UI)", () => {
|
||||
test("desktop: kebab menu copies the real sandbox id", async ({
|
||||
page,
|
||||
baseURL,
|
||||
}) => {
|
||||
await page
|
||||
.context()
|
||||
.grantPermissions(["clipboard-read", "clipboard-write"], {
|
||||
origin: baseURL,
|
||||
});
|
||||
await loginAs(page, SAME_USER);
|
||||
const threadId = await createThreadWithSandbox(page);
|
||||
|
||||
const row = page.locator(`a[href$="/agents/${threadId}"]`).first();
|
||||
await expect(row).toBeVisible();
|
||||
// The kebab is revealed on hover on pointer devices.
|
||||
await row.hover();
|
||||
await kebabFor(row).click();
|
||||
|
||||
await expect(copyItem(page)).toBeEnabled();
|
||||
await copyItem(page).click();
|
||||
|
||||
const clip = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(clip.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("iPad: kebab copies the sandbox id without navigating", async ({
|
||||
browser,
|
||||
baseURL,
|
||||
}) => {
|
||||
// iPad-class device: Chromium in mobile mode reports (hover: none), which
|
||||
// gates the touch-only kebab. 834px is wider than the 767px mobile
|
||||
// breakpoint, so the sidebar renders inline.
|
||||
const context = await browser.newContext({
|
||||
baseURL,
|
||||
viewport: { width: 834, height: 1112 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
});
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"], {
|
||||
origin: baseURL,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await loginAs(page, SAME_USER);
|
||||
|
||||
// Two threads: we sit on B's page and act on A's sidebar row, so a stray
|
||||
// Link navigation would be observable as a URL change to A.
|
||||
const threadA = await createThreadWithSandbox(page);
|
||||
const threadB = await createThreadWithSandbox(page);
|
||||
await expect(page).toHaveURL(new RegExp(`/agents/${threadB}$`));
|
||||
|
||||
const rowA = page.locator(`a[href$="/agents/${threadA}"]`).first();
|
||||
await expect(rowA).toBeVisible();
|
||||
|
||||
const kebab = kebabFor(rowA);
|
||||
await expect(kebab).toBeVisible();
|
||||
await kebab.tap();
|
||||
|
||||
await expect(copyItem(page)).toBeEnabled();
|
||||
await copyItem(page).tap();
|
||||
|
||||
const clip = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(clip.length).toBeGreaterThan(0);
|
||||
|
||||
// Tapping the kebab must open the menu, not follow A's Link — we stay on B.
|
||||
await expect(page).toHaveURL(new RegExp(`/agents/${threadB}$`));
|
||||
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
|
|
@ -196,7 +196,7 @@ async def test_update_agent_schedule_clears_slack_report_channel(fake_client) ->
|
|||
assert result["slackReportChannel"] is None
|
||||
|
||||
|
||||
def test_agent_run_config_seeds_slack_thread_channel() -> None:
|
||||
async def test_agent_run_config_seeds_slack_thread_channel() -> None:
|
||||
record = {
|
||||
"id": "sched_1",
|
||||
"model": "Default",
|
||||
|
|
@ -206,12 +206,12 @@ def test_agent_run_config_seeds_slack_thread_channel() -> None:
|
|||
"slack_report_channel": "C0123ABCD",
|
||||
}
|
||||
|
||||
config = schedules._agent_run_config(record, "thread_1")
|
||||
config = await schedules._agent_run_config(record, "thread_1")
|
||||
|
||||
assert config["configurable"]["slack_thread"] == {"channel_id": "C0123ABCD"}
|
||||
|
||||
|
||||
def test_agent_run_config_omits_slack_thread_without_channel() -> None:
|
||||
async def test_agent_run_config_omits_slack_thread_without_channel() -> None:
|
||||
record = {
|
||||
"id": "sched_1",
|
||||
"model": "Default",
|
||||
|
|
@ -221,7 +221,7 @@ def test_agent_run_config_omits_slack_thread_without_channel() -> None:
|
|||
"slack_report_channel": None,
|
||||
}
|
||||
|
||||
config = schedules._agent_run_config(record, "thread_1")
|
||||
config = await schedules._agent_run_config(record, "thread_1")
|
||||
|
||||
assert "slack_thread" not in config["configurable"]
|
||||
|
||||
|
|
|
|||
|
|
@ -161,3 +161,71 @@ async def test_agent_subagent_inherits_profile_model_override_without_explicit_p
|
|||
"thinking": {"type": "adaptive", "display": "summarized"},
|
||||
"output_config": {"effort": "high"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_gate_swaps_disabled_fable_profile_to_opus() -> None:
|
||||
config: RunnableConfig = {
|
||||
"configurable": {
|
||||
"__is_for_execution__": True,
|
||||
"thread_id": "thread-123",
|
||||
"github_login": "octocat",
|
||||
},
|
||||
"metadata": {},
|
||||
}
|
||||
main_model = MagicMock(name="main_model")
|
||||
subagent_model = MagicMock(name="subagent_model")
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_create_deep_agent(**kwargs: object) -> _DummyAgent:
|
||||
captured.update(kwargs)
|
||||
return _DummyAgent()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"agent.server.resolve_github_token", new_callable=AsyncMock, return_value=("ghp", None)
|
||||
),
|
||||
patch("agent.server.resolve_triggering_user_identity", return_value=None),
|
||||
patch(
|
||||
"agent.server.ensure_sandbox_for_thread",
|
||||
new_callable=AsyncMock,
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"agent.server.aresolve_sandbox_work_dir",
|
||||
new_callable=AsyncMock,
|
||||
return_value="/workspace",
|
||||
),
|
||||
patch(
|
||||
"agent.server.get_team_default_model_pair",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(
|
||||
("bedrock_converse:us.anthropic.claude-opus-4-8", "medium"),
|
||||
("bedrock_converse:us.anthropic.claude-opus-4-8", "low"),
|
||||
),
|
||||
),
|
||||
# Profile selected Fable back when it was allowed; it's now disabled.
|
||||
patch(
|
||||
"agent.server.load_profile",
|
||||
new_callable=AsyncMock,
|
||||
return_value={
|
||||
"default_model": "bedrock_converse:us.anthropic.claude-fable-5",
|
||||
"reasoning_effort": "high",
|
||||
},
|
||||
),
|
||||
patch("agent.server.get_team_fable_enabled", new_callable=AsyncMock, return_value=False),
|
||||
patch("agent.server.fallback_model_id_for", return_value=None),
|
||||
patch(
|
||||
"agent.utils.deferred_model.make_model", side_effect=[main_model, subagent_model]
|
||||
) as make_model,
|
||||
patch("agent.server.construct_system_prompt", return_value="prompt"),
|
||||
patch("agent.server.create_deep_agent", side_effect=fake_create_deep_agent),
|
||||
):
|
||||
await get_agent(config)
|
||||
|
||||
# Fable was scrubbed to Opus for both main and subagent; effort preserved.
|
||||
assert make_model.call_args_list[0].args == ("bedrock_converse:us.anthropic.claude-opus-4-8",)
|
||||
assert make_model.call_args_list[0].kwargs["additional_model_request_fields"][
|
||||
"output_config"
|
||||
] == {"effort": "high"}
|
||||
assert make_model.call_args_list[1].args == ("bedrock_converse:us.anthropic.claude-opus-4-8",)
|
||||
|
|
|
|||
55
tests/test_anthropic_model.py
Normal file
55
tests/test_anthropic_model.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import pytest
|
||||
|
||||
from agent.dashboard.options import SUPPORTED_MODELS, provider_fallback_pair
|
||||
from agent.utils.model import provider_model_kwargs
|
||||
|
||||
SONNET_5_ID = "bedrock_converse:us.anthropic.claude-sonnet-5"
|
||||
FABLE_5_ID = "bedrock_converse:us.anthropic.claude-fable-5"
|
||||
|
||||
|
||||
def test_sonnet_5_is_supported_with_documented_efforts() -> None:
|
||||
sonnet = next(m for m in SUPPORTED_MODELS if m["id"] == SONNET_5_ID)
|
||||
assert sonnet["label"] == "Sonnet 5 (Bedrock)"
|
||||
assert sonnet["efforts"] == ["low", "medium", "high", "xhigh", "max"]
|
||||
assert sonnet["default_effort"] == "high"
|
||||
assert sonnet["supports_images"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("effort", ["low", "medium", "high", "xhigh", "max"])
|
||||
def test_sonnet_5_efforts_map_to_bedrock_kwargs(effort: str) -> None:
|
||||
kwargs = provider_model_kwargs(SONNET_5_ID, effort, max_tokens=16_000)
|
||||
assert kwargs["max_tokens"] == 16_000
|
||||
fields = kwargs["additional_model_request_fields"]
|
||||
assert fields["output_config"] == {"effort": effort}
|
||||
assert fields["thinking"] == {"type": "adaptive", "display": "summarized"}
|
||||
|
||||
|
||||
def test_sonnet_46_fallback_uses_sonnet_5() -> None:
|
||||
assert provider_fallback_pair("bedrock_converse:us.anthropic.claude-sonnet-4-6", "xhigh") == (
|
||||
SONNET_5_ID,
|
||||
"xhigh",
|
||||
)
|
||||
|
||||
|
||||
def test_opus_fallback_stays_on_opus_family() -> None:
|
||||
assert provider_fallback_pair("bedrock_converse:us.anthropic.claude-opus-4-7", "xhigh") == (
|
||||
"bedrock_converse:us.anthropic.claude-opus-4-8",
|
||||
"xhigh",
|
||||
)
|
||||
|
||||
|
||||
def test_fable_5_is_supported_with_documented_efforts() -> None:
|
||||
fable = next(m for m in SUPPORTED_MODELS if m["id"] == FABLE_5_ID)
|
||||
assert fable["label"] == "Fable 5"
|
||||
assert fable["efforts"] == ["low", "medium", "high", "xhigh", "max"]
|
||||
assert fable["default_effort"] == "high"
|
||||
assert fable["supports_images"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("effort", ["low", "medium", "high", "xhigh", "max"])
|
||||
def test_fable_5_efforts_map_to_bedrock_kwargs(effort: str) -> None:
|
||||
kwargs = provider_model_kwargs(FABLE_5_ID, effort, max_tokens=16_000)
|
||||
assert kwargs["max_tokens"] == 16_000
|
||||
fields = kwargs["additional_model_request_fields"]
|
||||
assert fields["output_config"] == {"effort": effort}
|
||||
assert fields["thinking"] == {"type": "adaptive", "display": "summarized"}
|
||||
|
|
@ -1,16 +1,19 @@
|
|||
import base64
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from agent.dashboard import thread_api
|
||||
from agent.dashboard import routes, thread_api
|
||||
from agent.dashboard.agent_overrides import resolve_agent_model_id
|
||||
from agent.dashboard.options import model_supports_images
|
||||
|
||||
_TEXT_ONLY_MODEL = "fireworks:accounts/fireworks/models/deepseek-v4-pro"
|
||||
_VISION_MODEL = "bedrock_converse:us.anthropic.claude-opus-4-8"
|
||||
_FABLE = "bedrock_converse:us.anthropic.claude-fable-5"
|
||||
_PAIR = ("bedrock_converse:us.anthropic.claude-opus-4-8", "medium")
|
||||
|
||||
|
||||
def _image() -> thread_api.DashboardImageBody:
|
||||
|
|
@ -314,6 +317,18 @@ def test_thread_summary_omits_pr_when_no_pr_metadata() -> None:
|
|||
assert "diffStats" not in summary
|
||||
|
||||
|
||||
def test_thread_summary_exposes_sandbox_id() -> None:
|
||||
summary = thread_api._thread_summary(_thread_with_metadata({"sandbox_id": "sb-abc123"}))
|
||||
|
||||
assert summary["sandboxId"] == "sb-abc123"
|
||||
|
||||
|
||||
def test_thread_summary_hides_creating_sandbox_sentinel() -> None:
|
||||
summary = thread_api._thread_summary(_thread_with_metadata({"sandbox_id": "__creating__"}))
|
||||
|
||||
assert summary["sandboxId"] is None
|
||||
|
||||
|
||||
async def test_recovery_patch_requires_thread_owner(monkeypatch) -> None:
|
||||
class FakeThreads:
|
||||
async def get(self, thread_id: str) -> dict[str, object]:
|
||||
|
|
@ -1415,3 +1430,81 @@ async def test_status_filter_refreshes_threads_missing_run_status(monkeypatch) -
|
|||
assert {item["id"] for item in result["items"]} == {"t0"}
|
||||
assert result["items"][0]["status"] == "finished"
|
||||
assert set(run_list_thread_ids) == {"t0", "t1"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_options_omits_fable_when_disabled() -> None:
|
||||
with (
|
||||
patch(
|
||||
"agent.dashboard.routes.get_team_fable_enabled",
|
||||
new_callable=AsyncMock,
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"agent.dashboard.routes.get_team_default_model",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_PAIR,
|
||||
),
|
||||
patch(
|
||||
"agent.dashboard.routes.get_team_default_subagent_model",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_PAIR,
|
||||
),
|
||||
):
|
||||
payload = await routes.options()
|
||||
assert _FABLE not in [m["id"] for m in payload["models"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_options_includes_fable_when_enabled() -> None:
|
||||
with (
|
||||
patch(
|
||||
"agent.dashboard.routes.get_team_fable_enabled",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"agent.dashboard.routes.get_team_default_model",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_PAIR,
|
||||
),
|
||||
patch(
|
||||
"agent.dashboard.routes.get_team_default_subagent_model",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_PAIR,
|
||||
),
|
||||
):
|
||||
payload = await routes.options()
|
||||
assert _FABLE in [m["id"] for m in payload["models"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_options_gates_stale_fable_default_when_disabled() -> None:
|
||||
# A stale Fable team default must not be advertised as the default while Fable
|
||||
# is omitted from the selectable list, or the Cloud Agents page would offer a
|
||||
# default that PUT /profile then rejects.
|
||||
fable_pair = (_FABLE, "high")
|
||||
with (
|
||||
patch(
|
||||
"agent.dashboard.routes.get_team_fable_enabled",
|
||||
new_callable=AsyncMock,
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"agent.dashboard.routes.get_team_default_model",
|
||||
new_callable=AsyncMock,
|
||||
return_value=fable_pair,
|
||||
),
|
||||
patch(
|
||||
"agent.dashboard.routes.get_team_default_subagent_model",
|
||||
new_callable=AsyncMock,
|
||||
return_value=fable_pair,
|
||||
),
|
||||
):
|
||||
payload = await routes.options()
|
||||
model_ids = [m["id"] for m in payload["models"]]
|
||||
assert _FABLE not in model_ids
|
||||
assert payload["default_agent_model"] != _FABLE
|
||||
assert payload["default_agent_subagent_model"] != _FABLE
|
||||
assert payload["default_agent_model"] in model_ids
|
||||
assert payload["default_agent_subagent_model"] in model_ids
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ import pytest
|
|||
from agent.dashboard.agent_overrides import normalize_profile_overrides
|
||||
from agent.dashboard.options import (
|
||||
DEFAULT_MODEL_ID,
|
||||
FABLE_MODEL_IDS,
|
||||
default_model_pair,
|
||||
fable_disabled_fallback,
|
||||
gate_fable_model,
|
||||
provider_fallback_pair,
|
||||
)
|
||||
from agent.dashboard.team_settings import get_team_default_model
|
||||
|
|
@ -88,3 +91,37 @@ def test_profile_unknown_provider_defers_to_team_default() -> None:
|
|||
def test_global_default_is_supported() -> None:
|
||||
model, _ = default_model_pair()
|
||||
assert model == DEFAULT_MODEL_ID
|
||||
|
||||
|
||||
def test_gate_fable_passthrough_when_enabled() -> None:
|
||||
assert gate_fable_model(
|
||||
"bedrock_converse:us.anthropic.claude-fable-5", "high", fable_enabled=True
|
||||
) == (
|
||||
"bedrock_converse:us.anthropic.claude-fable-5",
|
||||
"high",
|
||||
)
|
||||
|
||||
|
||||
def test_gate_fable_swaps_to_opus_when_disabled() -> None:
|
||||
assert gate_fable_model(
|
||||
"bedrock_converse:us.anthropic.claude-fable-5", "high", fable_enabled=False
|
||||
) == (
|
||||
"bedrock_converse:us.anthropic.claude-opus-4-8",
|
||||
"high",
|
||||
)
|
||||
|
||||
|
||||
def test_gate_fable_leaves_non_fable_ids_alone() -> None:
|
||||
assert gate_fable_model(
|
||||
"fireworks:accounts/fireworks/models/deepseek-v4-pro", "high", fable_enabled=False
|
||||
) == (
|
||||
"fireworks:accounts/fireworks/models/deepseek-v4-pro",
|
||||
"high",
|
||||
)
|
||||
|
||||
|
||||
def test_fable_disabled_fallback_is_non_fable_claude() -> None:
|
||||
model, effort = fable_disabled_fallback("high")
|
||||
assert model == "bedrock_converse:us.anthropic.claude-opus-4-8"
|
||||
assert model not in FABLE_MODEL_IDS
|
||||
assert effort == "high"
|
||||
|
|
|
|||
122
tests/test_team_settings_fable.py
Normal file
122
tests/test_team_settings_fable.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.dashboard.options import FABLE_MODEL_IDS
|
||||
from agent.dashboard.team_settings import TeamSettingsUpdate, get_team_fable_enabled
|
||||
|
||||
_FABLE = "bedrock_converse:us.anthropic.claude-fable-5"
|
||||
|
||||
# --- accessor: get_team_fable_enabled (async, patched store) ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fable_enabled_defaults_false_when_absent() -> None:
|
||||
# Legacy record with no fable_enabled key -> off.
|
||||
with patch(
|
||||
"agent.dashboard.team_settings.get_team_settings",
|
||||
new_callable=AsyncMock,
|
||||
return_value={},
|
||||
):
|
||||
assert await get_team_fable_enabled() is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fable_enabled_true_when_set() -> None:
|
||||
with patch(
|
||||
"agent.dashboard.team_settings.get_team_settings",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"fable_enabled": True},
|
||||
):
|
||||
assert await get_team_fable_enabled() is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fable_enabled_false_for_non_bool_value() -> None:
|
||||
# Fail-closed: any non-bool (e.g. a stray string) resolves to False.
|
||||
with patch(
|
||||
"agent.dashboard.team_settings.get_team_settings",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"fable_enabled": "true"},
|
||||
):
|
||||
assert await get_team_fable_enabled() is False
|
||||
|
||||
|
||||
# --- validation: TeamSettingsUpdate (sync) ---
|
||||
|
||||
|
||||
def test_update_defaults_fable_disabled() -> None:
|
||||
assert TeamSettingsUpdate().fable_enabled is False
|
||||
|
||||
|
||||
def test_update_coerces_fable_model_when_disabled() -> None:
|
||||
# Disabling Fable must never fail: a lingering Fable default is swapped for a
|
||||
# safe non-Fable fallback (effort preserved) rather than rejected.
|
||||
update = TeamSettingsUpdate(
|
||||
default_agent_model=_FABLE,
|
||||
default_agent_reasoning_effort="high",
|
||||
)
|
||||
assert update.default_agent_model not in FABLE_MODEL_IDS
|
||||
assert update.default_agent_reasoning_effort == "high"
|
||||
|
||||
|
||||
def test_update_coerces_fable_in_any_role_when_disabled() -> None:
|
||||
# Same coercion applies to every model field, e.g. review chat.
|
||||
update = TeamSettingsUpdate(
|
||||
default_chat_model=_FABLE,
|
||||
default_chat_reasoning_effort="high",
|
||||
)
|
||||
assert update.default_chat_model not in FABLE_MODEL_IDS
|
||||
assert update.default_chat_reasoning_effort == "high"
|
||||
|
||||
|
||||
def test_disable_transition_coerces_all_fable_defaults() -> None:
|
||||
# The kill-switch flow: an admin who had picked Fable everywhere flips the
|
||||
# toggle off, and the UI re-sends the whole settings blob with the Fable
|
||||
# defaults still attached. The update must succeed and strip every Fable id.
|
||||
update = TeamSettingsUpdate(
|
||||
fable_enabled=False,
|
||||
default_agent_model=_FABLE,
|
||||
default_agent_reasoning_effort="high",
|
||||
default_agent_subagent_model=_FABLE,
|
||||
default_agent_subagent_reasoning_effort="high",
|
||||
default_reviewer_model=_FABLE,
|
||||
default_reviewer_reasoning_effort="high",
|
||||
default_reviewer_subagent_model=_FABLE,
|
||||
default_reviewer_subagent_reasoning_effort="high",
|
||||
default_grouping_model=_FABLE,
|
||||
default_grouping_reasoning_effort="high",
|
||||
default_chat_model=_FABLE,
|
||||
default_chat_reasoning_effort="high",
|
||||
)
|
||||
for field in (
|
||||
"default_agent_model",
|
||||
"default_agent_subagent_model",
|
||||
"default_reviewer_model",
|
||||
"default_reviewer_subagent_model",
|
||||
"default_grouping_model",
|
||||
"default_chat_model",
|
||||
):
|
||||
assert getattr(update, field) not in FABLE_MODEL_IDS, field
|
||||
|
||||
|
||||
def test_update_leaves_non_fable_defaults_untouched_when_disabled() -> None:
|
||||
# Coercion only rewrites Fable ids; other selections pass through unchanged.
|
||||
update = TeamSettingsUpdate(
|
||||
default_agent_model="bedrock_converse:us.anthropic.claude-sonnet-5",
|
||||
default_agent_reasoning_effort="medium",
|
||||
)
|
||||
assert update.default_agent_model == "bedrock_converse:us.anthropic.claude-sonnet-5"
|
||||
assert update.default_agent_reasoning_effort == "medium"
|
||||
|
||||
|
||||
def test_update_accepts_fable_model_when_enabled() -> None:
|
||||
update = TeamSettingsUpdate(
|
||||
fable_enabled=True,
|
||||
default_agent_model=_FABLE,
|
||||
default_agent_reasoning_effort="high",
|
||||
)
|
||||
assert update.fable_enabled is True
|
||||
assert update.default_agent_model == _FABLE
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { ContextMenu } from "@base-ui/react/context-menu"
|
||||
import { Menu } from "@base-ui/react/menu"
|
||||
import { Dialog } from "@base-ui/react/dialog"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import {
|
||||
|
|
@ -10,13 +10,14 @@ import {
|
|||
ChatCircleIcon,
|
||||
CheckCircleIcon,
|
||||
CircleNotchIcon,
|
||||
CopyIcon,
|
||||
DotsThreeVerticalIcon,
|
||||
GitMergeIcon,
|
||||
GitPullRequestIcon,
|
||||
LightningIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
TreeStructureIcon,
|
||||
XIcon,
|
||||
} from "@phosphor-icons/react"
|
||||
import { IoLogoGithub, IoLogoSlack } from "react-icons/io5"
|
||||
import { SiLinear } from "react-icons/si"
|
||||
|
|
@ -360,9 +361,9 @@ function ThreadRow({
|
|||
const isDeleting =
|
||||
deleteThread.isPending && deleteThread.variables === thread.id
|
||||
|
||||
const onDelete = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const onDelete = (e?: React.MouseEvent) => {
|
||||
e?.preventDefault()
|
||||
e?.stopPropagation()
|
||||
if (isDeleting) return
|
||||
setDeleteOpen(true)
|
||||
}
|
||||
|
|
@ -396,25 +397,25 @@ function ThreadRow({
|
|||
window.open(thread.traceUrl, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
|
||||
const copySandboxId = () => {
|
||||
if (!thread.sandboxId) return
|
||||
void navigator.clipboard.writeText(thread.sandboxId)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger
|
||||
render={
|
||||
<Link
|
||||
to="/agents/$threadId"
|
||||
params={{ threadId: thread.id }}
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
"group mb-0.5 flex items-center gap-2 rounded-lg px-2.5 transition-colors",
|
||||
compact ? "h-7 gap-1.5" : "h-8",
|
||||
isActive
|
||||
? "bg-[var(--ui-accent-bubble)] text-[var(--ui-text)]"
|
||||
: "text-[var(--ui-text-muted)] hover:bg-[var(--ui-sidebar-hover)]",
|
||||
isDeleting && "opacity-50"
|
||||
)}
|
||||
/>
|
||||
}
|
||||
<div className={cn("group relative mb-0.5", isDeleting && "opacity-50")}>
|
||||
<Link
|
||||
to="/agents/$threadId"
|
||||
params={{ threadId: thread.id }}
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg px-2.5 transition-colors group-hover:pr-8 [@media(hover:none)]:pr-8",
|
||||
compact ? "h-7 gap-1.5" : "h-8",
|
||||
isActive
|
||||
? "bg-[var(--ui-accent-bubble)] text-[var(--ui-text)]"
|
||||
: "text-[var(--ui-text-muted)] group-hover:bg-[var(--ui-sidebar-hover)]"
|
||||
)}
|
||||
>
|
||||
{thread.status === "running" ? (
|
||||
<CircleNotchIcon
|
||||
|
|
@ -461,73 +462,75 @@ function ThreadRow({
|
|||
{badge}
|
||||
</span>
|
||||
)}
|
||||
{!isReadOnly && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isResolved ? "Unresolve thread" : "Resolve thread"}
|
||||
title={isResolved ? "Unresolve thread" : "Resolve thread"}
|
||||
onClick={onToggleResolved}
|
||||
disabled={resolveThread.isPending}
|
||||
className="hidden size-4 shrink-0 items-center justify-center rounded text-[var(--ui-text-dim)] group-hover:flex hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)]"
|
||||
>
|
||||
{isResolved ? (
|
||||
<ArrowCounterClockwiseIcon className="size-3" weight="bold" />
|
||||
) : (
|
||||
<CheckCircleIcon className="size-3" weight="bold" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{!isReadOnly && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Delete thread"
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
className="hidden size-4 shrink-0 items-center justify-center rounded text-[var(--ui-text-dim)] group-hover:flex hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)]"
|
||||
>
|
||||
<XIcon className="size-3" weight="bold" />
|
||||
</button>
|
||||
)}
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Positioner className="z-50 outline-none">
|
||||
<ContextMenu.Popup className="min-w-[10rem] overflow-hidden rounded-md border border-[var(--ui-border)] bg-popover p-1 text-popover-foreground shadow-md outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95">
|
||||
<ContextMenu.Item
|
||||
disabled={!thread.traceUrl}
|
||||
onClick={openTrace}
|
||||
className="flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-xs outline-none select-none data-highlighted:bg-[var(--ui-sidebar-hover)] data-disabled:pointer-events-none data-disabled:opacity-50"
|
||||
</Link>
|
||||
{/* One actions menu for every input: revealed on hover, kept while
|
||||
open, and always shown on devices that can't hover (touch). It sits
|
||||
outside the Link so opening it never navigates the row. */}
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Thread actions"
|
||||
className="absolute top-1/2 right-1 hidden size-5 -translate-y-1/2 items-center justify-center rounded text-[var(--ui-text-dim)] group-hover:flex hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)] data-popup-open:flex [@media(hover:none)]:flex"
|
||||
>
|
||||
<TreeStructureIcon className="size-3.5" />
|
||||
Open trace
|
||||
</ContextMenu.Item>
|
||||
{!isReadOnly && (
|
||||
<ContextMenu.Item
|
||||
onClick={() => onToggleResolved()}
|
||||
disabled={resolveThread.isPending}
|
||||
className="flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-xs outline-none select-none data-highlighted:bg-[var(--ui-sidebar-hover)] data-disabled:pointer-events-none data-disabled:opacity-50"
|
||||
<DotsThreeVerticalIcon className="size-4" weight="bold" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
className="z-50 outline-none"
|
||||
>
|
||||
<Menu.Popup className="min-w-[10rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95">
|
||||
<Menu.Item
|
||||
disabled={!thread.traceUrl}
|
||||
onClick={openTrace}
|
||||
className="flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-xs outline-none select-none data-highlighted:bg-muted data-disabled:pointer-events-none data-disabled:opacity-50"
|
||||
>
|
||||
{isResolved ? (
|
||||
<ArrowCounterClockwiseIcon className="size-3.5" />
|
||||
) : (
|
||||
<CheckCircleIcon className="size-3.5" />
|
||||
)}
|
||||
{isResolved ? "Unresolve thread" : "Resolve thread"}
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
{!isReadOnly && (
|
||||
<ContextMenu.Item
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
className="flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-xs text-[var(--ui-danger)] outline-none select-none data-highlighted:bg-[var(--ui-sidebar-hover)] data-disabled:pointer-events-none data-disabled:opacity-50"
|
||||
<TreeStructureIcon className="size-3.5" />
|
||||
Open trace
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
disabled={!thread.sandboxId}
|
||||
onClick={copySandboxId}
|
||||
title={thread.sandboxId ?? undefined}
|
||||
className="flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-xs outline-none select-none data-highlighted:bg-muted data-disabled:pointer-events-none data-disabled:opacity-50"
|
||||
>
|
||||
<TrashIcon className="size-3.5" />
|
||||
Delete thread
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
</ContextMenu.Popup>
|
||||
</ContextMenu.Positioner>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu.Root>
|
||||
<CopyIcon className="size-3.5" />
|
||||
Copy sandbox ID
|
||||
</Menu.Item>
|
||||
{!isReadOnly && (
|
||||
<Menu.Item
|
||||
onClick={() => onToggleResolved()}
|
||||
disabled={resolveThread.isPending}
|
||||
className="flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-xs outline-none select-none data-highlighted:bg-muted data-disabled:pointer-events-none data-disabled:opacity-50"
|
||||
>
|
||||
{isResolved ? (
|
||||
<ArrowCounterClockwiseIcon className="size-3.5" />
|
||||
) : (
|
||||
<CheckCircleIcon className="size-3.5" />
|
||||
)}
|
||||
{isResolved ? "Unresolve thread" : "Resolve thread"}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!isReadOnly && (
|
||||
<Menu.Item
|
||||
onClick={() => onDelete()}
|
||||
disabled={isDeleting}
|
||||
className="flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-xs text-destructive outline-none select-none data-highlighted:bg-muted data-disabled:pointer-events-none data-disabled:opacity-50"
|
||||
>
|
||||
<TrashIcon className="size-3.5" />
|
||||
Delete thread
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</Menu.Root>
|
||||
</div>
|
||||
<Dialog.Root open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Backdrop className="fixed inset-0 z-50 bg-black/50 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0" />
|
||||
|
|
|
|||
|
|
@ -259,6 +259,7 @@ export function optimisticThread(
|
|||
createdAt: now,
|
||||
updatedAt: now,
|
||||
traceUrl: null,
|
||||
sandboxId: null,
|
||||
messages: message.chunks.length > 0 ? [message] : [],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -203,6 +203,7 @@ export interface AgentThread {
|
|||
createdAt: number
|
||||
updatedAt: number
|
||||
traceUrl?: string | null
|
||||
sandboxId?: string | null
|
||||
messages: Array<Message>
|
||||
queuedMessages?: Array<QueuedThreadMessage>
|
||||
pr?: {
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@ export interface TeamSettings {
|
|||
review_trace_links: boolean
|
||||
/** Tri-state LLM Gateway toggle; null inherits the LANGSMITH_GATEWAY_ENABLED default. */
|
||||
gateway_enabled?: boolean | null
|
||||
fable_enabled?: boolean
|
||||
review_tracing_project?: string | null
|
||||
org_guidelines?: string | null
|
||||
default_agent_model?: string | null
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { api } from "@/lib/api"
|
||||
import { RequireLogin } from "@/lib/auth-redirect"
|
||||
import { useSession } from "@/lib/session"
|
||||
|
|
@ -57,6 +58,8 @@ function AdminPage() {
|
|||
|
||||
<LLMGatewaySection />
|
||||
|
||||
<FableSection />
|
||||
|
||||
<TriggerReviewSection />
|
||||
|
||||
<SettingsSection title="Evals">
|
||||
|
|
@ -694,6 +697,44 @@ function PRTraceResolutionSection() {
|
|||
)
|
||||
}
|
||||
|
||||
function FableSection() {
|
||||
const qc = useQueryClient()
|
||||
const settings = useQuery({ queryKey: ["teamSettings"], queryFn: api.getTeamSettings })
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const save = useMutation({
|
||||
mutationFn: (body: TeamSettings) => api.saveTeamSettings(body),
|
||||
onSuccess: (saved) => {
|
||||
qc.setQueryData(["teamSettings"], saved)
|
||||
qc.invalidateQueries({ queryKey: ["options"] }) // refresh pickers so Fable appears/disappears
|
||||
setError(null)
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
})
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Fable"
|
||||
description="Claude Fable 5 requires opting into Bedrock provider data sharing: prompts and completions are retained and shared with Anthropic (up to 30 days, including human review). Off by default; enable only if your workspace accepts provider data sharing for this model."
|
||||
>
|
||||
<div className="divide-y divide-border">
|
||||
<SettingsRow
|
||||
label="Allow Fable models"
|
||||
description="When on, Fable 5 is selectable in the agent, reviewer, and chat model pickers. When off, it is hidden and any run that resolves to Fable falls back to Opus."
|
||||
control={
|
||||
<Switch
|
||||
checked={!!settings.data?.fable_enabled}
|
||||
onCheckedChange={(next) =>
|
||||
settings.data && save.mutate({ ...settings.data, fable_enabled: next })
|
||||
}
|
||||
disabled={!settings.data || save.isPending}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="px-4 pb-3 text-xs text-destructive">{error}</p>}
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
|
||||
function GlobalDefaultsSection({ models }: { models: Array<ModelOption> }) {
|
||||
const qc = useQueryClient()
|
||||
const settings = useQuery({
|
||||
|
|
|
|||
|
|
@ -177,7 +177,9 @@ const config = defineConfig({
|
|||
registerType: "prompt",
|
||||
outDir: ".output/public",
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
// Off in dev: the service worker precaches assets and defeats HMR (and
|
||||
// a stale registration lingers per-origin). Dev is HMR-only.
|
||||
enabled: false,
|
||||
},
|
||||
integration: {
|
||||
closeBundleOrder: "pre",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue