0 ? clampRetryAfter(seconds) : null;
}
+/** A one-word `error`, such as the role check's "Forbidden", whose sentence is in `message`. */
+const ONE_WORD_ERROR = /^[A-Za-z][A-Za-z0-9_]*$/;
+
/**
* What a failed request means for the reader. The server's `error` is a safe template (the rate
* limit's is an admin's text, possibly Markdown); it is shown as plain text either way.
@@ -421,7 +424,9 @@ export function workflowAssistRetryAfter(header: string | null, body: unknown, n
export function describeWorkflowAssistError(status: number, body: unknown, retryAfterHeader: string | null = null): WorkflowAssistFailure {
const code = isRecord(body) && typeof body.code === 'string' && /^[a-z0-9_]{1,64}$/.test(body.code) ? body.code : '';
const fallback = STATUS_FALLBACKS[status] ?? `The assistant request failed (status ${status}). Nothing was changed.`;
- let message = (isRecord(body) ? serverText(body.error, SERVER_TEXT_LIMIT) : null) || fallback;
+ const error = isRecord(body) ? serverText(body.error, SERVER_TEXT_LIMIT) : null;
+ const sentence = isRecord(body) ? serverText(body.message, SERVER_TEXT_LIMIT) : null;
+ let message = (error && ONE_WORD_ERROR.test(error) && sentence ? sentence : error) || fallback;
if (code === 'workflow_deleted') message = `${message} Your draft is still in the editor.`;
const retryAfterSeconds = status === 429 || status === 503 ? workflowAssistRetryAfter(retryAfterHeader, body) : null;
return {
diff --git a/functional_tests/test_v2_workflow_ask_ai_logic.ts b/functional_tests/test_v2_workflow_ask_ai_logic.ts
index de5e76796..0896cd9e7 100644
--- a/functional_tests/test_v2_workflow_ask_ai_logic.ts
+++ b/functional_tests/test_v2_workflow_ask_ai_logic.ts
@@ -526,6 +526,12 @@ function failureChecks() {
describe(400, { error: 'Choose a time zone.', code: 'time_zone_invalid' }).code === 'time_zone_invalid'
&& describe(400, { error: 'Too large.', code: 'assistant_input_too_large' }).code === 'assistant_input_too_large');
check('a 403 explains Ask AI is not available', describe(403, { code: 'workflow_assistant_disabled' }).message === "Ask AI isn't available to you right now.");
+ const role = 'Personal workflows require the WorkflowUser app role.';
+ check('a one-word 403 error shows its sentence', describe(403, { error: 'Forbidden', message: role }).message === role);
+ check('a one-word error without a sentence is shown as is', describe(403, { error: 'Forbidden' }).message === 'Forbidden'
+ && describe(403, { error: 'Forbidden', message: ' ' }).message === 'Forbidden');
+ check('a sentence error is not replaced by a message', describe(400, { error: 'Allow User Workflows is disabled.', message: 'x' }).message
+ === 'Allow User Workflows is disabled.');
check('a 404 says the workflow could not be found', describe(404, { code: 'workflow_not_found' }).message.includes("couldn't be found"));
const conflict = describe(409, { error: 'The workflow changed.', code: 'workflow_definition_conflict' });
check('a definition conflict offers a reload', conflict.reload && conflict.code === 'workflow_definition_conflict');
diff --git a/ui_tests/fixtures/workflow_ask_ai.py b/ui_tests/fixtures/workflow_ask_ai.py
new file mode 100644
index 000000000..12420c70f
--- /dev/null
+++ b/ui_tests/fixtures/workflow_ask_ai.py
@@ -0,0 +1,230 @@
+# workflow_ask_ai.py
+"""
+Browser stubs for the workflow editor's Ask AI tab (Phase 3c).
+Version: 0.261.211
+Implemented in: 0.261.211
+Refs: microsoft/simplechat#1548
+
+`POST /api/user/workflows/assist` is answered inside the page, never by a live model. A normal
+answer comes from 3b's real pipeline (`run_workflow_assist`) over the request the browser sent,
+with a scripted model and the fixture's own records, so its candidate, change list, warnings and
+documents are what the server would send for that draft. A refusal is the real
+`WorkflowAssistError` payload. Both are serialized the way the route does it: strict JSON with
+sorted keys, `Cache-Control: no-store, private`, and `Retry-After` when the error carries one.
+
+`POST /api/workflows/draft-instructions` is answered with fixed instructions, in the route's shape.
+
+A handler never raises: a problem is recorded in `unexpected` and answered with a 500, because a
+raise inside a Playwright route handler would leave the request hanging.
+"""
+
+import copy
+import json
+import sys
+from pathlib import Path
+
+from playwright.sync_api import Error as PlaywrightError
+
+ROOT = Path(__file__).resolve().parents[2]
+for _path in (ROOT / "application" / "single_app", ROOT / "functional_tests"):
+ if str(_path) not in sys.path:
+ sys.path.insert(0, str(_path))
+
+# The application and functional-test helpers are importable only after the path setup above.
+import functions_workflow_assist as core # noqa: E402
+from test_support import workflow_assist as wa # noqa: E402
+
+
+ASSIST_ROUTE = "**/api/user/workflows/assist"
+DRAFT_INSTRUCTIONS_ROUTE = "**/api/workflows/draft-instructions"
+
+
+def _fulfill(route, status, payload, headers=None):
+ route.fulfill(
+ status=status,
+ body=json.dumps(payload, allow_nan=False, sort_keys=True) + "\n",
+ content_type="application/json",
+ headers={"Cache-Control": "no-store, private", **(headers or {})},
+ )
+
+
+def _failure(exc, settings):
+ headers = {"Retry-After": str(exc.retry_after)} if exc.retry_after is not None else {}
+ return exc.status, exc.payload(settings), headers
+
+
+class AssistStub:
+ """Answers each assist request with the next queued answer, optionally holding the response.
+
+ An answer is a callable taking the raw request body and returning ``(status, payload, headers)``.
+ Build them with ``pipeline``, ``changed``, ``said``, ``refused``, ``canned`` or ``custom``.
+ """
+
+ def __init__(self, *, user_id, record_error, options, read_base, resolve, settings=None):
+ self.user_id = user_id
+ self.record_error = record_error
+ self.options = options
+ self.read_base = read_base
+ self.resolve = resolve
+ self.settings = settings if settings is not None else {}
+ self.bodies = []
+ self.headers = []
+ self.answers = []
+ self.models = []
+ self.hold = 0
+ self.held = []
+ self.unexpected = []
+
+ # -- answers -------------------------------------------------------------------------------
+
+ def queue(self, *answers):
+ self.answers.extend(answers)
+ return self
+
+ def pipeline(self, *replies, limiter=None):
+ """3b's pipeline over the sent request, with ``replies`` as the model's scripted answers.
+
+ A reply is the model's text, or a callable that gets the parsed request and returns that
+ text, for answers that name the request's own `task_N` handles.
+ """
+
+ def answer(raw):
+ try:
+ body = core.parse_assist_body(raw)
+ except core.WorkflowAssistError as exc:
+ # The route answers a body it can't read before the run, without Retry-After.
+ return exc.status, exc.payload(self.settings), {}
+ request = json.loads(raw.decode("utf-8"))
+ model = wa.ScriptedModel(*(reply(request) if callable(reply) else reply for reply in replies))
+ self.models.append(model)
+ services = wa.services(
+ model, options=self.options(), resolve=self.resolve, read_base=self.read_base,
+ limiter=limiter,
+ )
+ try:
+ return 200, core.run_workflow_assist(body, user_id=self.user_id, services=services), {}
+ except core.WorkflowAssistError as exc:
+ return _failure(exc, self.settings)
+
+ return answer
+
+ def changed(self, *operations, text="Done."):
+ """A ``changed`` answer. An operation may be a callable that gets the parsed request."""
+ return self.pipeline(lambda body: wa.reply(
+ "changed", text, [operation(body) if callable(operation) else operation for operation in operations],
+ ))
+
+ def said(self, outcome, text):
+ return self.pipeline(wa.reply(outcome, text))
+
+ def refused(self, code, message=None, retry_after=None):
+ """A refusal the pipeline raises before or around the model, as the route answers it."""
+ exc = core.WorkflowAssistError(code, message, retry_after=retry_after)
+ return lambda _raw: _failure(exc, self.settings)
+
+ @staticmethod
+ def canned(status, payload, headers=None):
+ return lambda _raw: (status, copy.deepcopy(payload), dict(headers or {}))
+
+ @staticmethod
+ def custom(build):
+ """``build(body)`` gets the parsed request and returns ``(status, payload, headers)``."""
+ return lambda raw: build(json.loads(raw.decode("utf-8")))
+
+ # -- the route -----------------------------------------------------------------------------
+
+ def handle(self, route):
+ request = route.request
+ if request.method != "POST":
+ self.unexpected.append(f"{request.method} {request.url}")
+ self._send(route, 405, {"error": "Method not allowed."})
+ return
+ headers = request.headers
+ self.headers.append(dict(headers))
+ if "application/json" not in headers.get("content-type", ""):
+ self.unexpected.append(f"assist request without a JSON body: {headers.get('content-type')!r}")
+ raw = request.post_data_buffer or b""
+ try:
+ self.bodies.append(json.loads(raw.decode("utf-8")))
+ except (UnicodeDecodeError, ValueError):
+ self.bodies.append(None)
+ self.unexpected.append("assist request body was not JSON")
+ if not self.answers:
+ self.unexpected.append("an assist request arrived with no answer queued")
+ self._send(route, 500, core.WorkflowAssistError("assistant_failed").payload())
+ return
+ answer = self.answers.pop(0)
+ try:
+ status, payload, reply_headers = answer(raw)
+ except Exception as exc: # A raise here would leave the browser waiting forever.
+ self.unexpected.append(f"the assist stub failed: {type(exc).__name__}: {exc}")
+ status, payload, reply_headers = 500, core.WorkflowAssistError("assistant_failed").payload(), {}
+ if self.hold:
+ self.hold -= 1
+ if status >= 400:
+ self.record_error(request.url, status)
+ self.held.append((route, status, payload, reply_headers))
+ return
+ self._send(route, status, payload, reply_headers)
+
+ def _send(self, route, status, payload, headers=None):
+ if status >= 400:
+ self.record_error(route.request.url, status)
+ _fulfill(route, status, payload, headers)
+
+ def release(self):
+ """Answer every held request. One the browser cancelled is gone, and that is fine."""
+ held, self.held = self.held, []
+ for route, status, payload, headers in held:
+ try:
+ _fulfill(route, status, payload, headers)
+ except PlaywrightError:
+ pass
+
+ def wait_for_requests(self, page, count, timeout_ms=10000):
+ waited = 0
+ while len(self.bodies) < count:
+ if waited >= timeout_ms:
+ raise AssertionError(f"expected {count} assist requests, saw {len(self.bodies)}")
+ page.wait_for_timeout(50)
+ waited += 50
+ return self.bodies[count - 1]
+
+ def assert_clean(self):
+ assert not self.unexpected, self.unexpected
+ assert not self.answers, f"{len(self.answers)} queued assist answer(s) were never requested"
+ assert not self.held and not self.hold, "an assist response was left held"
+
+
+class DraftInstructionsStub:
+ """Answers the draft-instructions route with the next queued instructions, or an error."""
+
+ def __init__(self, *, record_error):
+ self.record_error = record_error
+ self.bodies = []
+ self.answers = []
+ self.unexpected = []
+
+ def queue(self, instructions=None, *, status=200, error=None):
+ self.answers.append((status, {"success": True, "instructions": instructions} if status == 200 else {"error": error}))
+ return self
+
+ def handle(self, route):
+ request = route.request
+ try:
+ self.bodies.append(request.post_data_json)
+ except (ValueError, PlaywrightError):
+ self.bodies.append(None)
+ self.unexpected.append("draft-instructions body was not JSON")
+ if request.method != "POST" or not self.answers:
+ self.unexpected.append(f"unexpected {request.method} {request.url}")
+ status, payload = 500, {"error": "Failed to draft workflow instructions."}
+ else:
+ status, payload = self.answers.pop(0)
+ if status >= 400:
+ self.record_error(request.url, status)
+ route.fulfill(status=status, json=payload)
+
+ def assert_clean(self):
+ assert not self.unexpected, self.unexpected
+ assert not self.answers, "a queued draft-instructions answer was never requested"
diff --git a/ui_tests/test_v2_workflow_ask_ai.py b/ui_tests/test_v2_workflow_ask_ai.py
new file mode 100644
index 000000000..febf480c1
--- /dev/null
+++ b/ui_tests/test_v2_workflow_ask_ai.py
@@ -0,0 +1,1250 @@
+# test_v2_workflow_ask_ai.py
+"""
+Offline real-bundle browser regressions for the workflow editor's Ask AI tab (Phase 3c).
+Version: 0.261.211
+Implemented in: 0.261.211
+Refs: microsoft/simplechat#1548
+
+Covers the "Done when" instruction end to end (highlights, Previously, Undo this change, a single
+field Revert and Confirm and save, with the Run as case), the three `#` placement outcomes and a
+document read only as context, immediate send with the editor lock, its timer, Cancel and Retry,
+the stale-draft guard, the request each kind of draft sends, code-point counting, Undo with a key
+that changed later, every status the assist route answers, hostile model text, Jump to on a task
+field and on a flow block, where the tab is hidden, the keyboard, a narrow screen, Draft with AI,
+the quick actions, and a card from an earlier editing session.
+
+`POST /api/user/workflows/assist` is answered in the page by 3b's real pipeline with a scripted
+model (see `fixtures/workflow_ask_ai.py`), so no live model, workflow run, approval or Azure
+service is contacted. Reuses the closed Flow authoring harness, its local production assets and
+the real compiler. Run with PLAYWRIGHT_SERVICE_URL='' and PYTHONPATH including
+application/single_app, functional_tests and ui_tests/fixtures.
+"""
+
+import copy
+import json
+import re
+import sys
+from pathlib import Path
+
+import pytest
+from playwright.sync_api import expect
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+sys.path.insert(0, str(ROOT / "ui_tests"))
+sys.path.insert(0, str(ROOT / "ui_tests" / "fixtures"))
+
+# The shared fixtures import pure application helpers after setting their paths.
+from ui_tests import test_v2_workflow_flow_authoring as authoring
+from ui_tests.fixtures.workflow_ask_ai import (
+ ASSIST_ROUTE,
+ DRAFT_INSTRUCTIONS_ROUTE,
+ AssistStub,
+ DraftInstructionsStub,
+ core,
+)
+from ui_tests.fixtures.workflow_editor import OWNER_ID, WORKFLOW_ID
+from ui_tests.fixtures.workflow_flow import FLOW_WORKFLOW_ID, GROUP_ID
+from ui_tests.fixtures.workspace_authoring import ORIGIN
+from ui_tests.test_v2_workflow_change_tracking import (
+ RUN_AS_NOTE,
+ UNSUPPORTED_SCHEDULE,
+ assert_dialog_fits,
+ author,
+ changed,
+ changes_toggle,
+ open_classic,
+ open_panel,
+ previously,
+ side_panel,
+ task_item,
+)
+from ui_tests.test_v2_workflow_flow_authoring import connect_options # noqa: F401
+from functions_workflow_definitions import workflow_definition_revision # noqa: E402
+from functions_workflow_flow import compile_workflow_flow # noqa: E402
+
+
+TIME_ZONE = "America/Chicago"
+pytestmark = [pytest.mark.ui, pytest.mark.browser_context_args(timezone_id=TIME_ZONE)]
+
+DONE_WHEN = "run this at 7 AM on weekdays and only alert me when something is urgent"
+WEEKDAYS_AT_SEVEN = {"op": "set_schedule_calendar", "frequency": "weekdays", "time_of_day": "07:00"}
+URGENT_ONLY = [
+ {"op": "set_alert_mode", "mode": "rules"},
+ {
+ "op": "add_alert_rule", "name": "Urgent findings", "severity": "high",
+ "condition": {"type": "model_evaluation", "prompt": "The results include something urgent."},
+ },
+]
+REQUEST_KEYS = {"submission_id", "base", "instruction", "conversation", "focus", "time_zone", "draft", "references"}
+SUBMISSION_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
+BRIEF_REF = {"kind": "document", "id": "personal-brief", "scope": {"kind": "personal", "id": None}, "label": "Private brief"}
+BRIEF_KEY = "reference:personal%3Aworkspace-editor-user%3Apersonal-brief"
+STALE_DRAFT = "Nothing was applied because the draft changed while Ask AI was working. Send it again to use the current draft."
+CANCELLED = "Cancelled. Nothing was changed."
+EMAIL_MESSAGE = (
+ "This task sends email, which needs an agent with the Microsoft 365 action. "
+ "Choose such an agent for the task or the workflow."
+)
+M365_MESSAGE = "Connect your Microsoft 365 account so this workflow can send email."
+DRAFT_ERRORS_MESSAGE = "The draft already had a problem that saving will report. The assistant did not cause it; fix it before saving."
+RUN_AS_WARNING = "Saving this change requires re-approving Run as, because it changes how the workflow runs."
+NEEDS_NAME = "Name the workflow or this task first, so the assistant knows what to draft."
+UNREADABLE = "The assistant's answer couldn't be read. Nothing was changed."
+UNUSABLE = "The assistant's answer couldn't be used. Nothing was changed. Try again."
+EXPLAIN = "Explain what this workflow does, step by step. Don't change anything."
+WAIT_NOTE = re.compile(r"^You can send again in [1-9]\d* s\.$")
+# Model and admin text that would run, link or format if it were ever rendered as HTML or Markdown.
+HOSTILE = (
+ '

[Open this](javascript:window.__hostile=2) '
+ "**urgent** "
+)
+RATE_LIMIT_TEXT = (
+ "**Slow down.**\n\nSee [the policy](https://example.com/policy) "
+ '

and
wait a moment.'
+)
+MARKUP = "img, script, a, strong, b, em, i"
+
+
+class AskAiFixture(authoring.WorkflowAuthoringFixture):
+ """The Flow authoring harness with Ask AI on for this user and its two routes stubbed in the page."""
+
+ def __init__(self, page):
+ super().__init__(page)
+ self.ask_ai_enabled = True
+ self.document_searches = []
+
+ def record_error(url, status):
+ self.expected_http_errors.add((url, status))
+
+ self.assist = AssistStub(
+ user_id=OWNER_ID, record_error=record_error, options=self._assist_options,
+ read_base=self._read_base, resolve=self._resolve,
+ )
+ self.drafts = DraftInstructionsStub(record_error=record_error)
+ # page.route takes precedence over the harness's context route, so neither request reaches it.
+ page.route(ASSIST_ROUTE, self.assist.handle)
+ page.route(DRAFT_INSTRUCTIONS_ROUTE, self.drafts.handle)
+
+ def _bootstrap(self):
+ payload = super()._bootstrap()
+ # The bootstrap sends this per user: on only where the setting, personal workflows and the role allow it.
+ payload["features"]["enable_workflow_ai_assistant"] = self.ask_ai_enabled
+ return payload
+
+ def _options(self, group):
+ options = super()._options(group)
+ options["schedule"]["timezones"] = [
+ zone for zone in options["schedule"]["timezones"] if zone not in self.unlisted_schedule_timezones
+ ]
+ return options
+
+ def _assist_options(self):
+ """The personal editor options the browser was given, which the pipeline checks the draft against."""
+ options = self._options(False)
+ options["max_tasks"] = self.max_tasks
+ options["can_manage"] = True
+ return options
+
+ def _read_base(self, user_id, workflow_id):
+ stored = self.personal_workflows.get(workflow_id)
+ if stored is None or user_id != OWNER_ID:
+ return None
+ record = copy.deepcopy(stored)
+ record.setdefault("user_id", OWNER_ID)
+ return record
+
+ def _resolve(self, user_id, references):
+ """`resolve_scope_references` for this user's own documents; anything else is unavailable."""
+ documents = {document["id"]: document for document in self.documents}
+ resolved = []
+ for reference in references:
+ document = documents.get(reference.get("id"))
+ if reference.get("kind") != "document" or (reference.get("scope") or {}).get("kind") != "personal" \
+ or document is None:
+ raise core.WorkflowAssistError("reference_unavailable")
+ resolved.append({
+ "kind": "document", "id": document["id"], "label": document["file_name"],
+ "scope": {"kind": "personal", "id": user_id, "name": "Personal"},
+ })
+ return resolved
+
+ def _dispatch(self, route, entry):
+ # The # menu searches personal and group documents, the group route with one comma-joined list.
+ if entry.method == "GET" and entry.path in ("/api/documents", "/api/group_documents"):
+ if entry.path == "/api/documents":
+ documents = self.documents
+ else:
+ group_ids = [
+ group_id for value in entry.query.get("group_ids", []) for group_id in value.split(",") if group_id
+ ]
+ if not group_ids or any(group_id not in self.group_workflows for group_id in group_ids):
+ self.unexpected_requests.append(f"GET {entry.path} for {group_ids}")
+ documents = [document for group_id in group_ids for document in self.group_documents.get(group_id, [])]
+ search = entry.query.get("search", [""])[0].strip().lower()
+ if search:
+ self.document_searches.append((entry.path, search))
+ documents = [
+ document for document in documents
+ if search in f"{document.get('title', '')} {document.get('file_name', '')}".lower()
+ ]
+ self._json(route, {"documents": documents, "total_count": len(documents)})
+ return
+ super()._dispatch(route, entry)
+
+ def assert_clean(self):
+ self.assist.assert_clean()
+ self.drafts.assert_clean()
+ for page in self.pages:
+ if not page.is_closed() and page.url.startswith(ORIGIN):
+ assert page.evaluate("() => window.__hostile ?? null") is None, "Model text ran as HTML."
+ super().assert_clean()
+
+
+@pytest.fixture
+def ask_ui(page):
+ fixture = AskAiFixture(page)
+ yield fixture
+ try:
+ fixture.assert_clean()
+ finally:
+ fixture.assist.release()
+ fixture.release_flow_responses()
+ fixture.release_save_responses()
+ fixture.flow_response_gates.clear()
+
+
+# ---------------------------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------------------------
+
+def ask_toggle(editor):
+ return editor.get_by_role("button", name="Ask AI", exact=True)
+
+
+def composer(scope):
+ return scope.get_by_role("textbox", name="Message Ask AI", exact=True)
+
+
+def send_button(scope):
+ return scope.get_by_role("button", name="Send to Ask AI", exact=True)
+
+
+def open_ask_ai(editor):
+ toggle = ask_toggle(editor)
+ toggle.click()
+ expect(toggle).to_have_attribute("aria-expanded", "true")
+ panel = side_panel(editor)
+ expect(panel.get_by_role("tab", name="Ask AI", exact=True)).to_have_attribute("aria-selected", "true")
+ expect(composer(panel)).to_be_focused()
+ return panel
+
+
+def exchanges(panel):
+ return panel.locator("li[data-testid='assist-exchange']")
+
+
+def card(panel, submission_id):
+ return panel.locator(f"[data-workflow-assist-card='{submission_id}']")
+
+
+def type_and_send(panel, text):
+ box = composer(panel)
+ box.fill(text)
+ box.press("Enter")
+
+
+def ask(ui, panel, text):
+ """Send `text` and return the request body the assist route received."""
+ count = len(ui.assist.bodies)
+ type_and_send(panel, text)
+ return ui.assist.wait_for_requests(ui.page, count + 1)
+
+
+def answered(panel, body):
+ """The answer card for a request, once it is in."""
+ found = card(panel, body["submission_id"])
+ expect(found).to_be_visible()
+ return found
+
+
+def failed(panel):
+ exchange = exchanges(panel).last
+ expect(exchange).to_have_attribute("data-status", "failed")
+ return exchange
+
+
+def change_keys(found):
+ return found.locator("li[data-workflow-assist-change]").evaluate_all(
+ "items => items.map((item) => item.dataset.workflowAssistChange)")
+
+
+def undo_button(found):
+ return found.get_by_role("button", name=re.compile(r"^Undo this change: "))
+
+
+def with_warnings(answer, *warnings):
+ """An answer with more warnings after the pipeline's own, for the codes a fixture can't provoke."""
+
+ def wrapped(raw):
+ status, payload, headers = answer(raw)
+ payload = copy.deepcopy(payload)
+ payload["warnings"] = [*payload.get("warnings", []), *copy.deepcopy(warnings)]
+ return status, payload, headers
+
+ return wrapped
+
+
+def confirm_save(ui, editor, *, run_as=False):
+ """Save with AI changes: Review before saving opens first, and only Confirm and save writes."""
+ before = len(ui.workflow_writes)
+ editor.get_by_role("button", name="Save workflow", exact=True).click()
+ panel = side_panel(editor)
+ expect(panel.get_by_role("tab", name="Changes", exact=True)).to_have_attribute("aria-selected", "true")
+ review = panel.get_by_role("region", name="Review before saving", exact=True)
+ expect(review.get_by_role("heading", name="Review before saving", exact=True)).to_be_focused()
+ # The Run as note also heads the tab, so the review's own line is the one checked.
+ expect(review.locator("li").filter(has_text=RUN_AS_NOTE)).to_have_count(1 if run_as else 0)
+ assert len(ui.workflow_writes) == before, "Save wrote before Confirm and save."
+ review.get_by_role("button", name="Confirm and save", exact=True).click()
+ expect(editor).to_have_count(0)
+ assert len(ui.workflow_writes) == before + 1
+ ui.assert_authoring_only()
+ return ui.workflow_writes[-1].body
+
+
+def pick_brief(ui, panel, before="Use "):
+ """Type `before`, then pick Private brief from the # menu by keyboard."""
+ box = composer(panel)
+ box.fill(before)
+ # Typed, not filled: fill() sends no key events, so the # menu would not open until the next key.
+ box.press_sequentially("#priv")
+ options = ui.page.get_by_role("listbox", name="Context suggestions").get_by_role("option")
+ expect(options.first).to_contain_text("Private brief")
+ expect(options.first).to_have_attribute("aria-selected", "true")
+ box.press("Enter")
+ expect(box).to_have_value(f"{before}#[Private brief] ")
+ return box
+
+
+# ---------------------------------------------------------------------------------------------
+# Done when
+# ---------------------------------------------------------------------------------------------
+
+def test_done_when_instruction_is_highlighted_undone_reverted_and_confirmed(ask_ui):
+ ui, page = ask_ui, ask_ui.page
+ reply = "Scheduled for 7 AM on weekdays, with alerts only for urgent results."
+ ui.assist.queue(
+ ui.assist.changed(WEEKDAYS_AT_SEVEN, *URGENT_ONLY, text=reply),
+ ui.assist.changed(WEEKDAYS_AT_SEVEN, *URGENT_ONLY, text=reply),
+ )
+ editor = open_classic(ui)
+ panel = open_ask_ai(editor)
+
+ body = ask(ui, panel, DONE_WHEN)
+ assert set(body) == REQUEST_KEYS
+ assert SUBMISSION_ID.fullmatch(body["submission_id"])
+ stored = ui.personal_workflows[WORKFLOW_ID]
+ assert body["base"] == {"workflow_id": WORKFLOW_ID, "definition_revision": stored["definition_revision"]}
+ assert body["draft"]["id"] == WORKFLOW_ID
+ assert body["instruction"] == DONE_WHEN
+ assert body["conversation"] == [] and body["references"] == [] and body["focus"] is None
+ assert body["time_zone"] == TIME_ZONE
+
+ first = answered(panel, body)
+ expect(first).to_have_attribute("data-outcome", "changed")
+ expect(first).to_have_attribute("data-state", "applied")
+ expect(first.locator("[data-workflow-assist-reply]")).to_have_text(reply)
+ expect(first.locator("[data-workflow-assist-state]")).to_have_text(
+ "Changed 2 things in the draft. Review before saving.")
+ assert change_keys(first) == ["schedule", "alerts"]
+ expect(first.locator("li[data-workflow-assist-change='schedule']")).to_contain_text("Workflow: Trigger and schedule")
+ expect(first.locator("li[data-workflow-assist-change='alerts']")).to_contain_text("Workflow: Alerts")
+ expect(composer(panel)).to_be_focused()
+
+ schedule, alerts = changed(editor, "schedule"), changed(editor, "alerts")
+ expect(author(schedule)).to_have_text("AI assist")
+ expect(author(schedule)).to_have_attribute("data-workflow-change-author", "ai")
+ expect(author(alerts)).to_have_text("AI assist")
+ expect(previously(schedule)).to_contain_text("Manual")
+ expect(editor.get_by_label("Trigger", exact=True)).to_have_value("interval")
+ expect(editor.get_by_label("Repeats", exact=True)).to_have_value("weekdays")
+ expect(editor.get_by_label("Time", exact=True)).to_have_value("07:00")
+ expect(editor.get_by_label("Time zone", exact=True)).to_have_value(TIME_ZONE)
+ expect(editor.get_by_label("Alert rule 1 name", exact=True)).to_have_value("Urgent findings")
+ expect(changes_toggle(editor)).to_have_accessible_name("Changes (2 unsaved)")
+
+ undo_button(first).click()
+ result = first.locator("[data-workflow-assist-undo]")
+ expect(result).to_have_attribute("data-workflow-assist-undo", "applied")
+ expect(result).to_be_focused()
+ expect(result.locator("p").first).to_have_text("Undone: 2 reverted.")
+ expect(result.get_by_role("list", name="Reverted").get_by_role("listitem")).to_have_text(
+ ["Trigger and schedule", "Alerts"])
+ expect(first).to_have_attribute("data-state", "undone")
+ expect(first.locator("[data-workflow-assist-state]")).to_have_text("These changes were undone.")
+ expect(changed(editor, "schedule")).to_have_count(0)
+ expect(changed(editor, "alerts")).to_have_count(0)
+ expect(editor.get_by_label("Trigger", exact=True)).to_have_value("manual")
+ expect(changes_toggle(editor)).to_have_accessible_name("Changes (0 unsaved)")
+
+ # The next turn replays the first as its reply plus what happened to its changes.
+ again = ask(ui, panel, "Apply that schedule and alert again.")
+ assert again["conversation"] == [
+ {"role": "user", "text": DONE_WHEN},
+ {"role": "assistant", "text": (
+ f"{reply}\n\nThese changes were applied and later undone: "
+ "Workflow: Trigger and schedule; Workflow: Alerts."
+ )},
+ ]
+ second = answered(panel, again)
+ expect(second).to_have_attribute("data-state", "applied")
+ expect(author(changed(editor, "alerts"))).to_have_text("AI assist")
+
+ changed(editor, "alerts").get_by_role("button", name="Revert", exact=True).click()
+ expect(changed(editor, "alerts")).to_have_count(0)
+ expect(author(changed(editor, "schedule"))).to_have_text("AI assist")
+ expect(changes_toggle(editor)).to_have_accessible_name("Changes (1 unsaved)")
+
+ payload = confirm_save(ui, editor)
+ assert payload["trigger_type"] == "interval"
+ assert payload["schedule"]["kind"] == "calendar"
+ assert payload["schedule"]["frequency"] == "weekdays"
+ assert payload["schedule"]["time_of_day"] == "07:00"
+ assert payload["schedule"]["timezone"] == TIME_ZONE
+ assert "Urgent findings" not in json.dumps(payload)
+ assert len(ui.assist.bodies) == 2
+ page.wait_for_timeout(100)
+
+
+def test_run_as_warnings_and_review_before_saving(ask_ui):
+ ui = ask_ui
+ ui.personal_workflows[WORKFLOW_ID]["m365_run_as_user_id"] = OWNER_ID
+ instructions = "Collect only signed evidence."
+ ui.assist.queue(with_warnings(
+ ui.assist.changed({"op": "set_task_instructions", "task": "task_1", "instructions": instructions}),
+ {"code": "email_requires_m365_agent", "message": EMAIL_MESSAGE, "target": {"focus_key": "task:task-b"}},
+ {"code": "m365_not_connected", "message": M365_MESSAGE},
+ {"code": "draft_has_errors", "message": DRAFT_ERRORS_MESSAGE},
+ ))
+ editor = open_classic(ui)
+ expect(editor.get_by_label("Microsoft 365 Run as", exact=True)).to_have_value(OWNER_ID)
+ panel = open_ask_ai(editor)
+ found = answered(panel, ask(ui, panel, "Collect only signed evidence in the first task."))
+
+ assert change_keys(found) == ["task:task-a:instructions"]
+ warnings = found.get_by_role("list", name="Warnings").locator("li[data-workflow-assist-warning]")
+ expect(warnings).to_have_count(4)
+ assert warnings.evaluate_all("items => items.map((item) => item.dataset.workflowAssistWarning)") == [
+ "run_as_reapproval", "email_requires_m365_agent", "m365_not_connected", "draft_has_errors",
+ ]
+ expect(warnings).to_have_text([RUN_AS_WARNING, f"{EMAIL_MESSAGE}Jump to", M365_MESSAGE, DRAFT_ERRORS_MESSAGE])
+ frame = changed(editor, "task:task-a:instructions")
+ expect(author(frame)).to_have_text("AI assist")
+ expect(frame.get_by_label("Instructions", exact=True)).to_have_value(instructions)
+
+ found.get_by_role("button", name=f"Jump to: {EMAIL_MESSAGE}", exact=True).click()
+ expect(task_item(editor, "task-b").get_by_label("Task name", exact=True)).to_be_focused()
+
+ payload = confirm_save(ui, editor, run_as=True)
+ assert payload["m365_run_as_user_id"] == OWNER_ID
+ assert payload["tasks"][0]["instructions"] == instructions
+
+
+# ---------------------------------------------------------------------------------------------
+# # references
+# ---------------------------------------------------------------------------------------------
+
+@pytest.mark.parametrize("placement", ["shared", "document", "question", "context"])
+def test_hash_document_placements_and_context(ask_ui, placement):
+ ui = ask_ui
+ answers = {
+ "shared": ui.assist.changed({"op": "bind_reference", "document": "ref_1", "tasks": ["task_2"]}),
+ "document": ui.assist.changed({
+ "op": "set_task_document_target", "task": "task_2", "action": "analyze", "documents": ["ref_1"],
+ }),
+ "question": ui.assist.said("question", "Should both tasks read Private brief, or only the summary?"),
+ "context": ui.assist.said("explained", "The brief asks for a quarterly review of signed evidence."),
+ }
+ ui.assist.queue(answers[placement])
+ editor = open_classic(ui)
+ panel = open_ask_ai(editor)
+ box = pick_brief(ui, panel)
+ box.press_sequentially("for the summary task")
+ box.press("Enter")
+ body = ui.assist.wait_for_requests(ui.page, 1)
+ assert body["instruction"] == "Use #[Private brief] for the summary task"
+ assert body["references"] == [BRIEF_REF]
+ assert ui.document_searches and all(search == "priv" for _, search in ui.document_searches[-2:])
+
+ found = answered(panel, body)
+ expect(exchanges(panel).last.get_by_test_id("assist-reference-chip")).to_have_text("Document: Private brief")
+ context = found.locator("[data-workflow-assist-context]")
+ if placement == "shared":
+ assert change_keys(found) == [BRIEF_KEY, "task:task-a:reference_ids"]
+ expect(author(changed(editor, "task:task-a:reference_ids"))).to_have_text("AI assist")
+ shared = editor.get_by_role("region", name="Workflow shared references", exact=True)
+ expect(shared.locator(f"[data-workflow-change-key='{BRIEF_KEY}'] [data-workflow-change-author='ai']")).to_have_text(
+ "Added · AI assist")
+ expect(context).to_have_count(0)
+ elif placement == "document":
+ assert change_keys(found) == ["task:task-b:document_action"]
+ expect(author(changed(editor, "task:task-b:document_action"))).to_have_text("AI assist")
+ expect(context).to_have_count(0)
+ else:
+ expect(found).to_have_attribute("data-outcome", "question" if placement == "question" else "explained")
+ expect(found.get_by_text("Question", exact=True)).to_have_count(1 if placement == "question" else 0)
+ expect(found.locator("li[data-workflow-assist-change]")).to_have_count(0)
+ expect(context).to_have_text("Read as context: private-brief.pdf")
+ expect(editor.locator("[data-workflow-change-key]")).to_have_count(0)
+ expect(changes_toggle(editor)).to_have_accessible_name("Changes (0 unsaved)")
+ assert not ui.workflow_writes
+
+
+# ---------------------------------------------------------------------------------------------
+# Sending, the lock, Cancel and Retry
+# ---------------------------------------------------------------------------------------------
+
+def test_send_is_immediate_and_locks_the_editor_until_cancel_then_retry(ask_ui):
+ ui, page = ask_ui, ask_ui.page
+ description = "Reviews signed evidence every quarter."
+ ui.assist.hold = 1
+ ui.assist.queue(
+ ui.assist.changed({"op": "set_description", "description": description}),
+ ui.assist.changed({"op": "set_description", "description": description}),
+ )
+ editor = open_classic(ui)
+ panel = open_ask_ai(editor)
+ box = composer(panel)
+ type_and_send(panel, "Describe what this workflow reviews.")
+
+ exchange = exchanges(panel).last
+ expect(exchange).to_have_attribute("data-status", "pending")
+ expect(exchange).to_contain_text("Describe what this workflow reviews.")
+ expect(exchange).to_contain_text("Working…")
+ expect(box).to_have_value("")
+ expect(box).to_be_focused()
+ body = ui.assist.wait_for_requests(page, 1)
+
+ lock = editor.locator("[data-workflow-assist-lock]")
+ expect(lock).to_contain_text("Ask AI is working. The editor is locked until it answers.")
+ expect(lock.get_by_test_id("workflow-assist-lock-elapsed")).to_have_text(re.compile(r"^[1-9]\d* s$"), timeout=4000)
+ # A disabled fieldset disables every control in it; Playwright's to_be_disabled ignores the fieldset itself.
+ expect(editor.locator("fieldset[aria-busy='true']")).to_have_attribute("disabled", "")
+ expect(editor.get_by_label("Workflow name", exact=True)).to_be_disabled()
+ expect(editor.get_by_role("button", name="Save workflow", exact=True)).to_be_disabled()
+ expect(send_button(panel)).to_be_disabled()
+ quick = panel.get_by_role("group", name="Quick actions", exact=True).get_by_role("button")
+ expect(quick).to_have_count(5)
+ for index in range(5):
+ expect(quick.nth(index)).to_be_disabled()
+
+ lock.get_by_role("button", name="Cancel request", exact=True).click()
+ expect(exchange).to_have_attribute("data-status", "cancelled")
+ expect(exchange).to_contain_text(CANCELLED)
+ expect(lock).to_have_count(0)
+ expect(editor.get_by_label("Workflow name", exact=True)).to_be_enabled()
+ expect(box).to_be_focused()
+
+ # The answer that arrives after Cancel changes nothing.
+ ui.assist.release()
+ page.wait_for_timeout(300)
+ expect(exchange).to_have_attribute("data-status", "cancelled")
+ expect(changed(editor, "description")).to_have_count(0)
+ expect(editor.locator("section[aria-label='Workflow basics']").get_by_label("Description", exact=True)).to_have_value(
+ ui.personal_workflows[WORKFLOW_ID]["description"])
+
+ exchange.get_by_role("button", name="Retry", exact=True).click()
+ retried = ui.assist.wait_for_requests(page, 2)
+ assert retried["submission_id"] == body["submission_id"]
+ assert retried["conversation"] == []
+ found = answered(panel, retried)
+ expect(found).to_have_attribute("data-state", "applied")
+ expect(author(changed(editor, "description"))).to_have_text("AI assist")
+ expect(exchanges(panel)).to_have_count(1)
+ assert not ui.workflow_writes
+
+
+def basics_description(editor):
+ return editor.locator("section[aria-label='Workflow basics']").get_by_label("Description", exact=True)
+
+
+def test_an_answer_for_a_draft_that_changed_meanwhile_applies_nothing(ask_ui):
+ ui, page = ask_ui, ask_ui.page
+ instructions, description = "Collect only signed evidence.", "Reviews signed evidence."
+ operation = {"op": "set_task_instructions", "task": "task_1", "instructions": instructions}
+ ui.assist.hold = 1
+ ui.assist.queue(ui.assist.changed(operation), ui.assist.changed(operation))
+ editor = open_classic(ui)
+ panel = open_ask_ai(editor)
+ type_and_send(panel, "Collect only signed evidence in the first task.")
+ body = ui.assist.wait_for_requests(page, 1)
+
+ # The lock is the first guard. This stands in for any edit that gets past it.
+ fieldset = editor.locator("fieldset[aria-busy='true']")
+ expect(fieldset).to_have_attribute("disabled", "")
+ fieldset.evaluate("(element) => { element.disabled = false; }")
+ basics_description(editor).fill(description)
+ expect(author(changed(editor, "description"))).to_have_text("Edited")
+ ui.assist.release()
+
+ exchange = failed(panel)
+ expect(exchange.get_by_role("alert")).to_have_text(STALE_DRAFT)
+ expect(card(panel, body["submission_id"])).to_have_count(0)
+ expect(editor.locator("[data-workflow-change-author='ai']")).to_have_count(0)
+ expect(task_item(editor, "task-a").get_by_label("Instructions", exact=True)).to_have_value("Collect source evidence.")
+ expect(changes_toggle(editor)).to_have_accessible_name("Changes (1 unsaved)")
+
+ # Retry is the same turn, sent with the draft as it is now.
+ exchange.get_by_role("button", name="Retry", exact=True).click()
+ retried = ui.assist.wait_for_requests(page, 2)
+ assert retried["submission_id"] == body["submission_id"]
+ assert retried["draft"]["description"] == description
+ assert retried["conversation"] == []
+ found = answered(panel, retried)
+ assert change_keys(found) == ["task:task-a:instructions"]
+ expect(author(changed(editor, "task:task-a:instructions"))).to_have_text("AI assist")
+ expect(author(changed(editor, "description"))).to_have_text("Edited")
+ expect(basics_description(editor)).to_have_value(description)
+ expect(changes_toggle(editor)).to_have_accessible_name("Changes (2 unsaved)")
+ assert not ui.workflow_writes
+
+
+def test_a_new_draft_sends_no_base_and_no_id_and_saves_after_review(ask_ui):
+ ui, page = ask_ui, ask_ui.page
+ name, instructions = "Evidence review", "Collect the quarter's signed evidence."
+ ui.assist.queue(ui.assist.changed(
+ {"op": "set_name", "name": name},
+ {"op": "set_task_instructions", "task": "task_1", "instructions": instructions},
+ ))
+ ui.open("/workspace/workflows")
+ page.get_by_role("button", name="Create workflow", exact=True).click()
+ editor = page.get_by_role("dialog", name="Create workflow", exact=True)
+ expect(editor).to_be_visible()
+ editor.get_by_label("Model", exact=True).select_option(label="Workspace GPT · aoai")
+ panel = open_ask_ai(editor)
+
+ body = ask(ui, panel, "Call this Evidence review and have the task collect signed evidence.")
+ assert set(body) == REQUEST_KEYS
+ assert body["base"] is None
+ assert "id" not in body["draft"]
+ assert body["draft"]["name"] == ""
+ task_id = body["draft"]["tasks"][0]["id"]
+ found = answered(panel, body)
+ assert change_keys(found) == ["name", f"task:{task_id}:instructions"]
+ # A new draft points out only what the assistant changed.
+ expect(author(changed(editor, "name"))).to_have_text("AI assist")
+ expect(author(changed(editor, f"task:{task_id}:instructions"))).to_have_text("AI assist")
+ expect(editor.locator("[data-workflow-change-author='user']")).to_have_count(0)
+ expect(changes_toggle(editor)).to_have_accessible_name("Changes (2 unsaved)")
+
+ payload = confirm_save(ui, editor)
+ assert payload["name"] == name
+ assert payload["tasks"][0]["instructions"] == instructions
+ assert not payload.get("id")
+
+
+def test_task_focus_scopes_turns_and_only_completed_turns_are_replayed(ask_ui):
+ ui, page = ask_ui, ask_ui.page
+ # A browser zone the schedule editor doesn't list is left out, so the server uses its default.
+ ui.unlisted_schedule_timezones = {TIME_ZONE}
+ reply, later = "The summary task condenses the evidence into findings.", "Both tasks run in order."
+ ui.assist.queue(
+ ui.assist.refused("assistant_output_invalid"),
+ ui.assist.said("explained", "This answer arrives after Cancel."),
+ ui.assist.said("explained", reply),
+ ui.assist.said("explained", later),
+ )
+ editor = open_classic(ui)
+ editor.get_by_role("button", name="Ask AI about this task: Summarize evidence", exact=True).click()
+ panel = side_panel(editor)
+ expect(panel.get_by_role("tab", name="Ask AI", exact=True)).to_have_attribute("aria-selected", "true")
+ chip = panel.locator("[data-workflow-assist-focus='task-b']")
+ expect(chip).to_contain_text("About: Summarize evidence")
+ expect(composer(panel)).to_be_focused()
+ expect(composer(panel)).to_have_attribute("aria-describedby", re.compile(r"\S"))
+
+ first = ask(ui, panel, "Make this task shorter.")
+ assert first["focus"] == "task-b"
+ assert "time_zone" not in first
+ expect(failed(panel).get_by_role("alert")).to_have_text(core._ERRORS["assistant_output_invalid"][1])
+
+ ui.assist.hold = 1
+ second = ask(ui, panel, "What does it read?")
+ assert second["focus"] == "task-b" and second["conversation"] == []
+ editor.locator("[data-workflow-assist-lock]").get_by_role("button", name="Cancel request", exact=True).click()
+ expect(exchanges(panel).last).to_have_attribute("data-status", "cancelled")
+ ui.assist.release()
+
+ # The failed and the cancelled turns are never replayed, and the focus holds until it is cleared.
+ third = ask(ui, panel, "What does this task do?")
+ assert third["focus"] == "task-b" and third["conversation"] == []
+ expect(answered(panel, third).locator("[data-workflow-assist-reply]")).to_have_text(reply)
+ expect(chip).to_be_visible()
+ panel.get_by_role("button", name="Stop asking about Summarize evidence", exact=True).click()
+ expect(chip).to_have_count(0)
+ expect(composer(panel)).to_be_focused()
+
+ fourth = ask(ui, panel, "And the whole workflow?")
+ assert fourth["focus"] is None
+ assert fourth["conversation"] == [
+ {"role": "user", "text": "What does this task do?"},
+ {"role": "assistant", "text": reply},
+ ]
+ answered(panel, fourth)
+ assert not ui.workflow_writes
+
+
+def rename_flow_node(record, old, new):
+ """Give a task's flow node an ID of its own, so the node and the task can't be confused."""
+ nodes = record["flow"]["nodes"]
+ next(node for node in nodes if node["id"] == old)["id"] = new
+ for node in nodes:
+ if (node.get("target") or {}).get("node_id") == old:
+ node["target"]["node_id"] = new
+ for output in record["flow"]["outputs"]:
+ if output["source"]["node_id"] == old:
+ output["source"]["node_id"] = new
+ compile_workflow_flow(record)
+ record["definition_revision"] = workflow_definition_revision(record)
+
+
+def test_flow_focus_sends_the_node_and_jump_selects_its_block(ask_ui):
+ ui = ask_ui
+ rename_flow_node(ui.personal_workflows[FLOW_WORKFLOW_ID], "finish", "finish-step")
+ instructions = "Produce the finish result as three short bullet points."
+
+ def finish_instructions(body):
+ handle = [task["id"] for task in body["draft"]["tasks"]].index("finish") + 1
+ return {"op": "set_task_instructions", "task": f"task_{handle}", "instructions": instructions}
+
+ ui.assist.queue(ui.assist.changed(finish_instructions))
+ editor = authoring.open_editor(ui)
+ view = authoring.switch_surface(editor, "Flow")
+ fields = authoring.select_node(view, "finish-step")
+ fields.get_by_role("button", name="Ask AI about this task: Finish", exact=True).click()
+ panel = side_panel(editor)
+ expect(panel.locator("[data-workflow-assist-focus='finish']")).to_contain_text("About: Finish")
+ expect(composer(panel)).to_be_focused()
+
+ body = ask(ui, panel, "Make the finish result three bullet points.")
+ assert body["draft"]["definition_version"] == 3
+ assert body["focus"] == "finish-step"
+ found = answered(panel, body)
+ assert change_keys(found) == ["task:finish:instructions"]
+
+ authoring.select_node(view, "evaluate")
+ found.get_by_role("button", name="Jump to Finish: Instructions", exact=True).click()
+ expect(authoring.node_button(view, "finish-step")).to_have_attribute("aria-pressed", "true")
+ field = authoring.configuration(view).get_by_label("Instructions", exact=True)
+ expect(field).to_be_focused()
+ expect(field).to_have_value(instructions)
+ expect(author(changed(authoring.configuration(view), "task:finish:instructions"))).to_have_text("AI assist")
+ assert not ui.workflow_writes
+
+
+# ---------------------------------------------------------------------------------------------
+# The instruction limit and Undo
+# ---------------------------------------------------------------------------------------------
+
+def test_the_instruction_limit_counts_code_points_like_the_server(ask_ui):
+ ui, page = ask_ui, ask_ui.page
+ reply = "That is a lot of stars."
+ ui.assist.queue(ui.assist.said("explained", reply))
+ editor = open_classic(ui)
+ panel = open_ask_ai(editor)
+ box = composer(panel)
+ # Each star is two UTF-16 units, so counting units would refuse both of these.
+ star = "\U0001F31F"
+
+ over = star * 2001
+ box.fill(over)
+ expect(panel.get_by_text("2001/2000", exact=True)).to_be_visible()
+ expect(panel.get_by_text("This is 1 character over the limit. Shorten it to send.", exact=True)).to_be_visible()
+ expect(box).to_have_attribute("aria-invalid", "true")
+ expect(send_button(panel)).to_be_disabled()
+ box.press("Enter")
+ page.wait_for_timeout(300)
+ assert not ui.assist.bodies
+ expect(box).to_have_value(over)
+
+ exact = star * 2000
+ box.fill(exact)
+ expect(panel.get_by_text("2000/2000", exact=True)).to_be_visible()
+ expect(box).not_to_have_attribute("aria-invalid", "true")
+ expect(send_button(panel)).to_be_enabled()
+ box.press("Enter")
+ body = ui.assist.wait_for_requests(page, 1)
+ # Python counts code points, as the server does.
+ assert body["instruction"] == exact and len(body["instruction"]) == 2000
+ expect(answered(panel, body).locator("[data-workflow-assist-reply]")).to_have_text(reply)
+
+
+def test_undo_skips_a_field_changed_later_and_jump_reaches_a_task_field(ask_ui):
+ ui = ask_ui
+ described, instructions, mine = "Reviews the quarter.", "Collect only signed evidence.", "Reviews the signed quarter."
+ ui.assist.queue(ui.assist.changed(
+ {"op": "set_description", "description": described},
+ {"op": "set_task_instructions", "task": "task_1", "instructions": instructions},
+ ))
+ editor = open_classic(ui)
+ panel = open_ask_ai(editor)
+ found = answered(panel, ask(ui, panel, "Describe the review and have the first task collect signed evidence."))
+ assert change_keys(found) == ["description", "task:task-a:instructions"]
+
+ field = task_item(editor, "task-a").get_by_label("Instructions", exact=True)
+ found.get_by_role("button", name="Jump to Collect evidence: Instructions", exact=True).click()
+ expect(field).to_be_focused()
+ expect(field).to_have_value(instructions)
+ # At this width the panel sits beside the editor, so it stays open.
+ expect(ask_toggle(editor)).to_have_attribute("aria-expanded", "true")
+
+ basics_description(editor).fill(mine)
+ expect(author(changed(editor, "description"))).to_have_text("Edited")
+ undo_button(found).click()
+ result = found.locator("[data-workflow-assist-undo='applied']")
+ expect(result).to_be_focused()
+ expect(result.locator("p").first).to_have_text("Undone: 1 reverted, 1 skipped because it changed later.")
+ expect(result.get_by_role("list", name="Reverted").get_by_role("listitem")).to_have_text(
+ ["Collect evidence · Instructions"])
+ expect(result.get_by_role("list", name="Skipped").get_by_role("listitem")).to_have_text(
+ ["Description: Changed after this turn."])
+ expect(found).to_have_attribute("data-state", "partly_undone")
+ expect(found.locator("[data-workflow-assist-state]")).to_have_text("Some of these changes were undone.")
+ expect(undo_button(found)).to_have_count(0)
+ expect(field).to_have_value("Collect source evidence.")
+ expect(changed(editor, "task:task-a:instructions")).to_have_count(0)
+ expect(basics_description(editor)).to_have_value(mine)
+ expect(author(changed(editor, "description"))).to_have_text("Edited")
+ assert not ui.workflow_writes
+
+
+# ---------------------------------------------------------------------------------------------
+# Statuses
+# ---------------------------------------------------------------------------------------------
+
+def another_turns_answer(body):
+ """A well-formed answer, but for a different turn."""
+ return 200, {
+ "submission_id": f"{body['submission_id']}-other", "outcome": "explained", "reply": "This is for another turn.",
+ "candidate": None, "changes": [], "warnings": [], "context_documents": [],
+ }, {}
+
+
+def test_every_failure_changes_nothing_and_says_why(ask_ui):
+ ui, page = ask_ui, ask_ui.page
+ stub = ui.assist
+ cases = [
+ # The route's decorators answer before the assistant runs.
+ (stub.canned(400, {"error": "Allow User Workflows is disabled."}), "Allow User Workflows is disabled."),
+ (stub.canned(403, {"error": "Forbidden", "message": "Personal workflows require the WorkflowUser app role."}),
+ "Personal workflows require the WorkflowUser app role."),
+ (stub.canned(403, {"error": "The AI workflow assistant is not available.", "code": "workflow_assistant_disabled"}),
+ "The AI workflow assistant is not available."),
+ *[(stub.refused(code), core._ERRORS[code][1]) for code in (
+ "time_zone_invalid", "assistant_input_too_large", "workflow_not_found", "request_too_large",
+ "assistant_failed", "assistant_refused", "assistant_timeout", "assistant_limit_unavailable",
+ "reference_check_failed",
+ )],
+ (stub.canned(502, None), UNUSABLE),
+ (stub.custom(another_turns_answer), UNREADABLE),
+ ]
+ editor = open_classic(ui)
+ panel = open_ask_ai(editor)
+ for index, (answer, message) in enumerate(cases, 1):
+ stub.queue(answer)
+ ask(ui, panel, f"Change attempt {index}.")
+ exchange = failed(panel)
+ expect(exchange.get_by_role("alert")).to_have_text(message)
+ expect(exchange.get_by_role("button", name="Retry", exact=True)).to_be_enabled()
+ expect(panel.locator("[data-workflow-assist-wait]")).to_have_count(0)
+ expect(panel.locator("[data-workflow-assist-reload]")).to_have_count(0)
+ expect(editor.locator("[data-workflow-change-key]")).to_have_count(0)
+ expect(changes_toggle(editor)).to_have_accessible_name("Changes (0 unsaved)")
+
+ # A 502 changed nothing, so Retry sends the same turn again.
+ explained = "It collects evidence, then summarizes it."
+ stub.queue(stub.refused("assistant_output_invalid"), stub.said("explained", explained))
+ body = ask(ui, panel, "What does this workflow do?")
+ exchange = failed(panel)
+ expect(exchange.get_by_role("alert")).to_have_text(core._ERRORS["assistant_output_invalid"][1])
+ sent = len(stub.bodies)
+ exchange.get_by_role("button", name="Retry", exact=True).click()
+ retried = stub.wait_for_requests(page, sent + 1)
+ assert retried["submission_id"] == body["submission_id"]
+ expect(answered(panel, retried).locator("[data-workflow-assist-reply]")).to_have_text(explained)
+
+ # A 429, or a 503 from a throttled provider, asks for a wait; Send waits that long.
+ stub.settings.update({"enable_custom_rate_limit_message": True, "rate_limit_message": RATE_LIMIT_TEXT})
+ note = panel.locator("[data-workflow-assist-wait]")
+ waits = [
+ ("assistant_unavailable", core._ERRORS["assistant_unavailable"][1]),
+ ("assistant_busy", core._ERRORS["assistant_busy"][1]),
+ # The admin's Markdown is shown as the text it is, with its whitespace collapsed.
+ ("assistant_rate_limited", " ".join(RATE_LIMIT_TEXT.split())),
+ ]
+ for code, message in waits:
+ stub.queue(stub.refused(code, retry_after=5))
+ sent = len(stub.bodies)
+ ask(ui, panel, f"Try {code}.")
+ exchange = failed(panel)
+ expect(exchange.get_by_role("alert")).to_have_text(message)
+ expect(exchange.locator(MARKUP)).to_have_count(0)
+ expect(note).to_have_text(WAIT_NOTE)
+ box = composer(panel)
+ box.fill("Try once more.")
+ expect(send_button(panel)).to_be_disabled()
+ expect(exchange.get_by_role("button", name="Retry", exact=True)).to_be_disabled()
+ expect(panel.get_by_role("group", name="Quick actions", exact=True).get_by_role("button").first).to_be_disabled()
+ box.press("Enter")
+ page.wait_for_timeout(200)
+ assert len(stub.bodies) == sent + 1
+ expect(box).to_have_value("Try once more.")
+ expect(note).to_have_count(0, timeout=8000)
+ expect(send_button(panel)).to_be_enabled()
+ expect(exchange.get_by_role("button", name="Retry", exact=True)).to_be_enabled()
+ box.fill("")
+
+ expect(editor.locator("[data-workflow-change-key]")).to_have_count(0)
+ expect(changes_toggle(editor)).to_have_accessible_name("Changes (0 unsaved)")
+ assert not ui.workflow_writes
+
+
+def test_a_conflict_keeps_the_draft_and_offers_a_reload(ask_ui):
+ ui, page = ask_ui, ask_ui.page
+ stored = ui.personal_workflows[WORKFLOW_ID]
+ mine = "Quarterly review, my draft"
+ editor = open_classic(ui)
+ name = editor.get_by_label("Workflow name", exact=True)
+ name.fill(mine)
+ panel = open_ask_ai(editor)
+
+ stored["deleting"] = True
+ ui.assist.queue(ui.assist.pipeline())
+ ask(ui, panel, "Add a description.")
+ expect(failed(panel).get_by_role("alert")).to_have_text(
+ f"{core._ERRORS['workflow_deleted'][1]} Your draft is still in the editor.")
+ expect(panel.locator("[data-workflow-assist-reload]")).to_have_count(0)
+ expect(name).to_have_value(mine)
+
+ del stored["deleting"]
+ ui.mutate_revision()
+ ui.assist.queue(ui.assist.pipeline())
+ ask(ui, panel, "Add a description.")
+ expect(failed(panel).get_by_role("alert")).to_have_text(core._ERRORS["workflow_definition_conflict"][1])
+ expect(name).to_have_value(mine)
+ expect(author(changed(editor, "name"))).to_have_text("Edited")
+ assert not ui.workflow_writes
+
+ group = panel.get_by_role("group", name="Reload workflow", exact=True)
+ expect(group.locator("p").first).to_have_text(
+ "The saved workflow changed after you opened it. Reload it to keep using Ask AI.")
+ reload = group.get_by_role("button", name="Reload workflow", exact=True)
+ reload.click()
+ expect(group.locator("p").first).to_have_text("Reloading discards your unsaved changes to this workflow.")
+ keep = group.get_by_role("button", name="Keep editing", exact=True)
+ expect(keep).to_be_focused()
+ keep.click()
+ expect(reload).to_be_focused()
+ expect(name).to_have_value(mine)
+ reload.click()
+ group.get_by_role("button", name="Discard and reload", exact=True).click()
+
+ # The editor opens again on the saved workflow as it is now.
+ editor = page.get_by_role("dialog", name="Edit workflow", exact=True)
+ expect(editor.get_by_label("Workflow name", exact=True)).to_have_value("Quarterly review workflow")
+ expect(side_panel(editor)).to_have_count(0)
+ expect(editor.locator("[data-workflow-change-key]")).to_have_count(0)
+ ui.assist.queue(ui.assist.said("explained", "It reviews the quarter."))
+ panel = open_ask_ai(editor)
+ expect(panel.locator("[data-workflow-assist-reload]")).to_have_count(0)
+ body = ask(ui, panel, "What does it review?")
+ assert body["base"] == {"workflow_id": WORKFLOW_ID, "definition_revision": "revision:external-change"}
+ assert body["conversation"] == []
+ answered(panel, body)
+ assert not ui.workflow_writes
+
+
+# ---------------------------------------------------------------------------------------------
+# Untrusted text and the client's own diff
+# ---------------------------------------------------------------------------------------------
+
+def test_model_text_renders_as_text_and_only_the_clients_diff_is_listed(ask_ui):
+ ui, page = ask_ui, ask_ui.page
+ described, instructions = "Reviews the quarter.", "Collect only signed evidence."
+ answer = ui.assist.changed(
+ {"op": "set_description", "description": described},
+ {"op": "set_task_instructions", "task": "task_1", "instructions": instructions},
+ )
+
+ def tampered(raw):
+ status, payload, headers = answer(raw)
+ payload = copy.deepcopy(payload)
+ reported = {change["key"]: change for change in payload["changes"]}
+ description = reported["description"]
+ description.update(summary=HOSTILE, label=HOSTILE)
+ # The instructions entry is left out and a name change that never happened is made up.
+ made_up = {**copy.deepcopy(description), "key": "name", "label": "Workflow name",
+ "summary": "Workflow: Workflow name", "target": {"focus_key": "name"}}
+ payload.update(
+ reply=HOSTILE, changes=[description, made_up],
+ warnings=[{"code": "m365_not_connected", "message": HOSTILE}], context_documents=[HOSTILE],
+ )
+ return status, payload, headers
+
+ ui.assist.queue(tampered)
+ editor = open_classic(ui)
+ panel = open_ask_ai(editor)
+ found = answered(panel, ask(ui, panel, "Describe it and tighten the first task."))
+
+ expect(found.locator("[data-workflow-assist-reply]")).to_have_text(HOSTILE)
+ # The list is the client's diff of the draft it sent against the candidate.
+ assert change_keys(found) == ["description", "task:task-a:instructions"]
+ expect(found.locator("li[data-workflow-assist-change='description'] span")).to_have_text(HOSTILE)
+ expect(found.locator("li[data-workflow-assist-change='task:task-a:instructions'] span")).to_have_text(
+ "Collect evidence: Instructions")
+ expect(found.get_by_role("button", name=f"Jump to {HOSTILE}", exact=True)).to_have_count(1)
+ expect(found.locator("li[data-workflow-assist-warning='m365_not_connected'] span")).to_have_text(HOSTILE)
+ expect(found.locator("[data-workflow-assist-context]")).to_have_text(f"Read as context: {HOSTILE}")
+ expect(found.locator(MARKUP)).to_have_count(0)
+ assert page.evaluate("() => window.__hostile ?? null") is None
+
+ expect(author(changed(editor, "description"))).to_have_text("AI assist")
+ expect(author(changed(editor, "task:task-a:instructions"))).to_have_text("AI assist")
+ expect(changed(editor, "name")).to_have_count(0)
+ expect(changes_toggle(editor)).to_have_accessible_name("Changes (2 unsaved)")
+ assert not ui.workflow_writes
+
+
+# ---------------------------------------------------------------------------------------------
+# Where Ask AI is offered
+# ---------------------------------------------------------------------------------------------
+
+@pytest.mark.parametrize("restriction", ["setting_off", "group", "reader", "unsupported", "schedule"])
+def test_ask_ai_is_offered_only_for_personal_workflows_the_editor_can_change(ask_ui, restriction):
+ ui, page = ask_ui, ask_ui.page
+ if restriction == "setting_off":
+ # The bootstrap turns it off for this user: the setting, personal workflows or the role.
+ ui.ask_ai_enabled = False
+ editor = open_classic(ui)
+ elif restriction == "group":
+ editor = authoring.open_editor(ui, group_id=GROUP_ID)
+ elif restriction == "reader":
+ ui.group_can_manage = False
+ ui.open("/groups")
+ ui.select_group(GROUP_ID)
+ page.get_by_role("button", name="View Alpha read-only Flow", exact=True).click()
+ editor = page.get_by_role("dialog", name="Edit workflow", exact=True)
+ elif restriction == "unsupported":
+ ui.personal_workflows[FLOW_WORKFLOW_ID]["flow"]["nodes"][0]["future_executor"] = {"unchanged": True}
+ ui.open(f"/workspace/workflows?workflow_id={FLOW_WORKFLOW_ID}")
+ editor = page.get_by_role("dialog", name="Edit workflow", exact=True)
+ else:
+ ui.personal_workflows[WORKFLOW_ID].update(trigger_type="interval", schedule=copy.deepcopy(UNSUPPORTED_SCHEDULE))
+ ui.open(f"/workspace/workflows?workflow_id={WORKFLOW_ID}")
+ editor = page.get_by_role("dialog", name="Edit workflow", exact=True)
+ expect(editor).to_be_visible()
+
+ if restriction in ("setting_off", "group"):
+ # The editor can change these workflows, so a task with no instructions would offer Draft with
+ # AI if Ask AI were available.
+ instructions = editor.get_by_label("Instructions", exact=True).first
+ instructions.fill("")
+ expect(instructions).to_have_value("")
+ panel = open_panel(editor)
+ expect(panel.get_by_role("tab")).to_have_count(1)
+ expect(panel.locator("[data-workflow-ask-ai]")).to_have_count(0)
+ else:
+ expect(editor).to_contain_text("This workflow is read-only.")
+ expect(editor.locator("aside")).to_have_count(0)
+ expect(ask_toggle(editor)).to_have_count(0)
+ expect(editor.locator("[data-workflow-ask-ai-task]")).to_have_count(0)
+ expect(editor.locator("[data-workflow-draft-ai]")).to_have_count(0)
+ expect(editor.get_by_role("button", name=re.compile(r"^(Ask AI|Draft with AI)"))).to_have_count(0)
+ assert not ui.assist.bodies and not ui.drafts.bodies
+ assert not ui.workflow_writes
+
+
+# ---------------------------------------------------------------------------------------------
+# Keyboard and narrow screens
+# ---------------------------------------------------------------------------------------------
+
+def test_keyboard_tabs_the_hash_menu_and_escape(ask_ui):
+ ui, page = ask_ui, ask_ui.page
+ # A tag that "#brief" would match, if tags were offered.
+ ui.extra_gets["/api/documents/tags"] = {"tags": [{"name": "brief-notes"}]}
+ editor = open_classic(ui)
+ panel = open_ask_ai(editor)
+ changes_tab = panel.get_by_role("tab", name="Changes", exact=True)
+ ask_tab = panel.get_by_role("tab", name="Ask AI", exact=True)
+ expect(panel.get_by_role("tab")).to_have_count(2)
+ ask_tab.focus()
+ for key, tab, other in (
+ ("ArrowRight", changes_tab, ask_tab), ("ArrowLeft", ask_tab, changes_tab),
+ ("Home", changes_tab, ask_tab), ("End", ask_tab, changes_tab),
+ ):
+ page.keyboard.press(key)
+ expect(tab).to_be_focused()
+ expect(tab).to_have_attribute("aria-selected", "true")
+ expect(tab).to_have_attribute("tabindex", "0")
+ expect(other).to_have_attribute("aria-selected", "false")
+ expect(other).to_have_attribute("tabindex", "-1")
+ expect(panel.get_by_role("tabpanel")).to_have_attribute("aria-labelledby", tab.get_attribute("id"))
+
+ # The # menu offers documents only, and Escape closes it without closing the panel.
+ tag_reads = sum(1 for entry in ui.requests if entry.path.endswith("/tags"))
+ box = composer(panel)
+ box.fill("Use ")
+ box.press_sequentially("#brief")
+ menu = page.get_by_role("listbox", name="Context suggestions")
+ expect(menu.get_by_role("option").first).to_contain_text("Private brief")
+ expect(menu.get_by_text("Documents", exact=True)).to_be_visible()
+ expect(menu.get_by_text("Tags", exact=True)).to_have_count(0)
+ expect(menu.get_by_text("Workspaces", exact=True)).to_have_count(0)
+ expect(menu.get_by_text(re.compile(r"^Tag ·"))).to_have_count(0)
+ expect(menu.get_by_text("brief-notes")).to_have_count(0)
+ assert sum(1 for entry in ui.requests if entry.path.endswith("/tags")) == tag_reads
+ page.keyboard.press("Escape")
+ expect(menu).to_have_count(0)
+ expect(side_panel(editor)).to_be_visible()
+ expect(box).to_be_focused()
+ expect(box).to_have_value("Use #brief")
+
+ # So does the document picker, and focus carries on in the input.
+ box.fill("")
+ panel.get_by_role("button", name="Add context", exact=True).click()
+ search = panel.get_by_role("searchbox", name="Search documents", exact=True)
+ expect(search).to_be_focused()
+ page.keyboard.press("Escape")
+ expect(search).to_have_count(0)
+ expect(side_panel(editor)).to_be_visible()
+ expect(box).to_be_focused()
+
+ # With nothing open in it, Escape closes the panel and returns to its toggle, not the editor.
+ page.keyboard.press("Escape")
+ expect(side_panel(editor)).to_have_count(0)
+ expect(ask_toggle(editor)).to_be_focused()
+ expect(ask_toggle(editor)).to_have_attribute("aria-expanded", "false")
+ expect(editor).to_be_visible()
+ assert not ui.assist.bodies
+
+
+def test_a_narrow_screen_shows_ask_ai_in_place_of_the_editor(ask_ui):
+ ui = ask_ui
+ described = "Reviews the quarter's signed evidence."
+ ui.assist.queue(ui.assist.changed({"op": "set_description", "description": described}))
+ editor = open_classic(ui, width=390, height=844)
+ name = editor.get_by_label("Workflow name", exact=True)
+ assert_dialog_fits(ui, editor)
+ panel = open_ask_ai(editor)
+ expect(name).to_be_hidden()
+ assert_dialog_fits(ui, editor)
+
+ found = answered(panel, ask(ui, panel, "Describe what it reviews."))
+ found.get_by_role("button", name="Jump to Workflow: Description", exact=True).click()
+ expect(side_panel(editor)).to_have_count(0)
+ expect(ask_toggle(editor)).to_have_attribute("aria-expanded", "false")
+ description = basics_description(editor)
+ expect(description).to_be_focused()
+ expect(description).to_have_value(described)
+ expect(author(changed(editor, "description"))).to_have_text("AI assist")
+ assert_dialog_fits(ui, editor)
+ assert not ui.workflow_writes
+
+
+# ---------------------------------------------------------------------------------------------
+# Draft with AI, quick actions and an earlier editing session
+# ---------------------------------------------------------------------------------------------
+
+def test_draft_with_ai_fills_an_empty_task_as_an_undoable_ai_change(ask_ui):
+ ui, page = ask_ui, ask_ui.page
+ drafted = "Collect the quarter's signed evidence and list anything that is missing."
+ ui.open("/workspace/workflows")
+ page.get_by_role("button", name="Create workflow", exact=True).click()
+ editor = page.get_by_role("dialog", name="Create workflow", exact=True)
+ expect(editor).to_be_visible()
+ editor.get_by_label("Model", exact=True).select_option(label="Workspace GPT · aoai")
+ button = editor.get_by_role("button", name="Draft with AI: instructions for Task 1", exact=True)
+ task_id = button.get_attribute("data-workflow-draft-ai")
+ task = task_item(editor, task_id)
+ status = task.locator("p[role='status']")
+
+ # With nothing to go on, it asks for a name instead of guessing.
+ button.click()
+ expect(status).to_have_text(NEEDS_NAME)
+ assert not ui.drafts.bodies
+
+ editor.get_by_label("Workflow name", exact=True).fill("Evidence review")
+ ui.drafts.queue(drafted)
+ button.click()
+ instructions = task.get_by_label("Instructions", exact=True)
+ expect(instructions).to_be_focused()
+ expect(instructions).to_have_value(drafted)
+ assert ui.drafts.bodies == [{
+ "workflow_scope": "personal", "name": "Evidence review", "description": "", "brief": "Task 1",
+ }]
+ expect(author(changed(editor, f"task:{task_id}:instructions"))).to_have_text("AI assist")
+ expect(changes_toggle(editor)).to_have_accessible_name("Changes (1 unsaved)")
+ expect(button).to_have_count(0)
+ assert not ui.assist.bodies
+
+ payload = confirm_save(ui, editor)
+ assert payload["name"] == "Evidence review"
+ assert payload["tasks"][0]["instructions"] == drafted
+
+
+def test_quick_actions_send_visible_turns_and_cards_outlive_a_save(ask_ui):
+ ui, page = ask_ui, ask_ui.page
+ explained, described = "It collects evidence, then summarizes it.", "Reviews the quarter's evidence."
+ ui.assist.queue(
+ ui.assist.said("explained", explained),
+ ui.assist.changed({"op": "set_description", "description": described}, text="Described."),
+ ui.assist.said("explained", "It has a description now."),
+ )
+ editor = open_classic(ui)
+ panel = open_ask_ai(editor)
+ quick = panel.get_by_role("group", name="Quick actions", exact=True).get_by_role("button")
+ expect(quick).to_have_text([
+ "Explain this workflow", "Tighten task instructions", "Add a schedule", "Alert me only when urgent",
+ "Check what's needed to run",
+ ])
+ quick.first.click()
+ first = ui.assist.wait_for_requests(page, 1)
+ assert first["instruction"] == EXPLAIN
+ expect(exchanges(panel).first).to_contain_text(EXPLAIN)
+ expect(answered(panel, first).locator("[data-workflow-assist-reply]")).to_have_text(explained)
+
+ instruction = "Describe what it reviews."
+ second = ask(ui, panel, instruction)
+ expect(answered(panel, second)).to_have_attribute("data-state", "applied")
+ payload = confirm_save(ui, editor)
+ assert payload["description"] == described
+
+ # The thread outlives the editor; its card now belongs to an earlier editing session.
+ page.get_by_role("button", name="Edit Quarterly review workflow", exact=True).click()
+ editor = page.get_by_role("dialog", name="Edit workflow", exact=True)
+ expect(basics_description(editor)).to_have_value(described)
+ panel = open_ask_ai(editor)
+ found = card(panel, second["submission_id"])
+ expect(found).to_have_attribute("data-state", "earlier")
+ expect(found.locator("[data-workflow-assist-state]")).to_have_text("Made in an earlier editing session.")
+ expect(found.get_by_role("button", name=re.compile(r"^Jump to"))).to_have_count(0)
+ undo_button(found).click()
+ result = found.locator("[data-workflow-assist-undo='unavailable']")
+ expect(result).to_be_focused()
+ expect(result).to_have_text("This turn can no longer be undone.")
+ expect(basics_description(editor)).to_have_value(described)
+ expect(changes_toggle(editor)).to_have_accessible_name("Changes (0 unsaved)")
+
+ third = ask(ui, panel, "Does it have a description?")
+ assert third["conversation"] == [
+ {"role": "user", "text": EXPLAIN},
+ {"role": "assistant", "text": explained},
+ {"role": "user", "text": instruction},
+ {"role": "assistant", "text": (
+ "Described.\n\nThese changes were made in an earlier editing session and may not have been saved: "
+ "Workflow: Description."
+ )},
+ ]
+ answered(panel, third)
+ assert len(ui.workflow_writes) == 1
diff --git a/ui_tests/test_v2_workflow_ask_ai_proposal.py b/ui_tests/test_v2_workflow_ask_ai_proposal.py
new file mode 100644
index 000000000..3d62a7928
--- /dev/null
+++ b/ui_tests/test_v2_workflow_ask_ai_proposal.py
@@ -0,0 +1,169 @@
+# test_v2_workflow_ask_ai_proposal.py
+"""
+Real-component browser test for Ask AI on a workflow proposal's draft (Phase 3c).
+Version: 0.261.211
+Implemented in: 0.261.211
+Refs: microsoft/simplechat#1548
+
+Phase 4's proposal card opens the workflow editor on the proposal's draft through Edit. Ask AI
+works there too: the draft has no saved workflow behind it, so a turn sends `base: null` and a
+draft without an `id`, and a Save with an AI change in it opens Review before saving first, then
+accepts the proposal through the card and never through the workflow save route.
+
+Reuses the proposal card harness (the production MessageList, WorkflowProposalCards and
+WorkflowEditorDialog in Chromium with the production CSS). `POST /api/user/workflows/assist` is
+answered in the page by 3b's real pipeline with a scripted model (`fixtures/workflow_ask_ai.py`),
+so no live model or Azure service is contacted.
+
+Build CSS with the existing V2 build, keeping outputs in UI test artifacts:
+npm --prefix .\\application\\v2_ui run build -- --outDir ..\\..\\ui_tests\\artifacts\\orchestration-plan-editor
+Run with PLAYWRIGHT_SERVICE_URL='' and PYTHONPATH including application/single_app,
+functional_tests and ui_tests/fixtures.
+"""
+
+import re
+import sys
+from pathlib import Path
+from urllib.parse import urlsplit
+
+import pytest
+from playwright.sync_api import expect
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+sys.path.insert(0, str(ROOT / "ui_tests"))
+sys.path.insert(0, str(ROOT / "ui_tests" / "fixtures"))
+
+# The shared fixtures import pure application helpers after setting their paths.
+from test_v2_orchestration_workflow_proposal_card import ( # noqa: E402,F401
+ CONVERSATION,
+ HOSTILE_INSTRUCTIONS,
+ NAME,
+ card,
+ card_ui,
+ connect_options,
+ editor_assets,
+ editor_browser,
+ editor_options,
+ mount,
+ open_editor,
+)
+from ui_tests.fixtures.workflow_ask_ai import ASSIST_ROUTE, AssistStub # noqa: E402
+from ui_tests.test_v2_workflow_change_tracking import ( # noqa: E402
+ RUN_AS_NOTE,
+ author,
+ changed,
+ changes_toggle,
+ open_panel,
+ side_panel,
+)
+
+
+pytestmark = pytest.mark.ui
+DESCRIBED = "Reviews the week's email and flags anything urgent."
+
+
+@pytest.fixture
+def ask_card_ui(card_ui):
+ page, api = card_ui
+
+ def record_error(url, status):
+ api.expected_errors.add((urlsplit(url).path, status))
+
+ # A proposal draft has no saved workflow to read, and these tests send no # references.
+ stub = AssistStub(
+ user_id="proposal-tester", record_error=record_error, options=editor_options, read_base=None, resolve=None,
+ )
+ # Registered after the harness's catch-all route, so it answers the assist route first.
+ page.route(ASSIST_ROUTE, stub.handle)
+ try:
+ yield page, api, stub
+ stub.assert_clean()
+ finally:
+ stub.release()
+
+
+def enable_ask_ai(page):
+ """The bootstrap's per-user flag, as the server sends it when Ask AI is available to this user."""
+ page.evaluate("""() => {
+ const store = window.OrchHarness.stores.bootstrap.useBootstrapStore;
+ const data = store.getState().data;
+ store.setState({ data: { ...data, features: { ...data.features, enable_workflow_ai_assistant: true } } });
+ }""")
+
+
+def description_field(dialog):
+ return dialog.locator("section[aria-label='Workflow basics']").get_by_label("Description", exact=True)
+
+
+def test_a_proposal_draft_sends_no_base_and_saves_through_review(ask_card_ui):
+ page, api, stub = ask_card_ui
+ stub.queue(stub.changed({"op": "set_description", "description": DESCRIBED}, text="Updated the description."))
+ mount(page, api)
+ enable_ask_ai(page)
+ dialog, _ = open_editor(page, card(page))
+
+ toggle = dialog.get_by_role("button", name="Ask AI", exact=True)
+ toggle.click()
+ expect(toggle).to_have_attribute("aria-expanded", "true")
+ panel = side_panel(dialog)
+ expect(panel.get_by_role("tab", name="Ask AI", exact=True)).to_have_attribute("aria-selected", "true")
+ box = panel.get_by_role("textbox", name="Message Ask AI", exact=True)
+ expect(box).to_be_focused()
+ box.fill("Say that it flags anything urgent.")
+ box.press("Enter")
+ body = stub.wait_for_requests(page, 1)
+
+ # A proposal draft is a new draft: no base, and no id in the draft.
+ assert body["base"] is None
+ assert "id" not in body["draft"]
+ assert body["draft"]["name"] == NAME
+ assert body["draft"]["tasks"][0]["instructions"] == HOSTILE_INSTRUCTIONS
+ assert (body["conversation"], body["focus"], body["references"]) == ([], None, [])
+
+ found = panel.locator(f"[data-workflow-assist-card='{body['submission_id']}']")
+ expect(found).to_have_attribute("data-state", "applied")
+ expect(found.locator("[data-workflow-assist-reply]")).to_have_text("Updated the description.")
+ expect(description_field(dialog)).to_have_value(DESCRIBED)
+ expect(author(changed(dialog, "description"))).to_have_text("AI assist")
+ expect(changes_toggle(dialog)).to_have_accessible_name("Changes (1 unsaved)")
+
+ # The first Save reviews the AI change; only Confirm and save accepts the proposal.
+ dialog.get_by_role("button", name="Save workflow", exact=True).click()
+ expect(panel.get_by_role("tab", name="Changes", exact=True)).to_have_attribute("aria-selected", "true")
+ review = panel.get_by_role("region", name="Review before saving", exact=True)
+ expect(review.get_by_role("heading", name="Review before saving", exact=True)).to_be_focused()
+ expect(review.locator("li").filter(has_text=RUN_AS_NOTE)).to_have_count(0)
+ assert not api.writes(), "Save accepted the proposal before Confirm and save."
+ review.get_by_role("button", name="Confirm and save", exact=True).click()
+ expect(dialog).to_have_count(0)
+
+ bodies = [call["body"] for call in api.writes()]
+ assert len(bodies) == 1, bodies
+ assert set(bodies[0]) == {"conversation_id", "workflow"}, sorted(bodies[0])
+ assert bodies[0]["conversation_id"] == CONVERSATION
+ workflow = bodies[0]["workflow"]
+ assert workflow["description"] == DESCRIBED
+ assert "id" not in workflow
+ assert not [call for call in api.requests if call["path"].startswith("/api/user/workflows")
+ and call["method"] == "POST"], "The editor saved a workflow directly."
+ expect(card(page).get_by_role("status")).to_have_text("Created, paused")
+
+
+def test_ask_ai_is_hidden_in_a_proposal_draft_when_the_assistant_is_off(ask_card_ui):
+ page, api, stub = ask_card_ui
+ mount(page, api)
+ dialog, _ = open_editor(page, card(page))
+
+ # An empty task would offer Draft with AI if Ask AI were available.
+ instructions = dialog.get_by_label("Instructions", exact=True).first
+ instructions.fill("")
+ expect(instructions).to_have_value("")
+ panel = open_panel(dialog)
+ expect(panel.get_by_role("tab")).to_have_count(1)
+ expect(panel.locator("[data-workflow-ask-ai]")).to_have_count(0)
+ expect(dialog.get_by_role("button", name="Ask AI", exact=True)).to_have_count(0)
+ expect(dialog.locator("[data-workflow-ask-ai-task]")).to_have_count(0)
+ expect(dialog.locator("[data-workflow-draft-ai]")).to_have_count(0)
+ expect(dialog.get_by_role("button", name=re.compile(r"^(Ask AI|Draft with AI)"))).to_have_count(0)
+ assert not stub.bodies
From cc98c5d6a9d5326d3a51be892c1fba3a8c85a786 Mon Sep 17 00:00:00 2001
From: Paul Lizer
Date: Wed, 30 Sep 2026 21:46:51 -0400
Subject: [PATCH 06/15] Keep the A1 and A2 source guards passing with the Ask
AI opt-ins
List the new dependencies in the order A2's guards expect, and update A1's guards for the opt-in code point count and the quick actions that leave the input alone. UTF-16 counting stays the default.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---
.../v2_ui/src/components/chat/ComposerEditor.tsx | 3 +--
.../v2_ui/src/components/chat/ContextMenu.tsx | 2 +-
functional_tests/test_v2_assist_thread.py | 12 +++++++++---
3 files changed, 11 insertions(+), 6 deletions(-)
diff --git a/application/v2_ui/src/components/chat/ComposerEditor.tsx b/application/v2_ui/src/components/chat/ComposerEditor.tsx
index 41b0dd045..314b985e3 100644
--- a/application/v2_ui/src/components/chat/ComposerEditor.tsx
+++ b/application/v2_ui/src/components/chat/ComposerEditor.tsx
@@ -230,8 +230,7 @@ export function ComposerEditor({
// workspaces are not offered there.
workspacesEnabled: !restricted,
documentsOnly: restricted && contextDocumentsOnly,
- }), [bootstrap?.scope, features.enable_group_workspaces, features.enable_public_workspaces, restricted,
- contextDocumentsOnly]);
+ }), [bootstrap?.scope, contextDocumentsOnly, features.enable_group_workspaces, features.enable_public_workspaces, restricted]);
const { candidates, loading } = useContextSuggestions(
disabled || !contextEnabled ? null : contextQuery?.query ?? null, scope,
);
diff --git a/application/v2_ui/src/components/chat/ContextMenu.tsx b/application/v2_ui/src/components/chat/ContextMenu.tsx
index 5cba8d6ab..1daaf87be 100644
--- a/application/v2_ui/src/components/chat/ContextMenu.tsx
+++ b/application/v2_ui/src/components/chat/ContextMenu.tsx
@@ -113,7 +113,7 @@ export function useContextSuggestions(
window.clearTimeout(timer);
controller.abort();
};
- }, [query, groupIds, publicIds, scope.groupsEnabled, scope.publicEnabled, scope.workspacesEnabled, scope.documentsOnly]);
+ }, [query, groupIds, publicIds, scope.documentsOnly, scope.groupsEnabled, scope.publicEnabled, scope.workspacesEnabled]);
return { candidates, loading };
}
diff --git a/functional_tests/test_v2_assist_thread.py b/functional_tests/test_v2_assist_thread.py
index 96f2370fe..74e0e7b32 100644
--- a/functional_tests/test_v2_assist_thread.py
+++ b/functional_tests/test_v2_assist_thread.py
@@ -132,7 +132,9 @@ def test_sending_does_not_wait_for_the_server():
submit = submit[: submit.find("\n}\n")]
assert "status: 'pending'" in submit
- assert "draft: blankDraft()" in submit
+ # A typed message clears the input. Ask AI's quick actions (#1548) send their own text and
+ # leave what the reader is typing in place.
+ assert "draft: ownText ? current.draft : blankDraft()" in submit
# The thread is updated first; the request only starts afterwards.
assert submit.find("updateThread(") < submit.find("runExchange("), (
"the input must clear before the request is sent"
@@ -202,8 +204,12 @@ def test_over_limit_input_is_refused_not_cut_short():
)
thread = _read(THREAD_TS)
- assert "draft.text.length > maxLength ? 'too_long'" in thread
- assert "overLimit: draft.text.length > maxLength" in thread
+ assert "assistTextLength(draft.text, countCodePoints) > maxLength ? 'too_long'" in thread
+ assert "overLimit: length > maxLength" in thread
+ # The limit counts UTF-16 units unless an editor opts in to code points, as Ask AI (#1548)
+ # does because its server counts that way.
+ assert "return countCodePoints ? codePointLength(text) : text.length;" in thread
+ assert "const countCodePoints = options.countCodePoints === true;" in thread
component = _read(THREAD_TSX)
assert "!thread.overLimit" in component, "Send must be refused while the input is over the limit"
From c443be0749d8cfd177aea253ac246beddb9e136f Mon Sep 17 00:00:00 2001
From: Paul Lizer
Date: Wed, 30 Sep 2026 21:46:52 -0400
Subject: [PATCH 07/15] Test that the workflow editor leaves a chat's idle
assist threads alone
Through the proposal card's Edit: an Ask AI turn and closing the editor keep the chat's idle image thread, and once the editor lets its thread go the chat prunes as before.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---
ui_tests/test_v2_workflow_ask_ai_proposal.py | 65 ++++++++++++++++++++
1 file changed, 65 insertions(+)
diff --git a/ui_tests/test_v2_workflow_ask_ai_proposal.py b/ui_tests/test_v2_workflow_ask_ai_proposal.py
index 3d62a7928..e8c62fff3 100644
--- a/ui_tests/test_v2_workflow_ask_ai_proposal.py
+++ b/ui_tests/test_v2_workflow_ask_ai_proposal.py
@@ -10,6 +10,10 @@
draft without an `id`, and a Save with an AI change in it opens Review before saving first, then
accepts the proposal through the card and never through the workflow save route.
+The editor holds its assist thread while it is open, and the thread lives outside the chat's
+conversations, so an Ask AI turn opened from a chat must not sweep that chat's idle image or plan
+transcripts. Closing the editor lets its thread go, and the chat prunes as it always did.
+
Reuses the proposal card harness (the production MessageList, WorkflowProposalCards and
WorkflowEditorDialog in Chromium with the production CSS). `POST /api/user/workflows/assist` is
answered in the page by 3b's real pipeline with a scripted model (`fixtures/workflow_ask_ai.py`),
@@ -61,6 +65,10 @@
pytestmark = pytest.mark.ui
DESCRIBED = "Reviews the week's email and flags anything urgent."
+# An image editor's transcript on the proposal's answer, and a diagram thread in another chat.
+IMAGE_THREAD = f"image:{CONVERSATION}:answer-1"
+OTHER_CONVERSATION = "other-conversation"
+OTHER_THREAD = f"block:{OTHER_CONVERSATION}:mermaid:other-answer:0"
@pytest.fixture
@@ -96,6 +104,26 @@ def description_field(dialog):
return dialog.locator("section[aria-label='Workflow basics']").get_by_label("Description", exact=True)
+def thread_keys(page):
+ return page.evaluate(
+ "() => Object.keys(window.OrchHarness.stores.assistThread.useAssistThreadStore.getState().threads).sort()"
+ )
+
+
+def touch_thread(page, key, conversation, text):
+ """Record a completed exchange the way an image or diagram editor does, which prunes the store around it."""
+ page.evaluate("""(spec) => {
+ const store = window.OrchHarness.stores.assistThread.useAssistThreadStore;
+ store.getState().updateThread(spec.key, spec.conversation, (record) => ({
+ ...record,
+ exchanges: [...record.exchanges, {
+ id: `${spec.key}:turn`, text: spec.text, status: 'done', startedAt: Date.now(), reply: 'Done.',
+ draft: { text: spec.text, contextItems: [], attachedPrompt: null, promptValues: {}, uploads: [] },
+ }],
+ }));
+ }""", {"key": key, "conversation": conversation, "text": text})
+
+
def test_a_proposal_draft_sends_no_base_and_saves_through_review(ask_card_ui):
page, api, stub = ask_card_ui
stub.queue(stub.changed({"op": "set_description", "description": DESCRIBED}, text="Updated the description."))
@@ -167,3 +195,40 @@ def test_ask_ai_is_hidden_in_a_proposal_draft_when_the_assistant_is_off(ask_card
expect(dialog.locator("[data-workflow-draft-ai]")).to_have_count(0)
expect(dialog.get_by_role("button", name=re.compile(r"^(Ask AI|Draft with AI)"))).to_have_count(0)
assert not stub.bodies
+
+
+def test_the_editor_leaves_the_chats_idle_assist_threads_alone(ask_card_ui):
+ page, api, stub = ask_card_ui
+ stub.queue(stub.changed({"op": "set_description", "description": DESCRIBED}, text="Updated the description."))
+ mount(page, api)
+ enable_ask_ai(page)
+ # Idle, so a touch in any other conversation would sweep it.
+ touch_thread(page, IMAGE_THREAD, CONVERSATION, "Make it brighter.")
+ assert thread_keys(page) == [IMAGE_THREAD]
+
+ dialog, _ = open_editor(page, card(page))
+ dialog.get_by_role("button", name="Ask AI", exact=True).click()
+ panel = side_panel(dialog)
+ box = panel.get_by_role("textbox", name="Message Ask AI", exact=True)
+ box.fill("Say that it flags anything urgent.")
+ box.press("Enter")
+ body = stub.wait_for_requests(page, 1)
+ found = panel.locator(f"[data-workflow-assist-card='{body['submission_id']}']")
+ expect(found).to_have_attribute("data-state", "applied")
+
+ # The editor's thread is in its own conversation, and recording the turn swept nothing.
+ keys = thread_keys(page)
+ workflow_keys = [key for key in keys if key.startswith("workflow:new:")]
+ assert len(workflow_keys) == 1 and IMAGE_THREAD in keys, keys
+
+ dialog.get_by_role("button", name="Cancel", exact=True).click()
+ page.get_by_role("dialog", name="Discard unsaved workflow changes?").get_by_role(
+ "button", name="Discard changes", exact=True).click()
+ expect(dialog).to_have_count(0)
+ # Closing the editor sweeps nothing either.
+ assert thread_keys(page) == sorted([IMAGE_THREAD, workflow_keys[0]])
+
+ # Once the editor has let its thread go, the chat prunes as before: a touch in another
+ # conversation sweeps both idle threads.
+ touch_thread(page, OTHER_THREAD, OTHER_CONVERSATION, "Add a legend.")
+ assert thread_keys(page) == [OTHER_THREAD]
From 0228a36f724003830567b8e39215b49896546563 Mon Sep 17 00:00:00 2001
From: Paul Lizer
Date: Wed, 30 Sep 2026 22:40:28 -0400
Subject: [PATCH 08/15] Send the Ask AI parity requests through the real assist
route
Every tab-built request is now POSTed as its exact text to POST
/api/user/workflows/assist through 3b's route harness (the real route
body, helpers and gates on a closed Flask app over a scripted model), and
the tab replays the route's real 200 bodies. A new test checks that the
route read exactly the bytes the tab sent, ran the pipeline once for the
signed-in user, and answered uncached JSON.
Refs #1548
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---
.../test_v2_workflow_ask_ai_parity.py | 71 +++++++++++++++----
1 file changed, 58 insertions(+), 13 deletions(-)
diff --git a/functional_tests/test_v2_workflow_ask_ai_parity.py b/functional_tests/test_v2_workflow_ask_ai_parity.py
index 11ba38ced..236e1a0c9 100644
--- a/functional_tests/test_v2_workflow_ask_ai_parity.py
+++ b/functional_tests/test_v2_workflow_ask_ai_parity.py
@@ -12,13 +12,16 @@
* node builds each request with ``buildWorkflowAssistRequest`` from the draft the editor holds (a
saved workflow, a v3 flow, New workflow's draft and a chat proposal's draft), with ``#`` documents
as the picker makes them and a thread history, and prints the exact JSON text the browser POSTs;
-* the assistant reads that text with its own checks (``parse_assist_body``, ``parse_assist_request``)
- and answers it. A new or proposal draft must arrive with ``base: null`` and no ``id``, a saved one
- with its ``id`` and ``definition_revision``, and the focus, time zone, documents and completed
- turns as the contract says;
-* node replays each answer as the tab does: ``parseWorkflowAssistResponse``, ``rebaseAssistCandidate``
- over the live draft, the editor's own diff (``verifyAssistChanges``) and ``applyAssist``. The
- editor's diff must find exactly the change keys, Jump to targets and wording the server reported.
+* the assistant reads that text with its own checks (``parse_assist_body``, ``parse_assist_request``),
+ and that exact text is POSTed to the real ``POST /api/user/workflows/assist`` route: 3b's route
+ harness from route_tests/test_workflow_assist_policy.py, which runs the real route body, helpers
+ and gates on a closed Flask app over a scripted model. A new or proposal draft must arrive with
+ ``base: null`` and no ``id``, a saved one with its ``id`` and ``definition_revision``, and the
+ focus, time zone, documents and completed turns as the contract says;
+* node replays each 200 body the route answered as the tab does: ``parseWorkflowAssistResponse``,
+ ``rebaseAssistCandidate`` over the live draft, the editor's own diff (``verifyAssistChanges``) and
+ ``applyAssist``. The editor's diff must find exactly the change keys, Jump to targets and wording
+ the server reported.
Every scenario in test_workflow_assist_candidate_parity.py runs this way, plus cases for the request
shapes. The TypeScript side is test_v2_workflow_ask_ai_parity_logic.ts. The node checks are skipped
@@ -27,6 +30,7 @@
import ast
import copy
+import importlib.util
import json
import os
import subprocess
@@ -53,6 +57,7 @@
V2_DIR = REPO_ROOT / "application" / "v2_ui"
LOGIC_CHECK = Path(__file__).with_name("test_v2_workflow_ask_ai_parity_logic.ts")
+ROUTE_POLICY = REPO_ROOT / "functional_tests" / "route_tests" / "test_workflow_assist_policy.py"
FIXTURE_ENV = "WORKFLOW_ASK_AI_PARITY_FIXTURE"
REQUEST_FIELDS = {"submission_id", "base", "instruction", "conversation", "focus", "time_zone", "draft", "references"}
@@ -63,6 +68,26 @@
"schedule": build_workflow_schedule_editor_options(min_interval_seconds=300),
}
+
+def _load_route_policy():
+ """3b's route policy module, loaded under its own name for its route harness."""
+ spec = importlib.util.spec_from_file_location("_ask_ai_parity_route_policy", ROUTE_POLICY)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+route_policy = _load_route_policy()
+
+
+class RouteHarness(route_policy.Harness):
+ """3b's route harness, with the services given the editor options the tab was given."""
+
+ def build_services(self, settings, *, client_factory):
+ self.built.append((settings, client_factory))
+ self.bundle = wa.services(self.model, stored=self.stored, limiter=self.limiter, options=ASK_AI_OPTIONS)
+ return self.bundle
+
# A thread history: eleven completed turns, so the oldest falls out of the ten the tab replays, with
# a failed and a cancelled turn among them that must never reach the server.
REPLAY = {
@@ -233,13 +258,23 @@ def _emitted(run, label):
def _answer(case, text):
- """What the assistant does with the exact text the tab sent."""
- body = core.parse_assist_body(text.encode("utf-8"))
+ """What the assistant's route answers to the exact text the tab sent, over a scripted model."""
+ raw = text.encode("utf-8")
+ body = core.parse_assist_body(raw)
request = core.parse_assist_request(copy.deepcopy(body), wa.USER_ID)
- model = wa.ScriptedModel(case["reply"])
- bundle = wa.services(model, stored=case["stored"] if body["base"] is not None else None, options=ASK_AI_OPTIONS)
- result = wa.run(body, bundle)
- return {"body": body, "request": request, "bundle": bundle, "result": result}
+ harness = RouteHarness()
+ harness.model = wa.ScriptedModel(case["reply"])
+ harness.stored = case["stored"] if body["base"] is not None else None
+ response = harness.post(data=raw, content_type="application/json", headers={"Accept": "application/json"})
+ answered = response.get_data(as_text=True)
+ # Raised rather than asserted, so an optimized run still stops on a refusal.
+ if response.status_code != 200:
+ raise AssertionError(f"{case['name']}: the route answered {response.status_code}: {answered[:500]}")
+ return {
+ "body": body, "request": request, "bundle": harness.bundle, "result": json.loads(answered),
+ "read": list(harness.parsed), "runs": list(harness.runs), "mimetype": response.mimetype,
+ "cache_control": response.headers.get("Cache-Control"),
+ }
@pytest.fixture(scope="module")
@@ -303,6 +338,16 @@ def test_every_request_the_tab_builds_is_one_the_assistant_accepts(round_trip):
assert body["conversation"] == [], name
+def test_the_route_answers_the_exact_text_the_tab_sends(round_trip):
+ for case, item, answer in zip(round_trip["cases"], round_trip["requests"], round_trip["answers"]):
+ name = case["name"]
+ assert answer["read"] == [len(item["text"].encode("utf-8"))], name
+ assert answer["runs"] == [wa.USER_ID], name
+ assert answer["mimetype"] == "application/json", name
+ assert answer["cache_control"] == "no-store, private", name
+ assert answer["result"]["submission_id"] == case["submission_id"], name
+
+
def test_a_new_or_proposal_draft_is_sent_without_a_base_or_an_id(round_trip):
unsaved = [
(case, answer) for case, answer in zip(round_trip["cases"], round_trip["answers"])
From 9ee340bba5b13dfb759968a6f89f3baf6e9be853 Mon Sep 17 00:00:00 2001
From: Paul Lizer
Date: Wed, 30 Sep 2026 23:07:50 -0400
Subject: [PATCH 09/15] Document the Ask AI tab and guard the instruction limit
- Feature docs: the Ask AI tab, its thread, requests, rebase and change
check, turn cards, failures, Draft with AI and accessibility
(WORKFLOW_AI_ASSISTANT.md); the shared thread's opt-in options and the
workflow limit (V2_SHARED_ASSIST_THREAD.md); AI-authored changes now
coming from Ask AI and Draft with AI (WORKFLOW_EDITOR_CHANGE_TRACKING.md).
- Admin and guide: the setting now shows the tab and Draft with AI; a new
"Ask AI to change a workflow" section and two troubleshooting rows.
- Release notes: v0.261.211 above #1585's v0.261.210, which is unchanged.
- test_v2_workflow_ask_ai.py: the four chat editors pass none of the
shared thread's new options, and the client's instruction limit matches
ASSIST_INSTRUCTION_MAX_LENGTH and is counted in code points.
- workflowAssist.ts: correct a comment about per-field Revert.
Refs microsoft/simplechat#1548
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---
application/v2_ui/src/lib/workflowAssist.ts | 3 +-
docs/_data/features.yml | 2 +-
docs/admin/workflow.md | 19 +-
.../features/V2_SHARED_ASSIST_THREAD.md | 113 +++++-
.../features/WORKFLOW_AI_ASSISTANT.md | 362 ++++++++++++++++--
.../WORKFLOW_EDITOR_CHANGE_TRACKING.md | 25 +-
docs/explanation/release_notes.md | 20 +
docs/guides/create-a-workflow.md | 89 ++++-
functional_tests/test_v2_workflow_ask_ai.py | 44 ++-
9 files changed, 625 insertions(+), 52 deletions(-)
diff --git a/application/v2_ui/src/lib/workflowAssist.ts b/application/v2_ui/src/lib/workflowAssist.ts
index dafe90055..be4a37347 100644
--- a/application/v2_ui/src/lib/workflowAssist.ts
+++ b/application/v2_ui/src/lib/workflowAssist.ts
@@ -690,7 +690,8 @@ export interface WorkflowAssistHistoryView {
/**
* Where a completed turn's changes stand now, read from the authoring history rather than
- * remembered, so Undo, Redo and a per-field revert are all reflected.
+ * remembered, so Undo and Redo are reflected. A per-field Revert is a step of its own, without
+ * the turn's id, so it leaves the turn applied.
*/
export function workflowAssistTurnState(
turnId: string,
diff --git a/docs/_data/features.yml b/docs/_data/features.yml
index 4a6bf210e..7121005d7 100644
--- a/docs/_data/features.yml
+++ b/docs/_data/features.yml
@@ -431,7 +431,7 @@ workflow-ai-assistant:
name: "AI workflow assistant"
area: "Agents and actions"
audience: "End users"
- summary: "Proposes an edit to a personal workflow from a plain-language request, such as a new schedule, alert rules, tasks or attached documents, for the author to review before saving. The V2 editor's Ask AI tab that uses it arrives in a later release."
+ summary: "Changes a personal workflow from a plain-language request, such as a new schedule, alert rules, tasks or attached documents, through the V2 workflow editor's Ask AI tab. The changes are highlighted for the author to review before saving, and empty task instructions can be drafted with Draft with AI."
why: "Use this when authors know what a workflow should do but not which fields to set, for example \"run this at 7 AM on weekdays and only alert me when something is urgent\". It only proposes: the author reviews each change and saves through the normal checks, and it can't turn a workflow on, change Run as, sharing or approvals, or use documents, agents or models the author can't use."
default_state: "Enabled"
admin_tab: workflow
diff --git a/docs/admin/workflow.md b/docs/admin/workflow.md
index 0f15dfe86..bd415e973 100644
--- a/docs/admin/workflow.md
+++ b/docs/admin/workflow.md
@@ -54,7 +54,7 @@ visible to its members. Turning one on does not turn on the other.
| --- | --- | --- | --- |
| Enable Personal Workflows | Lets users build workflows in their personal workspace and run them manually or on a schedule, either at a fixed interval or at a local time on a calendar schedule. | Off | `allow_user_workflows` |
| Require WorkflowUser App Role | Restricts personal workflows to holders of the `WorkflowUser` Enterprise App role. Covers opening, creating, editing, running and inspecting them, so assign the role before turning this on or every user loses access at once. | Off | `require_member_of_workflow_user` |
-| Enable AI Workflow Assistant | Lets people who can edit personal workflows describe a change in plain language, such as "run this at 7 AM on weekdays", and review the assistant's proposed edit in the V2 workflow editor before they save it. The assistant never saves, runs or shares a workflow, and it only uses documents the person can already open. | On | `enable_workflow_ai_assistant`; personal workflows only; has no effect while Enable Personal Workflows is off |
+| Enable AI Workflow Assistant | Lets people who can edit personal workflows use the V2 workflow editor's **Ask AI** tab: describe a change in plain language, such as "run this at 7 AM on weekdays", and review the assistant's edit before they save it. It also shows each task's **Draft with AI** button. The assistant never saves, runs or shares a workflow, and it only uses documents the person can already open. | On | `enable_workflow_ai_assistant`; personal workflows only; has no effect while Enable Personal Workflows is off |
| Enable Group Workflows | Lets permitted members create, manage and run workflows from group workspaces. Owners and Admins may author them unless Workspaces restricts group agent, action and workflow management to Owners. | Off | `allow_group_workflows` |
| Require Group Assignment to Use Workflow | Narrows group workflows to an explicit allow list instead of every group. Groups outside the list lose the capability. | Off | `require_group_assignment_for_group_workflows` |
| Assigned Groups | The groups that may use group workflows while assignment is required. Ignored when it is not. | Empty list | `group_workflow_allowed_group_ids` |
@@ -93,14 +93,18 @@ for the daylight saving rules and the stored format.
### AI workflow assistant {#workflow-ai-assistant}
-Version **0.261.208** adds the server side of the AI workflow assistant. A person
+Version **0.261.208** adds the server side of the AI workflow assistant, and
+version **0.261.211** adds the V2 workflow editor's **Ask AI** tab. A person
editing a personal workflow can ask for a change in plain language, such as "run
-this at 7 AM on weekdays and only alert me when something is urgent", and gets a
-proposed edit that the V2 editor shows as highlighted, revertible changes. The
-editor's **Ask AI** tab arrives in a later release.
+this at 7 AM on weekdays and only alert me when something is urgent", and the
+editor applies the proposed edit as highlighted changes they can revert one by
+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. Things to know before relying on 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:
- **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
@@ -118,6 +122,9 @@ follows the same `WorkflowUser` role rule. Things to know before relying on it:
it's temporarily unavailable rather than running without a limit.
- **Its logs hold no content.** Telemetry records counts, outcomes and error
categories, never instructions, workflows, replies or document text.
+- **The conversation isn't kept.** The **Ask AI** conversation lives only in
+ the browser page. The server doesn't store it, and reloading the page starts
+ it over.
See [AI workflow assistant](../explanation/features/WORKFLOW_AI_ASSISTANT.md)
for the request contract, the changes it can propose, and its security model.
diff --git a/docs/explanation/features/V2_SHARED_ASSIST_THREAD.md b/docs/explanation/features/V2_SHARED_ASSIST_THREAD.md
index 18091824f..a05d8491c 100644
--- a/docs/explanation/features/V2_SHARED_ASSIST_THREAD.md
+++ b/docs/explanation/features/V2_SHARED_ASSIST_THREAD.md
@@ -4,9 +4,12 @@ Implemented in version: **0.261.200**.
Document and tag references in the plan editor added in version: **0.261.201**.
+Options for the workflow editor's **Ask AI** tab added in version: **0.261.211**.
+
Application version tracking: `application\single_app\config.py`.
-Related issues: #1552 (the shared thread) and #1556 (`#` document references), part of #1543.
+Related issues: #1552 (the shared thread), #1556 (`#` document references) and #1548 (the
+workflow editor's **Ask AI** tab), part of #1543.
## Overview and dependencies
@@ -44,6 +47,12 @@ them for the person asking as the request arrives, adds them to that revision's
exactly once, and stores them on the user turn so the thread can show them. The other three
editors don't offer them. See **Document and tag references in the plan editor** below.
+From version **0.261.211**, the V2 workflow editor's **Ask AI** tab runs on the same thread. It
+needed a few things the four editors above don't, such as counting in code points and keeping its
+thread while the editor is open. Each one is an option that's off by default, so the four editors
+are unchanged. See **Options for the workflow editor** below, and
+[Workflow AI assistant](WORKFLOW_AI_ASSISTANT.md) for the tab itself.
+
Dependencies:
- The V2 interface in `application/v2_ui`. The classic interface has none of these editors and is
@@ -75,7 +84,11 @@ A thread is keyed by what it edits:
- `block::::` for a diagram or chart;
- `image::` for an image;
-- `plan::` for a plan.
+- `plan::` for a plan;
+- from 0.261.211, `workflow:personal:` for a saved personal workflow, or
+ `workflow:new:` for a new or proposal draft, with an id made when the workflow editor opens.
+ These threads use the conversation id `workflow-editor`, because the workflow editor isn't part
+ of a chat.
Closing an editor and opening it again returns to the same thread, including a request that is
still running, and any text you hadn't sent. Nothing in the store is saved: reloading the page
@@ -84,7 +97,8 @@ starts every thread empty.
The store drops threads nobody needs. When you use an editor, threads from other conversations
with nothing running, no failed or cancelled turn and no unsent text are dropped. Past 50 threads,
the least recently used go too. A thread with a request in flight is never dropped, because its
-answer still has to land.
+answer still has to land. From 0.261.211, a thread an open editor holds is never dropped either,
+and using it doesn't drop other conversations' threads. See **Held threads** below.
### Restricted input
@@ -102,6 +116,10 @@ all the plan route accepts: its search scope sets `workspacesEnabled: false`, wh
`searchContextCandidates` (as `includeWorkspaces`) and `DocumentPickerPopover` honour. The main
composer and the orchestration question card don't pass `restricted`, so they're unchanged.
+From 0.261.211 the workflow editor's **Ask AI** tab passes `allowContext` with
+`contextDocumentsOnly`, so its input offers documents only, without tags, because the workflow
+assist route refuses tags.
+
### Stored and local threads
The diagram, chart and plan editors already show the transcript the server stored. Their threads
@@ -229,9 +247,12 @@ Every editor accepts up to 2,000 characters, which matches the server:
| Diagram and chart | `MAX_INSTRUCTION_LENGTH` in `functions_block_revision_assist.py` |
| Image | `MAX_INSTRUCTION_LENGTH` in `functions_message_image_revisions.py` |
| Plan | `EDIT_INSTRUCTION_LIMIT` in `functions_orchestration_plan_revisions.py` |
+| Workflow (**Ask AI**, from 0.261.211) | `ASSIST_INSTRUCTION_MAX_LENGTH` in `functions_workflow_assist.py`, counted in code points |
The counter turns red past the limit and says how many characters to remove. The send button stays
-off until the message fits. A functional test fails if either side changes without the other.
+off until the message fits. A functional test fails if either side changes without the other; for
+the workflow editor that's `test_the_instruction_limit_matches_the_server` in
+`functional_tests/test_v2_workflow_ask_ai.py`.
### Accessibility
@@ -399,6 +420,69 @@ messages identical.
Document provenance, the origin ids and `workflow` tag added in 0.261.194, is metadata. It never
makes a document readable, and a document a workflow created is referenced like any other.
+### Options for the workflow editor
+
+Added in **0.261.211** (#1548). The V2 workflow editor's **Ask AI** tab runs on this thread. Its
+route counts characters differently, its server writes nothing, and the editor can open from a
+chat, so it needed a few options the other editors don't use. Each one is off by default, and the
+diagram, chart, image and plan editors don't pass any of them.
+
+The tab calls the hook like this (`application/v2_ui/src/components/workflows/useWorkflowAssist.ts`):
+
+```ts
+useAssistThread({
+ key, // workflow:personal: or workflow:new:
+ conversationId: 'workflow-editor',
+ mode: 'local',
+ maxLength: WORKFLOW_ASSIST_INSTRUCTION_LIMIT, // 2,000
+ send,
+ countCodePoints: true,
+ retain: true,
+});
+```
+
+| Option | Where | Default | What it does |
+|---|---|---|---|
+| `countCodePoints` | `useAssistThread` and `AssistSubmitOptions` | Off | Measures `maxLength` in Unicode code points rather than UTF-16 units, the way the workflow assist route measures `ASSIST_INSTRUCTION_MAX_LENGTH`, so an emoji counts once and the counter agrees with the server. `assistTextLength` and `describeDraftProblem` take the same flag. The helpers are in `lib/codePoints.ts`, which counts a lone surrogate as one, as Python does. |
+| `retain` | `useAssistThread` | Off | Holds the thread while the editor is mounted, through `holdAssistThread(key)`. See **Held threads** below. |
+| `text` | `AssistSubmitOptions` | Unset | Sends that text as a message of its own and leaves the input as it is. The controller's new `sendText(text)` uses it for the tab's quick actions. |
+| `cancelledMessage` | `AssistThread` | The default note | Replaces "Cancelled. The change may still be applied if the server had already started it." on a cancelled turn. It's only for an editor whose server writes nothing, so a cancel is safe. The workflow tab passes "Cancelled. Nothing was changed." |
+| `contextDocumentsOnly` | `AssistThread` and `ComposerEditor` | Off | With `allowContext`, offers `#` documents only: no tags and no workspaces, because the workflow assist route refuses tags. It sets `documentsOnly` on the search scope, which `useContextSuggestions` and `DocumentPickerPopover` honour, and the picker's search box reads "Search documents…". |
+
+Two smaller changes go with them:
+
+- `renderReply` already existed, and no editor passed it. With it, the reply is now wrapped in a
+ `div` with `break-words` instead of the default paragraph with `whitespace-pre-wrap`, so an
+ editor can render a card with blocks of its own. Without it, the default paragraph is unchanged.
+ The workflow tab's card puts the model's reply, warnings and errors in as text, never as HTML or
+ Markdown.
+- `ComposerEditor` sets `data-composer-menu-open` on its holder while its `#` list or another menu
+ is open. In the workflow editor, Escape inside the side panel normally closes the panel. The
+ dialog reads this attribute, and the **Add context** picker, so that Escape closes only the open
+ list or picker and focus stays in the **Ask AI** input.
+
+#### Held threads
+
+Using a thread sweeps idle threads of other conversations. The chat editors all belong to a chat,
+so the sweep only clears what you left behind in other chats. The workflow editor belongs to none:
+its threads use the conversation id `workflow-editor`. Without a hold, using **Ask AI** would drop
+the idle threads of the chat you were in, such as an image editor's local transcript. The workflow
+proposal card's **Edit** opens the workflow editor from a chat, so this is a real case.
+
+`holdAssistThread(key)` in `assistThreadStore.ts` keeps a thread while its editor is mounted:
+
+- A held thread is never dropped, by the sweep or by the 50-thread cap.
+- Using a held thread doesn't sweep other conversations' threads.
+- Holds are counted per key. The release function it returns is safe to call twice.
+- A key that was held stays *quiet* after it's released, until its thread leaves the store. Using
+ a quiet thread doesn't sweep either, so an answer that lands after the editor closed leaves the
+ chat's threads alone.
+- With nothing held, `pruneThreads` behaves exactly as before.
+
+Closing the workflow editor stops waiting for a request that's still running. The workflow assist
+route writes nothing, so nothing is left half done. The hook cancels the request before it releases
+its hold, so the cancel doesn't sweep either.
+
### Files
Server:
@@ -444,6 +528,20 @@ Added for document and tag references in 0.261.201:
`application/v2_ui/src/lib/contextMentions.ts`, `assistThread.ts`, `orchestration.ts`,
`orchestrationController.ts` and `planSubmissionIds.ts`.
+Added for the workflow editor's **Ask AI** tab in 0.261.211:
+
+- `application/v2_ui/src/lib/codePoints.ts` (new): code point length and a prefix that never
+ splits a surrogate pair.
+- `application/v2_ui/src/lib/assistThread.ts`: `countCodePoints`, `retain`, `text` and `sendText`.
+- `application/v2_ui/src/stores/assistThreadStore.ts`: held and quiet threads, and
+ `holdAssistThread`.
+- `application/v2_ui/src/components/chat/AssistThread.tsx`: `cancelledMessage`,
+ `contextDocumentsOnly` and the `renderReply` wrapper.
+- `application/v2_ui/src/components/chat/ComposerEditor.tsx`, `ContextMenu.tsx` and
+ `DocumentPickerPopover.tsx`: documents-only search and `data-composer-menu-open`.
+
+The tab itself, and its files, are described in [Workflow AI assistant](WORKFLOW_AI_ASSISTANT.md).
+
## Usage
### Enable or configure
@@ -526,6 +624,8 @@ In **Ask planner**, while the `#` list is open:
| `functional_tests/test_assist_reference_canonicalization.py` | The server's canonical form against `fixtures/plan_reference_canonicalization.json`, the request identity it gives, `references` allowed only on `ask`, and the bounded chips and notice on stored turns. |
| `functional_tests/test_v2_plan_editor_references.py` | Only the plan editor offers references, documents and tags only, canonical references on the request, the browser's limits, chips as text, chips never lost, Escape, and no remote assets. It runs `test_v2_plan_references_logic.ts`, which checks the browser's canonical form against the same fixture, the submission ids a changed selection goes out under, and the notice text. |
| `ui_tests/test_v2_plan_editor_references.py` | In a browser with stubbed routes: `#` by keyboard to a chip, a chip in the thread and a plan that reads it; **Add context** with group documents and tags, and Escape keeping the editor open; a removed chip not sent; the refusal with **Edit and resend**; chips coming back; titles rendered as text; a lost reply retried under the same id; the main composer and question card keeping their tools; and no `#` in the diagram, chart and image editors. |
+| `functional_tests/test_v2_workflow_ask_ai.py` | The workflow editor's options, from 0.261.211: each defaults to off, and the diagram, chart, image and plan editors pass none of them. The tab's limit matches `ASSIST_INSTRUCTION_MAX_LENGTH` and is counted in code points. It runs `test_v2_workflow_ask_ai_logic.ts`, which checks the limit in code points against the default count in UTF-16 units, a quick action sent as its own message with the input left alone, and held and quiet threads: a held thread, a released one whose answer lands late, one held twice, and pruning as before once nothing holds it. |
+| `ui_tests/test_v2_workflow_ask_ai.py` and `ui_tests/test_v2_workflow_ask_ai_proposal.py` | The workflow **Ask AI** tab in a browser, including the limit in code points, a `#` list with documents only, Escape closing only that list or the picker, the cancelled wording and quick actions. The proposal file opens the workflow editor from a chat with an idle image thread through the proposal card's **Edit**, sends a turn and closes the editor. The image thread is still there, and once the editor has let go the chat prunes as before. |
### Known limitations
@@ -539,8 +639,9 @@ In **Ask planner**, while the `#` list is open:
- Threads belong to one browser tab. Another tab, or a reload, doesn't see a request in flight.
Its stored result appears there when the conversation is reloaded, or through the live update in
a shared conversation.
-- `#` references are offered only in **Ask planner**. The diagram, chart and image editors don't
- offer them.
+- `#` references are offered only in **Ask planner** and, from 0.261.211, the workflow editor's
+ **Ask AI** tab, which offers documents only. The diagram, chart and image editors don't offer
+ them.
- A planner reply that changes nothing doesn't keep the documents and tags you sent. The chips go
back in the input, and sending again uses them.
- A revision can add documents and tags to a plan but not take them away. To search everything
diff --git a/docs/explanation/features/WORKFLOW_AI_ASSISTANT.md b/docs/explanation/features/WORKFLOW_AI_ASSISTANT.md
index fcdacfd70..5f069bd10 100644
--- a/docs/explanation/features/WORKFLOW_AI_ASSISTANT.md
+++ b/docs/explanation/features/WORKFLOW_AI_ASSISTANT.md
@@ -1,4 +1,4 @@
-# Workflow AI Assistant (v0.261.208)
+# Workflow AI Assistant (v0.261.211)
## Overview
@@ -8,13 +8,19 @@ plain language: "run this at 7 AM on weekdays and only alert me when something i
workflow editor shows it as highlighted, attributed, revertible changes that the person reviews
before saving. The assistant never saves anything.
-This release adds the server half: `POST /api/user/workflows/assist`. It takes one instruction and
-the editor's current draft, asks the model for a small set of allowlisted operations, applies them
-to a copy of the draft, checks the result the way a save would, and returns a candidate draft for
-the editor's `applyAssist` seam. The **Ask AI** tab that calls it comes in the next release.
+The server half is `POST /api/user/workflows/assist`, added in 0.261.208. It takes one
+instruction and the editor's current draft, asks the model for a small set of allowlisted
+operations, applies them to a copy of the draft, checks the result the way a save would, and
+returns a candidate draft. It writes nothing.
-Implemented in version: **0.261.208**, tracked in `application/single_app/config.py`.
-Phase 3b of the [chat orchestration workflows roadmap](CHAT_ORCHESTRATION_WORKFLOWS_ROADMAP.md)
+The editor half is the **Ask AI** tab, added in 0.261.211. It sends the instruction, checks the
+answer against the draft it was sent with, and applies the candidate through the editor's
+`applyAssist` seam, so every change is highlighted, attributed to the turn and undoable. The same
+release adds **Draft with AI**, which writes instructions for an empty task.
+
+Implemented in version: **0.261.208** (the endpoint) and **0.261.211** (the **Ask AI** tab and
+**Draft with AI**), tracked in `application/single_app/config.py`.
+Phases 3b and 3c of the [chat orchestration workflows roadmap](CHAT_ORCHESTRATION_WORKFLOWS_ROADMAP.md)
(#1548, part of #1543).
The assistant ships in three layers:
@@ -22,8 +28,8 @@ The assistant ships in three layers:
| Layer | What it adds | Status |
| --- | --- | --- |
| 3a | Change tracking in the V2 editor, including `WorkflowAuthoringSession.applyAssist(candidate, {turnId, label})` | Shipped in 0.261.203 ([Workflow editor change tracking](WORKFLOW_EDITOR_CHANGE_TRACKING.md)) |
-| 3b | This endpoint: instruction in, validated candidate out | This release |
-| 3c | The **Ask AI** tab on the shared assist thread, which calls this endpoint | Next |
+| 3b | The endpoint: instruction in, validated candidate out | Shipped in 0.261.208 |
+| 3c | The **Ask AI** tab on the shared assist thread, which calls the endpoint, and **Draft with AI** | Shipped in 0.261.211 |
Dependencies:
@@ -35,6 +41,10 @@ Dependencies:
(`load_workflow_reference`).
- The draft-instructions model deployment that `/api/workflows/draft-instructions` already uses.
No new model setting is added.
+- For the tab: Track A1's shared assist thread (`AssistThread`, `useAssistThread` and
+ `assistThreadStore`, see [V2 shared assist thread](V2_SHARED_ASSIST_THREAD.md)), Track A2's
+ `#` references in `lib/planReferences.ts`, and 3a's `applyAssist`, `revertTurn` and **Changes**
+ tab.
## What it does and doesn't do
@@ -89,8 +99,8 @@ setting on top:
| `enable_workflow_ai_assistant` | On | Users who can edit personal workflows can use the AI assistant in the V2 workflow editor. It has no effect while **Enable Personal Workflows** is off. |
The V2 bootstrap route (`/api/v2/bootstrap`) reports the per-user result as
-`features.enable_workflow_ai_assistant`, so 3c can hide the **Ask AI** tab when the assistant isn't
-available.
+`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).
### Request
@@ -460,6 +470,249 @@ only these fields: `user_id`, `submission_id`, `status`, `outcome`, `code`, `sta
`warning_codes`, `fault_location` (file and line, on a 500 only) and `duration_ms`. A body that
can't be read logs `[WorkflowAssist] Assist request refused` with its code, status and stage.
+### The Ask AI tab
+
+**Ask AI** is the second tab in the editor's side panel, after **Changes**. It runs on Track A1's
+shared assist thread, so it has the same turn list, **Cancel**, **Retry**, **Edit and resend** and
+`#` composer as the chat's image and plan editors. A turn that changes the draft goes through 3a's
+`applyAssist`, so its changes get the same highlights, **Previously:** values, per-field
+**Revert** and **Changes** list as your own edits, and saving asks you to review them first.
+
+| File | Role |
+| --- | --- |
+| `components/workflows/WorkflowAskAiTab.tsx` | The tab, its turn cards, the footer toggle and the lock banner |
+| `components/workflows/useWorkflowAssist.ts` | The tab's state: sending a turn, applying the answer, **Undo this change**, **Jump to** and **Draft with AI** |
+| `lib/workflowAssist.ts` | Pure logic: the request, replay, the response guard, error messages, the rebase and the change check |
+| `lib/codePoints.ts` | Counting and cutting text by code points, as the server counts |
+| `stores/workflowAssistStore.ts` | Turn cards and the rate-limit wait, in memory only |
+| `components/workflows/WorkflowEditorDialog.tsx` | Wiring: the gate, the tab, the toggle, the lock, Escape and focus |
+| `components/workflows/WorkflowTaskFields.tsx` | Each task's **Ask AI** and **Draft with AI** buttons |
+| `components/workflows/WorkflowChangeTracking.tsx` | A side panel whose selected tab the dialog controls, and a class for each tab's panel |
+| `pages/workspace/WorkflowsSection.tsx` | Reopening a saved workflow after a conflict |
+
+Paths are under `application/v2_ui/src/`. The shared thread gained a few options for this tab, all
+off by default. See [V2 shared assist thread](V2_SHARED_ASSIST_THREAD.md#options-for-the-workflow-editor).
+
+### When the tab is offered
+
+The editor offers Ask AI only when all three of these hold:
+
+1. The bootstrap reports `features.enable_workflow_ai_assistant` as true for this user. That
+ follows the admin setting, personal workflows and the `WorkflowUser` role.
+2. The workflow is personal. Group workflows never offer it (roadmap decision 3).
+3. The editor can change the workflow. It can't when you don't have edit rights, when the workflow
+ has an active run, when access was lost while the editor was open, or when the workflow uses
+ something this editor can't change, such as an unsupported flow feature or a stored schedule it
+ can't show exactly.
+
+When it's offered, the footer shows an **Ask AI** toggle before **Changes**, each task has an
+**Ask AI** button, and a task with empty instructions has **Draft with AI**. When it isn't, the
+editor is as it was: the side panel has only **Changes**, and a read-only editor has no side panel.
+
+It also works on a workflow proposal opened from chat with **Edit** (Phase 4). That draft is a new
+personal workflow, so the tab sends it without a base, and there is no saved workflow to reload.
+
+### The thread
+
+A saved workflow has one thread, keyed `workflow:personal:`. A new workflow or a
+proposal draft gets a new key, `workflow:new:`, each time the editor opens, so it starts empty.
+Every thread uses the conversation ID `workflow-editor` and the thread's local mode, which keeps
+the last 20 finished exchanges.
+
+While the editor is open it holds its thread, so A1's pruning never drops it, and using it never
+sweeps away the chat's idle image and plan threads. Closing the editor releases the thread but
+leaves it in memory, so reopening a saved workflow in the same page shows the conversation. After
+that, A1's usual pruning applies: using an image or plan editor in chat drops idle threads of other
+conversations, and the store keeps at most 50 threads.
+
+The conversation is ephemeral in version 1 (roadmap §9). Nothing is stored on the server or in the
+browser's storage, so reloading the page starts over. The turn cards, which record what each turn
+changed and what its Undo did, live in `workflowAssistStore`, up to 200 across every editor in the
+page, oldest dropped first.
+
+### Sending a turn
+
+**Send**, a quick action, **Retry** and **Edit and resend** each post one request:
+
+| Field | What the tab sends |
+| --- | --- |
+| `submission_id` | A new ID for the turn |
+| `instruction` | The text, trimmed, without the control characters the server refuses (`[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]`). Tab, line feed and carriage return are kept. |
+| `draft` | The editor's draft as it stands |
+| `base` | `{workflow_id, definition_revision}` for a saved workflow. A new or proposal draft sends `null`, and must have no `id`. |
+| `conversation` | At most 20 `{role, text}` items: the last 10 completed exchanges, whole |
+| `references` | The `#` documents in the message, deduplicated and ordered by A2's `canonicalPlanReferences` |
+| `focus` | The task you asked about, as its task ID, or on a structured (v3) workflow as its flow block's ID. Sent only while that task still exists. |
+| `time_zone` | The browser's IANA time zone, when the schedule editor offers it, so "7 AM" means your 7 AM |
+
+The instruction is limited to 2,000 characters counted as code points, the way the server counts,
+so an emoji counts as one. The counter and the over-limit message use the same count.
+
+Only completed turns are replayed. Failed and cancelled turns never are, because the draft doesn't
+reflect them. A user item is the instruction as it was sent. An assistant item is the reply,
+followed by what became of its changes, read from the editor's history when the request is built:
+
+| The turn's changes | What the assistant item adds |
+| --- | --- |
+| Applied | "Changes applied: …" |
+| Partly undone | "Changes applied: … The user later undid some of them." |
+| Undone | "These changes were applied and later undone: …" |
+| Waiting for the impact confirmation | "These changes are waiting for the user's confirmation and are not applied yet: …" |
+| Not applied | "No changes were applied." |
+| From an earlier editing session | "These changes were made in an earlier editing session and may not have been saved: …" |
+
+The list names at most 12 changes, each cut to 120 characters, then "and N more". Each item is
+capped at 4,000 code points without splitting a surrogate pair, and an item that is blank after
+trimming is left out. This lets the model build on what the draft actually holds, rather than on
+what it proposed.
+
+References are checked before anything is sent, and nothing is dropped silently. More than 20
+documents is refused with "Attach at most 20 documents." The picker offers documents only, but a
+tag or a whole workspace can still arrive through **Edit and resend**, and is refused with a
+message, because a workflow reference is a document.
+
+While a turn runs, the editor is locked, because the answer is a whole draft built from the one
+that was sent. A banner reads "Ask AI is working. The editor is locked until it answers.", with the
+seconds elapsed and **Cancel request**. The fields, the flow canvas, Undo and Redo (including
+Ctrl+Z and Ctrl+Y) and **Save workflow** are disabled. Closing the editor cancels the turn. The
+browser gives up after 170 seconds; the server's own deadline is 150.
+
+### Applying the answer
+
+The tab first checks that nothing moved while it was working: the same editor, the same opened
+version, and a draft equal to the one it sent. If anything differs, nothing is applied, and the
+turn fails with "Nothing was applied because the draft changed while Ask AI was working. Send it
+again to use the current draft." **Retry** sends it against the current draft.
+
+On `changed`, three steps follow:
+
+1. **Rebase.** The candidate arrives as JSON, which has no `undefined` and no non-finite numbers.
+ The editor's draft can hold both; a proposal or saved draft, for example, has `id: undefined`
+ once the editor normalizes it. So the candidate is rebased on the live draft. Wherever it
+ equals the live value as JSON, the live value is kept, object identity included. Arrays are
+ matched by `id` when both sides have unique IDs, and by position otherwise. Keys the model
+ removed stay removed. Without this step, `applyAssist` would refuse every answer for a new
+ draft as a change to `id`, and unchanged objects would look edited.
+2. **Check.** The card lists only what the editor's own `diffWorkflowChanges` finds between the
+ draft that was sent and the rebased candidate. The server's summaries are used for keys the
+ diff also found, and its flow block only when that block exists in the result. A change the
+ server lists that the diff doesn't find isn't shown. When the diff finds nothing, the turn is
+ treated as an explanation.
+3. **Apply.** `applyAssist(candidate, {turnId, label: "Ask AI: "})` applies it as one
+ history step with origin `ai`. When the change removes blocks or affects other references, 3a's
+ impact confirmation asks first, and Send stays busy until you answer it. A candidate that
+ `applyAssist` refuses, such as one that changes a forbidden field, applies nothing, and the card
+ says why.
+
+On `explained` or `question`, the reply is shown and nothing changes. A `question` reply is
+labeled **Question**.
+
+### The turn card
+
+Each answered turn shows the reply as text. A turn that changed the draft adds a card, whose state
+is read from the editor's history rather than remembered, so the editor's Undo and Redo are
+reflected:
+
+| State | What the card says |
+| --- | --- |
+| Applied | "Changed N things in the draft. Review before saving." |
+| Partly undone | "Some of these changes were undone." |
+| Undone | "These changes were undone." |
+| Waiting for the impact confirmation | "Confirm to apply these changes." |
+| Not applied | "Nothing was applied: ", or "You chose not to apply these changes." |
+| From an earlier editing session | "Made in an earlier editing session." |
+
+A per-field **Revert** is a step of its own, so it leaves the card applied.
+
+- **Changes in this turn** lists each change. While they're applied or partly undone, **Jump to**
+ moves focus to the change, and on the Flow surface it selects the changed block.
+- **Read as context** names the documents the assistant read.
+- **Warnings** lists the server's warnings, with **Jump to** when a warning names a field.
+- **Undo this change** calls 3a's `revertTurn`. It reverts each key the turn changed that still
+ holds what the turn left; a key changed afterwards is skipped. The result is shown and focused,
+ for example "Undone: 1 reverted, 1 skipped because it changed later.", "Nothing left to undo for
+ this turn." or "This turn can no longer be undone.", which is what a turn from an earlier
+ editing session gets. Undo is disabled while a turn runs.
+
+Saving closes the editor, so reopening a saved workflow starts a new editing session, and its
+earlier cards are marked as such. Their changes may have been saved, or discarded with Cancel.
+
+### When a turn fails
+
+A failed turn changes nothing. The tab shows the server's `error` as plain text. When `error` is a
+single code word and the body has a `message`, it shows the message. When there's neither, it
+shows a message for the status:
+
+| Status | Message |
+| --- | --- |
+| 400 | The assistant couldn't use this request. Nothing was changed. |
+| 401 | Your session expired. Sign in again to use Ask AI. |
+| 403 | Ask AI isn't available to you right now. |
+| 404 | This workflow couldn't be found. It may have been deleted. |
+| 409 | The saved workflow changed after the editor opened. Your draft was kept. Reload the workflow to use the assistant. |
+| 413 | This request is too large for the assistant. Ask for a smaller change, or start a new thread. |
+| 429 | The assistant is busy. Wait a moment, then try again. |
+| 500 | The assistant failed. Nothing was changed. Try again. |
+| 502 | The assistant's answer couldn't be used. Nothing was changed. Try again. |
+| 503 | The assistant is unavailable right now. Nothing was changed. Try again later. |
+
+- **A conflict.** A 409 `workflow_definition_conflict` means the saved workflow changed after the
+ editor opened. The draft is kept, and the tab offers **Reload workflow**. It asks first,
+ "Reloading discards your unsaved changes to this workflow.", and **Discard and reload** reopens
+ the editor on the saved version. When the workflow was deleted, the message adds "Your draft is
+ still in the editor."
+- **A wait.** On 429 and 503, `Retry-After` is honored, as seconds or an HTTP date, or the body's
+ `retry_after_seconds`, clamped to an hour. Send, the quick actions and Retry wait, and the tab
+ shows "You can send again in N s." A 429 without either waits one second. The wait belongs to the
+ user, so every editor in the page shares it.
+- **The rate-limit message** is the admin's Markdown, and is shown as plain text.
+- **No answer in time.** After 170 seconds: "The assistant took too long to answer. Nothing was
+ changed. Try again, or ask for a smaller change."
+- **No connection.** "Couldn't reach the assistant. Nothing was changed. Check your connection and
+ try again."
+- **An unreadable answer**, such as a sign-in page after the session expired: "The assistant's
+ answer couldn't be read. Nothing was changed."
+- **Cancel** shows "Cancelled. Nothing was changed."
+
+The tab calls the route with `fetch` rather than the app's API client, because the client's errors
+don't carry `Retry-After`, and a throttled 503 has nothing else. It keeps the client's base URL,
+credentials mode and JSON headers.
+
+### Draft with AI
+
+**Draft with AI** appears on a task whose instructions are empty. It asks
+`/api/workflows/draft-instructions`, the route behind the classic editor's draft button, for
+instructions based on the workflow's name and description and the task's name. The answer is added
+as one undoable AI change labeled "Draft with AI: ", and focus moves to the instructions.
+
+- It needs something to go on: a workflow name or description, or a task name other than the
+ default "Task N". Otherwise it asks you to name the workflow or the task first.
+- 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.
+
+### Accessibility and plain text
+
+- **Plain text.** Every string from the model or the server, including replies, errors, warnings,
+ change summaries, document names and the rate-limit message, renders as React text, never as
+ HTML or Markdown. Line breaks are kept.
+- **Tabs.** The side panel is an ARIA tab list with automatic activation. The **Ask AI** toggle
+ opens the tab and focuses its input.
+- **Escape.** In an open `#` menu or document picker, Escape closes just that. Otherwise it closes
+ the panel and returns focus to the toggle that matches the tab.
+- **Announcements.** The thread is a polite live log, and a failed turn is an alert. The lock
+ banner isn't announced separately, because the log already announces the pending turn. Undo's
+ result takes focus, so it is read out.
+- **Focus.** When the lock ends and focus went with it, focus moves to the Ask AI input. Draft with
+ AI puts focus back on its button when it adds nothing.
+- **Names.** Buttons name what they act on, such as "Ask AI about this task: Collect evidence",
+ "Jump to Workflow: Description" and "Undo this change: ". States are text, not
+ only color.
+- **Narrow screens.** The panel takes the editor's place, as **Changes** does, and the footer wraps
+ to two rows. Below the small breakpoint the toggle shows an icon with its name kept.
+
## Usage
### Enable or disable the assistant
@@ -468,15 +721,31 @@ The assistant is on by default wherever personal workflows are on. An administra
off under **Admin Settings > Workflow > Enable AI Workflow Assistant**. See the
[workflow admin settings](../../admin/workflow.md).
-### How 3c calls it
-
-1. The **Ask AI** tab posts the instruction, the draft as it stands, the base the editor opened,
- the tab's recent completed turns, any `#` documents and the focused task.
-2. On `changed`, it passes `candidate` to `applyAssist(candidate, {turnId, label})` and shows
- `reply`, `changes` (with **Jump to**) and `warnings` on the turn's card.
-3. On `explained` or `question`, it shows `reply` and changes nothing.
-4. On 409, it keeps the draft and offers to reload the workflow. On 429, it waits for
- `Retry-After`. On any other error, it shows `error` as text, including the rate-limit message.
+### Use the Ask AI tab
+
+1. Open a personal workflow in the V2 editor, or choose **Edit** on a workflow proposal in chat.
+2. Choose **Ask AI** in the footer. To ask about one task, choose **Ask AI** on that task instead.
+ The tab shows **About:** with the task's name, and each turn is about that task until you clear
+ it.
+3. Type a change or a question, or choose a quick action. Type `#` to pick one of your documents;
+ the assistant can read it as context or add it to the workflow.
+4. Wait for the answer. The editor is locked meanwhile, and **Cancel request** stops it.
+5. Review the card and the highlighted fields. **Jump to** takes you to each change.
+6. Keep what you want. **Revert** on a field puts back one change, **Undo this change** on the card
+ takes back the turn, and the editor's Undo works as usual.
+7. Save. Because the draft has AI changes, the first **Save workflow** opens **Review before
+ saving**, and **Confirm and save** saves.
+
+The quick actions send a fixed instruction, with no `#` documents, and leave what you've typed in
+the input alone:
+
+| Quick action | What it sends |
+| --- | --- |
+| Explain this workflow | "Explain what this workflow does, step by step. Don't change anything." |
+| Tighten task instructions | "Tighten each task's instructions so they are clear and specific, without changing what they ask for." |
+| Add a schedule | "Add a schedule that fits this workflow. Ask me if the timing isn't clear." |
+| Alert me only when urgent | "Change the alerts so I am alerted only when something is urgent." |
+| Check what's needed to run | "Check what this workflow still needs before it can run, such as missing fields or connections. Don't change anything." |
## Testing and validation
@@ -503,10 +772,54 @@ $env:PYTHONPATH = "application/single_app;functional_tests"
python -u -m pytest functional_tests/test_workflow_assist_scenarios.py -q
```
+### Ask AI tab tests
+
+The tab's tests connect the browser code to 3b's real code rather than to hand-written answers,
+so a change on either side that breaks the contract fails a test.
+
+| Test | Covers |
+| --- | --- |
+| `test_v2_workflow_ask_ai.py` | The tab renders model text as text and never as HTML, imports only local modules, is gated on the bootstrap flag, a personal workflow and a writable editor, and leaves the shared thread's new options off by default. Its instruction limit matches `ASSIST_INSTRUCTION_MAX_LENGTH` and is counted in code points. It runs `test_v2_workflow_ask_ai_logic.ts`. |
+| `test_v2_workflow_ask_ai_logic.ts` | Code-point counting and cutting, the request each kind of draft builds, replay (whole exchanges, the control characters, the cap after the suffix, earlier sessions and pending confirmations), each turn's state read from a real authoring session, the response guard and every failure message, the browser request with `Retry-After` and the deadline, the rebase and the change check, and the stores: the card cap, the rate-limit wait, the thread options and held-thread pruning |
+| `test_v2_workflow_ask_ai_parity.py` | Requests the tab builds from editor-normalized drafts (new, proposal, saved version 2 with its revision, saved version 3, focus, time zone, references, a 20-item conversation with emoji, tabs and new lines, and a 2,000-code-point instruction) are each posted as their exact text through 3b's real Flask route with a scripted model. Every candidate-parity scenario is answered the same way. `test_v2_workflow_ask_ai_parity_logic.ts` then replays each real 200 body through the tab: the guard reads it, the rebased candidate passes `workflowAssistViolation`, and the changes it finds are exactly the server's `changes[].key`. |
+| `test_v2_assist_thread.py` | A1's and A2's checks, updated for the shared thread's new options |
+| `ui_tests/test_v2_workflow_ask_ai.py` | 25 browser cases: the "Done when" instruction from Send to Confirm and save, Run as warnings, each `#` placement and a document read as context, the lock with Cancel and Retry, the stale-draft guard, the request a new, saved, focused or flow-focused draft sends, the code-point limit, Undo skipping a field changed later, **Jump to** a task field and a flow block, every failure and the 409 reload, hostile model text, where the tab is hidden (setting off, group, reader, unsupported, schedule), the keyboard and Escape, a narrow screen, Draft with AI, the quick actions, and a card from an earlier editing session |
+| `ui_tests/test_v2_workflow_ask_ai_proposal.py` | A proposal opened with **Edit** sends no base and saves through review, the tab is hidden when the assistant is off, and the editor's thread leaves the chat's idle image thread alone |
+
+The browser tests answer `POST /api/user/workflows/assist` inside the page with 3b's real
+`run_workflow_assist` and a scripted model, from `ui_tests/fixtures/workflow_ask_ai.py`. Build the
+V2 bundle first, then run each file on its own:
+
+```powershell
+npm --prefix application/v2_ui run build
+$env:PYTHONPATH = "application/single_app;functional_tests;ui_tests/fixtures"
+$env:PLAYWRIGHT_SERVICE_URL = ""
+python -u -m pytest ui_tests/test_v2_workflow_ask_ai.py -q
+```
+
## Known limitations and follow-ups
-- **Tags.** Workflow references hold documents, so a `#` tag is refused with `tags_unsupported`.
- 3c's picker should offer documents only.
+- **Personal workflows only.** Group workflows never offer Ask AI (roadmap decision 3).
+- **Tags and workspaces.** Workflow references hold documents, so the server refuses a `#` tag with
+ `tags_unsupported`. The tab's picker offers documents only. A tag or a whole workspace that still
+ reaches the input, for example through **Edit and resend**, fails the turn with a message before
+ anything is sent.
+- **Quick actions send no documents.** They send fixed text only. To use a document, type the
+ request and add it with `#`.
+- **A field Revert leaves the card applied.** Reverting one field is your own step, so the turn's
+ card still reads as applied, even when you've reverted every field it changed. **Undo this
+ change** then reverts what's left and leaves the reverted field alone. With nothing left, it says
+ "Nothing left to undo for this turn."
+- **Earlier editing sessions.** Change tracking starts fresh when the editor reopens, so a turn
+ 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**.
+- **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
`workflowForSave` port now saves an edited File Sync configuration as the editor does. The
assistant still has no File Sync operations and won't change a File Sync trigger. Adding them is
@@ -529,5 +842,6 @@ python -u -m pytest functional_tests/test_workflow_assist_scenarios.py -q
- **A global cap.** The limits are per user. A global in-flight cap, like Score's cap of 8, is a
possible follow-up; provider throttling already returns 503.
- **The rate-limit message.** `assistant_rate_limited` carries the app's shared rate-limit message,
- which may be Markdown. 3c should render it as plain text.
-- **The conversation isn't stored.** It's ephemeral in v1, as the roadmap decided.
+ which may be Markdown. The tab shows it as plain text, so any Markdown appears as written.
+- **The conversation isn't stored.** It's ephemeral in v1, as the roadmap decided. The server
+ keeps nothing, and reloading the page starts the thread and its cards over.
diff --git a/docs/explanation/features/WORKFLOW_EDITOR_CHANGE_TRACKING.md b/docs/explanation/features/WORKFLOW_EDITOR_CHANGE_TRACKING.md
index ca00530e7..c5a3ef263 100644
--- a/docs/explanation/features/WORKFLOW_EDITOR_CHANGE_TRACKING.md
+++ b/docs/explanation/features/WORKFLOW_EDITOR_CHANGE_TRACKING.md
@@ -39,8 +39,8 @@ A changed field is framed in its author's color and gets a text badge, so color
only cue:
- **Edited**, in violet, for your own changes.
-- **AI assist**, in blue, for changes an AI assist turn made. Nothing in this release produces
- one; the Ask AI tab in a later phase will.
+- **AI assist**, in blue, for changes an AI assist turn made. From 0.261.211 the **Ask AI** tab
+ and **Draft with AI** make them; see [Workflow AI assistant](WORKFLOW_AI_ASSISTANT.md).
Beside the badge, **Previously** opens the value the field had when the editor opened, as plain
text. Values longer than 280 characters are cut short behind **Show more**. **Revert** puts that
@@ -73,7 +73,9 @@ saved order; a task you added keeps its slot.
### The Changes panel
**Changes** in the editor footer opens a side panel and shows how many unsaved changes there are.
-The panel is built as a tab list, so later phases can add tabs beside **Changes**.
+The panel is built as a tab list, so later phases can add tabs beside **Changes**. From 0.261.211
+**Ask AI** is the second tab, on a personal workflow you can edit when the admin has turned the
+assistant on.
The Changes tab has two lists:
@@ -222,7 +224,8 @@ candidate as one history entry with origin `ai`. It rejects a candidate that:
- changes what an ID it keeps refers to
An accepted candidate takes the normal edit path: eligibility, impact confirmation, and the
-history budget's oversized-edit prompt. Nothing in this release calls it; the unit tests do.
+history budget's oversized-edit prompt. From 0.261.211 the **Ask AI** tab calls it with each
+answer, and **Draft with AI** calls it with drafted task instructions.
### The Run as consequence
@@ -244,11 +247,11 @@ task. The panel reads the diff one deferred render later, so a keystroke never w
- **The assist endpoint (3b)** returns candidates; `applyAssist` is where they enter the editor.
It already enforces the forbidden fields and approvals client-side.
-- **The Ask AI tab (3c)** is a second entry in `WorkflowEditorSidePanel`'s tab list, beside
- Changes. Its per-turn "Revert this turn" calls `revertTurn` and reports the reverted and skipped
- counts it returns. Ask AI is personal-only; change tracking itself also covers group workflows.
-- The Confirm and save step appears once 3c produces AI-authored changes. Until then only the
- unit tests reach it.
+- **The Ask AI tab (3c)** shipped in 0.261.211 as the second entry in `WorkflowEditorSidePanel`'s
+ tab list, beside Changes. Each turn's **Undo this change** calls `revertTurn` and reports the
+ reverted and skipped counts it returns. Ask AI is personal-only; change tracking itself also
+ covers group workflows.
+- The Confirm and save step now appears when a save includes changes from Ask AI or Draft with AI.
## Files
@@ -273,8 +276,6 @@ task. The panel reads the diff one deferred render later, so a keystroke never w
strip and the Changes tab.
- A new workflow points out only AI assist changes, so your own edits are highlighted only after
the first save.
-- Nothing produces AI-authored changes until the Ask AI tab ships, so the Confirm and save step is
- covered by unit tests only.
- Change tracking lasts for one editing session. Saving, cancelling or reopening starts fresh.
## Testing and validation
@@ -297,6 +298,8 @@ task. The panel reads the diff one deferred render later, so a keystroke never w
can't show; a group workflow; light and dark themes; a narrow viewport;
a new workflow; and keyboard focus staying in a schedule, Run when, output contract or final
outputs control, with every typed character kept, while its highlight appears or clears.
+- From 0.261.211, `ui_tests/test_v2_workflow_ask_ai.py` reaches the Review before saving step
+ in a browser: an **Ask AI** answer is applied, and the save goes through **Confirm and save**.
## Related
diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md
index 20b1cef07..5cf0423e9 100644
--- a/docs/explanation/release_notes.md
+++ b/docs/explanation/release_notes.md
@@ -2,6 +2,26 @@
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.211)**
+
+#### New Features
+
+* **Ask AI Tab In The V2 Workflow Editor**
+ * The V2 workflow editor's side panel has a new **Ask AI** tab, after **Changes**, that edits a personal workflow from a plain-language request, such as "run this at 7 AM on weekdays and only alert me when something is urgent". It calls the AI workflow assistant endpoint added in 0.261.208 and never saves. Each answer is applied to the draft as one AI change, with the same highlights, **Previously:** values, per-field **Revert** and **Changes** list as your own edits, and the first save asks you to review the AI changes.
+ * Open it with the footer's **Ask AI** toggle, or with a task's **Ask AI** button to ask about that task. Quick actions explain the workflow, tighten task instructions, add a schedule, alert only when urgent, or check what's needed to run. Type `#` to attach up to 20 documents; tags and whole workspaces aren't offered, because a workflow refers to documents. The browser's time zone is sent when the schedule editor offers it, and the 2,000-character limit is counted the way the server counts it, so an emoji counts as one.
+ * The editor is locked while a request runs, and **Cancel request** stops it with nothing changed. An answer that changes the draft gets a card listing what changed, taken from the editor's own comparison of the draft that was sent with the answer, with **Jump to**, the documents read as context, any warnings, and **Undo this change**, which reverts the turn except fields edited since. Follow-up requests include the earlier completed turns and what became of their changes.
+ * When the saved workflow changed after the editor opened, the draft is kept and **Reload workflow** is offered. `Retry-After` is honored with "You can send again in N s.", and a failed, cancelled or timed-out request changes nothing and can be retried against the current draft.
+ * The tab appears only when **Enable AI Workflow Assistant** is on, for a personal workflow the editor can change. Group workflows, read-only editors and workflows with an active run don't offer it. It also works on a workflow proposal opened from chat with **Edit**.
+ * Replies, errors, warnings, document names and the admin's rate-limit message are shown as plain text, never as HTML or Markdown. The side panel is an ARIA tab list, the thread is a live log, and Escape closes the panel and returns focus to its toggle.
+ * The conversation lives only in the page. Nothing is stored on the server or in the browser, so reloading starts over. Reopening a saved workflow in the same page shows its earlier turns, marked as coming from an earlier editing session.
+ * The shared assist thread gained opt-in options for the tab: counting by code points, holding its thread while the editor is open, a custom cancelled message, documents-only `#` references and a reply wrapper. All are off by default, so the chat's image, chart, diagram and plan editors are unchanged. Because the editor holds its thread, using it never clears a chat's idle image or plan editor conversation.
+ * (Ref: #1548, #1543, `WorkflowAskAiTab.tsx`, `useWorkflowAssist.ts`, `workflowAssist.ts`, `codePoints.ts`, `workflowAssistStore.ts`, `assistThreadStore.ts`, `WorkflowEditorDialog.tsx`, [AI Workflow Assistant](features/WORKFLOW_AI_ASSISTANT.md#the-ask-ai-tab), [V2 Shared Assist Thread](features/V2_SHARED_ASSIST_THREAD.md#options-for-the-workflow-editor), [Create a workflow](../guides/create-a-workflow.md#ask-ai-to-change-a-workflow))
+
+* **Draft With AI For Workflow Task Instructions**
+ * A task with empty instructions in the V2 workflow editor has **Draft with AI**. It drafts instructions from the workflow's name and description and the task's name, adds them as one undoable AI change, and moves focus to them. Instructions longer than the editor's 12,000-character limit aren't added, and the message says so.
+ * It uses the route behind the classic editor's **Draft Workflow Instructions** button, and it's offered only where **Ask AI** is. The classic button is unchanged.
+ * (Ref: #1548, `useWorkflowAssist.ts`, `WorkflowTaskFields.tsx`, `/api/workflows/draft-instructions`, [AI Workflow Assistant](features/WORKFLOW_AI_ASSISTANT.md#draft-with-ai))
+
### **(v0.261.210)**
#### Bug Fixes
diff --git a/docs/guides/create-a-workflow.md b/docs/guides/create-a-workflow.md
index 844f2c421..0be73d4b3 100644
--- a/docs/guides/create-a-workflow.md
+++ b/docs/guides/create-a-workflow.md
@@ -411,8 +411,8 @@ personal and group workflows, in every format.
### See what changed
Each changed field is framed and labeled **Edited**. Changes made by AI assist
-are framed in blue and labeled **AI assist** instead; nothing in the editor
-makes them yet.
+are framed in blue and labeled **AI assist** instead. From **0.261.211**,
+[**Ask AI**](#ask-ai-to-change-a-workflow) and **Draft with AI** make them.
- **Previously** shows the value the field had when you opened the editor.
Long values are cut short, with **Show more**.
@@ -463,6 +463,89 @@ everything is new, so only AI assist changes are pointed out. After you convert
an ordered workflow to the structured format, changes are still listed, but
they can only be discarded together, with **Cancel**.
+## Ask AI to change a workflow
+
+Starting in **0.261.211**, the V2 editor's **Ask AI** tab changes a personal
+workflow from a plain-language request, such as "run this at 7 AM on weekdays
+and only alert me when something is urgent". Use it when you know what you want
+the workflow to do but not which fields hold it, or to make the same kind of
+change across many tasks. It never saves. Its changes are highlighted like your
+own, and you review them before you save.
+
+**Ask AI** appears when your administrator has turned the assistant on, the
+workflow is personal, and you can edit it. Group workflows don't offer it.
+
+### Ask for a change
+
+1. Open a personal workflow in the V2 editor, or choose **Edit** on a workflow
+ proposal in chat.
+2. Choose **Ask AI** in the editor footer. To ask about one task, choose
+ **Ask AI** on that task instead. The tab shows **About:** with the task's
+ name until you clear it.
+3. Type what you want and send it, or choose a quick action such as **Explain
+ this workflow** or **Add a schedule**.
+4. Wait for the answer. The editor is locked while Ask AI works, so the answer
+ fits the draft you sent. **Cancel request** stops it, and nothing changes.
+
+A message can be up to 2,000 characters, and an emoji counts as one. When your
+browser's time zone is one the schedule editor offers, Ask AI sends it, so
+"7 AM" means 7 AM where you are.
+
+{% include media.html src="guides/create-a-workflow-ask-ai.png"
+ alt="The V2 workflow editor with the Ask AI tab open beside it, showing a request to run the workflow at 7 AM on weekdays, the assistant's reply, and a card listing the schedule changes with Jump to and Undo this change, while the changed schedule fields are framed in blue and labeled AI assist."
+ title="Asking AI to change a workflow"
+ capture="Open a saved personal workflow in the V2 editor at a wide window size, choose Ask AI, and send 'Run this at 7 AM on weekdays'. Capture the answered turn with its card and the highlighted schedule fields. Use sample data." %}
+
+### Point it at a document
+
+Type `#` in your message to pick a document from a personal, group or public
+workspace you can use. Depending on what you ask, Ask AI reads it as
+background, adds it as a reference shared by every task or used by particular
+tasks, or makes it the document a task works on. For example, "Use #X to
+design the steps" only reads it, and "Summarize #X every Friday" adds it to
+that task. When it can't tell, it asks.
+
+You can attach up to 20 documents in one message. Tags and whole workspaces
+aren't offered here, because a workflow refers to documents.
+
+### Review what it changed
+
+An answer that changes the draft shows a card listing the changes, and the
+changed fields are framed in blue and labeled **AI assist**.
+
+- **Jump to** moves to a change. On the Flow surface it selects the block.
+- **Read as context** names the documents Ask AI read.
+- **Warnings** point out anything to check, such as a change that means saving
+ requires re-approving [Run as](#microsoft-365-run-as).
+- **Undo this change** takes back the whole answer. A field you changed again
+ afterwards keeps your value, and the card says it was skipped.
+- **Revert** on one field, and the editor's Undo, work as they do for your own
+ edits.
+
+An answer that explains or asks you something changes nothing. Ask your
+follow-up in the same thread: Ask AI sees the earlier turns, and which of their
+changes you kept or undid.
+
+Because the draft now has AI changes, the first **Save workflow** opens
+[**Review before saving**](#save-after-reviewing). Choose **Confirm and save**.
+
+### Draft task instructions
+
+A task with empty instructions has **Draft with AI**. It writes instructions
+from the workflow's name and description and the task's name, adds them as one
+change you can undo, and moves you to them. Give the workflow or the task a
+name first, so it has something to go on.
+
+### When Ask AI can't help
+
+- If someone saved the workflow after you opened it, Ask AI keeps your draft
+ and offers **Reload workflow**. Reloading discards your unsaved changes.
+- If you've sent a lot of requests, Ask AI tells you when you can send again.
+- A failed or cancelled request changes nothing. **Retry** sends it again with
+ the draft as it is now.
+- The conversation isn't saved with the workflow. Reopening a saved workflow
+ in the same page shows it again; reloading the page starts over.
+
## Preview the structure without changing execution
In **0.261.121**, choose **View Flow for ...** beside a saved structured
@@ -748,6 +831,8 @@ by a workflow**. See
| Save is refused with "This schedule runs more often than the administrator allows." | The new or changed interval is shorter than the administrator's minimum | Choose the interval the message names or longer, or use a calendar schedule. |
| A calendar schedule's time zone isn't accepted | The name isn't an exact IANA time zone that the server offers; names are case-sensitive | Choose a zone from the **Time zone** list, such as `America/New_York`. |
| The classic editor won't open a workflow | It uses a calendar schedule or advanced data flow, which the classic editor can't represent | Edit it in V2. Run and Cancel still work in the classic workspace. |
+| The V2 editor has no **Ask AI** tab | The assistant is turned off, the workflow belongs to a group, or the editor can't change the workflow, for example because it's running or you can only view it | Ask an admin to turn on **Enable AI Workflow Assistant**, or open a personal workflow you can edit. |
+| Ask AI says the saved workflow changed | Someone saved the workflow after you opened it | Choose **Reload workflow**. Reloading discards your unsaved changes, so copy anything you need first. |
## Related
diff --git a/functional_tests/test_v2_workflow_ask_ai.py b/functional_tests/test_v2_workflow_ask_ai.py
index a993c903f..4da1cc77a 100644
--- a/functional_tests/test_v2_workflow_ask_ai.py
+++ b/functional_tests/test_v2_workflow_ask_ai.py
@@ -42,8 +42,21 @@
TAB_TSX = V2_SRC / "components" / "workflows" / "WorkflowAskAiTab.tsx"
DIALOG_TSX = V2_SRC / "components" / "workflows" / "WorkflowEditorDialog.tsx"
THREAD_TSX = V2_SRC / "components" / "chat" / "AssistThread.tsx"
+THREAD_TS = V2_SRC / "lib" / "assistThread.ts"
+COMPOSER_TSX = V2_SRC / "components" / "chat" / "ComposerEditor.tsx"
+SERVER_ASSIST_PY = REPO_ROOT / "application" / "single_app" / "functions_workflow_assist.py"
LOGIC_CHECK = Path(__file__).with_name("test_v2_workflow_ask_ai_logic.ts")
+# The four editors that used the shared thread before Ask AI. They must keep today's behaviour.
+CHAT_EDITORS = tuple(
+ V2_SRC / "components" / "chat" / name
+ for name in ("DiagramEditor.tsx", "ChartEditor.tsx", "ImageEditor.tsx", "OrchestrationPlanEditor.tsx")
+)
+THREAD_OPTIONS = (
+ "countCodePoints", "retain", "sendText", "cancelledMessage", "contextDocumentsOnly",
+ "documentsOnly", "renderReply", "holdAssistThread",
+)
+
NEW_FILES = (CODE_POINTS_TS, ASSIST_TS, STORE_TS, HOOK_TS, TAB_TSX)
IMPORT_RE = re.compile(r"^\s*import\s[^;]*?from\s+'([^']+)'", re.MULTILINE | re.DOTALL)
@@ -111,7 +124,35 @@ def test_the_shared_thread_options_default_off():
thread = THREAD_TSX.read_text(encoding="utf-8")
assert re.search(r"contextDocumentsOnly\s*=\s*false", thread), "contextDocumentsOnly must default to false"
assert "cancelledMessage" in thread, "the thread must accept the editor's own cancel wording"
- print(" ok the shared thread's new options default to today's behaviour")
+ composer = COMPOSER_TSX.read_text(encoding="utf-8")
+ assert re.search(r"contextDocumentsOnly\s*=\s*false", composer), (
+ "the composer must offer tags and documents unless an editor opts in to documents only"
+ )
+ logic = THREAD_TS.read_text(encoding="utf-8")
+ for option in ("retain", "countCodePoints"):
+ assert f"const {option} = options.{option} === true;" in logic, f"{option} must default to off"
+
+ for editor in CHAT_EDITORS:
+ source = editor.read_text(encoding="utf-8")
+ used = [option for option in THREAD_OPTIONS if re.search(rf"\b{option}\b", source)]
+ assert not used, f"{editor.name} must keep today's thread behaviour, but uses {used}"
+ print(" ok the shared thread's new options default to today's behaviour, and the chat editors pass none")
+
+
+def test_the_instruction_limit_matches_the_server():
+ """The tab refuses exactly what the route refuses: the same limit, counted in code points."""
+ server = re.search(r"^ASSIST_INSTRUCTION_MAX_LENGTH = (\d+)$",
+ SERVER_ASSIST_PY.read_text(encoding="utf-8"), re.MULTILINE)
+ client = re.search(r"^export const WORKFLOW_ASSIST_INSTRUCTION_LIMIT = (\d+);$",
+ ASSIST_TS.read_text(encoding="utf-8"), re.MULTILINE)
+ assert server and client, "both instruction limits must stay plain constants"
+ assert server.group(1) == client.group(1), (
+ f"the tab allows {client.group(1)} code points but the server allows {server.group(1)}"
+ )
+ hook = HOOK_TS.read_text(encoding="utf-8")
+ assert "maxLength: WORKFLOW_ASSIST_INSTRUCTION_LIMIT," in hook, "the thread must use the shared limit"
+ assert "countCodePoints: true," in hook, "the thread must count code points, as the server does"
+ print(f" ok the tab and the server both allow {server.group(1)} code points")
def test_the_typescript_logic_checks_pass():
@@ -171,6 +212,7 @@ def test_the_typescript_logic_checks_pass():
test_model_and_server_text_render_as_plain_text,
test_the_dialog_gates_ask_ai_on_the_bootstrap_flag,
test_the_shared_thread_options_default_off,
+ test_the_instruction_limit_matches_the_server,
test_the_typescript_logic_checks_pass,
]
From dc9d22ad177d4de4d0f2579389816897392486c7 Mon Sep 17 00:00:00 2001
From: Paul Lizer
Date: Thu, 1 Oct 2026 00:12:08 -0400
Subject: [PATCH 10/15] Clear CodeQL notes in the Ask AI browser tests
Explain the ignored PlaywrightError in AssistStub.release(), and import
card_ui's fixtures by package path from the modules that define them, so
CodeQL can see they are pytest fixtures rather than unused imports.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---
ui_tests/fixtures/workflow_ask_ai.py | 1 +
ui_tests/test_v2_workflow_ask_ai_proposal.py | 12 ++++++------
2 files changed, 7 insertions(+), 6 deletions(-)
diff --git a/ui_tests/fixtures/workflow_ask_ai.py b/ui_tests/fixtures/workflow_ask_ai.py
index 12420c70f..b267e44fd 100644
--- a/ui_tests/fixtures/workflow_ask_ai.py
+++ b/ui_tests/fixtures/workflow_ask_ai.py
@@ -179,6 +179,7 @@ def release(self):
try:
_fulfill(route, status, payload, headers)
except PlaywrightError:
+ # A request the browser cancelled has no route left to answer.
pass
def wait_for_requests(self, page, count, timeout_ms=10000):
diff --git a/ui_tests/test_v2_workflow_ask_ai_proposal.py b/ui_tests/test_v2_workflow_ask_ai_proposal.py
index e8c62fff3..20521ffa2 100644
--- a/ui_tests/test_v2_workflow_ask_ai_proposal.py
+++ b/ui_tests/test_v2_workflow_ask_ai_proposal.py
@@ -38,21 +38,21 @@
sys.path.insert(0, str(ROOT / "ui_tests"))
sys.path.insert(0, str(ROOT / "ui_tests" / "fixtures"))
-# The shared fixtures import pure application helpers after setting their paths.
-from test_v2_orchestration_workflow_proposal_card import ( # noqa: E402,F401
+# The shared fixtures import pure application helpers after setting their paths. pytest looks up
+# card_ui's own fixtures by name in this module, so each comes from the module that defines it.
+from ui_tests.fixtures.playwright_connection import connect_options # noqa: E402,F401
+from ui_tests.fixtures.workflow_ask_ai import ASSIST_ROUTE, AssistStub # noqa: E402
+from ui_tests.test_v2_orchestration_plan_editor import editor_assets, editor_browser # noqa: E402,F401
+from ui_tests.test_v2_orchestration_workflow_proposal_card import ( # noqa: E402,F401
CONVERSATION,
HOSTILE_INSTRUCTIONS,
NAME,
card,
card_ui,
- connect_options,
- editor_assets,
- editor_browser,
editor_options,
mount,
open_editor,
)
-from ui_tests.fixtures.workflow_ask_ai import ASSIST_ROUTE, AssistStub # noqa: E402
from ui_tests.test_v2_workflow_change_tracking import ( # noqa: E402
RUN_AS_NOTE,
author,
From 439d1115bd0eb3d727dd635568b82c0548beeb3e Mon Sep 17 00:00:00 2001
From: Paul Lizer
Date: Thu, 1 Oct 2026 03:05:32 -0400
Subject: [PATCH 11/15] Fix the Ask AI reload race and pin held-thread pruning
Review part 1 for #1593.
R1: WorkflowsSection's reloadEditing reopened the editor and cleared the
dirty flag after its reads finished, even if the editor had been closed,
saved or replaced by another workflow's editor in the meantime. A request
token now supersedes the reload when an editor opens, closes or saves, so
a late reload is ignored. While a reload loads, the draft it is about to
discard is locked (fields, Save, undo and redo, flow authoring, the
Changes tab, Send, the quick actions and each card's Undo). Cancel and
Escape still close the editor. A failed reload unlocks the draft.
T1: the logic checks now pin the held-thread guarantees of pruneThreads:
another conversation's sweep keeps an idle held workflow thread, the
50-thread cap keeps a held least-recently-touched thread, and a held
keepKey sweeps nothing even when it isn't marked quiet.
Browser tests cover a reload whose editor is closed, replaced by another
workflow's editor or saved while it loads, and a reload that fails.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---
.../components/workflows/WorkflowAskAiTab.tsx | 8 +-
.../workflows/WorkflowEditorDialog.tsx | 32 ++-
.../src/pages/workspace/WorkflowsSection.tsx | 8 +
.../features/WORKFLOW_AI_ASSISTANT.md | 11 +-
.../test_v2_workflow_ask_ai_logic.ts | 18 ++
ui_tests/test_v2_workflow_ask_ai.py | 188 +++++++++++++++++-
6 files changed, 247 insertions(+), 18 deletions(-)
diff --git a/application/v2_ui/src/components/workflows/WorkflowAskAiTab.tsx b/application/v2_ui/src/components/workflows/WorkflowAskAiTab.tsx
index aecaa6956..f29e4e67d 100644
--- a/application/v2_ui/src/components/workflows/WorkflowAskAiTab.tsx
+++ b/application/v2_ui/src/components/workflows/WorkflowAskAiTab.tsx
@@ -125,17 +125,19 @@ function UndoResult({ undo, state, confirming }: {
}
/** One answer's card: the reply, then what it did to the draft. */
-function WorkflowAssistTurnCard({ turnId, record, assist }: {
+function WorkflowAssistTurnCard({ turnId, record, assist, reloading }: {
turnId: string;
record: WorkflowAssistTurnRecord;
assist: WorkflowAssist;
+ /** The saved workflow is reloading, so the draft this card would undo in is locked. */
+ reloading: boolean;
}) {
const state = workflowAssistTurnState(turnId, record, assist.historyView, assist.epoch);
const undoRef = useRef(null);
const seen = useRef({ sequence: record.undoSequence, state });
const pendingAction = assist.historyView.pending?.action;
const confirmingUndo = pendingAction?.origin === 'restore' && pendingAction.turnId === turnId;
- const locked = Boolean(assist.pending) || !assist.available;
+ const locked = Boolean(assist.pending) || !assist.available || reloading;
const jumpable = state === 'applied' || state === 'partly_undone';
const undoable = state === 'applied' || state === 'earlier';
const shortInstruction = record.instruction.length > 60 ? `${record.instruction.slice(0, 59).trimEnd()}…` : record.instruction;
@@ -256,7 +258,7 @@ export function WorkflowAskAiTab({ assist, inputId, onReload }: {
if (!record || record.threadKey !== thread.key) {
return {exchange.reply}
;
}
- return ;
+ return ;
};
return (
diff --git a/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx b/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx
index b3bc86e14..5b35de5bf 100644
--- a/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx
+++ b/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx
@@ -320,19 +320,31 @@ export function WorkflowEditorDialog({
}
setJump({ change, retry: true, sequence: ++focusSequence.current });
};
+ // Reloading the saved workflow locks the draft it is about to discard. Close stays available.
+ const [reloading, setReloading] = useState(false);
const assist = useWorkflowAssist({
available: askAiAvailable,
history,
options,
workflowId: original?.id || null,
- blocked: saving || Boolean(history.pending) || Boolean(authoring.pending) || interactionDisabled,
+ blocked: saving || Boolean(history.pending) || Boolean(authoring.pending) || interactionDisabled || reloading,
onJump: jumpToChange,
});
// While Ask AI works, the editor is locked so its answer applies to the draft it was sent with.
const assistPending = Boolean(assist.pending);
+ const draftLocked = assistPending || reloading;
const historyBlockedReason = saving ? 'Workflow history is unavailable while saving.'
: authoring.pending || history.pending ? 'Finish or cancel the current confirmation first.'
- : assistPending ? 'Wait for Ask AI to finish, or cancel it.' : '';
+ : assistPending ? 'Wait for Ask AI to finish, or cancel it.'
+ : reloading ? 'Wait for the saved workflow to reload.' : '';
+ const reloadSaved = onReload ? async () => {
+ setReloading(true);
+ try {
+ await onReload();
+ } finally {
+ setReloading(false);
+ }
+ } : undefined;
useEffect(() => {
if (!panelFocus) return;
@@ -488,7 +500,7 @@ export function WorkflowEditorDialog({
return;
}
history.session.closeGroup();
- if (saving || history.session.saving || readOnly || authoring.pending || history.session.getSnapshot().pending || assistPending) {
+ if (saving || history.session.saving || readOnly || authoring.pending || history.session.getSnapshot().pending || draftLocked) {
return;
}
const savingDraft = history.session.draft;
@@ -598,7 +610,7 @@ export function WorkflowEditorDialog({
{readOnly ? 'Close' : 'Cancel'}
{!readOnly ? (
- void save()}>
+ void save()}>
{saving ? 'Saving…' : 'Save workflow'}
) : null}
@@ -610,7 +622,7 @@ export function WorkflowEditorDialog({
onKeyDownCapture={(event) => {
// Workflow undo and redo wait for Ask AI too.
const key = event.key.toLowerCase();
- if (assistPending && (event.ctrlKey || event.metaKey) && (key === 'z' || key === 'y')) event.preventDefault();
+ if (draftLocked && (event.ctrlKey || event.metaKey) && (key === 'z' || key === 'y')) event.preventDefault();
}}>
{assist.pending ? : null}
@@ -675,8 +687,8 @@ export function WorkflowEditorDialog({
: null}
{!accessLost ?