diff --git a/tests/test_auth.py b/tests/test_auth.py index d1cfce4f..5160c378 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -197,6 +197,35 @@ class TestRequiredScope: """Only POST is elevated — GET falls through to read.""" assert required_scope("GET", "/api/_internal/mcp-reload") == "read" + # Workstream sub-resource mutations (parametric paths) + def test_ws_delete_needs_write(self): + assert required_scope("POST", "/api/workstreams/abc123/delete") == "write" + + def test_ws_open_needs_write(self): + assert required_scope("POST", "/api/workstreams/abc123/open") == "write" + + def test_ws_refresh_title_needs_write(self): + assert required_scope("POST", "/api/workstreams/abc123/refresh-title") == "write" + + def test_ws_title_needs_write(self): + assert required_scope("POST", "/api/workstreams/abc123/title") == "write" + + def test_v1_ws_delete_needs_write(self): + assert required_scope("POST", "/v1/api/workstreams/abc123/delete") == "write" + + def test_proxy_ws_delete_needs_write(self): + assert required_scope("POST", "/node/n1/v1/api/workstreams/abc123/delete") == "write" + + def test_proxy_ws_open_needs_write(self): + assert required_scope("POST", "/node/n1/v1/api/workstreams/abc123/open") == "write" + + def test_proxy_ws_title_needs_write(self): + assert required_scope("POST", "/node/n1/v1/api/workstreams/abc123/title") == "write" + + def test_ws_get_is_still_read(self): + """GET on workstream sub-resource is not elevated.""" + assert required_scope("GET", "/api/workstreams/abc123/delete") == "read" + # --------------------------------------------------------------------------- # TestExtractBearer diff --git a/tests/test_judge.py b/tests/test_judge.py index 06812124..a7713141 100644 --- a/tests/test_judge.py +++ b/tests/test_judge.py @@ -234,6 +234,27 @@ class TestErrorHandling: ) assert result is None + def test_empty_content_length_stop_no_retry(self): + """When finish_reason is 'length', don't retry — return None immediately.""" + provider = _make_mock_provider(response_content="") + result_mock = provider.create_completion.return_value + result_mock.tool_calls = None + result_mock.content = "" + result_mock.finish_reason = "length" + + judge = _make_judge(provider) + with ThreadPoolExecutor(max_workers=1) as pool: + result = judge._evaluate_single( + _make_item(), + [{"role": "user", "content": "test"}], + cancel_event=None, + executor=pool, + client=MagicMock(), + ) + assert result is None + # Should have been called exactly once — no retries + assert provider.create_completion.call_count == 1 + # --------------------------------------------------------------------------- # Multi-turn tool use diff --git a/tests/test_providers.py b/tests/test_providers.py index 80c547af..a7910a67 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -1074,6 +1074,72 @@ class TestProviderFactory: p2 = create_provider("openai") assert p1 is p2 + # -- Google provider ------------------------------------------------------- + + def test_create_provider_google(self) -> None: + from turnstone.core.providers import create_provider + from turnstone.core.providers._google import GoogleProvider + + provider = create_provider("google") + assert isinstance(provider, GoogleProvider) + assert provider.provider_name == "google" + + def test_create_provider_google_singleton(self) -> None: + from turnstone.core.providers import create_provider + + p1 = create_provider("google") + p2 = create_provider("google") + assert p1 is p2 + + @patch("openai.OpenAI") + def test_create_client_google_default_base_url(self, mock_openai_cls: MagicMock) -> None: + from turnstone.core.providers import create_client + from turnstone.core.providers._google import GOOGLE_DEFAULT_BASE_URL + + mock_openai_cls.return_value = MagicMock() + create_client("google", base_url="", api_key="test-key") + mock_openai_cls.assert_called_once_with( + base_url=GOOGLE_DEFAULT_BASE_URL, api_key="test-key" + ) + + @patch("openai.OpenAI") + def test_create_client_google_custom_base_url(self, mock_openai_cls: MagicMock) -> None: + from turnstone.core.providers import create_client + + mock_openai_cls.return_value = MagicMock() + create_client("google", base_url="http://custom:8080/v1", api_key="k") + mock_openai_cls.assert_called_once_with(base_url="http://custom:8080/v1", api_key="k") + + def test_google_capabilities_defaults(self) -> None: + from turnstone.core.providers import create_provider + + provider = create_provider("google") + caps = provider.get_capabilities("gemini-2.5-pro") + assert caps.context_window == 2_000_000 + assert caps.max_output_tokens == 65_536 + assert caps.token_param == "max_tokens" + assert caps.supports_temperature is True + assert caps.supports_vision is True + + def test_google_capabilities_same_for_all_models(self) -> None: + from turnstone.core.providers import create_provider + + provider = create_provider("google") + c1 = provider.get_capabilities("gemini-2.5-pro") + c2 = provider.get_capabilities("gemini-2.0-flash") + c3 = provider.get_capabilities("") + assert c1 is c2 is c3 + + def test_list_known_models_google_empty(self) -> None: + from turnstone.core.providers import list_known_models + + assert list_known_models("google") == [] + + def test_lookup_model_capabilities_google_returns_none(self) -> None: + from turnstone.core.providers import lookup_model_capabilities + + assert lookup_model_capabilities("google", "gemini-2.5-pro") is None + # =========================================================================== # TestDataclasses diff --git a/turnstone/console/collector.py b/turnstone/console/collector.py index 09b18c57..e4729b30 100644 --- a/turnstone/console/collector.py +++ b/turnstone/console/collector.py @@ -506,7 +506,8 @@ class ClusterCollector: { "type": "ws_created", "ws_id": ws_id, - "name": data.get("name", ""), + "name": data.get("title", "") or data.get("name", ""), + "title": data.get("title", ""), "node_id": node_id, } ) diff --git a/turnstone/console/server.py b/turnstone/console/server.py index a37b324c..e40c6195 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -130,9 +130,10 @@ _JS_PROXY_SHIM = """\ _CONSOLE_BANNER_TEMPLATE = ( '
" ) @@ -144,8 +145,8 @@ _CONSOLE_PROXY_STYLE = ( ".dashboard-overlay{top:32px!important}" ".console-banner{background:#111827;border-bottom:1px solid rgba(229,160,66,0.3);" "padding:6px 20px;font-family:'IBM Plex Mono',monospace;font-size:12px;" - "display:flex;align-items:center;gap:12px;position:relative;z-index:9999}" - ".console-banner-link,.console-banner-node{color:#8a93ad;text-decoration:none}" + "display:flex;align-items:center;gap:12px;position:relative;z-index:200}" + ".console-banner-link,.console-banner-node{color:#9aa0b8;text-decoration:none}" ".console-banner-link{font-weight:500;padding:2px 0}" ".console-banner-node{font-size:11px}" ".console-banner-sep{color:#3b4463}" @@ -156,7 +157,7 @@ _CONSOLE_PROXY_STYLE = ( ':root[data-theme="light"] .console-banner-node{color:#64748b}' ':root[data-theme="light"] .console-banner-sep{color:#cbd5e1}' ':root[data-theme="light"] .console-banner-link:hover,' - ':root[data-theme="light"] .console-banner-node:hover{color:#e5a042}' + ':root[data-theme="light"] .console-banner-node:hover{color:#8c5e1b}' "" ) @@ -939,7 +940,9 @@ async def proxy_index(request: Request) -> Response: page = page.replace('href="/shared/', f'href="{prefix}/shared/') page = page.replace('src="/shared/', f'src="{prefix}/shared/') # Inject console-return banner + proxy shim after - banner = _CONSOLE_BANNER_TEMPLATE.replace("NODE_ID_PLACEHOLDER", html.escape(node_id)).replace("NODE_LINK_PLACEHOLDER", html.escape(prefix + "/")) + banner = _CONSOLE_BANNER_TEMPLATE.replace( + "NODE_ID_PLACEHOLDER", html.escape(node_id) + ).replace("NODE_LINK_PLACEHOLDER", html.escape(prefix + "/")) shim = ( "