From 2bed2f26ca4b8c193ae66ae274a503d2950462dc Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 1 Oct 2026 09:10:32 -0400 Subject: [PATCH] Gate personal workflow instruction drafting on the AI workflow assistant setting POST /api/workflows/draft-instructions now refuses personal-scope requests with 403 workflow_assistant_disabled when the Enable AI Workflow Assistant setting is off for the signed-in user. The check runs after the existing personal workflow availability and WorkflowUser role checks, so those responses are unchanged, and no model client is created when it refuses. Group scope keeps following the group workflow settings. The classic personal workspace no longer renders the Task Brief box and Draft Workflow Instructions button when the assistant isn't available to the user. The group workspace is unchanged. VERSION 0.261.215. Refs #1548. Part of #1543. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../single_app/route_backend_workflows.py | 12 +- .../single_app/route_frontend_workspace.py | 4 + .../single_app/templates/workspace.html | 2 + docs/admin/workflow.md | 9 +- .../CHAT_ORCHESTRATION_WORKFLOWS_ROADMAP.md | 5 +- ...VOICE_ASSISTED_WORKFLOW_ACTION_CREATION.md | 3 +- .../features/WORKFLOW_AI_ASSISTANT.md | 22 +- ...W_DRAFT_INSTRUCTIONS_ASSISTANT_GATE_FIX.md | 150 +++++ docs/explanation/release_notes.md | 11 + ...kflow_draft_instructions_assistant_gate.py | 620 ++++++++++++++++++ 11 files changed, 825 insertions(+), 15 deletions(-) create mode 100644 docs/explanation/fixes/WORKFLOW_DRAFT_INSTRUCTIONS_ASSISTANT_GATE_FIX.md create mode 100644 functional_tests/test_workflow_draft_instructions_assistant_gate.py diff --git a/application/single_app/config.py b/application/single_app/config.py index c5e814cb4..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.213" +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 e32e53c40..c0c01c3bf 100644 --- a/application/single_app/route_backend_workflows.py +++ b/application/single_app/route_backend_workflows.py @@ -117,7 +117,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 @@ -1616,6 +1616,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 58978621b..bf166f53f 100644 --- a/docs/admin/workflow.md +++ b/docs/admin/workflow.md @@ -102,9 +102,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 554549239..879abb7c6 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.213)** #### 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)