+ {# These live here, not in the Agents pane, because sibling
+ {% include %} panes each render in their own scope: a value
+ set in one pane is not visible to the next one. #}
+ {% set analyze_capability = settings.document_action_capabilities.analyze %}
+ {% set comparison_capability = settings.document_action_capabilities.comparison %}
+
+```
+
+No route change was required. `admin_settings()` already sets
+`settings['document_action_capabilities'] = normalize_document_action_capabilities(settings)`,
+and `get_default_document_action_capabilities()` always supplies both `analyze`
+and `comparison` with `enabled`, `chat_max_documents` and
+`workflow_max_documents`, so no defensive `default()` filter is needed.
+
+### `functions_appinsights.ensure_console_error_logging()`
+
+Attaches a `logging.StreamHandler(sys.stderr)` pinned at `ERROR` to the supplied
+logger, tagged with a marker attribute so repeated initialisation and gunicorn
+worker reloads cannot stack duplicates. The root logger is deliberately left
+untouched and the level is pinned at `ERROR`, so library `DEBUG` output can never
+flood the container log. Called from `app.py` immediately after
+`setup_appinsights_logging(settings)`.
+
+## Testing
+
+### `functional_tests/test_admin_settings_pane_variable_scope.py`
+
+Five tests, reading each pane **individually** rather than through the composing
+helpers:
+
+1. `test_provided_names_are_discoverable` — the allowlist of template context
+ names is parsed with `ast` from the `render_template('admin_settings.html', ...)`
+ call in `route_frontend_admin_settings.py` and from the `dict(...)` returned by
+ `inject_settings` in `app.py`, so it cannot go stale as variables are added or
+ removed.
+2. `test_every_pane_declares_the_values_it_uses` — `jinja2.meta.find_undeclared_variables`
+ per pane, minus the discovered context names, minus any name assigned by a
+ `{% set %}` in the same file (which suppresses the loop-scoped false positives).
+3. `test_parent_template_declares_the_values_it_uses` — the same rule for the
+ uncomposed `admin_settings.html`.
+4. `test_document_action_capabilities_resolve_in_the_actions_pane` — targeted
+ regression guard asserting `actions.html` both sets and uses the two capability
+ values, and that `agents.html` no longer sets them.
+5. `test_capability_panes_render_as_the_parent_composes_them` — renders
+ `agents.html` and `actions.html` together through a real Jinja
+ `FileSystemLoader`, exactly as the parent includes them, and asserts the
+ configured limits reach the markup.
+
+### `functional_tests/test_flask_exception_console_logging.py`
+
+Four tests covering the logging guarantee: that a root handler satisfies
+`has_level_handler` (the upstream behaviour that caused the suppression), that
+tracebacks reach stderr, that the handler is attached exactly once, and that the
+root logger is left alone.
+
+### `functional_tests/test_admin_settings_template_composition.py`
+
+That suite enforces that functional tests compose the parent template before
+asserting on partial-only markup. Reading panes individually is now an explicit
+exemption, because composing the parent inlines every pane into a single scope
+and would hide precisely the dependency this fix is about.
+
+### `ui_tests/test_admin_document_action_capabilities_card.py`
+
+Retargeted from the Agents tab to the Actions tab, where the card now lives, and
+extended to assert that each capability limit input renders a value. Because an
+unresolved capability value takes the whole page down rather than rendering an
+empty input, the existing `assert response.ok` in that test is a direct browser
+level guard against this 500 returning.
+
+## Validation
+
+The new scope test was run against the pre-fix templates and failed as intended:
+
+```
+admin/_panes/actions.html -> analyze_capability, comparison_capability
+admin/_panes/cosmos.html -> enable_dai_debug
+admin_settings.html -> enable_document_classification, enable_external_links
+```
+
+After the fix:
+
+```
+test_admin_settings_pane_variable_scope.py ......... 5/5 passed
+test_flask_exception_console_logging.py ............ 4/4 passed
+test_admin_settings_template_composition.py ........ passed
+test_admin_document_action_capabilities_location.py passed
+test_admin_settings_field_contract.py .............. passed
+test_admin_settings_nav_map.py ..................... passed
+test_admin_settings_sidebar_card_parity.py ......... passed
+test_admin_settings_dependencies.py ................ passed
+test_admin_settings_group_shared_regions.py ........ passed
+test_admin_settings_modal_placement.py ............. passed
+test_admin_settings_walkthrough_targets.py ......... passed
+test_admin_settings_sidebar_and_agent_catalog_guard.py ... passed
+route_tests/test_route_blueprint_policy_inventory.py ..... passed
+route_tests/test_route_policy_test_coverage.py ........... passed
+route_tests/test_route_unauthenticated_policy_contract.py passed
+```
+
+These suites fail identically before and after this change and are unrelated to
+it: `test_admin_settings_tab_preservation.py` (2/3),
+`test_single_app_template_json_bootstrap_safety.py`,
+`test_stored_xss_admin_rendering_fix.py` and
+`test_admin_settings_safe_int_fallback_fix.py` (which asserts an exact
+`VERSION = "0.240.002"` literal).
+
+## Before and after
+
+| Behaviour | Before | After |
+|-----------|--------|-------|
+| `GET /admin/settings` | 500 on every request | Renders normally |
+| Unhandled exception traceback | Application Insights only | Application Insights **and** stderr / `AppServiceConsoleLogs` |
+| DAI debug controls with `enable_dai_debug` on | Never rendered | Render as intended |
+| Dead `window.enable*` globals | Emitted empty strings | Removed |
diff --git a/docs/explanation/fixes/index.md b/docs/explanation/fixes/index.md
index 201728c95..96011aa6d 100644
--- a/docs/explanation/fixes/index.md
+++ b/docs/explanation/fixes/index.md
@@ -36,3 +36,4 @@ category: Version History
- [New Chat Conversation Documents Drawer Reset Fix](NEW_CHAT_CONVERSATION_DOCUMENTS_DRAWER_RESET_FIX.md)
- [Collaboration Mention Tab Autocomplete Fix](COLLABORATION_MENTION_TAB_AUTOCOMPLETE_FIX.md)
- [Generated Artifact Paging, Truncation, and Guidance Carry-Forward Fix](GENERATED_ARTIFACT_PAGING_AND_GUIDANCE_FIX.md)
+- [Admin Settings Pane Variable Scope Fix](ADMIN_SETTINGS_PANE_VARIABLE_SCOPE_FIX.md)
diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md
index ca1ac6fe9..aa5828690 100644
--- a/docs/explanation/release_notes.md
+++ b/docs/explanation/release_notes.md
@@ -2,6 +2,24 @@
For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/).
+### **(v0.260.019)**
+
+#### Bug Fixes
+
+* **Admin Settings Loads Again**
+ * Admin Settings returned a 500 error on every request after the settings restructure. The Document Action Capabilities card moved to the Actions tab but the two values it reads stayed behind in the Agents tab, and each tab is rendered separately, so those values were never there when the card asked for them.
+ * Both values are now defined in the tab that uses them, and a new test renders the two tabs together to keep them there.
+ * (Ref: `admin/_panes/actions.html`, `admin/_panes/agents.html`, document action capabilities)
+
+* **Server Errors Are Visible In The App Service Log Again**
+ * Once Application Insights was configured it took ownership of logging, which had the side effect of stopping Flask writing unhandled errors to the container log. A failing page left nothing behind but its access-log line, so diagnosing it meant querying Application Insights.
+ * Unhandled errors are now written to both, so the reason for a failure is visible in the App Service log stream.
+ * (Ref: `functions_appinsights.py`, `ensure_console_error_logging`, App Service console logs)
+
+* **Document Access Index Diagnostics Appear When Enabled**
+ * The Cosmos DB tab checked the wrong thing for the debug setting, so the backfill controls, shadow validation metrics and reset option stayed hidden even after an admin turned the setting on.
+ * (Ref: `admin/_panes/cosmos.html`, `enable_dai_debug`)
+
### **(v0.260.018)**
#### Bug Fixes
diff --git a/functional_tests/test_admin_settings_pane_variable_scope.py b/functional_tests/test_admin_settings_pane_variable_scope.py
new file mode 100644
index 000000000..5aca4fe27
--- /dev/null
+++ b/functional_tests/test_admin_settings_pane_variable_scope.py
@@ -0,0 +1,292 @@
+#!/usr/bin/env python3
+# test_admin_settings_pane_variable_scope.py
+"""
+Functional test for the Admin Settings pane variable scope contract.
+Version: 0.260.019
+Implemented in: 0.260.019
+
+Admin Settings renders its tabs as sibling {% include %} partials. Jinja gives
+each include its own context copy, so a value assigned with {% set %} inside one
+pane is invisible to the next pane. When the Document Action Capabilities card
+moved into actions.html and its two {% set %} statements stayed behind in
+agents.html, every /admin/settings request began raising
+
+ jinja2.exceptions.UndefinedError: 'analyze_capability' is undefined
+
+The composed-template helpers cannot catch this: they inline every include into
+one flat string, which makes a sibling pane's {% set %} look reachable. These
+tests therefore read each pane on its own and assert that every variable a pane
+uses is either declared in that same pane or supplied by the route.
+"""
+
+import ast
+import re
+import sys
+from pathlib import Path
+
+import jinja2
+from jinja2 import meta
+
+from test_support.versioning import assert_app_version_at_least
+
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+APP_DIR = REPO_ROOT / "application" / "single_app"
+TEMPLATE_DIR = APP_DIR / "templates"
+PANES_DIR = TEMPLATE_DIR / "admin" / "_panes"
+ADMIN_SETTINGS_TEMPLATE = TEMPLATE_DIR / "admin_settings.html"
+ADMIN_ROUTE_FILE = APP_DIR / "route_frontend_admin_settings.py"
+APP_FILE = APP_DIR / "app.py"
+
+# Names Jinja and Flask resolve on their own, so no template has to declare them.
+TEMPLATE_RUNTIME_NAMES = frozenset({
+ "config",
+ "csrf_token",
+ "cycler",
+ "dict",
+ "g",
+ "get_flashed_messages",
+ "joiner",
+ "lipsum",
+ "namespace",
+ "range",
+ "request",
+ "session",
+ "url_for",
+})
+
+SET_ASSIGNMENT_RE = re.compile(r"\{%-?\s*set\s+([A-Za-z_][A-Za-z0-9_]*)\s*=")
+
+
+def _render_template_keywords(source, template_name):
+ """Return the keyword argument names of a render_template call for a template."""
+ module = ast.parse(source)
+ for node in ast.walk(module):
+ if not isinstance(node, ast.Call):
+ continue
+ function = node.func
+ function_name = getattr(function, "id", None) or getattr(function, "attr", None)
+ if function_name != "render_template":
+ continue
+ if not node.args:
+ continue
+ first_argument = node.args[0]
+ if not isinstance(first_argument, ast.Constant) or first_argument.value != template_name:
+ continue
+ return {keyword.arg for keyword in node.keywords if keyword.arg}
+ raise AssertionError(f"No render_template('{template_name}', ...) call found.")
+
+
+def _context_processor_keywords(source, function_name):
+ """Return the keyword names of the dict(...) returned by a context processor."""
+ module = ast.parse(source)
+ for node in ast.walk(module):
+ if not isinstance(node, ast.FunctionDef) or node.name != function_name:
+ continue
+ for inner in ast.walk(node):
+ if not isinstance(inner, ast.Return) or not isinstance(inner.value, ast.Call):
+ continue
+ call = inner.value
+ if getattr(call.func, "id", None) != "dict":
+ continue
+ return {keyword.arg for keyword in call.keywords if keyword.arg}
+ raise AssertionError(f"No dict(...) return found in {function_name}().")
+
+
+def get_provided_template_names():
+ """Return every name the Admin Settings template can rely on from Python.
+
+ The names are parsed out of the route and the context processor instead of
+ being listed here, so adding or removing a template variable cannot leave
+ this test asserting against a stale allowlist.
+ """
+ route_names = _render_template_keywords(
+ ADMIN_ROUTE_FILE.read_text(encoding="utf-8"),
+ "admin_settings.html",
+ )
+ context_names = _context_processor_keywords(
+ APP_FILE.read_text(encoding="utf-8"),
+ "inject_settings",
+ )
+ return route_names | context_names | TEMPLATE_RUNTIME_NAMES
+
+
+def get_unresolved_template_names(source):
+ """Return names a template uses without declaring, ignoring provided names.
+
+ Names assigned anywhere in the same file are treated as declared. Jinja's
+ meta helper reports a {% set %} inside a {% for %} body as undeclared even
+ though it resolves correctly at render time, and those loop-local names are
+ not the failure mode being guarded here.
+ """
+ environment = jinja2.Environment()
+ undeclared = meta.find_undeclared_variables(environment.parse(source))
+ locally_assigned = set(SET_ASSIGNMENT_RE.findall(source))
+ provided = get_provided_template_names()
+ return sorted(undeclared - locally_assigned - provided)
+
+
+def test_provided_names_are_discoverable():
+ """The allowlist has to come from the real route, or the test proves nothing."""
+ print("Testing Admin Settings template context discovery...")
+
+ provided = get_provided_template_names()
+ for expected_name in ("settings", "app_settings", "admin_landing_tab", "admin_nav"):
+ assert expected_name in provided, (
+ f"'{expected_name}' was not discovered in the Admin Settings template context. "
+ "The route or context processor parsing in this test is out of date."
+ )
+
+ print(f"Discovered {len(provided)} template context names.")
+
+
+def test_every_pane_declares_the_values_it_uses():
+ """A pane may not depend on a value another pane happens to set.
+
+ Sibling includes do not share scope, so any such dependency renders as
+ Undefined and takes the whole page down with a 500 the moment the value is
+ used for attribute access.
+ """
+ print("Testing Admin Settings panes declare their own values...")
+
+ pane_paths = sorted(PANES_DIR.glob("*.html"))
+ assert pane_paths, f"No Admin Settings panes found under {PANES_DIR}"
+
+ offenders = []
+ for pane_path in pane_paths:
+ unresolved = get_unresolved_template_names(pane_path.read_text(encoding="utf-8"))
+ if unresolved:
+ offenders.append(f"admin/_panes/{pane_path.name} -> {', '.join(unresolved)}")
+
+ assert not offenders, (
+ "These panes use values that are neither declared in the pane nor passed by "
+ "the Admin Settings route. Sibling {% include %} panes each render in their "
+ "own scope, so a value set in another pane is Undefined here:\n "
+ + "\n ".join(offenders)
+ )
+
+ print(f"All {len(pane_paths)} panes declare or receive every value they use.")
+
+
+def test_parent_template_declares_the_values_it_uses():
+ """The parent shell is subject to the same rule as the panes."""
+ print("Testing admin_settings.html declares the values it uses...")
+
+ unresolved = get_unresolved_template_names(
+ ADMIN_SETTINGS_TEMPLATE.read_text(encoding="utf-8")
+ )
+
+ assert not unresolved, (
+ "admin_settings.html uses values the route does not pass, which render as "
+ f"empty strings or raise on attribute access: {', '.join(unresolved)}"
+ )
+
+ print("Parent template declares or receives every value it uses.")
+
+
+def test_document_action_capabilities_resolve_in_the_actions_pane():
+ """Regression guard for the 500 this test was written for."""
+ print("Testing document action capability values resolve in the Actions pane...")
+
+ actions_markup = (PANES_DIR / "actions.html").read_text(encoding="utf-8")
+ agents_markup = (PANES_DIR / "agents.html").read_text(encoding="utf-8")
+
+ for capability_name in ("analyze_capability", "comparison_capability"):
+ assert re.search(rf"\{{%-?\s*set\s+{capability_name}\s*=", actions_markup), (
+ f"actions.html uses {capability_name} but never sets it. It has to be "
+ "set in this pane, because a sibling pane's {% set %} is not visible here."
+ )
+ assert capability_name in actions_markup, (
+ f"{capability_name} is set in actions.html but no longer used there."
+ )
+ assert not re.search(rf"\{{%-?\s*set\s+{capability_name}\s*=", agents_markup), (
+ f"agents.html sets {capability_name} again. Only the pane that renders "
+ "the Document Action Capabilities card should define it."
+ )
+
+ print("Document action capability values are declared where they are used.")
+
+
+def test_capability_panes_render_as_the_parent_composes_them():
+ """Render the two panes together the way admin_settings.html includes them.
+
+ The static checks above describe the contract; this one exercises it. If the
+ {% set %} statements ever drift back into a sibling pane, this render raises
+ the same UndefinedError that took /admin/settings down.
+ """
+ print("Testing the Agents and Actions panes render together...")
+
+ environment = jinja2.Environment(loader=jinja2.FileSystemLoader(str(TEMPLATE_DIR)))
+ parent = environment.from_string(
+ '{% include "admin/_panes/agents.html" %}{% include "admin/_panes/actions.html" %}'
+ )
+
+ settings = {
+ "document_action_capabilities": {
+ "analyze": {
+ "enabled": True,
+ "chat_max_documents": 25,
+ "workflow_max_documents": 120,
+ },
+ "comparison": {
+ "enabled": False,
+ "chat_max_documents": 8,
+ "workflow_max_documents": 40,
+ },
+ },
+ "agents_page_promoted_popular_agents": [],
+ }
+
+ try:
+ markup = parent.render(
+ settings=settings,
+ app_settings=settings,
+ admin_landing_tab="secrets",
+ user_settings={},
+ mcp_ui_enabled=False,
+ )
+ except jinja2.exceptions.UndefinedError as render_error:
+ raise AssertionError(
+ "Rendering the Agents and Actions panes together raised "
+ f"UndefinedError: {render_error}. A value used by one pane is being "
+ "set in the other, and sibling includes do not share scope."
+ ) from render_error
+
+ assert 'id="document-action-capabilities-card"' in markup, (
+ "The Document Action Capabilities card did not render."
+ )
+ for expected_value in ('value="25"', 'value="120"', 'value="8"', 'value="40"'):
+ assert expected_value in markup, (
+ f"Configured capability limit {expected_value} did not reach the rendered markup."
+ )
+
+ print("Both panes render together with their capability limits intact.")
+
+
+if __name__ == "__main__":
+ assert_app_version_at_least(
+ "0.260.019",
+ reason="Admin Settings pane variable scope fix.",
+ )
+
+ tests = [
+ test_provided_names_are_discoverable,
+ test_every_pane_declares_the_values_it_uses,
+ test_parent_template_declares_the_values_it_uses,
+ test_document_action_capabilities_resolve_in_the_actions_pane,
+ test_capability_panes_render_as_the_parent_composes_them,
+ ]
+
+ results = []
+ for test in tests:
+ try:
+ test()
+ results.append(True)
+ except Exception as exc:
+ print(f"FAILED {test.__name__}: {exc}")
+ import traceback
+ traceback.print_exc()
+ results.append(False)
+
+ print(f"\nResults: {sum(results)}/{len(results)} passed")
+ sys.exit(0 if all(results) else 1)
diff --git a/functional_tests/test_admin_settings_template_composition.py b/functional_tests/test_admin_settings_template_composition.py
index 1e71cea5a..18be1e234 100644
--- a/functional_tests/test_admin_settings_template_composition.py
+++ b/functional_tests/test_admin_settings_template_composition.py
@@ -40,6 +40,16 @@
"read_composed_template",
)
+# Tests that read the pane partials straight off disk are asserting on a single
+# pane in isolation. That is the only way to catch a pane depending on a value a
+# sibling pane sets, because composing the parent inlines every pane into one
+# scope and makes such a dependency look satisfied.
+PARTIAL_READER_MARKERS = (
+ "admin/_panes/",
+ '"_panes"',
+ "'_panes'",
+)
+
def test_every_pane_partial_is_balanced():
"""An unbalanced pane silently nests the panes that follow it.
@@ -162,6 +172,8 @@ def test_no_functional_test_reads_the_template_uncomposed():
continue
if any(helper in source for helper in COMPOSITION_HELPERS):
continue
+ if any(marker in source for marker in PARTIAL_READER_MARKERS):
+ continue
referenced = sorted(
card_id for card_id in partial_only_cards if card_id in source
diff --git a/functional_tests/test_flask_exception_console_logging.py b/functional_tests/test_flask_exception_console_logging.py
new file mode 100644
index 000000000..cea532226
--- /dev/null
+++ b/functional_tests/test_flask_exception_console_logging.py
@@ -0,0 +1,206 @@
+#!/usr/bin/env python3
+# test_flask_exception_console_logging.py
+"""
+Functional test for unhandled exception tracebacks reaching the console.
+Version: 0.260.019
+Implemented in: 0.260.019
+
+When configure_azure_monitor() runs it attaches a handler to the root logger.
+That has two side effects the application depends on being corrected:
+
+ 1. logging.basicConfig() becomes a no-op, because it returns early whenever
+ root already has handlers.
+ 2. Flask's create_logger() calls has_level_handler(), finds that root handler,
+ and therefore skips attaching its own stderr handler.
+
+The result is that app.logger.exception("Exception on /path [GET]") is delivered
+to Application Insights and nowhere else, so an App Service container log shows
+only the access-log line for a 500. Diagnosing the Admin Settings 500 required an
+Application Insights query for exactly this reason.
+
+These tests pin the correction: ensure_console_error_logging() puts a stderr
+handler back, without stacking duplicates and without touching the root logger.
+"""
+
+import io
+import logging
+import sys
+from contextlib import redirect_stderr
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+APP_DIR = REPO_ROOT / "application" / "single_app"
+if str(APP_DIR) not in sys.path:
+ sys.path.insert(0, str(APP_DIR))
+
+from functions_appinsights import ( # noqa: E402 (path setup must precede this import)
+ CONSOLE_ERROR_HANDLER_ATTRIBUTE,
+ ensure_console_error_logging,
+)
+
+from test_support.versioning import assert_app_version_at_least # noqa: E402
+
+
+def _make_logger(name):
+ """Return a clean, isolated logger for a single assertion."""
+ logger = logging.getLogger(name)
+ logger.handlers.clear()
+ logger.setLevel(logging.NOTSET)
+ logger.propagate = False
+ return logger
+
+
+def test_root_handler_suppresses_the_flask_default_handler():
+ """Document the upstream behaviour that makes the helper necessary."""
+ print("Testing that a root handler suppresses Flask's stderr handler...")
+
+ def has_level_handler(logger):
+ """Mirror of flask.logging.has_level_handler."""
+ level = logger.getEffectiveLevel()
+ current = logger
+ while current:
+ for handler in current.handlers:
+ if handler.level <= level:
+ return True
+ if not current.propagate:
+ break
+ current = current.parent
+ return False
+
+ probe_logger = logging.getLogger("simplechat_test_probe")
+ probe_logger.handlers.clear()
+ probe_logger.setLevel(logging.NOTSET)
+ probe_logger.propagate = True
+
+ root_logger = logging.getLogger()
+ original_handlers = list(root_logger.handlers)
+ original_level = root_logger.level
+ try:
+ root_logger.handlers.clear()
+ root_logger.setLevel(logging.WARNING)
+ assert not has_level_handler(probe_logger), (
+ "Expected no level handler before the Azure Monitor handler is added."
+ )
+
+ # Stand in for the handler configure_azure_monitor attaches to root.
+ azure_monitor_handler = logging.NullHandler()
+ azure_monitor_handler.setLevel(logging.INFO)
+ root_logger.addHandler(azure_monitor_handler)
+
+ assert has_level_handler(probe_logger), (
+ "A root handler should satisfy has_level_handler, which is why Flask "
+ "stops attaching its own stderr handler."
+ )
+ finally:
+ root_logger.handlers.clear()
+ for handler in original_handlers:
+ root_logger.addHandler(handler)
+ root_logger.setLevel(original_level)
+ probe_logger.propagate = False
+
+ print("Confirmed a root handler suppresses Flask's stderr default handler.")
+
+
+def test_console_handler_emits_tracebacks_to_stderr():
+ """An unhandled request exception has to be readable in the container log."""
+ print("Testing that unhandled exception tracebacks reach stderr...")
+
+ logger = _make_logger("simplechat_test_console_traceback")
+ ensure_console_error_logging(logger)
+
+ captured_stderr = io.StringIO()
+ with redirect_stderr(captured_stderr):
+ for handler in logger.handlers:
+ if getattr(handler, CONSOLE_ERROR_HANDLER_ATTRIBUTE, False):
+ handler.stream = captured_stderr
+ try:
+ raise ValueError("simulated admin settings render failure")
+ except ValueError:
+ logger.exception("Exception on /admin/settings [GET]")
+
+ console_output = captured_stderr.getvalue()
+ assert "Exception on /admin/settings [GET]" in console_output, (
+ f"Log message missing from stderr output: {console_output!r}"
+ )
+ assert "Traceback (most recent call last)" in console_output, (
+ f"Traceback missing from stderr output: {console_output!r}"
+ )
+ assert "simulated admin settings render failure" in console_output, (
+ f"Exception detail missing from stderr output: {console_output!r}"
+ )
+
+ print("Tracebacks are written to stderr.")
+
+
+def test_console_handler_is_attached_only_once():
+ """Worker reloads and repeated init must not stack duplicate handlers."""
+ print("Testing that the console handler is not attached twice...")
+
+ logger = _make_logger("simplechat_test_console_once")
+ first_handler = ensure_console_error_logging(logger)
+ second_handler = ensure_console_error_logging(logger)
+
+ assert first_handler is second_handler, (
+ "A second call should return the existing handler, not create a new one."
+ )
+
+ console_handlers = [
+ handler for handler in logger.handlers
+ if getattr(handler, CONSOLE_ERROR_HANDLER_ATTRIBUTE, False)
+ ]
+ assert len(console_handlers) == 1, (
+ f"Expected exactly one console handler, found {len(console_handlers)}."
+ )
+
+ print("Console handler is attached exactly once.")
+
+
+def test_console_handler_does_not_lower_the_root_logger():
+ """The handler is scoped to the app logger so libraries cannot flood stderr."""
+ print("Testing that the console handler leaves the root logger alone...")
+
+ root_logger = logging.getLogger()
+ handlers_before = list(root_logger.handlers)
+
+ logger = _make_logger("simplechat_test_console_scope")
+ handler = ensure_console_error_logging(logger)
+
+ assert list(root_logger.handlers) == handlers_before, (
+ "ensure_console_error_logging must not modify the root logger's handlers."
+ )
+ assert handler.level == logging.ERROR, (
+ f"Console handler should be pinned at ERROR, found {handler.level}."
+ )
+ assert logger.getEffectiveLevel() <= logging.ERROR, (
+ "The logger must be low enough to emit ERROR records."
+ )
+
+ print("Console handler is scoped to the application logger at ERROR.")
+
+
+if __name__ == "__main__":
+ assert_app_version_at_least(
+ "0.260.019",
+ reason="Console error logging restored for unhandled exceptions.",
+ )
+
+ tests = [
+ test_root_handler_suppresses_the_flask_default_handler,
+ test_console_handler_emits_tracebacks_to_stderr,
+ test_console_handler_is_attached_only_once,
+ test_console_handler_does_not_lower_the_root_logger,
+ ]
+
+ results = []
+ for test in tests:
+ try:
+ test()
+ results.append(True)
+ except Exception as exc:
+ print(f"FAILED {test.__name__}: {exc}")
+ import traceback
+ traceback.print_exc()
+ results.append(False)
+
+ print(f"\nResults: {sum(results)}/{len(results)} passed")
+ sys.exit(0 if all(results) else 1)
diff --git a/ui_tests/test_admin_document_action_capabilities_card.py b/ui_tests/test_admin_document_action_capabilities_card.py
index 64186c8f3..89561fa9f 100644
--- a/ui_tests/test_admin_document_action_capabilities_card.py
+++ b/ui_tests/test_admin_document_action_capabilities_card.py
@@ -1,12 +1,18 @@
# test_admin_document_action_capabilities_card.py
"""
UI test for admin document action capabilities placement.
-Version: 0.241.089
+Version: 0.260.019
Implemented in: 0.241.089
+Updated in: 0.260.019
-This test ensures the Document Action Capabilities card is visible at the top
-of the Agents and Actions tab and explains that it controls the Action
-dropdown in Chat and Workflow.
+This test ensures the Document Action Capabilities card is visible at the top of
+the Actions tab, explains that it controls the Action dropdown in Chat and
+Workflow, and renders its configured limits.
+
+The limit assertions are the regression guard for the Admin Settings 500: the
+card reads values that used to be set in the Agents pane, and sibling
+{% include %} panes do not share scope, so the whole page raised
+jinja2.exceptions.UndefinedError before the values moved into this pane.
"""
import os
@@ -30,9 +36,34 @@ def _require_storage_state() -> None:
pytest.skip("Set SIMPLECHAT_UI_ADMIN_STORAGE_STATE or SIMPLECHAT_UI_STORAGE_STATE to a valid authenticated Playwright storage state file.")
+def _open_admin_settings(context, tab_id):
+ """Open Admin Settings on a tab and return the page, or skip when unavailable."""
+ page = context.new_page()
+ page.add_init_script(
+ """
+ () => {
+ sessionStorage.removeItem('adminSettingsActiveTab');
+ }
+ """
+ )
+
+ response = page.goto(f"{BASE_URL}/admin/settings#{tab_id}", wait_until="domcontentloaded")
+ assert response is not None, "Expected a navigation response when loading /admin/settings."
+ if response.status in {401, 403, 404}:
+ pytest.skip("Admin settings page was not available for the configured admin session.")
+
+ assert response.ok, (
+ f"Expected /admin/settings to load successfully, got HTTP {response.status}. "
+ "A 500 here usually means a pane uses a value that a different pane sets."
+ )
+
+ page.locator(f"#{tab_id}-tab").click()
+ return page
+
+
@pytest.mark.ui
-def test_admin_document_action_capabilities_card_is_top_of_agents_tab(playwright):
- """Validate the document action capabilities card is visible at the top of the Agents and Actions tab."""
+def test_admin_document_action_capabilities_card_is_top_of_actions_tab(playwright):
+ """Validate the document action capabilities card renders at the top of the Actions tab."""
_require_base_url()
_require_storage_state()
@@ -43,28 +74,9 @@ def test_admin_document_action_capabilities_card_is_top_of_agents_tab(playwright
)
try:
- page = context.new_page()
- page.add_init_script(
- """
- () => {
- sessionStorage.removeItem('adminSettingsActiveTab');
- }
- """
- )
-
- response = page.goto(f"{BASE_URL}/admin/settings#agents", wait_until="domcontentloaded")
- assert response is not None, "Expected a navigation response when loading /admin/settings."
- if response.status in {401, 403, 404}:
- pytest.skip("Admin settings page was not available for the configured admin session.")
+ page = _open_admin_settings(context, "actions")
- assert response.ok, f"Expected /admin/settings to load successfully, got HTTP {response.status}."
-
- agents_tab = page.locator("#agents-tab")
card = page.locator("#document-action-capabilities-card")
- agents_config = page.locator("#agents-configuration")
-
- agents_tab.click()
-
expect(card).to_be_visible()
expect(card).to_contain_text("Document Action Capabilities")
expect(card).to_contain_text("Action")
@@ -72,11 +84,51 @@ def test_admin_document_action_capabilities_card_is_top_of_agents_tab(playwright
expect(card).to_contain_text("global agent and custom action cards below")
card_top = card.bounding_box()["y"]
- agents_config_top = agents_config.bounding_box()["y"]
+ pane_top = page.locator("#actions").bounding_box()["y"]
+ assert card_top - pane_top < 200, (
+ "Expected the document action capabilities card to render at the top of the Actions tab."
+ )
- assert card_top < agents_config_top, (
- "Expected the document action capabilities card to render before the main configuration cards in the Agents and Actions tab."
+ # Each limit input has to carry a value. An unresolved capability value
+ # takes the whole page down rather than rendering an empty box, so these
+ # also prove the values are read in the pane that renders them.
+ limit_input_ids = (
+ "document_action_analyze_chat_max_documents",
+ "document_action_analyze_workflow_max_documents",
+ "document_action_comparison_chat_max_documents",
+ "document_action_comparison_workflow_max_documents",
)
+ for input_id in limit_input_ids:
+ limit_input = page.locator(f"#{input_id}")
+ expect(limit_input).to_be_visible()
+ assert (limit_input.input_value() or "").strip(), (
+ f"Expected #{input_id} to render a configured limit."
+ )
+
+ for toggle_id in ("document_action_analyze_enabled", "document_action_comparison_enabled"):
+ expect(page.locator(f"#{toggle_id}")).to_be_visible()
+ finally:
+ context.close()
+ browser.close()
+
+
+@pytest.mark.ui
+def test_admin_agents_tab_still_renders_its_own_configuration(playwright):
+ """The Agents tab keeps its own cards now that the capabilities card has moved."""
+ _require_base_url()
+ _require_storage_state()
+
+ browser = playwright.chromium.launch()
+ context = browser.new_context(
+ storage_state=ADMIN_STORAGE_STATE,
+ viewport={"width": 1440, "height": 900},
+ )
+
+ try:
+ page = _open_admin_settings(context, "agents")
+
+ expect(page.locator("#agents-configuration")).to_be_visible()
+ expect(page.locator("#document-action-capabilities-card")).not_to_be_visible()
finally:
context.close()
- browser.close()
\ No newline at end of file
+ browser.close()