diff --git a/application/single_app/config.py b/application/single_app/config.py index dda8fe66a..85b6d0c59 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.327" +VERSION = "0.261.328" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/route_frontend_chats.py b/application/single_app/route_frontend_chats.py index c4b83d59d..f0c9a0f1b 100644 --- a/application/single_app/route_frontend_chats.py +++ b/application/single_app/route_frontend_chats.py @@ -19,6 +19,7 @@ ) from functions_model_endpoint_types import resolve_model_endpoint_request_model from functions_agent_catalog import build_accessible_agent_catalog +from functions_model_capabilities import resolve_model_reasoning_policy from functions_ai_notice import get_ai_notice_config, is_ai_notice_dismissed from functions_collaboration import ( assert_user_can_participate_in_collaboration_conversation, diff --git a/docs/explanation/fixes/V1_CHAT_PAGE_BOOTSTRAP_FIX.md b/docs/explanation/fixes/V1_CHAT_PAGE_BOOTSTRAP_FIX.md new file mode 100644 index 000000000..ef565f20e --- /dev/null +++ b/docs/explanation/fixes/V1_CHAT_PAGE_BOOTSTRAP_FIX.md @@ -0,0 +1,138 @@ +# V1 Chat Page Bootstrap Fix + +Fixed in version: **0.261.327** + +Cosmos review follow-up implemented in version: **0.261.328** (PR #1755). + +## Issue + +The classic V1 chat page could return HTTP 500 when navigating to `/chats`, +before the composer or conversation list appeared. This affected nonempty +legacy GPT, API Management (APIM), and multi-endpoint model catalogs. + +## Root Cause + +A model-catalog import cleanup removed the `functions_model_capabilities` +import from `route_frontend_chats.py`. The V1-specific +`_build_initial_chat_model_selection()` helper still called +`resolve_model_reasoning_policy()` when serializing the initial model. + +The `/chats` route invokes that helper before rendering `chats.html`, outside +the catalog-loading exception handlers. A nonempty catalog therefore raised +`NameError: name 'resolve_model_reasoning_policy' is not defined`. +An empty catalog returned `None` before reaching the missing dependency. + +Existing initial-selection tests checked source snippets but did not execute +the helper or authenticated route, so they did not detect the regression. + +## Changes + +- `application/single_app/route_frontend_chats.py` explicitly imports the + reasoning resolver again. Its module uses standard-library dependencies and + the local capability catalog; the shared model-catalog extraction remains + intact. +- `functional_tests/test_chat_v1_page_bootstrap.py` imports the real web app in + fresh normal and optimized Python processes with external I/O blocked. + It executes the real selector, catalog builder, authentication decorators, + settings sanitizer, and templates. +- `application/single_app/config.py` advances the application version from + `0.261.326` to `0.261.327`. + +The fix does not change preferred model selection, legacy/APIM configured-first +ordering, deployment aliases, reasoning policies, security requirements, or +V2 routes. It does not catch the missing dependency or hide it by emptying the +model catalog. + +## Validation + +Before the import was restored, the new page regressions raised the missing +resolver `NameError` through the real authenticated `/chats` route in both +normal and optimized Python. + +After the fix, cold and warm bootstrap paths render HTTP 200 for legacy, APIM, +multi-endpoint, and empty catalogs. Coverage also checks selected deployments, +canonical model names, supported/unsupported/unknown reasoning metadata, +preferred model ids and selection keys, stale preferences, catalog immutability, +and JSON serialization. Anonymous requests still redirect to login, users +without the required role receive HTTP 403, and test credentials/private +endpoint URLs do not appear in rendered HTML. + +The following commands ran with an isolated Python runtime for application +integration tests; the route policy scripts also passed with the default +interpreter: + +| Command | Result | +| --- | --- | +| `python -m pytest -q .\functional_tests\test_chat_v1_page_bootstrap.py .\functional_tests\test_chat_model_selector_initial_multiendpoint_bootstrap.py` | 7 passed, including the final run after the version update | +| `python -m pytest -q .\functional_tests\test_model_reasoning_capability_resolution.py .\functional_tests\test_chat_retry_import_boundaries.py` | 31 passed, including 64 passing subtests | +| `python -m pytest -q .\functional_tests\test_v2_ui_spa_route.py` | 6 passed | +| `python .\functional_tests\route_tests\test_route_blueprint_policy_inventory.py` | 15 passed | +| `python .\functional_tests\route_tests\test_route_unauthenticated_policy_contract.py` | 10 passed | +| `python .\functional_tests\route_tests\test_route_policy_test_coverage.py` | 3 passed | +| `python .\functional_tests\test_docs_app_surface_coverage.py` | 7 passed; generated inventory remains current | +| `python .\functional_tests\test_docs_site_quality.py` | 6 passed | +| `python -m pytest -q .\ui_tests\test_chat_page_multi_endpoint_notice_render.py .\ui_tests\test_staging_chat_smoke.py` | 2 skipped; an authenticated deployment test environment was not configured | +| `git -c core.whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol diff --check` | Passed | + +The default local interpreter had an unrelated PyOpenSSL/cryptography +compatibility failure during Semantic Kernel imports. Integration checks used +a session-local virtual environment with the repository's Flask, Werkzeug, +and Semantic Kernel versions and a compatible TLS dependency. No shared +interpreter packages or production dependency manifests were changed. + +The investigation also found an unrelated stale expectation in +`test_chat_template_json_bootstrap_safety.py`: it still requires the removed +multi-endpoint migration notice, while the notice-removal regression requires +its absence. That baseline failure is outside this fix. + +## Cosmos Contract Review Follow-up + +PR #1755's `cosmos-contracts` check failed with three +`Changed owners requires review` diagnostics for `_build_new_chat_conversation`, +`_resolve_chat_upload_context`, and `register_route_frontend_chats.upload_file`, +plus `Stale source-only owner review` and +`New/changed owner needs owner-runtime SDK evidence` for the upload context owner. + +The gate fingerprints the entire module as well as each storage function. +Restoring the resolver import changed that module fingerprint, although all +three function bodies remained unchanged. Their base-commit boundaries matched +the committed inventory. This is a PR-caused review requirement, not a transient +runner failure or a defect in Cosmos mapping compatibility. + +The inventory is refreshed and each consumer is reviewed. Both write owners +discard point-write responses; the upload context owner reads mapping fields +and returns the mapping without sending it to a strict plain-dictionary sink. +It remains `sdk-compatible`, now with fresh real-owner runtime evidence instead +of an expanded source-only baseline. + +`functional_tests/test_chat_upload_cosmos_response_contract.py` uses the existing +SDK-default offline container and actual installed SDK `CosmosDict` responses. +Fresh normal and optimized web imports exercise an owned personal conversation, +cross-user denial without writes, new/missing conversation creation, retained +fields and ETags, and discarded upsert responses. No new Cosmos double, +production normalization, relaxed assertion, or CI configuration change is needed. +The follow-up advances `application/single_app/config.py` to `0.261.328`. + +Follow-up validation uses the session-local interpreter with the application +pin `azure-cosmos==4.9.0`. An initial full run exposed an inherited local SDK +version mismatch (`4.16.3` versus `4.9.0`); only the virtual environment was +corrected. Dependency assertions, production pins, and CI configuration remain +unchanged. + +| Command (session-local Python where applicable) | Result | +| --- | --- | +| `python -m pytest -q -p test_support.cosmos_network_guard .\functional_tests\test_cosmos_document_boundaries.py .\functional_tests\test_chat_upload_cosmos_response_contract.py .\functional_tests\test_chat_v1_page_bootstrap.py .\functional_tests\test_chat_model_selector_initial_multiendpoint_bootstrap.py --disable-warnings --tb=short` | 59 passed | +| `python .\scripts\check_cosmos_response_boundaries.py --run-contracts` | 1,051 owners, 394 doubles, zero review gaps; 683 passed and 231 subtests passed | +| `python .\functional_tests\test_docs_app_surface_coverage.py` | 7 passed; inventory remains current | +| `python .\functional_tests\test_docs_site_quality.py` | 6 passed | + +## Deployment Verification + +The missing import is reproduced and fixed locally. The supplied deployed +HTTP 500 screenshot did not include a server traceback, so the deployed +exception has not been independently matched to this regression. + +After deploying the fixed build, verify authenticated V1 `/chats` navigation +and a complete send/response cycle with the configured model. Offline route +coverage does not establish that the deployed model service can complete a +chat request. diff --git a/functional_tests/cosmos_response_boundaries.json b/functional_tests/cosmos_response_boundaries.json index 161afd3c5..250feec13 100644 --- a/functional_tests/cosmos_response_boundaries.json +++ b/functional_tests/cosmos_response_boundaries.json @@ -1043,7 +1043,6 @@ "application/single_app/route_enhanced_citations.py::_get_authorized_chat_artifact_message": "b2d9c0fc54c4cf9545a6c46bb93b71c0e1a69d429c8ef1c05ba3fb071582bc4b", "application/single_app/route_enhanced_citations.py::register_enhanced_citations_routes.get_enhanced_citation_tabular": "ff7e5776314ddaf82390e3ac0817fb8270307d32da2b29a7bf4f4c7ef529d61e", "application/single_app/route_external_public_documents.py::register_route_external_public_documents.external_get_public_documents": "7a2cab5fcb36b8d80f2733e2a3fa2df33e04fbeeea7095bec72bf185778aef02", - "application/single_app/route_frontend_chats.py::_resolve_chat_upload_context": "ffacf10e9ecdb47901fa0deb6665e82bcdf06b7cb701feb024f76ab6db06d854", "application/single_app/route_frontend_control_center.py::get_control_center_statistics": "6fd3ab6692fe40b5e63490edcdae51eae7ebeb8067996c361cde4cac14c296e6", "application/single_app/route_frontend_conversations.py::_authorize_frontend_personal_conversation_access": "a8650a20c098eb598306d3827dce49f4b4c1501404cbd3b200d4887b1fbde552", "application/single_app/route_frontend_conversations.py::register_route_frontend_conversations.conversations": "a1d6c7ed62f7b68cb59058ec65e83993ef1f9756d129e439a9bccdc2377ea365", @@ -24387,12 +24386,14 @@ ], "queries": [], "batches": [], - "module_sha256": "8ebafa6d3ad84d52c1f94fefab84dd67ce8c270142c20114a1ed49b11369676f" + "module_sha256": "0641427633cbce91db00156c6934580b2a5c9578f65b4816bce1fbdeaf9eb97f" }, "classification": "response-unused", - "contract": "_build_new_chat_conversation: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", + "contract": "Constructs and returns its own plain conversation dictionary; the SDK upsert response is discarded. Real-owner new/missing upload context tests verify persisted SDK responses retain ETags without changing the returned domain document.", "evidence_scope": "source-review", - "tests": [] + "tests": [ + "functional_tests/test_chat_upload_cosmos_response_contract.py::test_chat_upload_context_real_owner_sdk_responses" + ] }, "application/single_app/route_frontend_chats.py::_resolve_chat_upload_context": { "boundary": { @@ -24406,13 +24407,13 @@ ], "queries": [], "batches": [], - "module_sha256": "8ebafa6d3ad84d52c1f94fefab84dd67ce8c270142c20114a1ed49b11369676f" + "module_sha256": "0641427633cbce91db00156c6934580b2a5c9578f65b4816bce1fbdeaf9eb97f" }, "classification": "sdk-compatible", - "contract": "_resolve_chat_upload_context: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners.", - "evidence_scope": "sdk-api-and-source-review", + "contract": "Point conversation responses use mapping field access for backing detection, collaboration identity, and personal ownership; the returned upload context retains the mapping and ETag. Upload consumers read/mutate fields and upsert the mapping, without an exact-dictionary sink. Real-owner runtime checks cover SDK personal reads, cross-user denial, and new/missing context creation.", + "evidence_scope": "owner-runtime", "tests": [ - "functional_tests/test_m365_cosmos_sdk_contract.py::test_real_sdk_point_documents_are_wrapped_but_nested_json_and_query_rows_are_plain" + "functional_tests/test_chat_upload_cosmos_response_contract.py::test_chat_upload_context_real_owner_sdk_responses" ] }, "application/single_app/route_frontend_chats.py::register_route_frontend_chats.upload_file": { @@ -24478,10 +24479,10 @@ } ], "batches": [], - "module_sha256": "8ebafa6d3ad84d52c1f94fefab84dd67ce8c270142c20114a1ed49b11369676f" + "module_sha256": "0641427633cbce91db00156c6934580b2a5c9578f65b4816bce1fbdeaf9eb97f" }, "classification": "response-unused", - "contract": "register_route_frontend_chats.upload_file: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", + "contract": "All six message/conversation upsert responses are discarded; upload responses project constructed message/workspace fields and conversation title/id, not the SDK write response. Query rows supply thread ids and counts separately. The unchanged body reads/mutates the SDK-compatible upload context mapping.", "evidence_scope": "source-review", "tests": [] }, diff --git a/functional_tests/test_chat_upload_cosmos_response_contract.py b/functional_tests/test_chat_upload_cosmos_response_contract.py new file mode 100644 index 000000000..65befe9d6 --- /dev/null +++ b/functional_tests/test_chat_upload_cosmos_response_contract.py @@ -0,0 +1,87 @@ +# test_chat_upload_cosmos_response_contract.py +"""Real chat upload context owners with the installed SDK's point responses. + +Version: 0.261.328 +Implemented in: 0.261.328 + +Fresh normal/optimized processes import the real web application offline and +exercise existing SDK-default storage doubles, without replacing the owners. +""" + +import pytest + +from test_orchestration_result_imports import run_probe + + +PROBE = r''' +import sys + +sys.path[:0] = sys.argv[1:3] +from azure.cosmos._cosmos_responses import CosmosDict +from test_support.offline_bootstrap import offline_app_imports + + +def check(condition, message): + if not condition: + raise AssertionError(message) + + +with offline_app_imports() as environment: + import app as web + import route_frontend_chats as route + + container = route.cosmos_conversations_container + saved = container.upsert_item({ + "id": "upload-owned", "user_id": "upload-user", + "context": [], "tags": ["retained"], "strict": False, + }) + check(type(saved) is CosmosDict, "Point writes must return the installed SDK wrapper.") + context = route._resolve_chat_upload_context( + " upload-owned ", "upload-user", {"userId": "upload-user"}, + ) + document = context["conversation_item"] + check(type(document) is CosmosDict, "The real owner should retain its compatible SDK mapping.") + check(document["_etag"] == saved["_etag"], "Point read lost its ETag.") + check(document["tags"] == ["retained"], "Point read lost domain fields.") + check(type(document["context"]) is list, "Nested JSON should remain plain.") + check(context["conversation_id"] == "upload-owned", "Partition-routed identity changed.") + check(context["response_conversation_id"] == "upload-owned", "Personal response identity changed.") + check(context["collaboration_conversation"] is None, "Personal read invented collaboration.") + + before = dict(container.items) + try: + route._resolve_chat_upload_context( + "upload-owned", "other-user", {"userId": "other-user"}, + ) + except PermissionError: + pass + else: + raise AssertionError("SDK mapping bypassed personal conversation ownership.") + check(container.items == before, "Forbidden upload context changed storage.") + + with web.app.test_request_context(): + for requested_id in ("", "missing-upload"): + created = route._resolve_chat_upload_context( + requested_id, "upload-user", {"userId": "upload-user"}, + ) + document = created["conversation_item"] + check(type(document) is dict, "New conversation must return the constructed domain document.") + check(document["user_id"] == "upload-user", "New conversation lost its owner.") + check(created["conversation_id"] == document["id"], "Created conversation identity changed.") + check(created["response_conversation_id"] == document["id"], "Created response identity changed.") + check(created["collaboration_conversation"] is None, "Missing context invented collaboration.") + stored = container.read_item(item=document["id"], partition_key=document["id"]) + check(type(stored) is CosmosDict, "Persisted new conversation is not SDK-shaped.") + check(bool(stored["_etag"]), "New conversation write lost its ETag.") + check(stored["title"] == "New Conversation", "New conversation defaults changed.") + check("_etag" not in document, "Unused write response leaked into the constructed document.") + + check(not environment.network_attempts, "Upload context attempted external I/O.") +print("PASS: chat upload SDK point response contract") +''' + + +@pytest.mark.parametrize("optimized", [False, True]) +def test_chat_upload_context_real_owner_sdk_responses(optimized): + """Verify mapping compatibility, ownership denial, and new/missing contexts.""" + run_probe(PROBE, (), optimized) diff --git a/functional_tests/test_chat_v1_page_bootstrap.py b/functional_tests/test_chat_v1_page_bootstrap.py new file mode 100644 index 000000000..b7a4644a0 --- /dev/null +++ b/functional_tests/test_chat_v1_page_bootstrap.py @@ -0,0 +1,217 @@ +# test_chat_v1_page_bootstrap.py +"""Runtime regressions for the V1 chat page's initial model selection. + +Version: 0.261.328 +Implemented in: 0.261.327 + +Fresh normal/optimized interpreters import the real web app with external I/O +blocked. The selector, reasoning resolver, catalog builder, authentication +decorators, settings sanitizer, and templates are not replaced. +""" + +import pytest + +from test_orchestration_result_imports import run_probe + + +PROBE = r''' +from contextlib import ExitStack +from copy import deepcopy +import json +import re +import sys +from unittest.mock import Mock, patch + +sys.path[:0] = sys.argv[1:3] +from test_support.offline_bootstrap import offline_app_imports + + +def check(condition, message): + if not condition: + raise AssertionError(message) + + +with offline_app_imports() as environment: + import app as web + import functions_settings as settings_owner + import route_frontend_chats as route + from functions_model_capabilities import resolve_model_reasoning_policy + + def option(deployment, model_name, **fields): + return { + "selection_key": deployment, + "deployment_name": deployment, + "model_name": model_name, + "display_name": deployment, + **fields, + } + + if sys.argv[3] == "selection": + check( + route.resolve_model_reasoning_policy is resolve_model_reasoning_policy, + "V1 must explicitly bind the real reasoning resolver.", + ) + legacy = [ + option("z-production", "gpt-4o"), + option("a-production", "gpt-5.6-luna"), + ] + apim = [option("z-apim", "z-apim"), option("a-apim", "a-apim")] + scoped = [ + option("personal", "gpt-4o", scope_type="personal", model_id="personal-id"), + option( + "z-global", "gpt-4o", scope_type="global", model_id="global-id", + selection_key="global::endpoint:global-id", + ), + option( + "a-global", "gpt-5.6-luna", scope_type="global", model_id="reasoning-id", + selection_key="global::endpoint:reasoning-id", + ), + ] + cases = [ + (legacy, {}, "z-production", "unsupported"), + (legacy, {"preferred_model_deployment": "a-production"}, "a-production", "supported"), + (apim, {}, "z-apim", "unknown"), + (scoped, {}, "a-global", "supported"), + (scoped, {"preferred_model_id": "global-id"}, "z-global", "unsupported"), + (scoped, {"preferred_model_id": "global::endpoint:global-id"}, "z-global", "unsupported"), + (scoped, {"preferred_model_deployment": "personal"}, "personal", "unsupported"), + (scoped, {"preferred_model_id": "deleted", "preferred_model_deployment": "deleted"}, + "a-global", "supported"), + ] + for options, preferences, deployment, status in cases: + before = deepcopy(options) + selected = route._build_initial_chat_model_selection( + chat_model_options=options, **preferences, + ) + check(selected["option_value"] == deployment, "Initial model ordering/preference changed.") + check(selected["model_name"] == next( + item["model_name"] for item in options if item["deployment_name"] == deployment + ), "Deployment alias replaced the canonical model identity.") + check(selected["reasoning_capabilities"]["status"] == status, "Reasoning status changed.") + expected_policy = resolve_model_reasoning_policy(selected["model_name"]) + check(selected["reasoning_capabilities"] == expected_policy, "Reasoning policy shape changed.") + serialized = json.dumps(selected) + restored = json.loads(serialized) + check(restored == selected, "Initial selection is not JSON serializable.") + check(options == before, "Initial selection mutated the catalog.") + + for options in (None, [], [None, "invalid"]): + selected = route._build_initial_chat_model_selection(chat_model_options=options) + check(selected is None, "Empty/invalid catalogs should not invent a model.") + else: + defaults = settings_owner.get_settings() + defaults.update({ + "enable_user_workspace": False, + "enable_group_workspaces": False, + "enable_public_workspaces": False, + "enable_semantic_kernel": False, + "enable_user_agreement": False, + "enable_idle_timeout": False, + "allow_user_custom_endpoints": False, + "allow_group_custom_endpoints": False, + "azure_openai_gpt_key": "private-bootstrap-test-key", + }) + legacy_models = [ + {"deploymentName": "z-production", "modelName": "gpt-4o"}, + {"deploymentName": "a-production", "modelName": "gpt-5.6-luna"}, + ] + endpoint = { + "id": "test-endpoint", "provider": "aoai", "enabled": True, + "auth": {"type": "api_key", "api_key": "private-endpoint-test-key"}, + "connection": {"endpoint": "https://private-bootstrap.example.test", "api_version": "2024-10-21"}, + "models": [ + { + "id": "ordinary-id", "modelName": "gpt-4o", + "deploymentName": "ordinary-alias", "displayName": "Zulu ordinary", + "enabled": True, + }, + { + "id": "reasoning-id", "modelName": "gpt-5.6-luna", + "deploymentName": "reasoning-alias", "displayName": "Alpha reasoning", + "enabled": True, + }, + ], + } + cases = [ + ("legacy", { + "enable_multi_model_endpoints": False, "enable_gpt_apim": False, + "gpt_model": {"selected": legacy_models}, + }, {}, "z-production", "unsupported"), + ("apim", { + "enable_multi_model_endpoints": False, "enable_gpt_apim": True, + "azure_apim_gpt_deployment": "z-apim, a-apim", + }, {}, "z-apim", "unknown"), + ("multi", { + "enable_multi_model_endpoints": True, "enable_gpt_apim": False, + "model_endpoints": [endpoint], + }, {"preferredModelId": "reasoning-id"}, "reasoning-alias", "supported"), + ("empty", { + "enable_multi_model_endpoints": False, "enable_gpt_apim": False, + "gpt_model": {"selected": []}, + }, {}, None, None), + ] + web.app.config.update(TESTING=True) + for name, changes, preferences, deployment, status in cases: + settings = {**deepcopy(defaults), **deepcopy(changes)} + user_document = { + "id": "bootstrap-user", "display_name": "Bootstrap User", + "settings": {"profileImage": None, **preferences}, + } + read_cache = Mock(return_value=None) + write_cache = Mock() + with ExitStack() as patches: + patches.enter_context(patch.object(web, "get_request_settings", return_value=settings)) + patches.enter_context(patch.object(route, "get_settings", return_value=settings)) + patches.enter_context(patch.object(route, "get_user_settings", return_value=user_document)) + patches.enter_context(patch.object(settings_owner, "get_user_settings", return_value=user_document)) + patches.enter_context(patch.object(settings_owner, "get_user_ui_settings", return_value=user_document)) + patches.enter_context(patch.object(route, "get_cached_chat_bootstrap_payload", read_cache)) + patches.enter_context(patch.object(route, "set_cached_chat_bootstrap_payload", write_cache)) + client = web.app.test_client() + with client.session_transaction() as user_session: + user_session["user"] = { + "oid": "bootstrap-user", "roles": ["User"], "name": "Bootstrap User", + "preferred_username": "bootstrap@example.test", + } + + for warm in (False, True): + with patch.object(route, "_build_chat_model_catalog", wraps=route._build_chat_model_catalog) as build: + response = client.get("/chats") + check(response.status_code == 200, name + ": /chats did not render successfully.") + check(build.call_count == (0 if warm else 1), name + ": wrong warm/cold catalog path.") + body = response.get_data(as_text=True) + check('id="chatbox"' in body and 'id="user-input"' in body, "V1 composer missing.") + match = re.search(r"window.initialChatModelSelection = (.*?);", body) + check(match is not None, "Initial selection bootstrap is missing.") + initial = json.loads(match.group(1)) + if deployment is None: + check(initial == {}, "Empty catalog invented an initial model.") + check("Select a Model" in body, "Empty-catalog placeholder missing.") + else: + check(initial["option_value"] == deployment, name + ": selected deployment changed.") + check(initial["reasoning_capabilities"]["status"] == status, name + ": policy changed.") + check('value="' + deployment + '"' in body, "Selected model option missing.") + check("private-bootstrap-test-key" not in body, "Raw settings reached the browser.") + check("private-endpoint-test-key" not in body, "Endpoint credentials reached the browser.") + check("private-bootstrap.example.test" not in body, "Private endpoint reached the browser.") + if not warm: + check(write_cache.call_count == 1, "Cold bootstrap did not produce a cache payload.") + read_cache.return_value = deepcopy(write_cache.call_args.args[1]) + + anonymous = web.app.test_client() + response = anonymous.get("/chats") + check(response.status_code == 302, "Anonymous V1 access no longer redirects to login.") + with anonymous.session_transaction() as user_session: + user_session["user"] = {"oid": "bootstrap-user", "roles": []} + response = anonymous.get("/chats") + check(response.status_code == 403, "V1 no longer requires the User/Admin role.") + check(not environment.network_attempts, "Page bootstrap attempted external I/O.") +print("PASS: V1 " + sys.argv[3] + " bootstrap") +''' + + +@pytest.mark.parametrize("optimized", [False, True]) +@pytest.mark.parametrize("scenario", ["selection", "page"]) +def test_v1_chat_bootstrap_real_runtime(scenario, optimized): + """Exercise V1 startup without supplying the resolver through a test stub.""" + run_probe(PROBE, (scenario,), optimized)