diff --git a/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py b/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py index 893e2348c..c9f9af6fe 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py @@ -218,12 +218,16 @@ async def _initialize_run( await populate_mcp_server_credentials(self._context) self._last_creds_refresh = time.monotonic() - # If the caller changed, destroy the worker and rebuild MCP servers + - # adapter so the new ClaudeSDKClient gets fresh mcp_servers config. - # The session ID is preserved — --resume works because each SDK client - # is a new CLI subprocess that spawns fresh MCP servers from os.environ. + # Rebuild MCP servers when credentials may have changed. + # On first run: always rebuild after credential fetch so credential-based servers + # (e.g. Jira via session endpoint) that were missed during _setup_platform(). + # On user change: destroy worker so the new SDK client picks up fresh creds. user_changed = current_user_id != prev_user - if user_changed and self._session_manager.get_existing(thread_id): + if self._first_run: + self._rebuild_mcp_servers() + self._rebuild_system_prompt() + self._adapter = None + elif user_changed and self._session_manager.get_existing(thread_id): logger.info( f"User changed for thread={thread_id}, " "rebuilding MCP servers and adapter with new credentials" @@ -719,6 +723,21 @@ def _rebuild_mcp_servers(self) -> None: self._allowed_tools = build_allowed_tools(self._mcp_servers) logger.info("Rebuilt MCP servers with updated credentials") + def _rebuild_system_prompt(self) -> None: + """Rebuild the system prompt with current env vars. + + Called on first run after credential refresh so the prompt accurately + reflects which integrations are configured (e.g. Jira via session + endpoint). The initial build in _setup_platform() may have run before + credentials were fully available. + """ + from ambient_runner.bridges.claude.prompts import build_sdk_system_prompt + + self._system_prompt = build_sdk_system_prompt( + self._context.workspace_path, self._cwd_path + ) + logger.info("Rebuilt system prompt with updated credentials") + # ------------------------------------------------------------------ # Private: adapter lifecycle # ------------------------------------------------------------------ diff --git a/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py b/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py index 7929f548e..d003a12c9 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py @@ -140,6 +140,24 @@ def build_mcp_servers( "Added credential MCP servers: %s", list(credential_mcp_servers.keys()) ) + # Fallback: add Jira MCP server from env vars when credentials are available but + # no credential binding exists + jira_server_name = _CREDENTIAL_MCP_REGISTRY["jira"]["server_name"] + if ( + jira_server_name not in mcp_servers + and os.getenv("JIRA_URL", "").strip() + and os.getenv("JIRA_API_TOKEN", "").strip() + ): + jira_entry = _CREDENTIAL_MCP_REGISTRY["jira"] + mcp_servers[jira_server_name] = { + "command": jira_entry["command"], + "args": list(jira_entry["args"]), + "env": {k: _expand_env_vars(v) for k, v in jira_entry["env"].items()}, + } + logger.info( + "Added Jira MCP server (credentials available via session endpoint)" + ) + # Gerrit MCP server (only if credentials are configured) gerrit_config = os.environ.get("GERRIT_CONFIG_PATH", "") if gerrit_config and Path(gerrit_config).exists(): @@ -243,11 +261,15 @@ def _build_sidecar_mcp_servers(credential_mcp_urls_raw: str) -> dict: try: credential_mcp_urls = json.loads(credential_mcp_urls_raw) except (json.JSONDecodeError, TypeError): - logger.warning("Failed to parse CREDENTIAL_MCP_URLS — skipping credential MCP servers") + logger.warning( + "Failed to parse CREDENTIAL_MCP_URLS — skipping credential MCP servers" + ) return {} if not isinstance(credential_mcp_urls, dict): - logger.warning("CREDENTIAL_MCP_URLS is not a JSON object — skipping credential MCP servers") + logger.warning( + "CREDENTIAL_MCP_URLS is not a JSON object — skipping credential MCP servers" + ) return {} servers: dict = {} @@ -298,7 +320,11 @@ def _wait_for_sidecar_readiness( if not endpoints: return - logger.info("Waiting for %d credential sidecar(s) to become ready (timeout=%ds)", len(endpoints), int(timeout)) + logger.info( + "Waiting for %d credential sidecar(s) to become ready (timeout=%ds)", + len(endpoints), + int(timeout), + ) deadline = time.monotonic() + timeout pending = list(endpoints) @@ -307,7 +333,9 @@ def _wait_for_sidecar_readiness( for name, host, port in pending: try: with socket.create_connection((host, port), timeout=1.0): - logger.info("Credential sidecar %s ready at %s:%d", name, host, port) + logger.info( + "Credential sidecar %s ready at %s:%d", name, host, port + ) except (ConnectionRefusedError, OSError, socket.timeout): still_pending.append((name, host, port)) pending = still_pending @@ -316,7 +344,9 @@ def _wait_for_sidecar_readiness( if pending: names = [p[0] for p in pending] - logger.warning("Credential sidecar(s) not ready after %ds: %s", int(timeout), names) + logger.warning( + "Credential sidecar(s) not ready after %ds: %s", int(timeout), names + ) def _build_subprocess_mcp_servers() -> dict: diff --git a/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/system_prompt.py b/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/system_prompt.py index e696f1a7f..755f549cc 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/system_prompt.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/system_prompt.py @@ -117,16 +117,31 @@ def _build_system_prompt(cwd_path: str) -> str: """Build the full system.md content string.""" from ambient_runner.platform.config import get_repos_config, load_ambient_config from ambient_runner.platform.prompts import ( - GITHUB_TOKEN_PROMPT, - GITLAB_TOKEN_PROMPT, GIT_PUSH_INSTRUCTIONS_BODY, GIT_PUSH_INSTRUCTIONS_HEADER, + GIT_PUSH_MCP_STEPS, GIT_PUSH_STEPS, - MCP_INTEGRATIONS_PROMPT, WORKSPACE_FIXED_PATHS_PROMPT, + _build_integrations_prompt, + _detect_github, ) from ambient_runner.platform.utils import derive_workflow_name + # Detect GitHub mode once so the git-push step selection is consistent + # with what _build_integrations_prompt() will tell the model to use. + _cmu: dict = {} + _cmu_raw = os.getenv("CREDENTIAL_MCP_URLS", "").strip() + if _cmu_raw: + try: + import json as _json + + _parsed = _json.loads(_cmu_raw) + if isinstance(_parsed, dict): + _cmu = _parsed + except (ValueError, TypeError): + pass + github_mode = _detect_github(_cmu) + # Pull in Gemini's dynamically-built default sections via variable substitution. # These are expanded at runtime by the CLI — no static text to maintain. sections = [ @@ -173,7 +188,8 @@ def _build_system_prompt(cwd_path: str) -> str: sections.append(GIT_PUSH_INSTRUCTIONS_BODY) for r in auto_push: sections.append(f"- **repos/{r.get('name', 'unknown')}/**") - sections.append(GIT_PUSH_STEPS.format(branch=branch)) + push_steps = GIT_PUSH_MCP_STEPS if github_mode == "mcp" else GIT_PUSH_STEPS + sections.append(push_steps.format(branch=branch)) # ---- Workflow directory ---- if active_workflow_url: @@ -200,14 +216,8 @@ def _build_system_prompt(cwd_path: str) -> str: except Exception as exc: logger.warning("Could not list uploaded files in %s: %s", uploads, exc) - # ---- MCP integration hints ---- - sections.append(MCP_INTEGRATIONS_PROMPT) - - # ---- Token visibility ---- - if os.getenv("GITHUB_TOKEN"): - sections.append(GITHUB_TOKEN_PROMPT) - if os.getenv("GITLAB_TOKEN"): - sections.append(GITLAB_TOKEN_PROMPT) + # ---- Integration status — conditional on actual credential state ---- + sections.append(_build_integrations_prompt()) # ---- Workflow custom instructions ---- ambient_config: dict = {} diff --git a/components/runners/ambient-runner/ambient_runner/platform/prompts.py b/components/runners/ambient-runner/ambient_runner/platform/prompts.py index 05cfa4c72..7cd5ca5e5 100644 --- a/components/runners/ambient-runner/ambient_runner/platform/prompts.py +++ b/components/runners/ambient-runner/ambient_runner/platform/prompts.py @@ -10,7 +10,9 @@ import json import logging import os +from dataclasses import dataclass from pathlib import Path +from typing import Callable logger = logging.getLogger(__name__) @@ -32,13 +34,13 @@ "- `/workspace/artifacts/` AI writes all output here\n\n" ) -MCP_INTEGRATIONS_PROMPT = ( - "## MCP Integrations\n" - "If you need Google Drive access: Ask user to go to Integrations page " - "in Ambient and authenticate with Google Drive.\n" - "If you need Jira access: Ask user to go to Workspace Settings in Ambient " - "and configure Jira credentials there.\n\n" -) +# --------------------------------------------------------------------------- +# Integration prompt constants +# Each integration has an "available" prompt and a "missing" prompt. +# Add new prompts here when adding a new integration to _INTEGRATION_REGISTRY. +# --------------------------------------------------------------------------- + +_PLACEHOLDER_EMAIL = "user@example.com" GITHUB_TOKEN_PROMPT = ( "## GitHub Access\n" @@ -60,6 +62,12 @@ "All GitHub write operations must go through MCP tools.\n\n" ) +GITHUB_MISSING_PROMPT = ( + "## GitHub Access\n" + "GitHub is not connected. If you need to push code, create PRs, or access " + "GitHub APIs, ask the user to connect GitHub on the Integrations page in Ambient.\n\n" +) + GITLAB_TOKEN_PROMPT = ( "## GitLab Access\n" "A `GITLAB_TOKEN` environment variable is set in this session. " @@ -67,6 +75,153 @@ "The token is automatically used for git operations.\n\n" ) +GITLAB_MISSING_PROMPT = ( + "## GitLab Access\n" + "GitLab is not connected. If you need to push code or access GitLab APIs, " + "ask the user to connect GitLab on the Integrations page in Ambient.\n\n" +) + +JIRA_MCP_PROMPT = ( + "## Jira Access\n" + "Jira is configured and available via the **mcp-atlassian** MCP server. " + "Use `mcp__mcp-atlassian__*` tools to read issues, add comments, update " + "status, and more. Do NOT tell the user to configure Jira — it is already set up.\n\n" +) + +JIRA_MISSING_PROMPT = ( + "## Jira Access\n" + "Jira is not configured. If you need Jira access, ask the user to go to " + "Workspace Settings in Ambient and configure Jira credentials there.\n\n" +) + +GOOGLE_MCP_PROMPT = ( + "## Google Workspace Access\n" + "Google Workspace is configured and available via the **google-workspace** MCP server. " + "Use `mcp__google-workspace__*` tools to interact with Google Drive, Gmail, etc. " + "Do NOT tell the user to set up Google integration — it is already set up.\n\n" +) + +GOOGLE_MISSING_PROMPT = ( + "## Google Workspace Access\n" + "Google Workspace is not connected. If you need Google Drive, Gmail, or other " + "Google services, ask the user to go to the Integrations page in Ambient and " + "authenticate with Google.\n\n" +) + +# Legacy alias kept for backward compatibility — use _build_integrations_prompt() instead. +MCP_INTEGRATIONS_PROMPT = JIRA_MISSING_PROMPT + GOOGLE_MISSING_PROMPT + + +# --------------------------------------------------------------------------- +# Integration registry +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class _Integration: + """Registry entry for a platform integration. + + To add a new integration: + 1. Add prompt constants above (available + missing states). + 2. Implement a detect callable: (cmu: dict) -> str | None. + Return a mode string ("mcp", "token") when configured, None when not. + 3. Add an _Integration entry to _INTEGRATION_REGISTRY below. + """ + + label: str + detect: Callable[[dict], str | None] + prompts: dict[str, str] # mode -> prompt text; "missing" for unconfigured state + + +def _detect_github(cmu: dict) -> str | None: + if "github" in cmu: + return "mcp" + if os.getenv("GITHUB_TOKEN"): + return "token" + return None + + +def _detect_gitlab(cmu: dict) -> str | None: + if os.getenv("GITLAB_TOKEN"): + return "token" + return None + + +def _detect_jira(cmu: dict) -> str | None: + if "jira" in cmu: + return "mcp" + if os.getenv("JIRA_URL", "").strip() and os.getenv("JIRA_API_TOKEN", "").strip(): + return "mcp" + return None + + +def _detect_google(cmu: dict) -> str | None: + if "google" in cmu: + return "mcp" + email = os.getenv("USER_GOOGLE_EMAIL", "").strip() + if email and email != _PLACEHOLDER_EMAIL: + return "mcp" + return None + + +_INTEGRATION_REGISTRY: list[_Integration] = [ + _Integration( + label="GitHub", + detect=_detect_github, + prompts={ + "mcp": GITHUB_MCP_PROMPT, + "token": GITHUB_TOKEN_PROMPT, + "missing": GITHUB_MISSING_PROMPT, + }, + ), + _Integration( + label="GitLab", + detect=_detect_gitlab, + prompts={"token": GITLAB_TOKEN_PROMPT, "missing": GITLAB_MISSING_PROMPT}, + ), + _Integration( + label="Jira", + detect=_detect_jira, + prompts={"mcp": JIRA_MCP_PROMPT, "missing": JIRA_MISSING_PROMPT}, + ), + _Integration( + label="Google Workspace", + detect=_detect_google, + prompts={"mcp": GOOGLE_MCP_PROMPT, "missing": GOOGLE_MISSING_PROMPT}, + ), +] + + +def _build_integrations_prompt() -> str: + """Build the integrations status section for the system prompt. + + Iterates _INTEGRATION_REGISTRY and emits the appropriate prompt for each + integration based on current credential state (env vars + CREDENTIAL_MCP_URLS). + When an integration is configured, Claude is told to use its MCP server/tools. + When not configured, Claude is told to ask the user to set it up. + """ + credential_mcp_urls_raw = os.getenv("CREDENTIAL_MCP_URLS", "").strip() + cmu: dict = {} + if credential_mcp_urls_raw: + try: + parsed = json.loads(credential_mcp_urls_raw) + if isinstance(parsed, dict): + cmu = parsed + except (ValueError, TypeError): + pass + + return "".join( + integration.prompts.get( + integration.detect(cmu) or "missing", + integration.prompts.get("missing", ""), + ) + for integration in _INTEGRATION_REGISTRY + ) + + +# Keep the old name as an alias for any call sites that haven't migrated. +_build_mcp_integrations_prompt = _build_integrations_prompt + GIT_PUSH_INSTRUCTIONS_HEADER = "## Git Push Instructions\n\n" GIT_PUSH_INSTRUCTIONS_BODY = ( @@ -256,21 +411,8 @@ def build_workspace_context_prompt( # Human-in-the-loop instructions prompt += HUMAN_INPUT_INSTRUCTIONS - # MCP integration setup instructions - prompt += MCP_INTEGRATIONS_PROMPT - - # Token visibility — tell Claude what credentials are available - if credential_mcp_urls: - try: - urls = json.loads(credential_mcp_urls) - if "github" in urls: - prompt += GITHUB_MCP_PROMPT - except (ValueError, TypeError): - pass - elif os.getenv("GITHUB_TOKEN"): - prompt += GITHUB_TOKEN_PROMPT - if not credential_mcp_urls and os.getenv("GITLAB_TOKEN"): - prompt += GITLAB_TOKEN_PROMPT + # Integration status — conditional on actual credential state for all providers + prompt += _build_integrations_prompt() # Workflow instructions if ambient_config.get("systemPrompt"): diff --git a/components/runners/ambient-runner/tests/test_bridge_claude.py b/components/runners/ambient-runner/tests/test_bridge_claude.py index 5f9377ff6..08fb7772e 100644 --- a/components/runners/ambient-runner/tests/test_bridge_claude.py +++ b/components/runners/ambient-runner/tests/test_bridge_claude.py @@ -370,6 +370,245 @@ def test_get_error_context_with_stderr(self): assert "line 42" in ctx +@pytest.mark.asyncio +class TestClaudeBridgeFirstRunMCPRebuild: + """Verify MCP servers and system prompt are rebuilt on first run after credential refresh.""" + + async def test_first_run_rebuilds_mcp_servers_and_clears_adapter(self): + """On first run, MCP servers and system prompt must be rebuilt after + populate_runtime_credentials so credential-based servers (e.g. Jira via + session endpoint) that were absent during _setup_platform() due to transient + backend latency are captured with the now-populated env vars.""" + bridge = ClaudeBridge() + ctx = RunnerContext(session_id="s1", workspace_path="/w") + bridge.set_context(ctx) + bridge._ready = True + bridge._first_run = True + bridge._cwd_path = "/w" + bridge._session_manager = MagicMock() + bridge._session_manager.get_existing.return_value = None + bridge._adapter = MagicMock() + + with ( + patch( + "ambient_runner.platform.auth.populate_runtime_credentials", + new_callable=AsyncMock, + ), + patch( + "ambient_runner.platform.auth.populate_mcp_server_credentials", + new_callable=AsyncMock, + ), + patch( + "ambient_runner.platform.auth.clear_runtime_credentials", + ), + patch.object(bridge, "_ensure_ready", new_callable=AsyncMock), + patch.object(bridge, "_rebuild_mcp_servers") as mock_rebuild, + patch.object(bridge, "_rebuild_system_prompt") as mock_rebuild_prompt, + patch.object(bridge, "_ensure_adapter"), + ): + await bridge._initialize_run( + thread_id="t1", + current_user_id="user1", + current_user_name="User One", + caller_token="tok", + ) + + mock_rebuild.assert_called_once() + mock_rebuild_prompt.assert_called_once() + assert bridge._adapter is None + + async def test_subsequent_run_does_not_rebuild_when_same_user(self): + """After first run, MCP servers and system prompt must NOT be rebuilt when + the user is unchanged.""" + bridge = ClaudeBridge() + ctx = RunnerContext(session_id="s1", workspace_path="/w") + ctx.set_current_user("user1", "User One", "tok") + bridge.set_context(ctx) + bridge._ready = True + bridge._first_run = False + bridge._cwd_path = "/w" + bridge._session_manager = MagicMock() + bridge._session_manager.get_existing.return_value = None + original_adapter = MagicMock() + bridge._adapter = original_adapter + + with ( + patch( + "ambient_runner.platform.auth.populate_runtime_credentials", + new_callable=AsyncMock, + ), + patch( + "ambient_runner.platform.auth.populate_mcp_server_credentials", + new_callable=AsyncMock, + ), + patch( + "ambient_runner.platform.auth.clear_runtime_credentials", + ), + patch.object(bridge, "_ensure_ready", new_callable=AsyncMock), + patch.object(bridge, "_rebuild_mcp_servers") as mock_rebuild, + patch.object(bridge, "_rebuild_system_prompt") as mock_rebuild_prompt, + patch.object(bridge, "_ensure_adapter"), + ): + await bridge._initialize_run( + thread_id="t1", + current_user_id="user1", + current_user_name="User One", + caller_token="tok", + ) + + mock_rebuild.assert_not_called() + mock_rebuild_prompt.assert_not_called() + assert bridge._adapter is original_adapter + + +class TestBuildIntegrationsPrompt: + """Verify _build_integrations_prompt is conditional on credential state for all integrations.""" + + _CLEAN_VARS = ( + "JIRA_URL", + "JIRA_API_TOKEN", + "USER_GOOGLE_EMAIL", + "CREDENTIAL_MCP_URLS", + "GITHUB_TOKEN", + "GITLAB_TOKEN", + ) + + def _clean_env(self, monkeypatch): + for var in self._CLEAN_VARS: + monkeypatch.delenv(var, raising=False) + + def _prompt(self): + from ambient_runner.platform.prompts import _build_integrations_prompt + + return _build_integrations_prompt() + + # ------------------------------------------------------------------ + # No credentials — all integrations show setup instructions + # ------------------------------------------------------------------ + + def test_no_creds_shows_missing_prompts_for_all(self, monkeypatch): + self._clean_env(monkeypatch) + result = self._prompt() + assert "configure Jira credentials" in result + assert "mcp-atlassian" not in result + assert "Google Workspace is not connected" in result + assert "google-workspace" not in result + assert "GitHub is not connected" in result + assert "GitLab is not connected" in result + + # ------------------------------------------------------------------ + # GitHub + # ------------------------------------------------------------------ + + @pytest.mark.parametrize("use_sidecar", [True, False]) + def test_github_configured_tells_claude_to_use_mcp_or_token( + self, monkeypatch, use_sidecar + ): + self._clean_env(monkeypatch) + if use_sidecar: + monkeypatch.setenv( + "CREDENTIAL_MCP_URLS", '{"github": "http://sidecar:8080"}' + ) + else: + monkeypatch.setenv("GITHUB_TOKEN", "ghp_token123") + + result = self._prompt() + assert "GitHub is not connected" not in result + # Sidecar uses MCP tools; token uses git/gh CLI + if use_sidecar: + assert "mcp__github__" in result + else: + assert "GITHUB_TOKEN" in result + + def test_github_not_configured_shows_missing_prompt(self, monkeypatch): + self._clean_env(monkeypatch) + result = self._prompt() + assert "GitHub is not connected" in result + + # ------------------------------------------------------------------ + # GitLab + # ------------------------------------------------------------------ + + def test_gitlab_token_configured_tells_claude_to_use_token(self, monkeypatch): + self._clean_env(monkeypatch) + monkeypatch.setenv("GITLAB_TOKEN", "glpat_token123") + result = self._prompt() + assert "GITLAB_TOKEN" in result + assert "GitLab is not connected" not in result + + def test_gitlab_not_configured_shows_missing_prompt(self, monkeypatch): + self._clean_env(monkeypatch) + result = self._prompt() + assert "GitLab is not connected" in result + + # ------------------------------------------------------------------ + # Jira + # ------------------------------------------------------------------ + + def test_jira_creds_present_tells_claude_to_use_mcp(self, monkeypatch): + self._clean_env(monkeypatch) + monkeypatch.setenv("JIRA_URL", "https://jira.example.com") + monkeypatch.setenv("JIRA_API_TOKEN", "tok123") + result = self._prompt() + assert "mcp-atlassian" in result + assert "Do NOT tell the user to configure Jira" in result + assert "configure Jira credentials" not in result + + def test_jira_url_without_token_shows_missing_prompt(self, monkeypatch): + self._clean_env(monkeypatch) + monkeypatch.setenv("JIRA_URL", "https://jira.example.com") + result = self._prompt() + assert "configure Jira credentials" in result + + # ------------------------------------------------------------------ + # Google Workspace + # ------------------------------------------------------------------ + + def test_google_oauth_email_tells_claude_to_use_mcp(self, monkeypatch): + self._clean_env(monkeypatch) + monkeypatch.setenv("USER_GOOGLE_EMAIL", "alice@example.com") + result = self._prompt() + assert "google-workspace" in result + assert "Google Workspace is not connected" not in result + assert "Do NOT tell the user to set up Google" in result + + def test_google_placeholder_email_shows_missing_prompt(self, monkeypatch): + self._clean_env(monkeypatch) + monkeypatch.setenv("USER_GOOGLE_EMAIL", "user@example.com") + result = self._prompt() + assert "Google Workspace is not connected" in result + + def test_google_sidecar_via_credential_mcp_urls(self, monkeypatch): + self._clean_env(monkeypatch) + monkeypatch.setenv("CREDENTIAL_MCP_URLS", '{"google": "http://sidecar:8080"}') + result = self._prompt() + assert "google-workspace" in result + assert "Google Workspace is not connected" not in result + + # ------------------------------------------------------------------ + # All integrations configured + # ------------------------------------------------------------------ + + def test_all_configured_shows_available_prompts_for_all(self, monkeypatch): + self._clean_env(monkeypatch) + monkeypatch.setenv("GITHUB_TOKEN", "ghp_token123") + monkeypatch.setenv("GITLAB_TOKEN", "glpat_token123") + monkeypatch.setenv("JIRA_URL", "https://jira.example.com") + monkeypatch.setenv("JIRA_API_TOKEN", "tok123") + monkeypatch.setenv("USER_GOOGLE_EMAIL", "alice@example.com") + result = self._prompt() + # Each integration shows its available prompt + assert "GITHUB_TOKEN" in result # GitHub token mode + assert "GITLAB_TOKEN" in result # GitLab token mode + assert "mcp-atlassian" in result # Jira MCP + assert "google-workspace" in result # Google MCP + # No missing prompts + assert "GitHub is not connected" not in result + assert "GitLab is not connected" not in result + assert "configure Jira credentials" not in result + assert "Google Workspace is not connected" not in result + + @pytest.mark.asyncio class TestClaudeBridgeRunGuards: """Test run() and interrupt() guard conditions."""