Compare commits

...

2 commits

Author SHA1 Message Date
Adam Moussa
5350b63c85
feat(open-swe): re-add Fable 5 behind an admin toggle (Bedrock) (#172)
Some checks are pending
CI / Lint (push) Waiting to run
CI / Format check (push) Waiting to run
CI / Typecheck (push) Waiting to run
CI / Unit tests (push) Waiting to run
CI / Playwright E2E (push) Waiting to run
CI / Docker build smoke (push) Waiting to run
CI / Triage ledger up to date (push) Waiting to run
CI / ui bun.lock in sync (push) Waiting to run
* feat(models): re-add Fable 5 with admin disable toggle (port of upstream #1677)

* refactor(models): convert re-added Fable 5 to Bedrock model IDs

* fix(open-swe): correct Fable copy to describe provider data sharing, not ZDR

The ported admin toggle description and code comments described Fable 5 as
incompatible with Zero Data Retention. That is backwards: Fable 5 requires
the account to opt into Bedrock provider_data_share — prompts/completions are
retained and shared with Anthropic (up to 30 days, incl. human review). The
old UI copy would lead an admin to believe the opposite of what enabling the
toggle does. Reword the toggle description and the gate_fable_model /
team_settings comments accordingly. Still off by default. Refs #171.
2026-07-10 14:05:20 -04:00
Adam Moussa
bc59c05642
feat(dashboard): surface thread sandbox ID in sidebar (#169)
* feat(dashboard): surface thread sandbox ID in sidebar

Port of upstream #1689 (feb7ac98): expose the thread's sandbox_id on the
dashboard thread summary and surface it in the sidebar via a "Copy sandbox
ID" action.

- thread_api: add sandboxId to the thread summary, hiding the
  "__creating__" in-flight sentinel.
- queries/types: thread the sandboxId field through AgentThread and the
  optimistic thread.
- AgentsSidebar: replace the hover-only resolve/delete buttons and the
  right-click context menu with one touch-friendly kebab (⋮) menu that
  works on both pointer and touch, and add the Copy sandbox ID item.
- vite: disable the PWA service worker in dev (it precaches assets and
  defeats HMR).
- tests: unit test for the summary field + e2e spec in the real-backend
  harness.

Closes #140. Part of #134.

* chore(triage): mark feb7ac98 (#1689) landed

Ported to dev via feat/thread-sandbox-id-sidebar (#140).
2026-07-10 13:17:03 -04:00
24 changed files with 795 additions and 105 deletions

View file

@ -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.

View file

@ -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,

View file

@ -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"

View file

@ -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)

View file

@ -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,

View file

@ -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())

View file

@ -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"] = {

View file

@ -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,

View file

@ -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,

View file

@ -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"}

View file

@ -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 |

View 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();
});
});

View file

@ -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"]

View file

@ -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",)

View 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"}

View file

@ -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

View file

@ -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"

View 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

View file

@ -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" />

View file

@ -259,6 +259,7 @@ export function optimisticThread(
createdAt: now,
updatedAt: now,
traceUrl: null,
sandboxId: null,
messages: message.chunks.length > 0 ? [message] : [],
}
}

View file

@ -203,6 +203,7 @@ export interface AgentThread {
createdAt: number
updatedAt: number
traceUrl?: string | null
sandboxId?: string | null
messages: Array<Message>
queuedMessages?: Array<QueuedThreadMessage>
pr?: {

View file

@ -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

View file

@ -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({

View file

@ -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",