Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
530 changes: 530 additions & 0 deletions services/studio/src/nmp/studio/coding_agents.py

Large diffs are not rendered by default.

17 changes: 16 additions & 1 deletion services/studio/src/nmp/studio/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from nmp.common.config import Configuration, create_service_config_class
from nmp.studio.env_mappings import ENV_MAPPINGS
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -47,6 +47,17 @@ def is_origin_allowed(self, origin: str, same_origin: str | None = None) -> bool
return any(fnmatchcase(origin, allowed_origin) for allowed_origin in self.allowed_origins)


class StudioFeatureFlagsConfig(BaseModel):
"""Feature flags that affect Studio service behavior."""

model_config = ConfigDict(extra="allow")

coding_agents_enabled: bool = Field(
default=False,
description="Enable Studio's local coding-agent backend bridge.",
)


class StudioConfig(create_service_config_class("studio")): # type: ignore[misc]
"""Configuration for the Studio service.

Expand Down Expand Up @@ -78,6 +89,10 @@ class StudioConfig(create_service_config_class("studio")): # type: ignore[misc]
default_factory=StudioOtelConfig,
description="Studio UI OpenTelemetry settings.",
)
feature_flags: StudioFeatureFlagsConfig = Field(
default_factory=StudioFeatureFlagsConfig,
description="Studio feature flags.",
)

@cached_property
def global_settings(self) -> dict[str, Any]:
Expand Down
5 changes: 5 additions & 0 deletions services/studio/src/nmp/studio/env_mappings.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ class EnvMapping:
config_path="studio.feature_flags.base_models_enabled",
default="true",
),
EnvMapping(
marker="STUDIO_UI_VITE_FF_CODING_AGENTS_ENABLED",
config_path="studio.feature_flags.coding_agents_enabled",
default="false",
),
EnvMapping(
marker="STUDIO_UI_VITE_FF_CUSTOMIZER_ENABLED",
config_path="studio.feature_flags.customizer_enabled",
Expand Down
26 changes: 23 additions & 3 deletions services/studio/src/nmp/studio/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from fastapi.responses import HTMLResponse
from nmp.common.http_clients import shared_async_http_client
from nmp.common.service import RouterConfig, Service
from nmp.studio import coding_agents
from nmp.studio.config import StudioConfig
from nmp.studio.static_files import SPAStaticFiles
from starlette.responses import Response
Expand Down Expand Up @@ -64,14 +65,23 @@ def title(self) -> str:
@property
def description(self) -> str:
"""Service description for OpenAPI docs."""
return "Serves the NeMo Studio web application"
return "Serves the NeMo Studio web application and local coding-agent bridge"

def get_routers(self) -> List[RouterConfig]:
"""Return routers for the studio service.

The studio service doesn't expose API routers - it serves static files.
Studio exposes API routes for local-only UI integrations in addition to
serving static files.
"""
return []
if not self._coding_agents_enabled():
return []
return [
RouterConfig(
coding_agents.router,
tag="Studio Coding Agents",
description="Local coding-agent bridge endpoints",
)
]

def configure_app(self, app: FastAPI) -> None:
"""Configure the platform app with static file mounting.
Expand All @@ -83,8 +93,18 @@ def configure_app(self, app: FastAPI) -> None:
app: The platform's FastAPI application
"""
self._mount_telemetry_proxy(app)
if self._coding_agents_enabled():
self._mount_coding_agent_mcp(app)
self._mount_static_files(app)

def _coding_agents_enabled(self) -> bool:
"""Return whether the exploratory local coding-agent bridge is enabled."""
return self._get_config().feature_flags.coding_agents_enabled

def _mount_coding_agent_mcp(self, app: FastAPI) -> None:
"""Mount the auth-bypassed MCP callback before the /studio static app."""
coding_agents.mount_public_mcp_route(app)

def _get_config(self) -> StudioConfig:
"""Get the studio config, creating a default if none is set.

Expand Down
240 changes: 240 additions & 0 deletions services/studio/tests/unit/test_coding_agents.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Unit tests for the Studio local coding-agent bridge."""

import json
import uuid
from pathlib import Path
from typing import Any

import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from nmp.studio import coding_agents
from nmp.studio.config import StudioConfig
from nmp.studio.service import StudioService


@pytest.fixture(autouse=True)
def reset_coding_agent_state():
"""Reset module-level bridge state between tests."""
coding_agents._initialized_sessions.clear()
coding_agents._session_streams.clear()
coding_agents._pending_permissions.clear()
yield
coding_agents._initialized_sessions.clear()
coding_agents._session_streams.clear()
coding_agents._pending_permissions.clear()


@pytest.fixture
def service_client() -> TestClient:
service = StudioService().with_config(coding_agents_enabled_config())
return TestClient(service.app)


def coding_agents_enabled_config() -> StudioConfig:
return StudioConfig(feature_flags={"coding_agents_enabled": True})


def test_create_session_returns_uuid(service_client: TestClient):
response = service_client.post("/v2/coding-agents/sessions")

assert response.status_code == 200
uuid.UUID(response.json()["session_id"])


def test_build_claude_argv_uses_new_session_then_resume_flag():
session_id = str(uuid.uuid4())

argv = coding_agents._build_claude_argv(session_id, "hello", "http://test/mcp")
assert argv[:3] == ["claude", "-p", "hello"]
assert "--output-format" in argv
assert "stream-json" in argv
assert "--permission-prompt-tool" in argv
assert f"mcp__{coding_agents.CLAUDE_MCP_SERVER_NAME}__approval_prompt" in argv
assert "--session-id" in argv
assert session_id in argv

coding_agents._initialized_sessions.add(session_id)
resumed_argv = coding_agents._build_claude_argv(session_id, "again", "http://test/mcp")
assert "-r" in resumed_argv
assert "--session-id" not in resumed_argv


def test_list_and_get_history_sessions(
service_client: TestClient,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
):
workdir = tmp_path / "repo"
projects_dir = tmp_path / "claude-projects"
project_dir = projects_dir / str(workdir).replace("/", "-")
project_dir.mkdir(parents=True)
session_id = str(uuid.uuid4())
history = project_dir / f"{session_id}.jsonl"
history.write_text(
"\n".join(
[
json.dumps({"type": "user", "message": {"content": "first prompt"}}),
json.dumps(
{
"type": "assistant",
"message": {
"content": [
{"type": "thinking", "thinking": "checking"},
{"type": "text", "text": "done"},
{"type": "tool_use", "name": "Bash", "input": {"command": "pwd"}},
]
},
}
),
json.dumps({"type": "user", "isSidechain": True, "message": {"content": "ignored"}}),
"not-json",
]
)
)

monkeypatch.setattr(coding_agents, "SERVER_CWD", workdir)
monkeypatch.setattr(coding_agents, "CLAUDE_PROJECTS_DIR", projects_dir)

list_response = service_client.get("/v2/coding-agents/history/sessions")

assert list_response.status_code == 200
assert list_response.json() == [
{
"session_id": session_id,
"mtime": history.stat().st_mtime,
"first_prompt": "first prompt",
"message_count": 1,
}
]

history_response = service_client.get(f"/v2/coding-agents/history/sessions/{session_id}")

assert history_response.status_code == 200
assert history_response.json() == {
"session_id": session_id,
"items": [
{"kind": "user", "text": "first prompt"},
{
"kind": "assistant",
"parts": [
{"type": "thinking", "thinking": "checking"},
{"type": "text", "text": "done"},
{"type": "tool_use", "name": "Bash", "input": {"command": "pwd"}},
],
},
],
}
assert session_id in coding_agents._initialized_sessions


def test_invalid_session_id_returns_400(service_client: TestClient):
response = service_client.get("/v2/coding-agents/history/sessions/not-a-uuid")

assert response.status_code == 400
assert response.json()["detail"] == "session_id must be a UUID"


def test_mcp_initialize_and_tools_list(service_client: TestClient):
session_id = str(uuid.uuid4())

initialize_response = service_client.post(
f"/v2/coding-agents/mcp/{session_id}",
json={
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {"protocolVersion": "2025-06-18"},
},
)
tools_response = service_client.post(
f"/v2/coding-agents/mcp/{session_id}",
json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"},
)

assert initialize_response.status_code == 200
assert initialize_response.json()["result"]["serverInfo"]["name"] == "nemo-studio-permissions"
assert tools_response.status_code == 200
assert tools_response.json()["result"]["tools"][0]["name"] == "approval_prompt"


def test_mcp_tools_call_denies_without_active_stream(service_client: TestClient):
session_id = str(uuid.uuid4())

response = service_client.post(
f"/v2/coding-agents/mcp/{session_id}",
json={
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "approval_prompt",
"arguments": {"tool_name": "Bash", "input": {"command": "pwd"}},
},
},
)

assert response.status_code == 200
result_text = response.json()["result"]["content"][0]["text"]
assert json.loads(result_text) == {
"behavior": "deny",
"message": "no active Studio coding-agent session",
}


def test_platform_route_stream_uses_public_mcp_callback(monkeypatch: pytest.MonkeyPatch):
service = StudioService().with_config(coding_agents_enabled_config())
app = FastAPI()
app.include_router(service.app.router, prefix="/apis/studio")
service.configure_app(app)
client = TestClient(app)
session_id = str(uuid.uuid4())
captured: dict[str, Any] = {}

async def fake_stream(session_id: str, message: str, mcp_url: str):
captured.update({"session_id": session_id, "message": message, "mcp_url": mcp_url})
yield coding_agents._sse(json.dumps({"type": "system", "subtype": "init"}))
yield coding_agents._sse("", event="done")

monkeypatch.setattr(coding_agents, "_stream_claude", fake_stream)

response = client.post(
f"/apis/studio/v2/coding-agents/sessions/{session_id}/messages",
json={"message": "hello"},
)

assert response.status_code == 200
assert response.headers["content-type"].startswith("text/event-stream")
assert "event: done" in response.text
assert captured == {
"session_id": session_id,
"message": "hello",
"mcp_url": f"http://testserver/studio/api/coding-agents/mcp/{session_id}",
}


def test_public_mcp_route_is_mounted_before_static_fallback():
service = StudioService().with_config(coding_agents_enabled_config())
app = FastAPI()
service.configure_app(app)
client = TestClient(app)
session_id = str(uuid.uuid4())

response = client.post(
f"/studio/api/coding-agents/mcp/{session_id}",
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"},
)

assert response.status_code == 200
assert response.json()["result"]["tools"][0]["name"] == "approval_prompt"


def test_coding_agent_routes_are_disabled_by_default():
client = TestClient(StudioService().app)

response = client.post("/v2/coding-agents/sessions")

assert response.status_code == 404
13 changes: 10 additions & 3 deletions services/studio/tests/unit/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,21 @@ def test_service_title(self):
def test_service_description(self):
"""Test that the service has the correct description."""
service = StudioService()
assert service.description == "Serves the NeMo Studio web application"
assert service.description == "Serves the NeMo Studio web application and local coding-agent bridge"

def test_get_routers_returns_empty_list(self):
"""Test that the service returns no API routers (it serves static files)."""
def test_get_routers_returns_empty_list_by_default(self):
"""Test that exploratory coding-agent routes are disabled by default."""
service = StudioService()
routers = service.get_routers()
assert routers == []

def test_get_routers_returns_coding_agent_router_when_enabled(self):
"""Test that the service exposes the local coding-agent API router when enabled."""
service = StudioService().with_config(StudioConfig(feature_flags={"coding_agents_enabled": True}))
routers = service.get_routers()
assert len(routers) == 1
assert routers[0].tag == "Studio Coding Agents"

def test_module_name(self):
"""Test that the service has the correct module name."""
service = StudioService()
Expand Down
1 change: 1 addition & 0 deletions web/packages/studio/env/.env.dev.local.sample
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ VITE_AUTH_SCOPE_PREFIX="api://<your-client-id>"
# See src/constants/featureFlags.ts for available flags and documentation.
VITE_FF_AGENTS_ENABLED='true'
VITE_FF_BASE_MODELS_ENABLED='true'
VITE_FF_CODING_AGENTS_ENABLED='false'
Comment thread
dmariali marked this conversation as resolved.
Resolved
VITE_FF_CUSTOMIZER_ENABLED='false'
VITE_FF_DASHBOARD_ENABLED='false'
VITE_FF_DATA_DESIGNER_ENABLED='false'
Expand Down
1 change: 1 addition & 0 deletions web/packages/studio/env/.env.fastapi
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ VITE_TELEMETRY_ENABLED=STUDIO_UI_VITE_TELEMETRY_ENABLED

# Feature Flags (VITE_FF_* prefix)
VITE_FF_AGENTS_ENABLED=STUDIO_UI_VITE_FF_AGENTS_ENABLED
VITE_FF_CODING_AGENTS_ENABLED=STUDIO_UI_VITE_FF_CODING_AGENTS_ENABLED
VITE_FF_CUSTOMIZER_ENABLED=STUDIO_UI_VITE_FF_CUSTOMIZER_ENABLED
VITE_FF_DASHBOARD_ENABLED=STUDIO_UI_VITE_FF_DASHBOARD_ENABLED
VITE_FF_DATA_DESIGNER_ENABLED=STUDIO_UI_VITE_FF_DATA_DESIGNER_ENABLED
Expand Down
1 change: 1 addition & 0 deletions web/packages/studio/src/constants/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const PLATFORM_BASE_URL = resolveBrowserBaseUrl(getEnvVar('VITE_PLATFORM_
// distinguish that logic from code that calls the URL itself
export const AGENTS_ENABLED = featureFlags.agentsEnabled !== false;
export const BASE_MODELS_ENABLED = featureFlags.baseModelsEnabled !== false;
export const CODING_AGENTS_ENABLED = featureFlags.codingAgentsEnabled !== false;
export const CUSTOMIZER_ENABLED = featureFlags.customizerEnabled !== false;
export const DASHBOARD_ENABLED = featureFlags.dashboardEnabled !== false;
export const DATA_DESIGNER_ENABLED = featureFlags.dataDesignerEnabled !== false;
Expand Down
Loading
Loading