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)