diff --git a/application/single_app/config.py b/application/single_app/config.py index 4a7f6cdcc..ea338c2db 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -100,7 +100,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.214" +VERSION = "0.261.215" 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_backend_workflows.py b/application/single_app/route_backend_workflows.py index f44724580..b0dcd3b1c 100644 --- a/application/single_app/route_backend_workflows.py +++ b/application/single_app/route_backend_workflows.py @@ -119,7 +119,7 @@ workflow_execution_records_page, workflow_execution_provenance_page, workflow_loop_items_page, ) from functions_workflow_repeat_history import workflow_repeat_iterations_page, workflow_repeat_state_page -from functions_settings import workflow_assistant_required +from functions_settings import workflow_assistant_required, is_workflow_assistant_enabled_for_user from functions_workflow_assist import ASSIST_MAX_BODY_BYTES, WorkflowAssistError, parse_assist_body, run_workflow_assist from functions_workflow_assist_runtime import build_workflow_assist_services from werkzeug.exceptions import BadRequest, RequestEntityTooLarge @@ -1618,6 +1618,16 @@ def draft_workflow_instructions(): _resolve_active_group_for_workflow_management(user_id) elif workflow_scope == 'personal': _assert_personal_workflow_draft_access(settings) + # Runs after the personal access check so its 400 and role 403 keep precedence; + # the assistant helper also returns False when personal workflows are unavailable. + if not is_workflow_assistant_enabled_for_user( + settings, + user_roles=(session.get('user') or {}).get('roles', []), + ): + return jsonify({ + 'error': 'The AI workflow assistant is not available.', + 'code': 'workflow_assistant_disabled', + }), 403 else: return jsonify({'error': 'Invalid workflow scope.'}), 400 except ValueError as exc: diff --git a/application/single_app/route_frontend_workspace.py b/application/single_app/route_frontend_workspace.py index 1c62c585a..c0f6c3427 100644 --- a/application/single_app/route_frontend_workspace.py +++ b/application/single_app/route_frontend_workspace.py @@ -38,6 +38,10 @@ def workspace(): settings, user_roles=current_user_roles, ) + public_settings['enable_workflow_ai_assistant'] = is_workflow_assistant_enabled_for_user( + settings, + user_roles=current_user_roles, + ) if not user_id: print("User not authenticated.") return redirect(url_for('frontend_authentication.login')) diff --git a/application/single_app/templates/workspace.html b/application/single_app/templates/workspace.html index 4917b47b7..945ad874e 100644 --- a/application/single_app/templates/workspace.html +++ b/application/single_app/templates/workspace.html @@ -1521,6 +1521,7 @@
Task sequence
+ {% if settings.enable_workflow_ai_assistant %}
@@ -1531,6 +1532,7 @@
Task sequence
+ {% endif %}
diff --git a/docs/admin/workflow.md b/docs/admin/workflow.md index ae0079e33..57861a78c 100644 --- a/docs/admin/workflow.md +++ b/docs/admin/workflow.md @@ -103,9 +103,12 @@ one or take back as a whole. The assistant is on by default wherever personal workflows are on, and it follows the same `WorkflowUser` role rule. Turning it off also hides each task's -**Draft with AI** button in the V2 editor. The classic workspace's **Draft -Workflow Instructions** button doesn't follow this setting. Things to know -before relying on it: +**Draft with AI** button in the V2 editor and, from version **0.261.215**, the +**Task Brief** box and **Draft Workflow Instructions** button in the classic +workspace's personal workflow editor. The server refuses personal draft requests +too, so calling the API directly doesn't get around the setting. Group workflows +aren't affected: their **Draft Workflow Instructions** button follows the group +workflow settings. Things to know before relying on it: - **It proposes; it never saves.** Nothing changes until the person reviews the edit and saves it through the normal save, with the usual checks and the Run diff --git a/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOWS_ROADMAP.md b/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOWS_ROADMAP.md index 85ebd9472..f3f9ce0a7 100644 --- a/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOWS_ROADMAP.md +++ b/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOWS_ROADMAP.md @@ -947,8 +947,9 @@ Phase 3 ships as three PRs. 3a needs nothing else, 3b needs Phase 2 and A2, and pending block focus request, so the Flow canvas can't take focus back. - **Follow-ups** from #1593: - A Latest Feature card, once `docs/images/guides/create-a-workflow-ask-ai.png` exists. - - A product decision: should `/api/workflows/draft-instructions` check `enable_workflow_ai_assistant` on the - server? It checks workflow access only, and the classic button ignores the setting. + - Settled for personal scope in v0.261.215: `/api/workflows/draft-instructions` refuses personal requests with + 403 `workflow_assistant_disabled` when `enable_workflow_ai_assistant` is off for the person, and the classic + personal workspace hides its draft controls. Group-scope AI drafting is to be revisited with Phase 7 (#1550). - Structural flow edits (For each and If blocks, adding or moving tasks), File Sync operations, and the rest of #1577's list. - Disable **Reload workflow** during a save, check focus after a reloaded editor closes, and add browser tests diff --git a/docs/explanation/features/VOICE_ASSISTED_WORKFLOW_ACTION_CREATION.md b/docs/explanation/features/VOICE_ASSISTED_WORKFLOW_ACTION_CREATION.md index 54a00efe1..d472c79a7 100644 --- a/docs/explanation/features/VOICE_ASSISTED_WORKFLOW_ACTION_CREATION.md +++ b/docs/explanation/features/VOICE_ASSISTED_WORKFLOW_ACTION_CREATION.md @@ -27,7 +27,7 @@ Voice-assisted creation extends the existing form speech-to-text controls from a - `POST /api/workflows/draft-instructions` - Request: `workflow_scope`, `name`, `description`, `brief`, `existing_instructions` - Response: `success`, `instructions` - - Access: authenticated users only; personal workflow app-role gates and group workflow management role gates are enforced. + - Access: authenticated users only; personal workflow app-role gates and group workflow management role gates are enforced. From version **0.261.215**, personal requests also need the AI workflow assistant (**Enable AI Workflow Assistant**, `enable_workflow_ai_assistant`) to be available to the user, and are refused with 403 `workflow_assistant_disabled` otherwise. Group requests don't follow that setting. ### Configuration Options @@ -50,3 +50,4 @@ No new settings are introduced. Voice buttons depend on the existing speech-to-t - Voice controls only render when speech-to-text is enabled and the browser supports local audio recording APIs. - Drafted workflow instructions still require user review before save. +- From version **0.261.215**, the personal workflow modal renders the task brief and **Draft Workflow Instructions** controls only when the AI workflow assistant is available to the user. The group workflow modal is unaffected. diff --git a/docs/explanation/features/WORKFLOW_AI_ASSISTANT.md b/docs/explanation/features/WORKFLOW_AI_ASSISTANT.md index 538d5eb79..dd48efcae 100644 --- a/docs/explanation/features/WORKFLOW_AI_ASSISTANT.md +++ b/docs/explanation/features/WORKFLOW_AI_ASSISTANT.md @@ -100,7 +100,11 @@ setting on top: The V2 bootstrap route (`/api/v2/bootstrap`) reports the per-user result as `features.enable_workflow_ai_assistant`, and the editor hides the **Ask AI** tab and **Draft with -AI** when it's false. See [When the tab is offered](#when-the-tab-is-offered). +AI** when it's false. See [When the tab is offered](#when-the-tab-is-offered). From 0.261.215 the +same result gates personal requests to `POST /api/workflows/draft-instructions`, which answers 403 +`workflow_assistant_disabled` when it's false, and the classic personal workspace hides its +**Task Brief** and **Draft Workflow Instructions** controls. Group drafting doesn't follow this +setting. ### Request @@ -702,8 +706,9 @@ as one undoable AI change labeled "Draft with AI: ", and focus moves to th - It doesn't lock the editor. When the task gained instructions or was removed while it worked, nothing is added, and the message says so. - Instructions longer than the editor's 12,000-character limit aren't added; the message says so. -- It's offered only where Ask AI is. The route checks personal workflow access but not - `enable_workflow_ai_assistant`, so hiding it with Ask AI keeps the setting's meaning simple. +- It's offered only where Ask AI is. From 0.261.215 the route also refuses a personal request + with 403 `workflow_assistant_disabled` when `enable_workflow_ai_assistant` is off for the person, + so hiding the button isn't the only control. ### Accessibility and plain text @@ -826,10 +831,13 @@ python -u -m pytest ui_tests/test_v2_workflow_ask_ai.py -q from an earlier session can't be undone. Its card says **Made in an earlier editing session.** and **Undo this change** reports "This turn can no longer be undone." Replay tells the assistant those changes may not have been saved. -- **Draft with AI isn't gated by the assistant setting on the server.** - `POST /api/workflows/draft-instructions` checks workflow access, not - `enable_workflow_ai_assistant`. The V2 editor hides **Draft with AI** whenever Ask AI is hidden, - but the classic personal and group workspace pages still offer **Draft Workflow Instructions**. +- **Group drafting doesn't follow the assistant setting.** From 0.261.215, + `POST /api/workflows/draft-instructions` refuses personal requests with 403 + `workflow_assistant_disabled` when `enable_workflow_ai_assistant` is off for the person, and the + classic personal workspace hides its **Task Brief** and **Draft Workflow Instructions** controls. + The setting covers personal workflows only, so the classic group workspace still offers + **Draft Workflow Instructions**, governed by the group workflow settings. Group-scope AI drafting + is to be revisited with Phase 7 (#1550). - **Hidden by an active run or lost access.** These hide the tab through the same read-only check as a reader's editor, which the browser tests cover, but neither has a browser case of its own. - **File Sync operations.** Phase 4 (#1547) made personal File Sync editable, and the Python diff --git a/docs/explanation/fixes/WORKFLOW_DRAFT_INSTRUCTIONS_ASSISTANT_GATE_FIX.md b/docs/explanation/fixes/WORKFLOW_DRAFT_INSTRUCTIONS_ASSISTANT_GATE_FIX.md new file mode 100644 index 000000000..d780d7fb2 --- /dev/null +++ b/docs/explanation/fixes/WORKFLOW_DRAFT_INSTRUCTIONS_ASSISTANT_GATE_FIX.md @@ -0,0 +1,150 @@ +# Workflow Draft Instructions Assistant Gate Fix + +Fixed in version: **0.261.215** + +The application version is recorded in `application/single_app/config.py`. + +Related: #1548 (Phase 3 of the [chat orchestration workflows roadmap](../features/CHAT_ORCHESTRATION_WORKFLOWS_ROADMAP.md)), part of #1543. +Found while reviewing Phase 3c (#1593). + +## Issue + +The admin setting **Enable AI Workflow Assistant** (`enable_workflow_ai_assistant`, on by default, personal +workflows only) didn't stop personal AI drafting of workflow task instructions. + +`POST /api/workflows/draft-instructions` calls the draft-instructions model to write a task's instructions. It +checked personal workflow access and the `WorkflowUser` role, but not the assistant setting. With the assistant +turned off: + +- The V2 editor hid **Draft with AI**, but that only hid a button. +- The classic workspace's personal workflow modal still offered **Task Brief** and **Draft Workflow + Instructions**, and the button still drafted instructions. +- Anyone who could edit personal workflows could call the route directly and get a model-written draft. + +## Root cause + +The setting was enforced only by the `workflow_assistant_required` decorator, which wraps the V2 editor's +**Ask AI** route (`POST /api/user/workflows/assist`). The draft route predates the assistant and serves both +personal and group scopes, so it wasn't given the decorator, and nothing else checked the setting for it. The +V2 editor followed the setting by hiding its button, and the classic page didn't look at it at all. + +## Resolution + +### Server + +In `draft_workflow_instructions`, the personal branch now checks the assistant after the existing personal +access check: + +1. `_assert_personal_workflow_draft_access(settings)` runs first, unchanged. Personal workflows that are off + still get 400 `Personal workflows are disabled.`, and a missing `WorkflowUser` role still gets 403 + `Personal workflows require the WorkflowUser app role.` +2. `is_workflow_assistant_enabled_for_user(settings, user_roles=...)` then decides whether the assistant is + available to the signed-in user, using the roles in the session. When it isn't, the route returns 403 with + the decorator's body: + + ```json + {"error": "The AI workflow assistant is not available.", "code": "workflow_assistant_disabled"} + ``` + + No model client is created and no model call is made. + +The order matters. The helper also returns False when personal workflows aren't available, so checking it +first would replace the personal-workflow 400 and the role 403 with the assistant's 403. + +The check runs before the route's "Provide a task brief..." 400, so an empty personal request also gets the +assistant's 403 when the assistant is off. + +The route isn't wrapped in `workflow_assistant_required`, because that would refuse group requests too. +`_assert_personal_workflow_draft_access` isn't changed, because other workflow routes use it. + +### Group scope + +Group drafting is unchanged. The setting is documented as personal-only, and group drafting stays governed by +the group workflow settings and the caller's group role. Whether group workflows should get an AI drafting +setting of their own is to be revisited with Phase 7 (#1550). + +### Classic workspace + +`/workspace` now overrides `enable_workflow_ai_assistant` in the sanitized settings it gives the template with +the same role-aware result, as it already does for `allow_user_workflows` and `enable_url_access`. The value is +a boolean computed on the server, and no raw setting is passed through. + +`workspace.html` renders the personal **Task Brief** box, the **Draft Workflow Instructions** button and its +status line only when that value is true. The brief only feeds the draft request and isn't saved, so hiding the +button alone would have left an input that does nothing. `workspace_workflows.js` already treats all three +elements as optional, so nothing else changes. **Task Instructions** stays, so people can still write +instructions themselves. + +The group workspace's controls in `group_workspaces.html` are unchanged. + +### V2 editor + +No change. The editor already offers **Draft with AI** only where it offers **Ask AI**, which needs the +bootstrap's role-aware `features.enable_workflow_ai_assistant`. + +## Files modified + +| File | Change | +| --- | --- | +| `application/single_app/route_backend_workflows.py` | Imports `is_workflow_assistant_enabled_for_user` and refuses personal drafting when the assistant isn't available | +| `application/single_app/route_frontend_workspace.py` | Passes a role-aware `enable_workflow_ai_assistant` to the classic workspace template | +| `application/single_app/templates/workspace.html` | Renders the personal Task Brief and Draft Workflow Instructions controls only when the assistant is available | +| `application/single_app/config.py` | `VERSION = "0.261.215"` | +| `functional_tests/test_workflow_draft_instructions_assistant_gate.py` | New regression test | + +Documentation updated: `docs/admin/workflow.md`, `docs/explanation/features/WORKFLOW_AI_ASSISTANT.md`, +`docs/explanation/features/VOICE_ASSISTED_WORKFLOW_ACTION_CREATION.md` and the Phase 3c follow-up in +`docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOWS_ROADMAP.md`. + +## Impact + +- **Admins** who turn the assistant off now get what the setting says: no personal AI drafting from the V2 + editor, the classic workspace or the API. +- **Users** with the assistant available see no change. +- **Users** without it no longer see the classic personal **Task Brief** and **Draft Workflow Instructions** + controls, and they can still write task instructions themselves. +- **Group workflows** are unaffected. +- **API callers** that drafted personal instructions with the assistant off now get 403 + `workflow_assistant_disabled`, the same answer the **Ask AI** route gives. + +## Validation + +`functional_tests/test_workflow_draft_instructions_assistant_gate.py` runs the real route bodies, the real gate +helpers from `functions_settings.py` and the real modal markup on closed Flask and Jinja environments, with a fake +model client, group resolver and Cosmos. It covers: + +- personal drafting with the assistant on reaching the model +- personal drafting with the assistant off refused with the decorator's body, before any model client is created +- a `WorkflowUser` who still needs the assistant when the role is required +- the personal-workflow 400 and the role 403, unchanged and taking precedence over the assistant's answer +- group drafting with the assistant off, still allowed, and group refusals unchanged +- an invalid scope, still a 400 +- the route not being wrapped in the assistant decorator +- the classic workspace getting a role-aware flag, never the raw setting +- the personal modal hiding its draft controls when the flag is off, and the group modal keeping them + +The test passes as a script and under pytest, each with and without `python -O`. + +Each of these mutations fails at least one test: + +- removing the server check +- also gating group scope +- inverting the flag +- checking before the personal access check +- reading the raw setting instead of the role-aware helper +- decorating the route +- removing the template condition or the route's override +- gating the group template + +The route policy tests under `functional_tests/route_tests/` are unchanged and pass, because the route, its +decorators and its auth policy are the same. + +### Before and after + +| Request or page, assistant off | Before | After | +| --- | --- | --- | +| Personal `POST /api/workflows/draft-instructions` | 200, with a drafted answer | 403 `workflow_assistant_disabled`, no model call | +| Group `POST /api/workflows/draft-instructions` | 200 | 200, unchanged | +| Classic personal workflow modal | Shows Task Brief and Draft Workflow Instructions | Hides them, and Task Instructions stays | +| Classic group workflow modal | Shows them | Shows them, unchanged | +| V2 editor | Hides Draft with AI | Hides Draft with AI, unchanged | diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 79c1272e2..9bb8828ff 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,17 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.261.215)** + +#### Bug Fixes + +* **Draft Workflow Instructions Follows The AI Workflow Assistant Setting** + * Turning off **Enable AI Workflow Assistant** now stops personal AI drafting of workflow task instructions everywhere, not just in the V2 editor. Before, only the V2 editor's **Draft with AI** button was hidden; the classic workspace's **Draft Workflow Instructions** button and direct calls to `/api/workflows/draft-instructions` still drafted instructions. + * The route now refuses a personal request with 403 `workflow_assistant_disabled`, the same answer the **Ask AI** route gives, before any model call. Personal workflows that are off and a missing `WorkflowUser` role keep their existing answers, which still come first. + * The classic workspace's personal workflow modal hides its **Task Brief** box and **Draft Workflow Instructions** button when the assistant isn't available to the signed-in user, using the same role-aware check as the V2 editor. **Task Instructions** stays, so instructions can still be written by hand. + * Group workflows aren't affected. Their **Draft Workflow Instructions** button follows the group workflow settings. + * (Ref: #1548, #1543, `route_backend_workflows.py`, `route_frontend_workspace.py`, `workspace.html`, `is_workflow_assistant_enabled_for_user`, [Workflow Draft Instructions Assistant Gate Fix](fixes/WORKFLOW_DRAFT_INSTRUCTIONS_ASSISTANT_GATE_FIX.md)) + ### **(v0.261.214)** #### New Features diff --git a/functional_tests/test_workflow_draft_instructions_assistant_gate.py b/functional_tests/test_workflow_draft_instructions_assistant_gate.py new file mode 100644 index 000000000..e302f6156 --- /dev/null +++ b/functional_tests/test_workflow_draft_instructions_assistant_gate.py @@ -0,0 +1,620 @@ +#!/usr/bin/env python3 +# test_workflow_draft_instructions_assistant_gate.py +""" +Functional test for the workflow draft-instructions AI assistant gate. +Version: 0.261.215 +Implemented in: 0.261.215 + +This test ensures that ``POST /api/workflows/draft-instructions`` follows the admin's Enable AI +Workflow Assistant setting for personal workflows. When the assistant isn't available to the +signed-in user, the route answers 403 with the same body as ``workflow_assistant_required`` and +never creates or calls a model client. The personal-workflow 400, the WorkflowUser role 403, +group-scope drafting and the invalid-scope 400 keep their existing answers. It also ensures the +classic personal workspace receives a role-aware ``enable_workflow_ai_assistant`` flag and hides its +Task Brief and Draft Workflow Instructions controls when the flag is off, while the group workspace +keeps them. + +The real route bodies, the real gate helpers from ``functions_settings.py`` and the real workflow +modal markup run on closed Flask and Jinja environments. The model client, group resolver and Cosmos +are fakes. No live application, credential, model or Azure service is used. +""" + +import ast +import copy +import logging +import re +import sys +import traceback +from functools import wraps +from pathlib import Path +from types import SimpleNamespace + +from flask import Flask, jsonify, request +from jinja2 import Environment, FileSystemLoader + +ROOT = Path(__file__).resolve().parents[1] +APP = ROOT / "application" / "single_app" +sys.path.insert(0, str(ROOT / "functional_tests")) + +# Shared test helpers follow the worktree import setup. +from test_support.versioning import assert_app_version_at_least # noqa: E402 + + +ROUTES = APP / "route_backend_workflows.py" +SETTINGS_MODULE = APP / "functions_settings.py" +WORKSPACE_ROUTES = APP / "route_frontend_workspace.py" +TEMPLATE_DIR = APP / "templates" +DRAFT_PATH = "/api/workflows/draft-instructions" +USER_ID = "user-n9-0001" +GROUP_ID = "group-n9-0001" +SECRET = "SECRET-SETTING-VALUE-n9-4c1d" +MODEL_NAME = "draft-instructions-deployment" +DRAFTED = "1. Review the new tickets.\n2. Summarize the themes." +MISSING = object() + +ASSISTANT_DISABLED = { + "error": "The AI workflow assistant is not available.", + "code": "workflow_assistant_disabled", +} +ROLE_REQUIRED = {"error": "Personal workflows require the WorkflowUser app role."} +PERSONAL_DISABLED = {"error": "Personal workflows are disabled."} +INVALID_SCOPE = {"error": "Invalid workflow scope."} +NO_DRAFT_INPUT = {"error": "Provide a task brief, workflow name, description, or existing instructions."} + +SETTINGS_FUNCTIONS = ( + "normalize_app_role_claims", "has_workflow_user_app_role", "is_user_workflows_enabled_for_user", + "is_workflow_assistant_enabled_for_user", "workflow_assistant_required", "enabled_required", + "sanitize_settings_for_user", +) +DRAFT_HELPERS = ( + "_normalize_workflow_instruction_draft_input", "_build_workflow_instruction_messages", + "_assert_personal_workflow_draft_access", +) +EXPECTED_DRAFT_DECORATORS = [ + f"bp.route('{DRAFT_PATH}', methods=['POST'])", + "swagger_route(security=get_auth_security())", + "login_required", + "user_required", +] +DRAFT_CONTROL_IDS = ("workflow-task-brief", "workflow-draft-instructions-btn", "workflow-draft-instructions-status") +PERSONAL_DRAFT = {"workflow_scope": "personal", "name": "Nightly review", "brief": "Summarize new support tickets."} +GROUP_DRAFT = {"workflow_scope": "group", "name": "Group digest", "brief": "Summarize group tickets."} +# Every request shape that resolves to personal scope, including an empty body: the assistant +# gate answers before the route checks that there is something to draft from. +PERSONAL_REQUESTS = ( + PERSONAL_DRAFT, + {"name": "Nightly review"}, + {"workflow_scope": " PERSONAL ", "brief": "Summarize new support tickets."}, + {}, +) +# The route uses the same strict helper as ``workflow_assistant_required``: only ``True`` is on. +ASSISTANT_OFF_VALUES = (False, None, "true", 1, MISSING) + + +def parsed_module(path): + return ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + + +def top_level_functions(path, names): + found = { + node.name: node for node in parsed_module(path).body + if isinstance(node, ast.FunctionDef) and node.name in names + } + missing = sorted(set(names) - set(found)) + if missing: + raise AssertionError(f"{path.name} no longer defines {missing}") + return [found[name] for name in names] + + +def top_level_constant(path, name): + for node in parsed_module(path).body: + if isinstance(node, ast.Assign) and any( + isinstance(target, ast.Name) and target.id == name for target in node.targets + ): + return ast.literal_eval(node.value) + raise AssertionError(f"{path.name} no longer defines {name}") + + +def nested_route(path, registrar_name, route_path): + registrar = next( + ( + node for node in parsed_module(path).body + if isinstance(node, ast.FunctionDef) and node.name == registrar_name + ), + None, + ) + if registrar is None: + raise AssertionError(f"{path.name} no longer defines {registrar_name}") + matches = [ + node for node in registrar.body + if isinstance(node, ast.FunctionDef) + and node.decorator_list + and isinstance(node.decorator_list[0], ast.Call) + and node.decorator_list[0].args + and getattr(node.decorator_list[0].args[0], "value", None) == route_path + ] + if len(matches) != 1: + raise AssertionError(f"expected one route for {route_path} in {path.name}, found {len(matches)}") + return matches[0] + + +def run_source(nodes, path, namespace): + exec(compile(ast.Module(body=list(nodes), type_ignores=[]), str(path), "exec"), namespace) + return namespace + + +class Blueprint: + """Registers a view the way ``bp.route`` does, on a closed Flask app.""" + + def __init__(self, app): + self.app = app + + def route(self, path, methods): + def register(view): + self.app.add_url_rule(path, endpoint=view.__name__, view_func=view, methods=methods) + return view + return register + + +def unchanged(view): + return view + + +def refuse(name): + def refused(*_args, **_kwargs): + raise AssertionError(f"The code under test called {name}, which this test does not expect") + return refused + + +def workflow_settings(**overrides): + settings = { + "allow_user_workflows": True, + "require_member_of_workflow_user": False, + "enable_workflow_ai_assistant": True, + "allow_group_workflows": True, + "enable_user_workspace": True, + "enable_url_access": False, + "document_action_capabilities": {"analyze": {"enabled": True}, "comparison": {"enabled": True}}, + "azure_openai_gpt_key": SECRET, + } + for key, value in overrides.items(): + if value is MISSING: + settings.pop(key, None) + else: + settings[key] = value + return settings + + +def load_settings_functions(session, get_settings): + """The real gate helpers, decorators and settings sanitizer from ``functions_settings.py``.""" + return run_source(top_level_functions(SETTINGS_MODULE, SETTINGS_FUNCTIONS), SETTINGS_MODULE, { + "wraps": wraps, + "request": request, + "jsonify": jsonify, + "session": session, + "get_settings": get_settings, + "WORKFLOW_USER_APP_ROLE": top_level_constant(SETTINGS_MODULE, "WORKFLOW_USER_APP_ROLE"), + "TABULAR_GENERATION_BACKEND_SETTING_KEYS": frozenset(), + "sanitize_model_endpoints_for_frontend": lambda endpoints, include_connection_details=False: [], + "normalize_support_latest_features_visibility": lambda value: {}, + "has_visible_support_latest_features": lambda settings: False, + "get_public_workspace_label_context": lambda settings: {}, + }) + + +class FakeCompletions: + def __init__(self, harness): + self.harness = harness + + def create(self, **params): + self.harness.completions.append(copy.deepcopy(params)) + message = SimpleNamespace(content=self.harness.drafted) + return SimpleNamespace(choices=[SimpleNamespace(message=message)]) + + +class DraftHarness: + """The real draft route, its helpers and gates on a closed Flask app with a fake model client.""" + + def __init__(self, roles=(), group_error=None, **settings_overrides): + self.settings = workflow_settings(**settings_overrides) + self.session = {"user": {"roles": list(roles)}} + self.group_error = group_error + self.drafted = DRAFTED + self.models_resolved = [] + self.clients_created = [] + self.completions = [] + self.groups_resolved = [] + self.logs = [] + + gates = load_settings_functions(self.session, lambda: self.settings) + helpers = run_source(top_level_functions(ROUTES, DRAFT_HELPERS), ROUTES, { + "re": re, + "session": self.session, + "is_user_workflows_enabled_for_user": gates["is_user_workflows_enabled_for_user"], + "WORKFLOW_INSTRUCTION_FIELD_LIMIT": top_level_constant(ROUTES, "WORKFLOW_INSTRUCTION_FIELD_LIMIT"), + }) + app = Flask("workflow-draft-instructions-assistant-gate") + run_source([nested_route(ROUTES, "register_route_backend_workflows", DRAFT_PATH)], ROUTES, { + "bp": Blueprint(app), + "swagger_route": lambda **_security: unchanged, + "get_auth_security": lambda: [{"bearerAuth": []}], + "login_required": unchanged, + "user_required": unchanged, + "request": request, + "jsonify": jsonify, + "session": self.session, + "logging": logging, + "get_settings": lambda: self.settings, + "get_current_user_id": lambda: USER_ID, + "log_event": self.log_event, + "is_workflow_assistant_enabled_for_user": gates["is_workflow_assistant_enabled_for_user"], + "_assert_personal_workflow_draft_access": helpers["_assert_personal_workflow_draft_access"], + "_build_workflow_instruction_messages": helpers["_build_workflow_instruction_messages"], + "_resolve_active_group_for_workflow_management": self.resolve_group, + "_resolve_agent_instruction_model": self.resolve_model, + "_create_agent_instruction_client": self.create_client, + "_build_agent_instruction_api_params": lambda model_name, messages: { + "model": model_name, "messages": messages, + }, + }) + # The real decorator on a probe view gives the answer the route has to match. + app.add_url_rule( + "/decorator-probe", + endpoint="decorator_probe", + view_func=gates["workflow_assistant_required"](lambda: jsonify({"reached": True})), + methods=["POST"], + ) + self.client = app.test_client() + + def log_event(self, message, extra=None, level=None, **kwargs): + self.logs.append((message, copy.deepcopy(extra), level, kwargs)) + + def resolve_group(self, user_id): + self.groups_resolved.append(user_id) + if self.group_error is not None: + raise self.group_error + return {"id": GROUP_ID} + + def resolve_model(self, settings): + self.models_resolved.append(settings is self.settings) + return MODEL_NAME + + def create_client(self, settings): + self.clients_created.append(settings is self.settings) + return SimpleNamespace(chat=SimpleNamespace(completions=FakeCompletions(self))) + + def post(self, body=PERSONAL_DRAFT): + return self.client.post(DRAFT_PATH, json=body) + + def decorator_probe(self): + return self.client.post("/decorator-probe", json={}) + + def model_work(self): + return self.models_resolved, self.clients_created, self.completions + + +class WorkspaceHarness: + """The real classic workspace route on a closed Flask app, capturing what it renders.""" + + def __init__(self, roles=(), **settings_overrides): + self.settings = workflow_settings(**settings_overrides) + self.session = {"user": {"roles": list(roles)}} + self.rendered = [] + self.gates = load_settings_functions(self.session, lambda: self.settings) + app = Flask("workflow-draft-instructions-workspace") + run_source([nested_route(WORKSPACE_ROUTES, "register_route_frontend_workspace", "/workspace")], WORKSPACE_ROUTES, { + "bp": Blueprint(app), + "swagger_route": lambda **_security: unchanged, + "get_auth_security": lambda: [{"bearerAuth": []}], + "login_required": unchanged, + "user_required": unchanged, + "enabled_required": self.gates["enabled_required"], + "session": self.session, + "logging": logging, + "log_event": refuse("log_event"), + "get_current_user_id": lambda: USER_ID, + "get_current_user_info": lambda: {"userId": USER_ID}, + "get_settings": lambda: self.settings, + "get_user_settings": lambda user_id: {"settings": {}}, + "sanitize_settings_for_user": self.gates["sanitize_settings_for_user"], + "is_user_workflows_enabled_for_user": self.gates["is_user_workflows_enabled_for_user"], + "is_workflow_assistant_enabled_for_user": self.gates["is_workflow_assistant_enabled_for_user"], + "is_url_access_enabled_for_user": lambda settings, user_roles=None: bool(settings.get("enable_url_access")), + "build_workspace_section_availability": lambda settings, user_id, user_info=None, user_roles=None: { + "file_sync_enabled": False, "governance": {}, + }, + "cosmos_user_documents_container": SimpleNamespace(query_items=lambda **_query: iter([0])), + "get_allowed_extension_categories": lambda **_options: {}, + "CLIENTS": {}, + "sanitize_model_endpoints_for_frontend": lambda endpoints: list(endpoints or []), + "filter_governed_model_endpoints": lambda user_id, endpoints, governance_key: list(endpoints or []), + "get_user_groups": lambda user_id: [], + "get_user_visible_public_workspace_docs": lambda user_id: [], + "redirect": refuse("redirect"), + "url_for": refuse("url_for"), + "render_template": self.render_template, + }) + self.client = app.test_client() + + def render_template(self, template_name, **context): + self.rendered.append((template_name, copy.deepcopy(context))) + return "rendered" + + def page_settings(self): + response = self.client.get("/workspace") + if response.status_code != 200 or len(self.rendered) != 1: + raise AssertionError(f"/workspace answered {response.status_code} with {len(self.rendered)} renders") + template_name, context = self.rendered[0] + if template_name != "workspace.html": + raise AssertionError(f"/workspace rendered {template_name}") + return context["settings"] + + +def workflow_modal_source(template_name, workflow_flag): + """The classic workflow modal, from its opening ``{% if settings. %}`` to the matching endif.""" + lines = (TEMPLATE_DIR / template_name).read_text(encoding="utf-8").splitlines() + modal_lines = [index for index, line in enumerate(lines) if 'id="workflowModal"' in line] + if len(modal_lines) != 1: + raise AssertionError(f"{template_name} has {len(modal_lines)} workflow modals") + opening = re.compile(r"\{%-?\s*if\s+settings\." + re.escape(workflow_flag) + r"\s*-?%\}") + start = next((index for index in range(modal_lines[0], -1, -1) if opening.search(lines[index])), None) + if start is None: + raise AssertionError(f"{template_name} no longer wraps its workflow modal in settings.{workflow_flag}") + depth = 0 + for end in range(start, len(lines)): + for match in re.finditer(r"\{%-?\s*(if|endif)\b", lines[end]): + depth += 1 if match.group(1) == "if" else -1 + if depth == 0: + return "\n".join(lines[start:end + 1]) + raise AssertionError(f"{template_name} never closes its settings.{workflow_flag} block") + + +def render_workflow_modal(template_name, workflow_flag, settings): + environment = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)), autoescape=True) + return environment.from_string(workflow_modal_source(template_name, workflow_flag)).render(settings=settings) + + +def modal_settings(assistant): + settings = { + "allow_user_workflows": True, + "allow_group_workflows": True, + "enable_url_access": True, + "document_action_capabilities": {"analyze": {"enabled": True}, "comparison": {"enabled": True}}, + } + if assistant is not MISSING: + settings["enable_workflow_ai_assistant"] = assistant + return settings + + +def element_count(html, element_id): + return len(re.findall(r'\bid="' + re.escape(element_id) + r'"', html)) + + +# (settings overrides, session roles, expected flag) +WORKSPACE_FLAG_CASES = ( + ({}, (), True), + ({"enable_workflow_ai_assistant": False}, (), False), + ({"require_member_of_workflow_user": True}, (), False), + ({"require_member_of_workflow_user": True}, ("WorkflowUser",), True), + ({"require_member_of_workflow_user": True, "enable_workflow_ai_assistant": False}, ("WorkflowUser",), False), + ({"allow_user_workflows": False}, (), False), +) + + +def test_personal_drafting_with_the_assistant_on_reaches_the_model(): + assert_app_version_at_least("0.261.215") + for body in PERSONAL_REQUESTS[:-1]: + harness = DraftHarness() + response = harness.post(body) + payload = response.get_json() + + assert response.status_code == 200, (body, payload) + assert payload == {"success": True, "instructions": DRAFTED} + assert harness.models_resolved == [True] and harness.clients_created == [True] + assert len(harness.completions) == 1 and harness.completions[0]["model"] == MODEL_NAME + assert harness.groups_resolved == [] + assert [entry[1]["workflow_scope"] for entry in harness.logs] == ["personal"] + + harness = DraftHarness() + response = harness.post() + assert response.status_code == 200 and len(harness.completions) == 1 + user_message = harness.completions[0]["messages"][1]["content"] + assert "Task brief: Summarize new support tickets." in user_message + assert "Workflow name: Nightly review" in user_message + + empty = DraftHarness() + empty_response = empty.post({}) + assert (empty_response.status_code, empty_response.get_json()) == (400, NO_DRAFT_INPUT) + assert empty.model_work() == ([], [], []) + + +def test_personal_drafting_with_the_assistant_off_is_refused_before_any_model_work(): + for value in ASSISTANT_OFF_VALUES: + for body in PERSONAL_REQUESTS: + harness = DraftHarness(enable_workflow_ai_assistant=value) + response = harness.post(body) + probe = harness.decorator_probe() + text = response.get_data(as_text=True) + + assert response.status_code == 403, (value, body, response.get_json()) + assert response.get_json() == ASSISTANT_DISABLED + assert (probe.status_code, probe.get_json()) == (403, response.get_json()) + assert harness.model_work() == ([], [], []) + assert harness.groups_resolved == [] and harness.logs == [] + assert SECRET not in text and "Nightly review" not in text + + +def test_a_workflow_user_still_needs_the_assistant_for_personal_drafting(): + allowed = DraftHarness(roles=("WorkflowUser",), require_member_of_workflow_user=True) + allowed_response = allowed.post() + refused = DraftHarness( + roles=("workflowuser",), require_member_of_workflow_user=True, enable_workflow_ai_assistant=False, + ) + refused_response = refused.post() + + assert allowed_response.status_code == 200 and allowed_response.get_json()["success"] is True + assert len(allowed.completions) == 1 + assert (refused_response.status_code, refused_response.get_json()) == (403, ASSISTANT_DISABLED) + assert refused.model_work() == ([], [], []) + + +def test_personal_availability_and_role_answers_are_unchanged_and_come_first(): + cases = ( + ({"require_member_of_workflow_user": True}, (), 403, ROLE_REQUIRED), + ({"require_member_of_workflow_user": True, "enable_workflow_ai_assistant": False}, (), 403, ROLE_REQUIRED), + ({"require_member_of_workflow_user": True, "enable_workflow_ai_assistant": False}, ("Admin",), 403, ROLE_REQUIRED), + ({"allow_user_workflows": False}, (), 400, PERSONAL_DISABLED), + ({"allow_user_workflows": False, "enable_workflow_ai_assistant": False}, (), 400, PERSONAL_DISABLED), + ( + {"allow_user_workflows": False, "require_member_of_workflow_user": True, "enable_workflow_ai_assistant": False}, + ("WorkflowUser",), 400, PERSONAL_DISABLED, + ), + ) + for overrides, roles, status, body in cases: + harness = DraftHarness(roles=roles, **overrides) + response = harness.post() + + assert (response.status_code, response.get_json()) == (status, body), (overrides, roles) + assert harness.model_work() == ([], [], []) + assert harness.groups_resolved == [] + + +def test_group_drafting_does_not_follow_the_personal_assistant_setting(): + variants = ( + {"enable_workflow_ai_assistant": True}, + {"enable_workflow_ai_assistant": False}, + {"enable_workflow_ai_assistant": MISSING}, + {"enable_workflow_ai_assistant": False, "allow_user_workflows": False}, + {"enable_workflow_ai_assistant": False, "require_member_of_workflow_user": True}, + ) + for overrides in variants: + harness = DraftHarness(**overrides) + response = harness.post(GROUP_DRAFT) + + assert response.status_code == 200, (overrides, response.get_json()) + assert response.get_json() == {"success": True, "instructions": DRAFTED} + assert harness.groups_resolved == [USER_ID] + assert harness.clients_created == [True] and len(harness.completions) == 1 + assert [entry[1]["workflow_scope"] for entry in harness.logs] == ["group"] + + +def test_group_refusals_are_unchanged(): + refusals = ( + (PermissionError("You do not have permission to manage group workflows."), 403), + (LookupError("No active group is selected."), 403), + (ValueError("Group workflows are disabled."), 400), + ) + for error, status in refusals: + for assistant in (True, False): + harness = DraftHarness(group_error=error, enable_workflow_ai_assistant=assistant) + response = harness.post(GROUP_DRAFT) + + assert (response.status_code, response.get_json()) == (status, {"error": str(error)}) + assert harness.groups_resolved == [USER_ID] + assert harness.model_work() == ([], [], []) + + +def test_an_invalid_scope_is_still_rejected(): + for assistant in (True, False): + for scope in ("public", "global", "admin"): + harness = DraftHarness(enable_workflow_ai_assistant=assistant) + response = harness.post({"workflow_scope": scope, "brief": "Summarize new support tickets."}) + + assert (response.status_code, response.get_json()) == (400, INVALID_SCOPE), (assistant, scope) + assert harness.groups_resolved == [] + assert harness.model_work() == ([], [], []) + + +def test_the_route_is_not_wrapped_in_the_assistant_decorator(): + route = nested_route(ROUTES, "register_route_backend_workflows", DRAFT_PATH) + decorators = [ast.unparse(decorator) for decorator in route.decorator_list] + + # A route-level ``workflow_assistant_required`` would also refuse group drafting. + assert decorators == EXPECTED_DRAFT_DECORATORS + + +def test_the_classic_workspace_gets_a_role_aware_assistant_flag(): + for overrides, roles, expected in WORKSPACE_FLAG_CASES: + harness = WorkspaceHarness(roles=roles, **overrides) + page_settings = harness.page_settings() + helper_answer = harness.gates["is_workflow_assistant_enabled_for_user"](harness.settings, user_roles=list(roles)) + + assert page_settings["enable_workflow_ai_assistant"] is expected, (overrides, roles) + assert helper_answer is expected + assert "azure_openai_gpt_key" not in page_settings + assert SECRET not in repr(harness.rendered) + + +def test_the_personal_modal_hides_the_draft_controls_when_the_assistant_is_off(): + on_html = render_workflow_modal("workspace.html", "allow_user_workflows", modal_settings(True)) + off_renders = [ + render_workflow_modal("workspace.html", "allow_user_workflows", modal_settings(value)) + for value in (False, MISSING) + ] + + for element_id in DRAFT_CONTROL_IDS: + assert element_count(on_html, element_id) == 1, element_id + assert element_count(on_html, "workflow-task-prompt") == 1 + for off_html in off_renders: + for element_id in DRAFT_CONTROL_IDS: + assert element_count(off_html, element_id) == 0, element_id + assert 'for="workflow-task-brief"' not in off_html + assert element_count(off_html, "workflow-task-prompt") == 1 + assert element_count(off_html, "workflowModal") == 1 + + +def test_the_group_modal_keeps_its_draft_controls(): + group_source = (TEMPLATE_DIR / "group_workspaces.html").read_text(encoding="utf-8") + renders = [ + render_workflow_modal("group_workspaces.html", "allow_group_workflows", modal_settings(value)) + for value in (True, False, MISSING) + ] + + assert "enable_workflow_ai_assistant" not in group_source + for html in renders: + for element_id in (*DRAFT_CONTROL_IDS, "workflow-task-prompt"): + assert element_count(html, element_id) == 1, element_id + + +def test_the_workspace_route_and_template_hide_the_draft_button_together(): + for overrides, roles, expected in WORKSPACE_FLAG_CASES: + harness = WorkspaceHarness(roles=roles, **overrides) + page_settings = harness.page_settings() + html = render_workflow_modal("workspace.html", "allow_user_workflows", page_settings) + + assert element_count(html, "workflow-draft-instructions-btn") == (1 if expected else 0), (overrides, roles) + + +TESTS = ( + test_personal_drafting_with_the_assistant_on_reaches_the_model, + test_personal_drafting_with_the_assistant_off_is_refused_before_any_model_work, + test_a_workflow_user_still_needs_the_assistant_for_personal_drafting, + test_personal_availability_and_role_answers_are_unchanged_and_come_first, + test_group_drafting_does_not_follow_the_personal_assistant_setting, + test_group_refusals_are_unchanged, + test_an_invalid_scope_is_still_rejected, + test_the_route_is_not_wrapped_in_the_assistant_decorator, + test_the_classic_workspace_gets_a_role_aware_assistant_flag, + test_the_personal_modal_hides_the_draft_controls_when_the_assistant_is_off, + test_the_group_modal_keeps_its_draft_controls, + test_the_workspace_route_and_template_hide_the_draft_button_together, +) + + +def main(): + failures = 0 + for test in TESTS: + try: + test() + except Exception as exc: + failures += 1 + print(f"FAIL {test.__name__}: {exc!r}") + traceback.print_exc() + else: + print(f"PASS {test.__name__}") + print(f"{len(TESTS) - failures}/{len(TESTS)} tests passed") + return failures == 0 + + +if __name__ == "__main__": + sys.exit(0 if main() else 1)