diff --git a/docs/docs.json b/docs/docs.json
index ed30fdccd..b72e559c8 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -370,6 +370,7 @@
"group": "Release Notes",
"pages": [
"releases/index",
+ "releases/v0.17.1",
"releases/v0.17.0",
"releases/v0.16.1",
"releases/v0.16.0",
@@ -414,7 +415,7 @@
"navbar": {
"links": [
{
- "label": "v0.17.0 \u00b7 Lemonade 10.0.0",
+ "label": "v0.17.1 \u00b7 Lemonade 10.0.0",
"href": "https://github.com/amd/gaia/releases"
},
{
diff --git a/docs/guides/agent-ui.mdx b/docs/guides/agent-ui.mdx
index 6464a14b1..58f0ae0c5 100644
--- a/docs/guides/agent-ui.mdx
+++ b/docs/guides/agent-ui.mdx
@@ -6,6 +6,10 @@ icon: "desktop"
GAIA Agent UI is a desktop interface for running AI agents **100% locally** on your AMD hardware. Use agents to analyze documents, generate code, answer questions, and accomplish tasks on your PC — all without sending data to the cloud.
+
+ This guide assumes you have completed the [Quickstart](/quickstart) and have GAIA installed.
+
+
**Tested Configuration:** The Agent UI has been tested exclusively on **AMD Ryzen AI MAX+ 395** processors running the **Qwen3.5-35B-A3B-GGUF** model via Lemonade Server. Other hardware or model combinations may work but are not officially verified.
@@ -99,6 +103,14 @@ Choose one of the two install paths:
The Agent UI starts on [http://localhost:4200](http://localhost:4200). Open this URL in your browser.
+
+ **Source/dev installs:** The frontend is built during `gaia init`. If you see a JSON response instead of the UI, run `gaia init` or manually build (from the repo root):
+
+ ```bash
+ cd src/gaia/apps/webui && npm install && npm run build
+ ```
+
+
**Options:**
| Flag | Description |
@@ -166,17 +178,11 @@ See the [Agent UI MCP Server guide](/guides/mcp/agent-ui) for setup instructions
- ```bash
- lemonade-server serve
- ```
-
- If not installed, run `gaia init --profile minimal` or follow the [Setup Guide](/setup).
+ Start Lemonade with `lemonade-server serve`. If Lemonade is not installed, follow the initialization steps in the [Quickstart](/quickstart#cli-install).
- ```bash
- gaia download --agent chat
- ```
+ Download models with `gaia init --profile chat`. See the [Quickstart](/quickstart#cli-install) for details.
@@ -198,6 +204,22 @@ See the [Agent UI MCP Server guide](/guides/mcp/agent-ui) for setup instructions
- Keep file size under 100MB
- For PDF image extraction, download the VLM model: `gaia download --agent chat`
+
+
+ The Agent UI frontend has not been built.
+
+ **npm install (`gaia-ui`):** This is handled automatically — `gaia-ui` tells the Python server where to find the pre-built frontend. If you still see this error, try reinstalling: `npm install -g @amd-gaia/agent-ui@latest` Then restart with `gaia-ui`.
+
+ **Source/dev installs (git clone):** Run `gaia init` to build it automatically, or manually (from the repo root):
+
+ ```bash
+ cd src/gaia/apps/webui && npm install && npm run build
+ ```
+
+ Then restart `gaia chat --ui`.
+
+ **pip/PyPI installs (without gaia-ui):** Use the [npm install path](#install-and-launch) — the pip package does not include frontend source files.
+
---
diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx
index 62c35ab33..cf1729f95 100644
--- a/docs/quickstart.mdx
+++ b/docs/quickstart.mdx
@@ -4,6 +4,10 @@ description: "Run AI agents locally in minutes, or build your first custom agent
icon: "rocket"
---
+
+ This guide has two paths: **Agent UI** (npm, handles everything automatically) and **CLI / Manual Install** (requires [Setup](/setup) prerequisites first). Pick the one that fits your workflow.
+
+
## Agent UI (Fastest)
Run AI agents **100% locally** on your AMD hardware — analyze documents, generate code, answer questions, and accomplish tasks on your PC without sending data to the cloud.
diff --git a/docs/releases/v0.17.1.mdx b/docs/releases/v0.17.1.mdx
new file mode 100644
index 000000000..c70051db7
--- /dev/null
+++ b/docs/releases/v0.17.1.mdx
@@ -0,0 +1,83 @@
+---
+title: "v0.17.1"
+description: "Agent UI works out of the box — automatic frontend build on gaia init, no manual npm steps"
+---
+
+# GAIA v0.17.1 Release Notes
+
+Agent UI now works from the moment you finish `gaia init` — no manual frontend build steps, no hunting for the right `npm` command. This patch release makes the first-install experience reliable and cleans up how you point to a custom frontend dist directory.
+
+```bash
+npm install -g @amd-gaia/agent-ui
+gaia-ui
+```
+
+**Why upgrade:**
+- **Agent UI works immediately after `gaia init`** — The frontend build now runs automatically during initialization on dev/source installs, so `gaia chat --ui` is ready to go without any extra steps
+- **Cleaner configuration with `--ui-dist`** — A new CLI flag replaces the opaque `GAIA_WEBUI_DIST` environment variable, making it obvious how to point to a custom frontend dist directory
+- **More reliable publishing pipeline** — The `@amd-gaia/agent-ui` npm package now publishes via OIDC trusted publishing, eliminating NPM token rotation issues that could delay releases
+
+Get started with the [Agent UI guide](/guides/agent-ui).
+
+---
+
+## What's New
+
+### Agent UI First-Install Reliability
+
+Previously, after running `gaia init` on a dev or source install, launching `gaia chat --ui` would fail because the React frontend hadn't been built yet. Users had to manually run `cd src/gaia/apps/webui && npm install && npm run build` — a step easy to miss and not clearly documented (commit f89810a3).
+
+**What you can do:**
+- Run `gaia init` once and immediately launch `gaia chat --ui` — the frontend is built automatically
+- Skip the manual build step entirely on dev/source installs
+
+**Under the hood:**
+- Extracted `_ensure_webui_built()` into a shared `gaia/ui/build.py` module used by both `gaia init` and `gaia chat --ui`
+- `gaia chat --ui` retains its staleness check and auto-rebuild as a safety net
+- Added unit tests for all new build code paths
+- Fixed Agent UI docs to clarify the Setup → Quickstart → Agent UI prerequisite chain
+
+---
+
+### `--ui-dist` Configuration Flag
+
+The `GAIA_WEBUI_DIST` environment variable for pointing to a custom frontend dist directory has been replaced with a `--ui-dist` CLI argument (commits f3af9b29, 1fa20969). This follows GAIA's CLI-first configuration pattern and makes the option discoverable via `--help`.
+
+**What you can do:**
+- `gaia chat --ui --ui-dist /path/to/dist` — point to a custom frontend build
+- `gaia-ui` npm launcher passes this automatically — no manual env var setup needed
+
+**Under the hood:**
+- `--ui-dist` accepted by both `gaia chat --ui` and the UI server directly
+- Code review advisory items A1–A5 addressed (commit b6114a42)
+
+---
+
+## Infrastructure
+
+- **OIDC trusted publishing** — `@amd-gaia/agent-ui` npm package now publishes via OIDC, removing the NPM_TOKEN secret requirement and making the publishing pipeline more reliable (PRs #638, #639)
+- **Merge queue fix** — Resolved phantom failures in the merge-queue-notify CI workflow (PR #640)
+
+---
+
+## Upgrade
+
+```bash
+npm install -g @amd-gaia/agent-ui@latest
+```
+
+---
+
+## Full Changelog
+
+**7 commits** since v0.17.0:
+
+- `1fa20969` - fix: replace GAIA_WEBUI_DIST env var with --ui-dist CLI argument
+- `b6114a42` - fix: address code review advisory items A1-A5
+- `f3af9b29` - fix: pass GAIA_WEBUI_DIST env var from gaia-ui to Python server
+- `f89810a3` - fix: build Agent UI frontend during gaia init and fix doc prerequisites
+- `334b011c` - fix: remove registry-url to enable OIDC trusted publishing (#639)
+- `776dc34a` - fix: resolve merge-queue-notify phantom failures (#640)
+- `83a4db1b` - fix: switch npm publish to OIDC trusted publishing (#638)
+
+Full Changelog: [v0.17.0...v0.17.1](https://github.com/amd/gaia/compare/v0.17.0...v0.17.1)
diff --git a/src/gaia/apps/webui/bin/gaia-ui.mjs b/src/gaia/apps/webui/bin/gaia-ui.mjs
index a881f0821..14db191b9 100755
--- a/src/gaia/apps/webui/bin/gaia-ui.mjs
+++ b/src/gaia/apps/webui/bin/gaia-ui.mjs
@@ -369,9 +369,8 @@ function openBrowser(url) {
function startBackend(gaiaBin, port) {
console.log(`Starting GAIA backend on port ${port}...`);
- const child = spawn(gaiaBin, ["chat", "--ui", "--ui-port", String(port)], {
+ const child = spawn(gaiaBin, ["chat", "--ui", "--ui-port", String(port), "--ui-dist", join(ROOT_DIR, "dist")], {
stdio: ["ignore", "pipe", "pipe"],
- env: { ...process.env },
detached: false,
});
diff --git a/src/gaia/apps/webui/package.json b/src/gaia/apps/webui/package.json
index 4bf054d8d..ea9499aa8 100644
--- a/src/gaia/apps/webui/package.json
+++ b/src/gaia/apps/webui/package.json
@@ -1,6 +1,6 @@
{
"name": "@amd-gaia/agent-ui",
- "version": "0.17.1-rc.1",
+ "version": "0.17.1",
"type": "module",
"productName": "GAIA Agent UI",
"description": "Privacy-first agentic AI interface with document Q&A - runs 100% locally on AMD Ryzen AI",
diff --git a/src/gaia/cli.py b/src/gaia/cli.py
index bad0fd4c8..787b698da 100644
--- a/src/gaia/cli.py
+++ b/src/gaia/cli.py
@@ -685,99 +685,16 @@ def run_cli(action, **kwargs):
def _ensure_webui_built(log=None):
- """Rebuild the Agent UI frontend if source files are newer than dist.
+ """Rebuild the Agent UI frontend if source files are newer than dist."""
+ from gaia.ui.build import ensure_webui_built
- Only runs in dev mode (editable install) where the webui src/ directory
- exists. Silently skips in installed-package mode or when node/npm are
- not available.
- """
- if log is None:
- log = get_logger(__name__)
-
- webui_dir = Path(__file__).resolve().parent / "apps" / "webui"
- src_dir = webui_dir / "src"
- dist_index = webui_dir / "dist" / "index.html"
-
- # Gate 1 — dev mode only (src/ absent in pip-installed package)
- if not src_dir.is_dir():
- return
-
- # Gate 2 — staleness check
- newest_src = 0.0
- for pattern in ("*.ts", "*.tsx", "*.css", "*.html"):
- for path in src_dir.rglob(pattern):
- mtime = path.stat().st_mtime
- if mtime > newest_src:
- newest_src = mtime
- for root_file in ("index.html", "vite.config.ts", "tsconfig.json"):
- p = webui_dir / root_file
- if p.exists():
- newest_src = max(newest_src, p.stat().st_mtime)
-
- if dist_index.exists() and newest_src <= dist_index.stat().st_mtime:
- log.debug("Agent UI frontend is up to date")
- return
-
- if dist_index.exists():
- log.info("Agent UI frontend source is newer than built output")
- else:
- log.info("Agent UI frontend has not been built yet")
-
- # Gate 3 — node/npm availability
- if not shutil.which("node"):
- print("Warning: Node.js not found. Cannot auto-rebuild Agent UI frontend.")
- print(" The UI may be stale. Install Node.js from https://nodejs.org/")
- return
- if not shutil.which("npm"):
- print("Warning: npm not found. Cannot auto-rebuild Agent UI frontend.")
- return
-
- # On Windows, npm is a .cmd batch file requiring shell execution
- _shell = sys.platform == "win32"
-
- # Step 1 — npm install (only if node_modules/ missing)
- if not (webui_dir / "node_modules").is_dir():
- print("Installing Agent UI frontend dependencies...")
- try:
- subprocess.run(
- ["npm", "install"],
- cwd=str(webui_dir),
- check=True,
- capture_output=True,
- text=True,
- shell=_shell,
- )
- except subprocess.CalledProcessError as e:
- log.error("npm install failed: %s", e.stderr)
- print(f"Warning: npm install failed: {e.stderr}")
- print(" Continuing with existing dist/ (may be stale).")
- return
- except FileNotFoundError:
- print("Warning: npm not found. Skipping frontend rebuild.")
- return
-
- # Step 2 — npm run build (stream output so user sees progress)
- print("Building Agent UI frontend...", flush=True)
- try:
- subprocess.run(
- ["npm", "run", "build"],
- cwd=str(webui_dir),
- check=True,
- shell=_shell,
- )
- print("Agent UI frontend built successfully.")
- except subprocess.CalledProcessError as e:
- log.error("Frontend build failed (exit code %d)", e.returncode)
- print(f"Warning: Frontend build failed (exit code {e.returncode}).")
- if dist_index.exists():
- print(" Continuing with existing (possibly stale) build.")
- else:
- print(" No existing build found. The UI will show a build hint.")
- except FileNotFoundError:
- print("Warning: npm not found. Skipping frontend rebuild.")
+ ensure_webui_built(
+ log_fn=log.info if log else print,
+ warn_fn=log.warning if log else print,
+ )
-def _launch_agent_ui(port=4200, base_url=None, log=None, debug=False):
+def _launch_agent_ui(port=4200, base_url=None, log=None, debug=False, webui_dist=None):
"""Launch the Agent UI server (FastAPI + uvicorn).
Reused by top-level --ui, gaia chat --ui, and the interactive menu.
@@ -811,7 +728,7 @@ def _launch_agent_ui(port=4200, base_url=None, log=None, debug=False):
import uvicorn
- app = create_app()
+ app = create_app(webui_dist=webui_dist)
uvicorn.run(
app,
host="127.0.0.1",
@@ -967,6 +884,11 @@ def main():
default=4200,
help="Port for the Agent UI server (default: 4200, used with --ui)",
)
+ parser.add_argument(
+ "--ui-dist",
+ default=None,
+ help="Path to pre-built Agent UI frontend dist directory (used with --ui)",
+ )
parser.add_argument(
"--cli",
action="store_true",
@@ -1139,6 +1061,11 @@ def main():
default=4200,
help="Port for the Agent UI server (default: 4200)",
)
+ chat_parser.add_argument(
+ "--ui-dist",
+ default=None,
+ help="Path to pre-built Agent UI frontend dist directory (used with --ui)",
+ )
talk_parser = subparsers.add_parser(
"talk", help="Start voice conversation with Gaia", parents=[parent_parser]
)
@@ -2710,6 +2637,7 @@ def main():
base_url=getattr(args, "base_url", None),
log=log,
debug=getattr(args, "debug", False),
+ webui_dist=getattr(args, "ui_dist", None),
)
return
@@ -2724,6 +2652,7 @@ def main():
base_url=getattr(args, "base_url", None),
log=log,
debug=getattr(args, "debug", False),
+ webui_dist=getattr(args, "ui_dist", None),
)
return
@@ -2743,6 +2672,7 @@ def main():
base_url=getattr(args, "base_url", None),
log=log,
debug=getattr(args, "debug", False),
+ webui_dist=getattr(args, "ui_dist", None),
)
return
diff --git a/src/gaia/installer/init_command.py b/src/gaia/installer/init_command.py
index 55e9088f6..db02d80e7 100644
--- a/src/gaia/installer/init_command.py
+++ b/src/gaia/installer/init_command.py
@@ -17,6 +17,7 @@
import subprocess
import sys
from dataclasses import dataclass
+from pathlib import Path
from typing import Callable, Optional
# Rich imports for better CLI formatting
@@ -441,9 +442,14 @@ def run(self) -> int:
profile_config = INIT_PROFILES[self.profile]
has_pip_extras = bool(profile_config.get("pip_extras"))
+ _webui_src = Path(__file__).resolve().parent.parent / "apps" / "webui" / "src"
+ _is_dev_install = _webui_src.is_dir()
+
total_steps = 4 if not self.skip_models else 3
if has_pip_extras:
total_steps += 1
+ if _is_dev_install:
+ total_steps += 1
try:
# Step 1: Check/Install Lemonade (skip for remote servers or CI)
@@ -502,6 +508,22 @@ def run(self) -> int:
)
self._install_pip_extras()
+ # Build Agent UI frontend (dev/source installs only)
+ if _is_dev_install:
+ step_num += 1
+ self._print("")
+ self._print_step(step_num, total_steps, "Building Agent UI frontend...")
+ try:
+ from gaia.ui.build import ensure_webui_built
+
+ built = ensure_webui_built(
+ log_fn=self._print, warn_fn=self._print_warning
+ )
+ if built:
+ self._print_success("Agent UI frontend ready")
+ except Exception as e:
+ self._print_warning(f"Frontend build skipped: {e}")
+
# Final step: Verify setup
step_num += 1
self._print("")
@@ -1516,8 +1538,6 @@ def _verify_setup(self) -> bool:
# Show path for each failed model
hf_cache = os.path.expanduser("~/.cache/huggingface/hub")
- from pathlib import Path
-
for model_id, error in models_failed:
# Find actual model directory (may have org prefix like ggml-org/model-name)
# Search for directories containing the model name
diff --git a/src/gaia/ui/build.py b/src/gaia/ui/build.py
new file mode 100644
index 000000000..9a68d0855
--- /dev/null
+++ b/src/gaia/ui/build.py
@@ -0,0 +1,125 @@
+# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
+# SPDX-License-Identifier: MIT
+
+"""
+Shared utility for building the Agent UI frontend.
+
+Extracted from cli.py so that init_command.py can call it without
+creating a circular import through the full CLI module.
+"""
+
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+
+def ensure_webui_built(log_fn=print, warn_fn=None, _webui_dir=None):
+ """Rebuild the Agent UI frontend if source files are newer than dist.
+
+ Only runs in dev mode (editable install) where the webui src/ directory
+ exists. Silently skips in installed-package mode or when node/npm are
+ not available.
+
+ Args:
+ log_fn: Callable used for informational output. Defaults to ``print``.
+ Pass ``logger.info`` or ``self._print`` to integrate with your
+ own output mechanism.
+ warn_fn: Callable used for warning/error output. Defaults to
+ ``log_fn`` when not provided. Pass ``logger.warning`` or
+ ``self._print_warning`` to route warnings separately from
+ informational messages.
+ _webui_dir: Override the webui directory path (used in tests only).
+
+ Returns:
+ True — build succeeded, or dist is already up-to-date.
+ False — build was skipped or failed (warn_fn already reported why).
+ """
+ if warn_fn is None:
+ warn_fn = log_fn
+
+ webui_dir = (
+ _webui_dir
+ if _webui_dir is not None
+ else Path(__file__).resolve().parent.parent / "apps" / "webui"
+ )
+ src_dir = webui_dir / "src"
+ dist_index = webui_dir / "dist" / "index.html"
+
+ # Gate 1 — dev mode only (src/ absent in pip-installed package)
+ if not src_dir.is_dir():
+ return False
+
+ # Gate 2 — staleness check
+ newest_src = 0.0
+ for pattern in ("*.ts", "*.tsx", "*.css", "*.html"):
+ for path in src_dir.rglob(pattern):
+ mtime = path.stat().st_mtime
+ if mtime > newest_src:
+ newest_src = mtime
+ for root_file in ("index.html", "vite.config.ts", "tsconfig.json"):
+ p = webui_dir / root_file
+ if p.exists():
+ newest_src = max(newest_src, p.stat().st_mtime)
+
+ if dist_index.exists() and newest_src <= dist_index.stat().st_mtime:
+ return True
+
+ if dist_index.exists():
+ log_fn("Agent UI frontend source is newer than built output")
+ else:
+ log_fn("Agent UI frontend has not been built yet")
+
+ # Gate 3 — node/npm availability
+ if not shutil.which("node"):
+ warn_fn("Warning: Node.js not found. Cannot auto-rebuild Agent UI frontend.")
+ warn_fn(" The UI may be stale. Install Node.js from https://nodejs.org/")
+ return False
+ if not shutil.which("npm"):
+ warn_fn("Warning: npm not found. Cannot auto-rebuild Agent UI frontend.")
+ return False
+
+ # On Windows, npm is a .cmd batch file requiring shell execution
+ _shell = sys.platform == "win32"
+
+ # Step 1 — npm install (only if node_modules/ missing)
+ if not (webui_dir / "node_modules").is_dir():
+ log_fn("Installing Agent UI frontend dependencies...")
+ try:
+ subprocess.run(
+ ["npm", "install"],
+ cwd=str(webui_dir),
+ check=True,
+ capture_output=True,
+ text=True,
+ shell=_shell,
+ )
+ except subprocess.CalledProcessError as e:
+ warn_fn(f"Warning: npm install failed: {e.stderr}")
+ warn_fn(" Continuing with existing dist/ (may be stale).")
+ return False
+ except FileNotFoundError:
+ warn_fn("Warning: npm not found. Skipping frontend rebuild.")
+ return False
+
+ # Step 2 — npm run build (stream output so user sees progress)
+ log_fn("Building Agent UI frontend...")
+ try:
+ subprocess.run(
+ ["npm", "run", "build"],
+ cwd=str(webui_dir),
+ check=True,
+ shell=_shell,
+ )
+ log_fn("Agent UI frontend built successfully.")
+ return True
+ except subprocess.CalledProcessError as e:
+ warn_fn(f"Warning: Frontend build failed (exit code {e.returncode}).")
+ if dist_index.exists():
+ warn_fn(" Continuing with existing (possibly stale) build.")
+ else:
+ warn_fn(" No existing build found. The UI will show a build hint.")
+ return False
+ except FileNotFoundError:
+ warn_fn("Warning: npm not found. Skipping frontend rebuild.")
+ return False
diff --git a/src/gaia/ui/server.py b/src/gaia/ui/server.py
index 6ecfd88c9..97076445e 100644
--- a/src/gaia/ui/server.py
+++ b/src/gaia/ui/server.py
@@ -127,11 +127,13 @@ async def dispatch(self, request: Request, call_next):
# ── Application Factory ────────────────────────────────────────────────────
-def create_app(db_path: str = None) -> FastAPI:
+def create_app(db_path: str = None, webui_dist: str = None) -> FastAPI:
"""Create and configure the FastAPI application.
Args:
db_path: Path to SQLite database. None for default, ":memory:" for testing.
+ webui_dist: Path to the pre-built frontend dist directory. When None,
+ falls back to the default location relative to this package.
Returns:
Configured FastAPI application.
@@ -295,7 +297,8 @@ async def _global_exception_handler(request: Request, exc: Exception):
# ── Serve Frontend Static Files ──────────────────────────────────────
# Look for built frontend assets in the webui dist directory
- _webui_dist = Path(__file__).resolve().parent.parent / "apps" / "webui" / "dist"
+ _default_dist = Path(__file__).resolve().parent.parent / "apps" / "webui" / "dist"
+ _webui_dist = Path(webui_dist) if webui_dist else _default_dist
if _webui_dist.is_dir():
logger.info("Serving frontend from %s", _webui_dist)
@@ -375,11 +378,16 @@ def main():
"--port", type=int, default=DEFAULT_PORT, help=f"Port (default: {DEFAULT_PORT})"
)
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
+ parser.add_argument(
+ "--ui-dist",
+ default=None,
+ help="Path to pre-built Agent UI frontend dist directory",
+ )
args = parser.parse_args()
log_level = "debug" if args.debug else "info"
print(f"Starting GAIA Agent UI server on http://{args.host}:{args.port}")
- server_app = create_app()
+ server_app = create_app(webui_dist=args.ui_dist)
uvicorn.run(
server_app,
host=args.host,
diff --git a/src/gaia/version.py b/src/gaia/version.py
index ae3291e33..e5e24ec15 100644
--- a/src/gaia/version.py
+++ b/src/gaia/version.py
@@ -6,7 +6,7 @@
import subprocess
from importlib.metadata import version as get_package_version_metadata
-__version__ = "0.17.1-rc.1"
+__version__ = "0.17.1"
# Lemonade version used across CI and installer
LEMONADE_VERSION = "10.0.0"
diff --git a/tests/unit/test_server_webui_dist.py b/tests/unit/test_server_webui_dist.py
new file mode 100644
index 000000000..bbc048a76
--- /dev/null
+++ b/tests/unit/test_server_webui_dist.py
@@ -0,0 +1,49 @@
+# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
+# SPDX-License-Identifier: MIT
+
+"""
+Unit tests verifying that the GAIA Agent UI server uses the webui_dist
+parameter of create_app() to locate the pre-built frontend.
+
+When installed via npm (`gaia-ui`), the launcher passes --ui-dist so the
+Python server can find the dist/ folder inside the npm package rather than
+looking in the (absent) PyPI package tree.
+"""
+
+import tempfile
+import unittest
+from pathlib import Path
+
+
+class TestServerWebuiDist(unittest.TestCase):
+ """Tests for webui_dist parameter handling in gaia.ui.server.create_app()."""
+
+ # ------------------------------------------------------------------
+ # Test 1: server uses webui_dist parameter and serves index.html from it
+ # ------------------------------------------------------------------
+
+ def test_server_uses_provided_webui_dist(self):
+ """
+ When webui_dist is passed to create_app(), the server serves index.html
+ from that directory instead of returning the JSON API banner.
+ """
+ from fastapi.testclient import TestClient
+
+ from gaia.ui.server import create_app
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ dist_dir = Path(tmpdir)
+ (dist_dir / "assets").mkdir()
+ (dist_dir / "index.html").write_text(
+ "GAIA Agent UI"
+ )
+
+ app = create_app(webui_dist=str(dist_dir), db_path=":memory:")
+ client = TestClient(app, raise_server_exceptions=False)
+ response = client.get("/")
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("GAIA Agent UI", response.text)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/test_webui_build.py b/tests/unit/test_webui_build.py
new file mode 100644
index 000000000..cc9e96a23
--- /dev/null
+++ b/tests/unit/test_webui_build.py
@@ -0,0 +1,314 @@
+# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
+# SPDX-License-Identifier: MIT
+
+"""
+Unit tests for gaia.ui.build.ensure_webui_built and the gaia init
+frontend build step.
+
+Tests use real temp directories for path logic and patch only subprocess
+and shutil.which so no actual npm/node invocations happen.
+"""
+
+import subprocess
+import tempfile
+import time
+import unittest
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+
+class TestEnsureWebuiBuilt(unittest.TestCase):
+ """Tests for gaia.ui.build.ensure_webui_built."""
+
+ def _call(
+ self, webui_dir, which_return="/usr/bin/node", run_side_effect=None, log=None
+ ):
+ """Helper: call ensure_webui_built with controlled environment."""
+ from gaia.ui.build import ensure_webui_built
+
+ msgs = []
+ log_fn = log if log is not None else msgs.append
+
+ with (
+ patch("gaia.ui.build.shutil.which", return_value=which_return),
+ patch(
+ "gaia.ui.build.subprocess.run",
+ side_effect=run_side_effect,
+ ) as mock_run,
+ ):
+ result = ensure_webui_built(log_fn=log_fn, _webui_dir=webui_dir)
+
+ return msgs, mock_run, result
+
+ # ------------------------------------------------------------------
+ # Test 1: skip when src/ is absent (pip install, no source tree)
+ # ------------------------------------------------------------------
+
+ def test_skips_pip_install(self):
+ """ensure_webui_built returns early when src/ directory is absent."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ webui_dir = Path(tmpdir)
+ # src/ deliberately NOT created
+
+ msgs, mock_run, result = self._call(webui_dir)
+
+ mock_run.assert_not_called()
+ self.assertFalse(result, "Expected False (silent skip) when src/ is absent")
+
+ # ------------------------------------------------------------------
+ # Test 2: skip when dist is fresh (staleness check)
+ # ------------------------------------------------------------------
+
+ def test_staleness_skip(self):
+ """No build when dist/index.html is newer than all source files."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ webui_dir = Path(tmpdir)
+ src_dir = webui_dir / "src"
+ src_dir.mkdir()
+ # Write a source file with an old mtime
+ src_file = src_dir / "app.ts"
+ src_file.write_text("const x = 1;")
+
+ # Create dist/index.html with a NEWER mtime
+ dist_dir = webui_dir / "dist"
+ dist_dir.mkdir()
+ dist_index = dist_dir / "index.html"
+ dist_index.write_text("")
+
+ # Force dist to appear newer than src
+ old_time = time.time() - 60
+ import os
+
+ os.utime(str(src_file), (old_time, old_time))
+ new_time = time.time()
+ os.utime(str(dist_index), (new_time, new_time))
+
+ msgs, mock_run, result = self._call(webui_dir)
+
+ mock_run.assert_not_called()
+ self.assertTrue(result, "Expected True when dist is already up-to-date")
+
+ # ------------------------------------------------------------------
+ # Test 3: node missing — logs warning, no exception
+ # ------------------------------------------------------------------
+
+ def test_node_missing(self):
+ """Log a warning and return gracefully when Node.js is not found."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ webui_dir = Path(tmpdir)
+ (webui_dir / "src").mkdir()
+ # No dist/index.html — build is needed
+
+ msgs, mock_run, result = self._call(webui_dir, which_return=None)
+
+ mock_run.assert_not_called()
+ self.assertFalse(result, "Expected False when Node.js is missing")
+ self.assertTrue(
+ any("Node.js not found" in m for m in msgs),
+ f"Expected 'Node.js not found' in log output, got: {msgs}",
+ )
+
+ # ------------------------------------------------------------------
+ # Test 4: happy path — builds when dist is absent, node/npm available
+ # ------------------------------------------------------------------
+
+ def test_builds_frontend(self):
+ """subprocess.run called with ['npm', 'run', 'build'] when dist absent."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ webui_dir = Path(tmpdir)
+ src_dir = webui_dir / "src"
+ src_dir.mkdir()
+ src_file = src_dir / "app.ts"
+ src_file.write_text("const x = 1;")
+ # node_modules present so npm install is skipped
+ (webui_dir / "node_modules").mkdir()
+ # No dist/index.html — build needed
+
+ msgs, mock_run, result = self._call(webui_dir)
+
+ called_cmds = [c.args[0] for c in mock_run.call_args_list]
+ self.assertTrue(
+ any(c == ["npm", "run", "build"] for c in called_cmds),
+ f"Expected ['npm', 'run', 'build'] call, got: {called_cmds}",
+ )
+ self.assertTrue(result, "Expected True when build succeeds")
+
+ # ------------------------------------------------------------------
+ # Test 5: npm install failure — no exception propagated
+ # ------------------------------------------------------------------
+
+ def test_npm_install_failure_continues(self):
+ """CalledProcessError from npm install does not propagate."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ webui_dir = Path(tmpdir)
+ (webui_dir / "src").mkdir()
+ # node_modules absent — triggers npm install
+ # No dist/index.html
+
+ def fail_install(cmd, **kwargs):
+ if "install" in cmd:
+ raise subprocess.CalledProcessError(1, cmd, stderr="ERR")
+ return MagicMock(returncode=0)
+
+ try:
+ msgs, mock_run, result = self._call(
+ webui_dir, run_side_effect=fail_install
+ )
+ except Exception as e:
+ self.fail(f"ensure_webui_built raised unexpectedly: {e}")
+
+ self.assertFalse(result, "Expected False when npm install fails")
+
+ # ------------------------------------------------------------------
+ # Test 6: npm run build failure — caught, returns False, no exception
+ # ------------------------------------------------------------------
+
+ def test_build_step_failure_continues(self):
+ """npm run build CalledProcessError is caught; returns False without raising."""
+ from gaia.ui.build import ensure_webui_built
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ webui_dir = Path(tmpdir)
+ (webui_dir / "src").mkdir()
+ # node_modules present so npm install is skipped
+ (webui_dir / "node_modules").mkdir()
+ # No dist/index.html — build is needed
+
+ def fail_build(cmd, **kwargs):
+ if "build" in cmd:
+ raise subprocess.CalledProcessError(1, cmd)
+ return MagicMock(returncode=0)
+
+ warnings = []
+ result = None
+ try:
+ with (
+ patch("gaia.ui.build.shutil.which", return_value="/usr/bin/node"),
+ patch("gaia.ui.build.subprocess.run", side_effect=fail_build),
+ ):
+ result = ensure_webui_built(
+ _webui_dir=webui_dir, warn_fn=warnings.append
+ )
+ except Exception as e:
+ self.fail(f"ensure_webui_built raised unexpectedly: {e}")
+
+ self.assertFalse(result, "Expected False when build step fails")
+ self.assertTrue(
+ any(
+ "build failed" in w.lower() or "Frontend build failed" in w
+ for w in warnings
+ ),
+ f"Expected build-failure warning, got: {warnings}",
+ )
+
+ # ------------------------------------------------------------------
+ # Test 7: node found, npm missing — warns and skips build
+ # ------------------------------------------------------------------
+
+ def test_npm_missing_warns_and_skips(self):
+ """If node is present but npm is missing, warn_fn is called and build is skipped."""
+ from gaia.ui.build import ensure_webui_built
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ webui_dir = Path(tmpdir)
+ (webui_dir / "src").mkdir()
+ # No dist/index.html — build would be needed
+
+ warnings = []
+
+ def fake_which(cmd):
+ return "/usr/bin/node" if cmd == "node" else None
+
+ with (
+ patch("gaia.ui.build.shutil.which", side_effect=fake_which),
+ patch("gaia.ui.build.subprocess.run") as mock_run,
+ ):
+ result = ensure_webui_built(
+ _webui_dir=webui_dir, warn_fn=warnings.append
+ )
+
+ mock_run.assert_not_called()
+ self.assertFalse(result, "Expected False when npm is missing")
+ self.assertTrue(
+ any("npm" in w.lower() for w in warnings),
+ f"Expected npm warning in warn_fn output, got: {warnings}",
+ )
+
+
+class TestInitCommandWebuiBuild(unittest.TestCase):
+ """Tests for the gaia init frontend build integration."""
+
+ def _run_init_with_src_dir_mock(self, src_is_dir: bool):
+ """
+ Run InitCommand.run() with all heavy operations mocked.
+
+ Returns the mock for ensure_webui_built so caller can assert on it.
+ """
+ from gaia.installer.init_command import InitCommand
+ from gaia.installer.lemonade_installer import LemonadeInstaller
+
+ # Fake src path whose .is_dir() is controlled by the caller
+ fake_src = MagicMock()
+ fake_src.is_dir.return_value = src_is_dir
+
+ # Build Path chain via MagicMock's auto-chaining of __truediv__.return_value.
+ # Path(__file__).resolve().parent.parent / "apps" / "webui" / "src" = fake_src
+ # Each / uses __truediv__.return_value on the previous mock.
+ mock_path = MagicMock()
+ (
+ mock_path.return_value.resolve.return_value.parent.parent.__truediv__.return_value.__truediv__.return_value.__truediv__.return_value # / "apps" # / "webui" # / "src"
+ )
+ # Now override the final .return_value to be fake_src
+ (
+ mock_path.return_value.resolve.return_value.parent.parent.__truediv__.return_value.__truediv__.return_value.__truediv__
+ ).return_value = fake_src
+
+ mock_installer = MagicMock(spec=LemonadeInstaller)
+
+ with (
+ patch("gaia.installer.init_command.Path", mock_path),
+ patch("gaia.ui.build.ensure_webui_built") as mock_ensure_built,
+ patch.object(InitCommand, "_print_header"),
+ patch.object(InitCommand, "_print"),
+ patch.object(InitCommand, "_print_step"),
+ patch.object(InitCommand, "_print_success"),
+ patch.object(InitCommand, "_print_completion"),
+ patch.object(InitCommand, "_ensure_lemonade_installed", return_value=True),
+ patch.object(InitCommand, "_ensure_server_running", return_value=True),
+ patch.object(InitCommand, "_verify_setup", return_value=True),
+ ):
+ cmd = InitCommand.__new__(InitCommand)
+ cmd.profile = "minimal"
+ cmd.skip_models = True
+ cmd.skip_lemonade = True
+ cmd.remote = False
+ cmd.verbose = False
+ cmd.force_reinstall = False
+ cmd._lemonade_base_url = None
+ cmd.installer = mock_installer
+ cmd.console = MagicMock()
+ cmd.run()
+
+ return mock_ensure_built
+
+ # ------------------------------------------------------------------
+ # Test 6: init calls ensure_webui_built in dev mode
+ # ------------------------------------------------------------------
+
+ def test_init_calls_build_in_dev_mode(self):
+ """ensure_webui_built is called when webui src/ exists (dev install)."""
+ mock_ensure_built = self._run_init_with_src_dir_mock(src_is_dir=True)
+ mock_ensure_built.assert_called_once()
+
+ # ------------------------------------------------------------------
+ # Test 7: init skips build for pip installs (no src/)
+ # ------------------------------------------------------------------
+
+ def test_init_skips_build_for_pip(self):
+ """ensure_webui_built is NOT called when webui src/ is absent (pip install)."""
+ mock_ensure_built = self._run_init_with_src_dir_mock(src_is_dir=False)
+ mock_ensure_built.assert_not_called()
+
+
+if __name__ == "__main__":
+ unittest.main()