From d0103947c317765d883a23eb69ac286c6db19fec Mon Sep 17 00:00:00 2001 From: fanziqing Date: Fri, 14 Aug 2026 16:53:50 +0800 Subject: [PATCH 01/17] feat: support custom Responses endpoints --- coworker/providers/openai_responses.py | 20 +++++++++----- tests/test_openai_responses.py | 37 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/coworker/providers/openai_responses.py b/coworker/providers/openai_responses.py index 566c620b..5b5ab317 100644 --- a/coworker/providers/openai_responses.py +++ b/coworker/providers/openai_responses.py @@ -1,4 +1,4 @@ -"""OpenAI Responses provider — native OpenAI models via `/v1/responses`. +"""OpenAI Responses provider — native and compatible models via `/responses`. Chat Completions rejects function tools combined with any `reasoning_effort` other than `none` on GPT-5.6+ ("use /v1/responses"), which had reasoning pinned OFF for native OpenAI @@ -8,9 +8,10 @@ reasoning + tools at real effort levels, streamed reasoning summaries (→ the s chain-of-thought continuity across tool round-trips via `store: false` + `include: ["reasoning.encrypted_content"]` — nothing retained server-side. -Routing: the `openai` provider entry with NO custom base_url builds this class; a custom -endpoint (Azure, vLLM, any OpenAI-compatible gateway) and every compat vendor keep the -Chat Completions `OpenAIProvider` (registry.py). +Routing: the `openai` provider entry with NO custom base_url builds this class. Most custom +endpoints (Azure, vLLM, and the existing compat vendors) keep the Chat Completions +`OpenAIProvider`; vendors that explicitly implement the Responses wire can opt into this +class with their own base URL (registry.py). Like the other native providers, this is mostly a pair of pure converters from the canonical OpenAI-chat-shaped history to Responses `input` items. What the converters @@ -289,14 +290,16 @@ class OpenAIResponsesProvider(ProviderClient): default_model: str = "gpt-5.6-sol", api_key: Optional[str] = None, secrets: Any = None, + base_url: Optional[str] = None, ): # Same deferred-client contract as OpenAIProvider: built lazily so an engine can be # assembled before any key exists; key resolves at call time (explicit → env → - # SecretStore). Tests inject a `client` directly. No base_url — a custom endpoint - # routes to the Chat Completions provider instead (registry.py). + # SecretStore). Tests inject a `client` directly. `base_url` is opt-in: stock OpenAI + # leaves it unset, while Responses-compatible vendors can supply their own endpoint. self._client = client self._api_key = api_key self._secrets = secrets + self._base_url = (base_url or "").strip().rstrip("/") or None self.default_model = default_model def _ensure_client(self) -> Any: @@ -310,7 +313,10 @@ class OpenAIResponsesProvider(ProviderClient): "No model API key configured. Set OPENAI_API_KEY in the environment, " "or add your key in Manage → Settings." ) - self._client = OpenAI(api_key=key) + kwargs = {"api_key": key} + if self._base_url: + kwargs["base_url"] = self._base_url + self._client = OpenAI(**kwargs) return self._client def _request_kwargs( diff --git a/tests/test_openai_responses.py b/tests/test_openai_responses.py index 9e871d57..1b375273 100644 --- a/tests/test_openai_responses.py +++ b/tests/test_openai_responses.py @@ -17,6 +17,43 @@ from coworker.providers.openai_responses import ( convert_tools, ) + +def test_responses_custom_base_url_reaches_sdk(monkeypatch): + captured: dict = {} + + def fake_openai(**kwargs): + captured.update(kwargs) + return SimpleNamespace() + + monkeypatch.setattr("openai.OpenAI", fake_openai) + provider = OpenAIResponsesProvider( + api_key="ark-key", + base_url="https://ark.example/api/v3/", + ) + + provider._ensure_client() + + assert captured == { + "api_key": "ark-key", + "base_url": "https://ark.example/api/v3", + } + + +def test_stock_openai_responses_path_unchanged(monkeypatch): + """Lockdown: stock OpenAI must not receive a vendor base URL.""" + captured: dict = {} + + def fake_openai(**kwargs): + captured.update(kwargs) + return SimpleNamespace() + + monkeypatch.setattr("openai.OpenAI", fake_openai) + provider = OpenAIResponsesProvider(api_key="openai-key") + + provider._ensure_client() + + assert captured == {"api_key": "openai-key"} + # -- fakes ------------------------------------------------------------------------ From 2cb6e36b24cccf652145d67f66ae23dc1fab314d Mon Sep 17 00:00:00 2001 From: fanziqing Date: Fri, 14 Aug 2026 16:54:48 +0800 Subject: [PATCH 02/17] feat: register BytePlus and Volcengine Ark providers --- coworker/providers/registry.py | 79 ++++++++++++++++++++++++++++++++++ tests/test_providers.py | 68 +++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/coworker/providers/registry.py b/coworker/providers/registry.py index 6c147705..01445564 100644 --- a/coworker/providers/registry.py +++ b/coworker/providers/registry.py @@ -210,6 +210,29 @@ def _openai_compat(vendor: str, default_base_url: str, env_key: Optional[str] = return build +def _openai_responses_compat( + vendor: str, default_base_url: str, env_key: Optional[str] = None +): + """Builder factory for vendors that explicitly implement the OpenAI Responses API. + + Credentials stay isolated to the vendor's own profile/environment variable, matching the + Chat Completions compat path above. In particular, an OpenAI key is never sent to Ark. + """ + + def build(profile: dict[str, Any], secrets: Any) -> ProviderClient: + base_url = ((profile or {}).get("base_url") or "").strip() or default_base_url + api_key = ((profile or {}).get("api_key") or "").strip() or ( + os.environ.get(env_key, "").strip() if env_key else "" + ) + if not api_key: + raise RuntimeError( + f"No {vendor} API key configured — add it in Settings ▸ Models." + ) + return OpenAIResponsesProvider(api_key=api_key, base_url=base_url) + + return build + + def _compat( name: str, title: str, @@ -248,6 +271,43 @@ def _compat( ) +def _responses_compat( + name: str, + title: str, + *, + base_url: str, + recommended_model: str, + env_key: str, + endpoint_help: str = "", +) -> ProviderDescriptor: + """Descriptor for a vendor exposing the OpenAI Responses API.""" + return ProviderDescriptor( + name=name, + title=title, + needs_key=True, + fields=[ + ProviderField( + "api_key", + f"{title} API key", + secret=True, + ), + ProviderField( + "base_url", + "Endpoint", + required=False, + default=base_url, + placeholder=base_url, + help=endpoint_help + or f"Prefilled with {title}'s official Responses endpoint.", + ), + ], + build=_openai_responses_compat(title, base_url, env_key), + recommended_model=recommended_model, + env_key=env_key, + blurb=f"Uses {title}'s OpenAI-compatible Responses API — the endpoint is prefilled, just add your key.", + ) + + DESCRIPTORS: list[ProviderDescriptor] = [ ProviderDescriptor( name="openai", @@ -462,6 +522,25 @@ DESCRIPTORS: list[ProviderDescriptor] = [ blurb="Runs models inside your own Google Cloud project. Gemini and Claude use " "their native APIs; open-weight models go through the Vertex MaaS endpoint.", ), + # Ark has two intentionally separate provider identities. BytePlus pay-as-you-go and + # Volcengine Agent Plan use different regions, endpoints, credentials, and model catalogs; + # combining them would let one provider profile route a model to the wrong service. + _responses_compat( + "ark", + "BytePlus Ark", + base_url="https://ark.ap-southeast.bytepluses.com/api/v3", + recommended_model="dola-seed-evolving-latest-version", + env_key="ARK_API_KEY", + endpoint_help="BytePlus Ark's Asia Pacific endpoint. This provider is separate from Volcengine Ark Agent Plan.", + ), + _responses_compat( + "ark-agent-plan-cn", + "Volcengine Ark Agent Plan", + base_url="https://ark.cn-beijing.volces.com/api/plan/v3", + recommended_model="doubao-seed-evolving", + env_key="ARK_AGENT_PLAN_CN_API_KEY", + endpoint_help="Volcengine Ark Agent Plan's China (Beijing) endpoint. It requires an Agent Plan API key.", + ), # OpenAI-compatible vendors, listed as first-class providers so users don't need to know the # "point the OpenAI slot at a different endpoint" trick (owner call, 2026-07-04). Each keeps # its own key profile; the endpoint is prefilled and editable (regional variants in `help`). diff --git a/tests/test_providers.py b/tests/test_providers.py index 943c6e4c..229d6dbf 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -338,6 +338,74 @@ def test_compat_builder_never_leaks_the_openai_key(monkeypatch): build_provider_client("kimi", {}, None) +ARK_RESPONSES_VENDORS = { + "ark": { + "base_url": "https://ark.ap-southeast.bytepluses.com/api/v3", + "env_key": "ARK_API_KEY", + "recommended_model": "dola-seed-evolving-latest-version", + }, + "ark-agent-plan-cn": { + "base_url": "https://ark.cn-beijing.volces.com/api/plan/v3", + "env_key": "ARK_AGENT_PLAN_CN_API_KEY", + "recommended_model": "doubao-seed-evolving", + }, +} + + +def test_ark_responses_descriptors_are_separate(): + from coworker.providers.registry import get_descriptor + + for name, expected in ARK_RESPONSES_VENDORS.items(): + d = get_descriptor(name) + assert d is not None and d.needs_key, name + assert d.env_key == expected["env_key"] + assert d.recommended_model == expected["recommended_model"] + assert "Responses API" in d.blurb + base = next(f for f in d.fields if f.key == "base_url") + assert base.default == expected["base_url"] + assert not base.required + + +def test_ark_responses_builders_use_their_own_keys_and_endpoints(monkeypatch): + from coworker.providers.openai_responses import OpenAIResponsesProvider + from coworker.providers.registry import build_provider_client + + monkeypatch.setenv("ARK_AGENT_PLAN_CN_API_KEY", "plan-key") + bp = build_provider_client("ark", {"api_key": "bp-key"}, None) + plan = build_provider_client("ark-agent-plan-cn", {}, None) + + assert isinstance(bp, OpenAIResponsesProvider) + assert (bp._api_key, bp._base_url) == ( + "bp-key", + ARK_RESPONSES_VENDORS["ark"]["base_url"], + ) + assert isinstance(plan, OpenAIResponsesProvider) + assert (plan._api_key, plan._base_url) == ( + "plan-key", + ARK_RESPONSES_VENDORS["ark-agent-plan-cn"]["base_url"], + ) + + +def test_ark_responses_never_leak_the_openai_key(monkeypatch): + import pytest + + from coworker.providers.registry import build_provider_client + + monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-real") + monkeypatch.delenv("ARK_API_KEY", raising=False) + with pytest.raises(RuntimeError, match="BytePlus Ark"): + build_provider_client("ark", {}, None) + + +def test_existing_chat_compat_paths_unchanged(): + """Lockdown: adding Responses vendors must not migrate existing compat providers.""" + from coworker.providers.registry import build_provider_client + + provider = build_provider_client("deepseek", {"api_key": "ds-key"}, None) + assert isinstance(provider, OpenAIProvider) + assert provider._base_url == COMPAT_VENDORS["deepseek"] + + def test_compat_models_route_and_get_tool_capabilities(): from coworker.providers.router import ProviderRouter From 3094d37627c2d4221a9c6b12227b3621dbf48382 Mon Sep 17 00:00:00 2001 From: fanziqing Date: Fri, 14 Aug 2026 16:55:29 +0800 Subject: [PATCH 03/17] feat: curate Ark model catalogs --- coworker/providers/matrix.py | 15 +++++++++++++ tests/test_providers.py | 41 ++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/coworker/providers/matrix.py b/coworker/providers/matrix.py index bf6a878c..8322f3b8 100644 --- a/coworker/providers/matrix.py +++ b/coworker/providers/matrix.py @@ -85,6 +85,21 @@ MATRIX: dict[str, ModelEntry] = { "gemini:gemini-2.5-flash": ModelEntry( "Gemini 2.5 Flash · Google", _AGENTIC_VISION, 1_048_576 ), + # Ark Responses API providers (verified 2026-08-14). BytePlus pay-as-you-go and + # Volcengine Agent Plan intentionally use separate provider prefixes because their + # endpoints, credentials, regions, and model catalogs are not interchangeable. + "ark:dola-seed-evolving-latest-version": ModelEntry( + "Dola Seed Evolving · BytePlus Ark" + ), + "ark:dola-seed-2-1-turbo-260628": ModelEntry( + "Dola Seed 2.1 Turbo · BytePlus Ark" + ), + "ark-agent-plan-cn:doubao-seed-evolving": ModelEntry( + "Doubao Seed Evolving · Volcengine Agent Plan" + ), + "ark-agent-plan-cn:doubao-seed-2.1-turbo": ModelEntry( + "Doubao Seed 2.1 Turbo · Volcengine Agent Plan" + ), # -- direct OpenAI-compatible vendors ---------------------------------------- # Muse Spark (Meta Model API, public preview 2026-07-09): multimodal + tools via # their OpenAI-compat surface. Vision yes; PDFs unverified over compat — falls diff --git a/tests/test_providers.py b/tests/test_providers.py index 229d6dbf..ad886429 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -406,6 +406,47 @@ def test_existing_chat_compat_paths_unchanged(): assert provider._base_url == COMPAT_VENDORS["deepseek"] +def test_ark_curated_models_are_strict_allowlists(): + from coworker.providers.matrix import models_for_provider + + assert models_for_provider("ark") == [ + "dola-seed-evolving-latest-version", + "dola-seed-2-1-turbo-260628", + ] + assert models_for_provider("ark-agent-plan-cn") == [ + "doubao-seed-evolving", + "doubao-seed-2.1-turbo", + ] + + +def test_ark_models_route_and_get_verified_agent_capabilities(): + from coworker.providers.router import ProviderRouter + + models = ( + "ark:dola-seed-evolving-latest-version", + "ark:dola-seed-2-1-turbo-260628", + "ark-agent-plan-cn:doubao-seed-evolving", + "ark-agent-plan-cn:doubao-seed-2.1-turbo", + ) + router = ProviderRouter.__new__(ProviderRouter) + for model in models: + prefix, bare = model.split(":", 1) + assert router._provider_name(model) == prefix + assert ProviderRouter._bare(model) == bare + caps = capabilities_for(model) + assert caps.tools and caps.parallel_tool_calls and caps.streaming + assert not caps.vision + + +def test_ark_recommended_models_are_curated(): + from coworker.providers.matrix import models_for_provider + from coworker.providers.registry import get_descriptor + + for name in ARK_RESPONSES_VENDORS: + d = get_descriptor(name) + assert d.recommended_model in models_for_provider(name) + + def test_compat_models_route_and_get_tool_capabilities(): from coworker.providers.router import ProviderRouter From e9c8aec43e6d970cbacb32160b3ea8a0171e187a Mon Sep 17 00:00:00 2001 From: fanziqing Date: Fri, 14 Aug 2026 16:56:16 +0800 Subject: [PATCH 04/17] feat: verify Ark credentials via Responses --- coworker/providers/registry.py | 24 ++++++++++++-- tests/test_provider_verify.py | 58 ++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/coworker/providers/registry.py b/coworker/providers/registry.py index 01445564..04ed994e 100644 --- a/coworker/providers/registry.py +++ b/coworker/providers/registry.py @@ -882,9 +882,11 @@ def verify_provider_key( fields: Optional[dict[str, Any]] = None, timeout: float = 10.0, ) -> dict[str, Any]: - """Validate a provider's credentials with one cheap, read-only call (list models) — the same - pattern connectors use to validate tokens. Transient: callers pass the key directly so a user - can Test before saving. Never raises; returns {ok, error?}. Multi-field cloud providers + """Validate a provider's credentials with one cheap call — usually list models. + + Ark's Responses-compatible data plane does not document a `/models` probe, so its Test button + sends a non-persisted one-token Responses request instead. Callers pass the key directly so a + user can Test before saving. Never raises; returns {ok, error?}. Multi-field cloud providers (Bedrock, Vertex) take their whole form via `fields`; everyone else uses api_key/base_url. """ import httpx @@ -911,6 +913,22 @@ def verify_provider_key( elif name == "ollama": base = _normalize_ollama_url(base_url) resp = httpx.get(base.rstrip("/") + "/models", timeout=timeout) + elif name in ("ark", "ark-agent-plan-cn"): + default_base = next( + (f.default for f in d.fields if f.key == "base_url" and f.default), "" + ) + base = (base_url or "").strip().rstrip("/") or default_base.rstrip("/") + resp = httpx.post( + base + "/responses", + headers={"Authorization": f"Bearer {key}"}, + json={ + "model": d.recommended_model, + "input": "Reply with OK.", + "max_output_tokens": 1, + "store": False, + }, + timeout=timeout, + ) else: # openai + any OpenAI-compatible endpoint (Azure, OpenRouter, vendors, vLLM…) default_base = next( (f.default for f in d.fields if f.key == "base_url" and f.default), "" diff --git a/tests/test_provider_verify.py b/tests/test_provider_verify.py index 8da7fe76..a6e553af 100644 --- a/tests/test_provider_verify.py +++ b/tests/test_provider_verify.py @@ -41,6 +41,18 @@ def _patch_get(monkeypatch, status=200, capture=None, raise_exc=None): monkeypatch.setattr("httpx.get", fake_get) +def _patch_post(monkeypatch, status=200, capture=None, raise_exc=None): + def fake_post(url, **kwargs): + if capture is not None: + capture["url"] = url + capture.update(kwargs) + if raise_exc is not None: + raise raise_exc + return SimpleNamespace(status_code=status) + + monkeypatch.setattr("httpx.post", fake_post) + + def test_verify_openai_ok(monkeypatch): cap: dict = {} _patch_get(monkeypatch, status=200, capture=cap) @@ -91,6 +103,52 @@ def test_verify_ollama_uses_v1_models_no_key(monkeypatch): assert "headers" not in cap # keyless +@pytest.mark.parametrize( + "name,base_url,model", + [ + ( + "ark", + "https://ark.ap-southeast.bytepluses.com/api/v3", + "dola-seed-evolving-latest-version", + ), + ( + "ark-agent-plan-cn", + "https://ark.cn-beijing.volces.com/api/plan/v3", + "doubao-seed-evolving", + ), + ], +) +def test_verify_ark_uses_non_persisted_responses_probe( + monkeypatch, name, base_url, model +): + """Reverse-verified probe: the captured fixture must be non-empty and provider-specific.""" + cap: dict = {} + _patch_post(monkeypatch, status=200, capture=cap) + + assert verify_provider_key(name, api_key="ark-key") == {"ok": True} + assert cap["url"] == base_url + "/responses" + assert cap["headers"]["Authorization"] == "Bearer ark-key" + assert cap["json"] == { + "model": model, + "input": "Reply with OK.", + "max_output_tokens": 1, + "store": False, + } + + +def test_verify_ark_profile_endpoint_override(monkeypatch): + cap: dict = {} + _patch_post(monkeypatch, status=200, capture=cap) + + verify_provider_key( + "ark", + api_key="ark-key", + base_url="https://gateway.example/ark/v3/", + ) + + assert cap["url"] == "https://gateway.example/ark/v3/responses" + + def test_verify_network_error_is_clean(monkeypatch): _patch_get(monkeypatch, raise_exc=ConnectionError("boom")) res = verify_provider_key("openai", api_key="sk-x") From e74bbf1b1ada75850831f0b5a43d810e8659e13c Mon Sep 17 00:00:00 2001 From: fanziqing Date: Fri, 14 Aug 2026 16:57:34 +0800 Subject: [PATCH 05/17] feat: add Ark provider branding --- surfaces/gui/src/providers/logos.ts | 6 ++++++ surfaces/gui/src/providers/logos/byteplus.svg | 1 + surfaces/gui/src/providers/logos/volcengine.svg | 1 + 3 files changed, 8 insertions(+) create mode 100644 surfaces/gui/src/providers/logos/byteplus.svg create mode 100644 surfaces/gui/src/providers/logos/volcengine.svg diff --git a/surfaces/gui/src/providers/logos.ts b/surfaces/gui/src/providers/logos.ts index 093c4fdd..028e0b3b 100644 --- a/surfaces/gui/src/providers/logos.ts +++ b/surfaces/gui/src/providers/logos.ts @@ -8,6 +8,8 @@ import anthropic from "./logos/anthropic.svg"; import openai from "./logos/openai.svg"; import gemini from "./logos/gemini.svg"; +import byteplus from "./logos/byteplus.svg"; +import volcengine from "./logos/volcengine.svg"; import ollama from "./logos/ollama.svg"; import bedrock from "./logos/bedrock.svg"; import vertex from "./logos/vertex.svg"; @@ -27,6 +29,8 @@ export const PROVIDER_LOGOS: Record = { anthropic, openai, gemini, + ark: byteplus, + "ark-agent-plan-cn": volcengine, meta, ollama, bedrock, @@ -47,6 +51,8 @@ export const PROVIDER_ORDER = [ "anthropic", "openai", "gemini", + "ark", + "ark-agent-plan-cn", "meta", "ollama", "bedrock", diff --git a/surfaces/gui/src/providers/logos/byteplus.svg b/surfaces/gui/src/providers/logos/byteplus.svg new file mode 100644 index 00000000..b2d64e33 --- /dev/null +++ b/surfaces/gui/src/providers/logos/byteplus.svg @@ -0,0 +1 @@ +BytePlus diff --git a/surfaces/gui/src/providers/logos/volcengine.svg b/surfaces/gui/src/providers/logos/volcengine.svg new file mode 100644 index 00000000..1c944722 --- /dev/null +++ b/surfaces/gui/src/providers/logos/volcengine.svg @@ -0,0 +1 @@ +Volcengine From 4bfd75a4df9a8a3b1adc3932867a034495e1791c Mon Sep 17 00:00:00 2001 From: fanziqing Date: Fri, 14 Aug 2026 16:58:53 +0800 Subject: [PATCH 06/17] feat: add Ark provider setup links --- .../gui/src/providers/ProviderSetup.test.tsx | 28 ++++++++++++++++++- surfaces/gui/src/providers/ProviderSetup.tsx | 2 ++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/surfaces/gui/src/providers/ProviderSetup.test.tsx b/surfaces/gui/src/providers/ProviderSetup.test.tsx index 870aa7a0..89d0e8d2 100644 --- a/surfaces/gui/src/providers/ProviderSetup.test.tsx +++ b/surfaces/gui/src/providers/ProviderSetup.test.tsx @@ -2,7 +2,7 @@ // only the selected method's fields render, and clicking a segment switches them. import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { ProviderForm, type ProviderSetupState } from "./ProviderSetup"; +import { KEY_HELP, ProviderForm, ProviderMark, type ProviderSetupState } from "./ProviderSetup"; import type { ProviderInfo } from "../api"; vi.mock("../tauri", () => ({ openExternal: vi.fn() })); @@ -94,3 +94,29 @@ describe("ProviderForm auth-method choice", () => { expect(screen.queryByTestId("t-field-bedrock_api_key")).toBeNull(); }); }); + +describe("Ark provider presentation", () => { + it("uses separate BytePlus and Volcengine brand marks", () => { + const { container, rerender } = render( + , + ); + expect(container.querySelector("img")).toBeTruthy(); + + rerender( + , + ); + expect(container.querySelector("img")).toBeTruthy(); + }); + + it("links each provider to its own API key console", () => { + expect(KEY_HELP.ark.url).toBe( + "https://console.byteplus.com/ark/region:ark+ap-southeast-1/apiKey", + ); + expect(KEY_HELP["ark-agent-plan-cn"].url).toContain( + "advancedActiveKey=agentPlan", + ); + }); +}); diff --git a/surfaces/gui/src/providers/ProviderSetup.tsx b/surfaces/gui/src/providers/ProviderSetup.tsx index 1d8dc881..99aeed53 100644 --- a/surfaces/gui/src/providers/ProviderSetup.tsx +++ b/surfaces/gui/src/providers/ProviderSetup.tsx @@ -21,6 +21,8 @@ export const KEY_HELP: Record = { anthropic: { url: "https://console.anthropic.com/settings/keys", label: "console.anthropic.com" }, openai: { url: "https://platform.openai.com/api-keys", label: "platform.openai.com" }, gemini: { url: "https://aistudio.google.com/apikey", label: "aistudio.google.com" }, + ark: { url: "https://console.byteplus.com/ark/region:ark+ap-southeast-1/apiKey", label: "console.byteplus.com" }, + "ark-agent-plan-cn": { url: "https://console.volcengine.com/ark/region:cn-beijing/openManagement?LLM=%7B%7D&advancedActiveKey=agentPlan", label: "console.volcengine.com" }, openrouter: { url: "https://openrouter.ai/keys", label: "openrouter.ai" }, bedrock: { url: "https://console.aws.amazon.com/bedrock/home#/api-keys", label: "the AWS Bedrock console" }, fireworks: { url: "https://fireworks.ai/account/api-keys", label: "fireworks.ai" }, From 68c7914b80eae0b25fb1aadcb24e276c8df744d2 Mon Sep 17 00:00:00 2001 From: fanziqing Date: Fri, 14 Aug 2026 17:01:25 +0800 Subject: [PATCH 07/17] test: cover Ark provider setup end to end --- surfaces/gui/e2e/fixtures.ts | 8 +++++++ surfaces/gui/e2e/settings.spec.ts | 38 +++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index e6dfa409..ec4deba7 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -45,6 +45,10 @@ const SETTINGS = { model_labels: { "anthropic:claude-opus-4-8": "Claude Opus 4.8 · Anthropic", "zai:glm-5.2": "GLM-5.2 · Z AI", + "ark:dola-seed-evolving-latest-version": "Dola Seed Evolving · BytePlus Ark", + "ark:dola-seed-2-1-turbo-260628": "Dola Seed 2.1 Turbo · BytePlus Ark", + "ark-agent-plan-cn:doubao-seed-evolving": "Doubao Seed Evolving · Volcengine Agent Plan", + "ark-agent-plan-cn:doubao-seed-2.1-turbo": "Doubao Seed 2.1 Turbo · Volcengine Agent Plan", }, // Context windows (subset — mirrors /v1/settings.model_context_windows); drives the // composer usage chip's context-fill meter. @@ -336,6 +340,10 @@ const PROVIDERS = [ { name: "anthropic", title: "Claude (Anthropic)", needs_key: true, fields: [{ key: "api_key", label: "API key", secret: true, required: true, help: "", placeholder: "sk-…" }], configured: true, values: {}, suggested_models: ["claude-opus-4-8"], key_set_at: null, last_used_at: null }, // zai: an OpenAI-compatible vendor — unconfigured, with a prefilled editable endpoint + blurb. { name: "zai", title: "Z AI (GLM)", needs_key: true, blurb: "Uses Z AI's OpenAI-compatible API — the endpoint is prefilled, just add your key.", fields: [{ key: "api_key", label: "Z AI API key", secret: true, required: true, help: "", placeholder: "" }, { key: "base_url", label: "Endpoint", secret: false, required: false, help: "Prefilled with Z AI's international endpoint.", placeholder: "https://api.z.ai/api/paas/v4", default: "https://api.z.ai/api/paas/v4" }], configured: false, values: {}, suggested_models: ["glm-5.2"], key_set_at: null, last_used_at: null }, + // Ark uses two provider identities: BytePlus pay-as-you-go and Volcengine Agent Plan CN + // have independent credentials, endpoints, and strict curated model lists. + { name: "ark", title: "BytePlus Ark", needs_key: true, blurb: "Uses BytePlus Ark's OpenAI-compatible Responses API — the endpoint is prefilled, just add your key.", fields: [{ key: "api_key", label: "BytePlus Ark API key", secret: true, required: true, help: "", placeholder: "" }, { key: "base_url", label: "Endpoint", secret: false, required: false, help: "BytePlus Ark's Asia Pacific endpoint.", placeholder: "https://ark.ap-southeast.bytepluses.com/api/v3", default: "https://ark.ap-southeast.bytepluses.com/api/v3" }], configured: false, values: {}, suggested_models: ["dola-seed-evolving-latest-version", "dola-seed-2-1-turbo-260628"], key_set_at: null, last_used_at: null }, + { name: "ark-agent-plan-cn", title: "Volcengine Ark Agent Plan", needs_key: true, blurb: "Uses Volcengine Ark Agent Plan's OpenAI-compatible Responses API — the endpoint is prefilled, just add your key.", fields: [{ key: "api_key", label: "Volcengine Ark Agent Plan API key", secret: true, required: true, help: "", placeholder: "" }, { key: "base_url", label: "Endpoint", secret: false, required: false, help: "Volcengine Ark Agent Plan's China (Beijing) endpoint.", placeholder: "https://ark.cn-beijing.volces.com/api/plan/v3", default: "https://ark.cn-beijing.volces.com/api/plan/v3" }], configured: false, values: {}, suggested_models: ["doubao-seed-evolving", "doubao-seed-2.1-turbo"], key_set_at: null, last_used_at: null }, // ollama: keyless local provider — "configured" without proving anything runs; the // onboarding gallery shows "No key needed" and its form is endpoint + Detect (§39). { name: "ollama", title: "Ollama (local models)", needs_key: false, fields: [{ key: "base_url", label: "Endpoint", secret: false, required: false, help: "", placeholder: "http://127.0.0.1:11434", default: "http://127.0.0.1:11434" }], configured: true, values: {}, suggested_models: ["qwen3-coder:30b"], key_set_at: null, last_used_at: null }, diff --git a/surfaces/gui/e2e/settings.spec.ts b/surfaces/gui/e2e/settings.spec.ts index 1bee81fa..1d1b6a5e 100644 --- a/surfaces/gui/e2e/settings.spec.ts +++ b/surfaces/gui/e2e/settings.spec.ts @@ -71,6 +71,44 @@ test("Models: provider gallery states; vendor form previews models", async ({ pa await expect(page.getByTestId("set-provider-openai")).toBeVisible(); }); +test("Models: BytePlus and Volcengine Ark stay visually and operationally separate", async ({ page }) => { + await page.goto("/"); + await page.getByTestId("account-row").click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByRole("button", { name: "Models", exact: true }).click(); + + const byteplusCard = page.getByTestId("set-provider-ark"); + const volcengineCard = page.getByTestId("set-provider-ark-agent-plan-cn"); + await expect(byteplusCard).toContainText("BytePlus Ark"); + await expect(volcengineCard).toContainText("Volcengine Ark Agent Plan"); + const byteplusLogo = await byteplusCard.locator("img").getAttribute("src"); + const volcengineLogo = await volcengineCard.locator("img").getAttribute("src"); + expect(byteplusLogo).toBeTruthy(); + expect(volcengineLogo).toBeTruthy(); + expect(byteplusLogo).not.toBe(volcengineLogo); + + await byteplusCard.click(); + await page.getByTestId("set-endpoint-link").click(); + await expect(page.getByTestId("set-field-base_url")).toHaveValue( + "https://ark.ap-southeast.bytepluses.com/api/v3", + ); + let preview = page.getByTestId("model-preview"); + await expect(preview).toContainText("Dola Seed Evolving · BytePlus Ark"); + await expect(preview).toContainText("Dola Seed 2.1 Turbo · BytePlus Ark"); + await expect(preview).not.toContainText("Doubao Seed"); + + await page.getByTestId("set-back").click(); + await volcengineCard.click(); + await page.getByTestId("set-endpoint-link").click(); + await expect(page.getByTestId("set-field-base_url")).toHaveValue( + "https://ark.cn-beijing.volces.com/api/plan/v3", + ); + preview = page.getByTestId("model-preview"); + await expect(preview).toContainText("Doubao Seed Evolving · Volcengine Agent Plan"); + await expect(preview).toContainText("Doubao Seed 2.1 Turbo · Volcengine Agent Plan"); + await expect(preview).not.toContainText("Dola Seed"); +}); + // UX-021: a configured provider's form shows the in-field saved state and the Remove key… // affordance; removing reverts the card to "Not set up". test("Models: Remove key reverts a configured provider", async ({ page }) => { From 158b45ee50db6cf9a8625d414fd5f073827cce6d Mon Sep 17 00:00:00 2001 From: fanziqing Date: Fri, 14 Aug 2026 17:01:50 +0800 Subject: [PATCH 08/17] docs: document Ark provider support --- README.md | 2 +- surfaces/gui/src/providers/logos.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b32a299d..72547b42 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Under the hood: Model access is yours: pick a provider, paste your key, switch anytime. Supported out of the box: -**OpenAI · Anthropic · Google Gemini · Inkling (Thinking Machines) · GLM (Z.ai) · DeepSeek · Kimi (Moonshot) · Qwen · MiniMax · Mistral · Grok (xAI)** - plus open-weight models via **Together** and **Fireworks**, and fully local models via **Ollama**. +**OpenAI · Anthropic · Google Gemini · BytePlus Ark · Volcengine Ark Agent Plan · Inkling (Thinking Machines) · GLM (Z.ai) · DeepSeek · Kimi (Moonshot) · Qwen · MiniMax · Mistral · Grok (xAI)** - plus open-weight models via **Together** and **Fireworks**, and fully local models via **Ollama**. A curated model list marks what we've verified for tool-calling work. Adding any model string works at your own risk. diff --git a/surfaces/gui/src/providers/logos.ts b/surfaces/gui/src/providers/logos.ts index 028e0b3b..f688315d 100644 --- a/surfaces/gui/src/providers/logos.ts +++ b/surfaces/gui/src/providers/logos.ts @@ -1,9 +1,9 @@ // Provider logo registry (UX-DECISIONS §39): official brand marks for the onboarding -// provider gallery, vendored from the MIT-licensed lobe-icons set (same -// bundled-asset posture as the connector registry — no CDN at runtime). Keys are -// /v1/providers names; unknown names get no mark (the gallery falls back to a -// neutral monogram). PROVIDER_ORDER is the gallery order — recognition first, -// long tail behind the scroll fold. +// provider gallery. Most are vendored from the MIT-licensed lobe-icons set; BytePlus is +// its official website mark, used with permission. All stay bundled like connector assets +// (no CDN at runtime). Keys are /v1/providers names; unknown names get no mark (the gallery +// falls back to a neutral monogram). PROVIDER_ORDER is the gallery order — recognition +// first, long tail behind the scroll fold. import anthropic from "./logos/anthropic.svg"; import openai from "./logos/openai.svg"; From 557723bf64eccf019027bccbe61f07a31dde8138 Mon Sep 17 00:00:00 2001 From: fanziqing Date: Fri, 14 Aug 2026 17:56:51 +0800 Subject: [PATCH 09/17] fix: make Responses reasoning summaries configurable --- coworker/providers/openai_responses.py | 7 ++++++- tests/test_openai_responses.py | 18 +++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/coworker/providers/openai_responses.py b/coworker/providers/openai_responses.py index 5b5ab317..9d34f84a 100644 --- a/coworker/providers/openai_responses.py +++ b/coworker/providers/openai_responses.py @@ -291,6 +291,7 @@ class OpenAIResponsesProvider(ProviderClient): api_key: Optional[str] = None, secrets: Any = None, base_url: Optional[str] = None, + reasoning_summary: bool = True, ): # Same deferred-client contract as OpenAIProvider: built lazily so an engine can be # assembled before any key exists; key resolves at call time (explicit → env → @@ -300,6 +301,9 @@ class OpenAIResponsesProvider(ProviderClient): self._api_key = api_key self._secrets = secrets self._base_url = (base_url or "").strip().rstrip("/") or None + if not isinstance(reasoning_summary, bool): + raise TypeError("reasoning_summary must be a bool") + self._reasoning_summary = reasoning_summary self.default_model = default_model def _ensure_client(self) -> Any: @@ -337,9 +341,10 @@ class OpenAIResponsesProvider(ProviderClient): # `_openai` sidecar instead, and summaries feed the GUI's thinking display. "store": False, "include": ["reasoning.encrypted_content"], - "reasoning": {"summary": "auto"}, **{k: v for k, v in settings.items() if k in _SETTINGS_WHITELIST}, } + if self._reasoning_summary: + kwargs["reasoning"] = {"summary": "auto"} if instructions: kwargs["instructions"] = instructions if tools: diff --git a/tests/test_openai_responses.py b/tests/test_openai_responses.py index 1b375273..64edfc90 100644 --- a/tests/test_openai_responses.py +++ b/tests/test_openai_responses.py @@ -291,7 +291,7 @@ def test_convert_tools_flattens_function_schemas(): # -- complete() ---------------------------------------------------------------------- -def test_complete_text_turn_and_request_shape(): +def test_complete_default_request_shape_PathsUnchanged(): fake = _FakeClient(response=_response([_message_item("hello")])) provider = OpenAIResponsesProvider(client=fake) turn = provider.complete( @@ -310,6 +310,22 @@ def test_complete_text_turn_and_request_shape(): assert fake.kwargs["reasoning"] == {"summary": "auto"} +def test_complete_can_omit_reasoning_summary_but_keep_encrypted_content(): + """BytePlus accepts encrypted reasoning output but rejects reasoning.summary.""" + fake = _FakeClient(response=_response([_message_item("hello")])) + provider = OpenAIResponsesProvider(client=fake, reasoning_summary=False) + + provider.complete(model="m", messages=[{"role": "user", "content": "hi"}]) + + assert "reasoning" not in fake.kwargs + assert fake.kwargs["include"] == ["reasoning.encrypted_content"] + + +def test_reasoning_summary_capability_rejects_unknown_mode(): + with pytest.raises(TypeError, match="reasoning_summary must be a bool"): + OpenAIResponsesProvider(client=SimpleNamespace(), reasoning_summary="auto") + + def test_complete_parses_function_calls_with_call_ids(): fake = _FakeClient( response=_response( From 19bbbbd7ad3ef06825a0ea74206e48bc7aaffa1c Mon Sep 17 00:00:00 2001 From: fanziqing Date: Fri, 14 Aug 2026 17:58:38 +0800 Subject: [PATCH 10/17] fix: omit reasoning summaries for BytePlus Ark --- coworker/providers/registry.py | 21 ++++++++++++++++++--- tests/test_providers.py | 8 +++++++- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/coworker/providers/registry.py b/coworker/providers/registry.py index 04ed994e..1f7aea8e 100644 --- a/coworker/providers/registry.py +++ b/coworker/providers/registry.py @@ -211,7 +211,11 @@ def _openai_compat(vendor: str, default_base_url: str, env_key: Optional[str] = def _openai_responses_compat( - vendor: str, default_base_url: str, env_key: Optional[str] = None + vendor: str, + default_base_url: str, + env_key: Optional[str] = None, + *, + reasoning_summary: bool = True, ): """Builder factory for vendors that explicitly implement the OpenAI Responses API. @@ -228,7 +232,11 @@ def _openai_responses_compat( raise RuntimeError( f"No {vendor} API key configured — add it in Settings ▸ Models." ) - return OpenAIResponsesProvider(api_key=api_key, base_url=base_url) + return OpenAIResponsesProvider( + api_key=api_key, + base_url=base_url, + reasoning_summary=reasoning_summary, + ) return build @@ -279,6 +287,7 @@ def _responses_compat( recommended_model: str, env_key: str, endpoint_help: str = "", + reasoning_summary: bool = True, ) -> ProviderDescriptor: """Descriptor for a vendor exposing the OpenAI Responses API.""" return ProviderDescriptor( @@ -301,7 +310,12 @@ def _responses_compat( or f"Prefilled with {title}'s official Responses endpoint.", ), ], - build=_openai_responses_compat(title, base_url, env_key), + build=_openai_responses_compat( + title, + base_url, + env_key, + reasoning_summary=reasoning_summary, + ), recommended_model=recommended_model, env_key=env_key, blurb=f"Uses {title}'s OpenAI-compatible Responses API — the endpoint is prefilled, just add your key.", @@ -532,6 +546,7 @@ DESCRIPTORS: list[ProviderDescriptor] = [ recommended_model="dola-seed-evolving-latest-version", env_key="ARK_API_KEY", endpoint_help="BytePlus Ark's Asia Pacific endpoint. This provider is separate from Volcengine Ark Agent Plan.", + reasoning_summary=False, ), _responses_compat( "ark-agent-plan-cn", diff --git a/tests/test_providers.py b/tests/test_providers.py index ad886429..064926db 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -343,11 +343,13 @@ ARK_RESPONSES_VENDORS = { "base_url": "https://ark.ap-southeast.bytepluses.com/api/v3", "env_key": "ARK_API_KEY", "recommended_model": "dola-seed-evolving-latest-version", + "reasoning_summary": False, }, "ark-agent-plan-cn": { "base_url": "https://ark.cn-beijing.volces.com/api/plan/v3", "env_key": "ARK_AGENT_PLAN_CN_API_KEY", "recommended_model": "doubao-seed-evolving", + "reasoning_summary": True, }, } @@ -366,7 +368,7 @@ def test_ark_responses_descriptors_are_separate(): assert not base.required -def test_ark_responses_builders_use_their_own_keys_and_endpoints(monkeypatch): +def test_ark_responses_builder_capabilities_PathsUnchanged(monkeypatch): from coworker.providers.openai_responses import OpenAIResponsesProvider from coworker.providers.registry import build_provider_client @@ -384,6 +386,10 @@ def test_ark_responses_builders_use_their_own_keys_and_endpoints(monkeypatch): "plan-key", ARK_RESPONSES_VENDORS["ark-agent-plan-cn"]["base_url"], ) + assert bp._reasoning_summary is ARK_RESPONSES_VENDORS["ark"]["reasoning_summary"] + assert plan._reasoning_summary is ARK_RESPONSES_VENDORS["ark-agent-plan-cn"][ + "reasoning_summary" + ] def test_ark_responses_never_leak_the_openai_key(monkeypatch): From fc3aa28d9f205c9928ff8d9ecd9638d86fda59be Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Tue, 18 Aug 2026 20:49:09 -0700 Subject: [PATCH 11/17] matrix: context windows for the Ark Seed models 256K per Volcengine's published specs; drives the context-fill meter. --- coworker/providers/matrix.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/coworker/providers/matrix.py b/coworker/providers/matrix.py index 8322f3b8..f48b6944 100644 --- a/coworker/providers/matrix.py +++ b/coworker/providers/matrix.py @@ -89,16 +89,16 @@ MATRIX: dict[str, ModelEntry] = { # Volcengine Agent Plan intentionally use separate provider prefixes because their # endpoints, credentials, regions, and model catalogs are not interchangeable. "ark:dola-seed-evolving-latest-version": ModelEntry( - "Dola Seed Evolving · BytePlus Ark" + "Dola Seed Evolving · BytePlus Ark", context_window=256_000 ), "ark:dola-seed-2-1-turbo-260628": ModelEntry( - "Dola Seed 2.1 Turbo · BytePlus Ark" + "Dola Seed 2.1 Turbo · BytePlus Ark", context_window=256_000 ), "ark-agent-plan-cn:doubao-seed-evolving": ModelEntry( - "Doubao Seed Evolving · Volcengine Agent Plan" + "Doubao Seed Evolving · Volcengine Agent Plan", context_window=256_000 ), "ark-agent-plan-cn:doubao-seed-2.1-turbo": ModelEntry( - "Doubao Seed 2.1 Turbo · Volcengine Agent Plan" + "Doubao Seed 2.1 Turbo · Volcengine Agent Plan", context_window=256_000 ), # -- direct OpenAI-compatible vendors ---------------------------------------- # Muse Spark (Meta Model API, public preview 2026-07-09): multimodal + tools via From b0d2bbd18314105934a479415303aabdda9c8ec3 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Thu, 20 Aug 2026 12:52:31 -0700 Subject: [PATCH 12/17] Surface MCP server startup failures Capture stdio stderr tail; record generic connect errors for /v1/mcp (status=error); persistent session notice via mcp_error; tests for client, manager, and GUI mapping. --- coworker/mcp/client.py | 46 ++++++++++++++++- coworker/server/app.py | 10 ++++ coworker/server/manager.py | 33 +++++++++++- surfaces/gui/node_modules | 1 + surfaces/gui/src/itemsFromMessages.test.ts | 14 ++++++ surfaces/gui/src/itemsFromMessages.ts | 6 ++- tests/test_mcp.py | 58 ++++++++++++++++++++++ 7 files changed, 164 insertions(+), 4 deletions(-) create mode 120000 surfaces/gui/node_modules diff --git a/coworker/mcp/client.py b/coworker/mcp/client.py index 04f8fcc0..ad343e36 100644 --- a/coworker/mcp/client.py +++ b/coworker/mcp/client.py @@ -13,8 +13,9 @@ Tool execution from the (sync) ToolRegistry bridges back here via from __future__ import annotations import asyncio +import tempfile from contextlib import AsyncExitStack -from typing import Any, Optional +from typing import Any, IO, Optional from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client @@ -23,6 +24,25 @@ from mcp.client.streamable_http import streamablehttp_client from .config import MCPServerDef +_STDERR_TAIL_LINES = 20 +_STDERR_TAIL_CHARS = 1500 + + +def _read_tail(errfile: Optional[IO[str]]) -> Optional[str]: + """Last few lines of a captured stderr file — the crash evidence, not the log.""" + if errfile is None: + return None + try: + errfile.seek(0) + text = errfile.read() + except (OSError, ValueError): + return None + lines = [ln for ln in text.strip().splitlines() if ln.strip()] + if not lines: + return None + return "\n".join(lines[-_STDERR_TAIL_LINES:])[-_STDERR_TAIL_CHARS:] + + class _Conn: def __init__(self, session: ClientSession, tools: list[Any]) -> None: self.session = session @@ -36,6 +56,7 @@ class MCPManager: def __init__(self, secrets: Any = None) -> None: self._conns: dict[str, _Conn] = {} self._tasks: dict[str, asyncio.Task] = {} + self._stderr_tails: dict[str, str] = {} self._lock = asyncio.Lock() # SecretStore for OAuth servers' token persistence (mcp/oauth.py); lazy default # so library/CLI construction without secrets keeps working. @@ -64,6 +85,10 @@ class MCPManager: async def tools(self, server: MCPServerDef) -> list[Any]: return (await self.ensure(server)).tools + def last_stderr(self, name: str) -> Optional[str]: + """Stderr tail from the most recent failed startup of `name`, if any.""" + return self._stderr_tails.get(name) + async def call( self, name: str, tool: str, arguments: Optional[dict[str, Any]] ) -> Any: @@ -88,6 +113,7 @@ class MCPManager: async def _serve( self, server: MCPServerDef, ready: asyncio.Future, *, interactive: bool = False ) -> None: + errfile = None try: async with AsyncExitStack() as stack: if server.transport == "http": @@ -124,18 +150,34 @@ class MCPManager: env=server.env or None, cwd=server.cwd, ) - read, write = await stack.enter_async_context(stdio_client(params)) + # Capture the child's stderr so a startup crash leaves evidence + # the UI can show (the SDK needs a real file descriptor here). + errfile = tempfile.TemporaryFile( + mode="w+", encoding="utf-8", errors="replace" + ) + read, write = await stack.enter_async_context( + stdio_client(params, errlog=errfile) + ) session = await stack.enter_async_context(ClientSession(read, write)) await session.initialize() listed = await session.list_tools() conn = _Conn(session, list(listed.tools)) + self._stderr_tails.pop(server.name, None) if not ready.done(): ready.set_result(conn) await conn.shutdown.wait() except Exception as exc: # connection / init failure + tail = _read_tail(errfile) + if tail: + self._stderr_tails[server.name] = tail if not ready.done(): ready.set_exception(exc) finally: + if errfile is not None: + try: + errfile.close() + except OSError: + pass self._conns.pop(server.name, None) self._tasks.pop(server.name, None) diff --git a/coworker/server/app.py b/coworker/server/app.py index e8b97638..2e8340c2 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -1784,6 +1784,16 @@ def create_app(manager: SessionManager) -> FastAPI: ) await ws.close() return + # MCP servers that failed to start while preparing this session's tools: + # leave a quiet, persistent notice instead of the session silently lacking + # them (drill 2026-08-20: three silent startup failures in a row). + for name, err in manager.pop_mcp_failures(session_id): + detail = f": {err}" if err else "" + engine._append_notice( + "mcp_error", + f"MCP server “{name}” failed to start{detail}"[:300] + + " — see Settings ▸ MCP", + ) # Auto-compaction failure prompt (OPE-27): only an ATTENDED session may be asked # Retry/Trim — unattended runs auto-trim (the policy in engine._compact_now). engine.is_attended = lambda: _visibility() == VIS_INLINE diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 66a51ecc..9e4be4c8 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -166,6 +166,9 @@ class SessionManager: # feeds list_mcp's status so the GUI can show "authorizing…" and failures. self._mcp_authorizing: set[str] = set() self._mcp_errors: dict[str, str] = {} + # Servers that failed to connect while preparing a session's tools — + # drained once by the WS handler to append a transcript notice. + self._mcp_session_failures: dict[str, list[str]] = {} self.gateway: Optional[Gateway] = None self._data_base = base # Desktop/UI prefs (default model, onboarding state) — not secrets; a plain JSON file. @@ -957,6 +960,7 @@ class SessionManager: ] try: conn = await self.mcp.ensure(server) + self._mcp_errors.pop(server.name, None) except Exception as exc: if mcp_oauth.is_auth_required(exc): # Stored tokens no longer refresh (vendor rotated/expired @@ -969,7 +973,22 @@ class SessionManager: logger.info( "mcp %s needs re-auth; skipped for this session", server.name ) - # else: bad command / unreachable url — skip, don't break the session + else: + # Bad command / crashed child / unreachable url — the session + # still runs without the tools, but the failure must not be + # silent (three-for-three silent failures in the 2026-08-20 + # drill): record it for the MCP page and the session notice. + msg = str(exc) or exc.__class__.__name__ + tail = self.mcp.last_stderr(server.name) + if tail: + msg = f"{msg} — {tail}" + self._mcp_errors[server.name] = msg[:500] + logger.warning( + "mcp %s failed to connect: %s", server.name, msg[:500] + ) + self._mcp_session_failures.setdefault(session_id, []).append( + server.name + ) continue callables = build_callables( server, @@ -988,6 +1007,12 @@ class SessionManager: out.extend(callables) return out + def pop_mcp_failures(self, session_id: str) -> list[tuple[str, Optional[str]]]: + """Drain (name, error) for servers that failed while preparing this session's + tools — consumed once by the WS handler to append a transcript notice.""" + names = self._mcp_session_failures.pop(session_id, []) + return [(n, self._mcp_errors.get(n)) for n in names] + def list_mcp(self) -> list[dict[str, Any]]: """Servers from the global config + connection status (does not connect).""" from ..mcp import oauth as mcp_oauth @@ -1011,6 +1036,12 @@ class SessionManager: status = "authorizing" elif is_oauth and not mcp_oauth.has_tokens(name, self.secrets): status = "needs_auth" + elif name in self._mcp_errors and not is_oauth: + # Startup/connection failure (stdio crash, unreachable url) — the + # drill class. OAuth servers keep their softer statuses: acquiring + # tokens supersedes a stale sign-in error (the GUI still prints + # last_error under the row either way). + status = "error" else: status = "configured" out.append( diff --git a/surfaces/gui/node_modules b/surfaces/gui/node_modules new file mode 120000 index 00000000..542a3b2b --- /dev/null +++ b/surfaces/gui/node_modules @@ -0,0 +1 @@ +/Users/rohit/fleet/ro4d/openworker/surfaces/gui/node_modules \ No newline at end of file diff --git a/surfaces/gui/src/itemsFromMessages.test.ts b/surfaces/gui/src/itemsFromMessages.test.ts index b87b8717..412f2de7 100644 --- a/surfaces/gui/src/itemsFromMessages.test.ts +++ b/surfaces/gui/src/itemsFromMessages.test.ts @@ -108,3 +108,17 @@ describe("itemsFromMessages reasoning", () => { expect(items[2]).toEqual({ kind: "assistant", text: "", reasoning: "stopped mid-thought" }); }); }); + +describe("itemsFromMessages mcp failure", () => { + it("replays the persisted mcp_error marker as a warn notice WITHOUT retry", () => { + const items = itemsFromMessages([ + { role: "user", content: "hi" }, + { role: "notice", kind: "mcp_error", text: "MCP server “sales-db” failed to start — see Settings ▸ MCP" }, + ] as any); + expect(items[1]).toEqual({ + kind: "notice", + tone: "warn", + text: "MCP server “sales-db” failed to start — see Settings ▸ MCP", + }); + }); +}); diff --git a/surfaces/gui/src/itemsFromMessages.ts b/surfaces/gui/src/itemsFromMessages.ts index 54cc3364..d78e7ace 100644 --- a/surfaces/gui/src/itemsFromMessages.ts +++ b/surfaces/gui/src/itemsFromMessages.ts @@ -78,7 +78,11 @@ export function itemsFromMessages(messages: ConversationMessage[]): Item[] { : m.kind === "compacted" ? // The subtle "compacted here" divider (OPE-27) — the transcript itself is intact. { kind: "notice", tone: "info", text: m.text || "Context compacted" } - : { kind: "notice", tone: "warn", text: "Error: " + (m.text || "unknown"), retriable: true }, + : m.kind === "mcp_error" + ? // A configured MCP server failed to start for this session — informational, + // NOT retriable (retry re-runs the model turn, which can't fix a dead server). + { kind: "notice", tone: "warn", text: m.text || "An MCP server failed to start" } + : { kind: "notice", tone: "warn", text: "Error: " + (m.text || "unknown"), retriable: true }, ); } // system messages are omitted; tool-result messages are folded into the tool row above diff --git a/tests/test_mcp.py b/tests/test_mcp.py index dba6d68b..a198cf73 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -254,3 +254,61 @@ def test_rest_crud(tmp_path, monkeypatch): assert client.delete("/v1/mcp/fs").json()["ok"] is True assert client.get("/v1/mcp").json()["servers"] == [] assert client.delete("/v1/mcp/fs").json()["ok"] is False + + +# -- failure surfacing (drill 2026-08-20: silent startup crashes) ---------------- + + +@pytest.mark.asyncio +async def test_stdio_startup_crash_captures_stderr_tail(tmp_path, monkeypatch): + """A stdio server that dies before initialize leaves its stderr tail behind.""" + from coworker.mcp.client import MCPManager + + mgr = MCPManager() + server = MCPServerDef( + name="doomed", + transport="stdio", + command="/bin/sh", + args=["-c", "echo 'usage: doomed --flag' >&2; exit 7"], + ) + with pytest.raises(Exception): + await mgr.ensure(server) + tail = mgr.last_stderr("doomed") + assert tail is not None and "usage: doomed --flag" in tail + + +@pytest.mark.asyncio +async def test_prepare_records_failure_status_and_session_notice( + tmp_path, monkeypatch +): + """A crashing global server surfaces: last_error + status=error + one-shot + session failure drain — instead of the pre-drill silent skip.""" + monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) + _write_json( + tmp_path / "state" / "mcp.json", + { + "mcpServers": { + "sales-db": { + "command": "/bin/sh", + "args": ["-c", "echo 'boom: bad args' >&2; exit 2"], + "enabled": True, + } + } + }, + ) + manager = SessionManager(data_dir=tmp_path / "data") + + tools = await manager.prepare_mcp_tools("s1", workspace=str(tmp_path / "wsp")) + assert tools == [] + + err = manager._mcp_errors.get("sales-db") + assert err and "boom: bad args" in err + + listed = {s["name"]: s for s in manager.list_mcp()} + assert listed["sales-db"]["status"] == "error" + assert "boom: bad args" in (listed["sales-db"]["last_error"] or "") + + drained = manager.pop_mcp_failures("s1") + assert [n for n, _ in drained] == ["sales-db"] + assert "boom: bad args" in (drained[0][1] or "") + assert manager.pop_mcp_failures("s1") == [] # one-shot From ead7d9e2a6ffcf175ef2ddba1309e3fc1eb1b414 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Thu, 20 Aug 2026 12:53:00 -0700 Subject: [PATCH 13/17] Drop accidental node_modules symlink from branch --- surfaces/gui/node_modules | 1 - 1 file changed, 1 deletion(-) delete mode 120000 surfaces/gui/node_modules diff --git a/surfaces/gui/node_modules b/surfaces/gui/node_modules deleted file mode 120000 index 542a3b2b..00000000 --- a/surfaces/gui/node_modules +++ /dev/null @@ -1 +0,0 @@ -/Users/rohit/fleet/ro4d/openworker/surfaces/gui/node_modules \ No newline at end of file From 5ebaa376d75d7bfb6d91ecf854b2b1ad8fb3fc95 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Thu, 20 Aug 2026 13:50:13 -0700 Subject: [PATCH 14/17] Add MCP server flow: Remote URL + JSON tabs, Test connection Explicit connect reports stderr tails; a 401 on an anonymous http probe becomes needs-sign-in with a one-click OAuth switch. Test button probes any enabled server row without opening a session. --- coworker/mcp/oauth.py | 15 ++ coworker/server/manager.py | 27 +++- .../results.json | 1 + surfaces/gui/e2e/fixtures.ts | 43 +++++- surfaces/gui/e2e/mcp-add-test.spec.ts | 71 +++++++++ surfaces/gui/src/api.ts | 2 + surfaces/gui/src/components/ManageTabs.tsx | 137 ++++++++++++++++-- tests/test_mcp.py | 82 +++++++++++ 8 files changed, 362 insertions(+), 16 deletions(-) create mode 100644 node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json create mode 100644 surfaces/gui/e2e/mcp-add-test.spec.ts diff --git a/coworker/mcp/oauth.py b/coworker/mcp/oauth.py index c6043e69..d6bcf2af 100644 --- a/coworker/mcp/oauth.py +++ b/coworker/mcp/oauth.py @@ -113,6 +113,21 @@ def is_auth_required(exc: BaseException) -> bool: return is_auth_required(cause) if cause is not None else False +def is_http_auth_error(exc: BaseException) -> bool: + """True if an HTTP 401/403 is anywhere in the exception tree — an anonymous + connect hit a server that wants credentials, so the fix is sign-in (switch + the entry to `auth: oauth`), not a different config. Same tree walk as + is_auth_required: the transport's task groups wrap and chain freely.""" + status = getattr(getattr(exc, "response", None), "status_code", None) + if status in (401, 403): + return True + for sub in getattr(exc, "exceptions", None) or []: # ExceptionGroup + if is_http_auth_error(sub): + return True + cause = exc.__cause__ or exc.__context__ + return is_http_auth_error(cause) if cause is not None else False + + # -- single-slot interactive flow ------------------------------------------------ _pending: Optional[asyncio.Future] = None # The last authorize URL we sent the user to — surfaced over REST so the GUI can offer diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 9e4be4c8..492dc833 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -166,6 +166,9 @@ class SessionManager: # feeds list_mcp's status so the GUI can show "authorizing…" and failures. self._mcp_authorizing: set[str] = set() self._mcp_errors: dict[str, str] = {} + # http servers whose anonymous connect came back 401/403 — the failure is + # "needs sign-in", so the GUI offers the OAuth switch instead of a raw error. + self._mcp_auth_hints: set[str] = set() # Servers that failed to connect while preparing a session's tools — # drained once by the WS handler to append a transcript notice. self._mcp_session_failures: dict[str, list[str]] = {} @@ -1060,6 +1063,7 @@ class SessionManager: "requires_approval": bool(raw.get("requires_approval", True)), "auth": "oauth" if is_oauth else None, "status": status, + "auth_hint": name in self._mcp_auth_hints, "last_error": self._mcp_errors.get(name), "tool_count": ( len(self.mcp._conns[name].tools) if connected else None @@ -1073,6 +1077,8 @@ class SessionManager: """Connect one server NOW — for OAuth servers this may open the browser and wait for the loopback callback, so callers run it as a background task and watch list_mcp for the status flip.""" + from ..mcp import oauth as mcp_oauth + for server in load_mcp_servers( self.default_workspace, secrets=self.secrets, @@ -1082,12 +1088,27 @@ class SessionManager: continue self._mcp_authorizing.add(name) self._mcp_errors.pop(name, None) + self._mcp_auth_hints.discard(name) try: # The ONE place a browser sign-in may start: an explicit connect. conn = await self.mcp.ensure(server, interactive=True) return {"ok": True, "tools": len(conn.tools)} except Exception as exc: - self._mcp_errors[name] = str(exc) or exc.__class__.__name__ + if ( + server.transport == "http" + and server.auth != "oauth" + and mcp_oauth.is_http_auth_error(exc) + ): + # Anonymous probe of a guarded server (the add-by-URL flow): + # the answer is sign-in, not a raw 401 dump. + self._mcp_auth_hints.add(name) + msg = "authentication required — sign in to connect" + else: + msg = str(exc) or exc.__class__.__name__ + tail = self.mcp.last_stderr(name) + if tail: + msg = f"{msg} — {tail}" + self._mcp_errors[name] = msg[:500] return {"ok": False, "error": self._mcp_errors[name]} finally: self._mcp_authorizing.discard(name) @@ -1151,6 +1172,10 @@ class SessionManager: def delete_mcp(self, name: str) -> dict[str, Any]: ok = delete_global_server(name) + if ok: + # A later re-add under the same name starts clean, not pre-failed. + self._mcp_errors.pop(name, None) + self._mcp_auth_hints.discard(name) return {"ok": ok, "name": name} async def mcp_tools(self, name: str) -> dict[str, Any]: diff --git a/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json new file mode 100644 index 00000000..713bd5c7 --- /dev/null +++ b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json @@ -0,0 +1 @@ +{"version":"4.1.11","results":[[":surfaces/gui/e2e/ask-upgrades.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/onboarding.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/src/components/Transcript.test.tsx",{"duration":8.069375000000036,"failed":true}],[":surfaces/gui/src/components/Sidebar.test.tsx",{"duration":6.320917000000009,"failed":true}],[":surfaces/gui/src/components/SkillsTab.test.tsx",{"duration":7.332417000000021,"failed":true}],[":surfaces/gui/src/components/Composer.skills.test.tsx",{"duration":8.12216699999999,"failed":true}],[":surfaces/gui/src/components/ApprovalCard.test.tsx",{"duration":8.156458000000043,"failed":true}],[":surfaces/gui/e2e/automations-quickstart.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/src/components/PersonaView.test.tsx",{"duration":4.019416999999976,"failed":true}],[":surfaces/gui/e2e/access-section.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/settings.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/github-page.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/gallery.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/unattended.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/slack-workspaces.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/sidebar-sessions.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/sources-channels.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/src/itemsFromMessages.test.ts",{"duration":3.396917000000002,"failed":false}],[":surfaces/gui/e2e/usage-chip.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/composer.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/src/providers/ProviderSetup.test.tsx",{"duration":4.667082999999991,"failed":true}],[":surfaces/gui/e2e/standing-approvals.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/chat.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/hubspot-page.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/persona-surfacing.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/session-shell.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/src/components/Composer.voice.test.tsx",{"duration":5.643791999999991,"failed":true}],[":surfaces/gui/e2e/cloud.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/session-intro.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/inbox.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/gmail-page.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/slack-directory.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/provider-keys.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/approval-card.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e-live/persona-install.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/src/components/ConnectorMessageCard.test.tsx",{"duration":5.102540999999974,"failed":true}],[":surfaces/gui/src/components/UpdateBanner.test.tsx",{"duration":12.045458999999994,"failed":true}],[":surfaces/gui/e2e/transcript-scroll.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/mcp-add-test.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/sidebar-automations.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/compaction.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/slack-health.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/sidebar-account.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/accounts-page.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/skills-settings.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/connectors-list.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/slack-howitworks.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/mcp-connectors.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/google-paused.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/src/connectors/ConnectorIcon.test.tsx",{"duration":4.432082999999977,"failed":true}],[":surfaces/gui/src/usage.test.ts",{"duration":3.7393339999999995,"failed":false}],[":surfaces/gui/e2e/connector-page.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/roots.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/gcal-page.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/automations-manage.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/cloud-status-pending.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/available-detail.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/error-retry.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/src/components/ModelChecklist.test.tsx",{"duration":3.568208000000027,"failed":true}],[":surfaces/gui/e2e/nav-collapse.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/family-gate.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/skills-session.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/boot.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/automation-toast.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/skills-upload.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/src/streamGate.test.ts",{"duration":2.195999999999998,"failed":false}],[":surfaces/gui/e2e-live/inbox.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e-live/persistence.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/cloud-signin-placement.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/mcp-oauth.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/src/components/Markdown.test.tsx",{"duration":6.359124999999949,"failed":true}],[":surfaces/gui/e2e-live/api-smoke.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/automations.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e-live/approval.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e-live/fib.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/reasoning.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/interrupt-partial.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/model-switch.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/skills-forcerun.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/src/api.auth.test.ts",{"duration":10.473583000000005,"failed":false}],[":surfaces/gui/e2e/composer-platform.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/composer-model-loading.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/src/humanize.skills.test.ts",{"duration":2.190916999999999,"failed":false}],[":surfaces/gui/e2e/sidebar-rows.spec.ts",{"duration":0,"failed":true}],[":surfaces/gui/e2e/smoke.spec.ts",{"duration":0,"failed":true}]]} \ No newline at end of file diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index ec4deba7..51519488 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -1531,8 +1531,16 @@ export async function mockApi(page: import("@playwright/test").Page) { if (p.endsWith("/v1/mcp") && m === "GET") { for (const s2 of mcpServers) { if (s2.status === "authorizing" && s2._flip) { - s2.status = "connected"; - s2.tool_count = 6; + // Servers named locked-* simulate a guarded remote: the anonymous + // probe 401s (→ needs sign-in) until the entry is switched to oauth. + if (s2.name.startsWith("locked") && s2.auth !== "oauth") { + s2.status = "error"; + s2.auth_hint = true; + s2.last_error = "authentication required — sign in to connect"; + } else { + s2.status = "connected"; + s2.tool_count = 6; + } } if (s2.status === "authorizing") s2._flip = true; } @@ -1547,6 +1555,7 @@ export async function mockApi(page: import("@playwright/test").Page) { requires_approval: true, auth: b.config?.auth === "oauth" ? "oauth" : null, status: b.config?.auth === "oauth" ? "needs_auth" : "configured", + auth_hint: false, last_error: null, tool_count: null, config: b.config || {}, @@ -1557,7 +1566,12 @@ export async function mockApi(page: import("@playwright/test").Page) { const mc = p.match(/\/v1\/mcp\/([^/]+)\/connect$/); if (mc && m === "POST") { const s2 = mcpServers.find((x) => x.name === decodeURIComponent(mc[1])); - if (s2) s2.status = "authorizing"; + if (s2) { + s2.status = "authorizing"; + s2.auth_hint = false; + s2.last_error = null; + s2._flip = false; + } return json({ ok: true, started: true }); } const ms = p.match(/\/v1\/mcp\/([^/]+)\/signout$/); @@ -1570,6 +1584,29 @@ export async function mockApi(page: import("@playwright/test").Page) { } return json({ ok: true }); } + const mp = p.match(/\/v1\/mcp\/([^/]+)$/); + if (mp && m === "PATCH") { + const s2 = mcpServers.find((x) => x.name === decodeURIComponent(mp[1])); + const b = req.postDataJSON() || {}; + if (s2) { + if (b.enabled !== undefined) s2.enabled = b.enabled; + if (b.auth === "oauth") { + // The needs-sign-in fix: entry switches to oauth; the follow-up + // connect runs the browser flow. + s2.auth = "oauth"; + s2.auth_hint = false; + s2.status = "needs_auth"; + } + s2.config = { ...s2.config, ...b }; + } + return json({ ok: !!s2, name: mp[1] }); + } + const md = p.match(/\/v1\/mcp\/([^/]+)$/); + if (md && m === "DELETE") { + const i = mcpServers.findIndex((x) => x.name === decodeURIComponent(md[1])); + if (i >= 0) mcpServers.splice(i, 1); + return json({ ok: i >= 0 }); + } } if (p.endsWith("/v1/unrouted")) return json([]); diff --git a/surfaces/gui/e2e/mcp-add-test.spec.ts b/surfaces/gui/e2e/mcp-add-test.spec.ts new file mode 100644 index 00000000..26e99d0e --- /dev/null +++ b/surfaces/gui/e2e/mcp-add-test.spec.ts @@ -0,0 +1,71 @@ +// UX-033: the Add MCP server flow (Remote URL + JSON tabs) and the Test button. +// Remote URL adds an http entry and probes it immediately (testing… → connected); +// a guarded server (mock: locked-*) lands on "needs sign-in" with the OAuth switch; +// the JSON paste box remains for stdio/advanced, and every row can be re-tested. +import { expect } from "@playwright/test"; +import { test } from "./fixtures"; + +async function openMcpTab(page) { + await page.goto("/"); + await page.getByTestId("account-row").click(); + await page.getByRole("button", { name: "Connectors", exact: true }).click(); + await page.getByRole("button", { name: "MCP servers", exact: true }).click(); +} + +test("remote URL tab: add & test flips to connected with tool count", async ({ page }) => { + await openMcpTab(page); + await page.getByRole("button", { name: "Add a server" }).click(); + + // URL tab is the default door; bad URL is caught before anything is added. + await page.getByTestId("mcp-add-name").fill("notes"); + await page.getByTestId("mcp-add-url").fill("mcp.example.com/mcp"); + await page.getByRole("button", { name: "Add & test" }).click(); + await expect(page.getByText("Enter the server's full URL")).toBeVisible(); + + await page.getByTestId("mcp-add-url").fill("https://mcp.example.com/mcp"); + await page.getByRole("button", { name: "Add & test" }).click(); + + const row = page.locator(".space-y-2 > div").filter({ hasText: "notes" }).first(); + await expect(row).toContainText("testing…"); + await expect(row).toContainText("connected", { timeout: 10_000 }); + await expect(row).toContainText("6 tools"); +}); + +test("guarded server: 401 → needs sign-in → OAuth switch connects", async ({ page }) => { + await openMcpTab(page); + await page.getByRole("button", { name: "Add a server" }).click(); + await page.getByTestId("mcp-add-name").fill("locked-crm"); + await page.getByTestId("mcp-add-url").fill("https://mcp.locked.example/mcp"); + await page.getByRole("button", { name: "Add & test" }).click(); + + // The anonymous probe 401s: the row says needs sign-in and offers the fix. + const row = page.locator(".space-y-2 > div").filter({ hasText: "locked-crm" }).first(); + await expect(row).toContainText("needs sign-in", { timeout: 10_000 }); + await expect(row).toContainText("authentication required"); + + // Sign in switches the entry to oauth and starts the browser flow; the poll + // flips it to connected. + await row.getByTestId("mcp-authfix-locked-crm").click(); + await expect(row).toContainText("signing in…"); + await expect(row).toContainText("connected", { timeout: 10_000 }); + await expect(row).toContainText("oauth"); +}); + +test("JSON tab still adds stdio servers; Test probes an existing row", async ({ page }) => { + await openMcpTab(page); + await page.getByRole("button", { name: "Add a server" }).click(); + await page.getByTestId("mcp-add-tab-json").click(); + await page + .locator("textarea") + .fill('{"files": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem"]}}'); + await page.getByRole("button", { name: "Add", exact: true }).click(); + + const row = page.locator(".space-y-2 > div").filter({ hasText: "files" }).first(); + await expect(row).toContainText("stdio · configured"); + + // Test on the untouched row: testing… then the mock's connected · 6 tools. + await row.getByTestId("mcp-test-files").click(); + await expect(row).toContainText("testing…"); + await expect(row).toContainText("connected", { timeout: 10_000 }); + await expect(row).toContainText("6 tools"); +}); diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index 13cca52e..3638959c 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -273,6 +273,8 @@ export interface McpServer { // "needs_auth" (no tokens yet) | "authorizing" (browser sign-in in flight) status: string; auth?: "oauth" | null; + // http server whose anonymous connect hit a 401/403 — offer OAuth sign-in. + auth_hint?: boolean; last_error?: string | null; tool_count: number | null; config: Record; diff --git a/surfaces/gui/src/components/ManageTabs.tsx b/surfaces/gui/src/components/ManageTabs.tsx index f9ab2992..e78e369d 100644 --- a/surfaces/gui/src/components/ManageTabs.tsx +++ b/surfaces/gui/src/components/ManageTabs.tsx @@ -374,6 +374,23 @@ function McpRow({ await signoutMcp(server.name); onRefresh(); }; + // Test = the same explicit connect the OAuth Sign-in uses, for every server: + // the row flips to "testing…" and the tab's poll lands on connected · N tools + // or the error/stderr excerpt. A connected server just reports live state. + const runTest = async () => { + await connectMcp(server.name); + onRefresh(); + // The connect runs as a background task; if the first refresh outpaced its + // start, the row never shows "authorizing" and the tab's poll never arms. + window.setTimeout(onRefresh, 600); + }; + // Anonymous connect came back 401/403: the fix is sign-in, so switch the entry + // to OAuth (DCR — nothing to register) and start the browser flow right away. + const signInWithOauth = async () => { + await patchMcpServer(server.name, { auth: "oauth" }); + await connectMcp(server.name); + onRefresh(); + }; const loadTools = async () => { if (tools) { @@ -395,12 +412,28 @@ function McpRow({
{server.name}
- {server.transport} · {authorizing ? "signing in…" : server.status.replace("_", " ")} + {server.transport} ·{" "} + {authorizing + ? isOauth + ? "signing in…" + : "testing…" + : server.auth_hint && !isOauth + ? "needs sign-in" + : server.status.replace("_", " ")} {server.tool_count != null ? ` · ${server.tool_count} tools` : ""} {server.requires_approval ? " · asks" : ""} {isOauth ? " · oauth" : ""}
+ {!isOauth && server.auth_hint && !authorizing && ( + + )} {isOauth && (server.status === "needs_auth" ? ( ) : null)} + {server.enabled && + !authorizing && + !server.auth_hint && + !(isOauth && server.status !== "connected") && ( + + )}