Skip to content

Phase 3b: workflow assist endpoint (POST /api/user/workflows/assist) - #1577

Merged
Paul Lizer (paullizer) merged 12 commits into
microsoft:paullizer-react-v2-uifrom
paullizer:paullizer-phase-3b-workflow-assist-endpoint
Sep 30, 2026
Merged

Paul Lizer (paullizer) merged 12 commits into
microsoft:paullizer-react-v2-uifrom
paullizer:paullizer-phase-3b-workflow-assist-endpoint

Conversation

@paullizer

@paullizer Paul Lizer (paullizer) commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Phase 3b of the AI workflow assistant: the server endpoint that turns one plain-language instruction into a validated candidate draft. It's server only. There's no V2 UI, no stored conversation and no group workflows. 3c's Ask AI tab will call it and hand candidate to 3a's WorkflowAuthoringSession.applyAssist.

Route. POST /api/user/workflows/assist, with decorators in this order: @bp.route → @swagger_route(security=get_auth_security()) → @login_required → @user_required → @enabled_required('allow_user_workflows') → @workflow_user_required → @workflow_assistant_required.

  • It answers changed, explained or question. The response carries:
    • the applied, un-normalized candidate (gotcha 25)
    • per-change summaries with target.focus_key for Jump to
    • warnings
  • Cache-Control: no-store, private.

It never writes a workflow, run or document. Its only write is the caller's per-user rate-limit document in the settings container. The no-writes test runs a whole request against containers that fail on any write except that document.

Setting. enable_workflow_ai_assistant, default on.

  • It's effective only when allow_user_workflows is on and the caller passes the WorkflowUser role rule. is_workflow_assistant_enabled_for_user builds on is_user_workflows_enabled_for_user.
  • It appears in the V1 Workflow admin pane and the V2 admin schema.
  • The V2 bootstrap exposes it as the per-user flag features.enable_workflow_ai_assistant.

Request (roadmap §5, "Contract").

  • Fields:
    • submission_id
    • base: {workflow_id, definition_revision} | null
    • instruction: 1–2,000 characters
    • conversation: at most 20 completed turns of up to 4,000 characters each
    • focus
    • draft: the editor's WorkflowDefinition
    • references: A2's # shape
    • optional time_zone: checked against workflow_schedule_timezones()
  • Bounds:
    • the body is at most 6 MiB; a larger one is 413 request_too_large and is refused before it's read
    • JSON nesting depth is at most 200
    • the body is read the way the browser's JSON.parse reads it, with every number finite: NaN, Infinity, -Infinity, 1e999 and integers JavaScript can only read as Infinity are 400 invalid_request, and so is a lone surrogate
  • Validation is strict, and nothing is truncated.

Authorization.

  • Personal workflows only. A group draft or a group_id returns 400 group_workflow_unsupported.
  • base must be the caller's own workflow, or it's a 404. A stale definition_revision returns 409 workflow_definition_conflict, and the client keeps its draft.
  • # documents go through resolve_scope_references. Tags return 400 tags_unsupported.
  • Agents and models come from get_workflow_editor_options(user_id, settings), the same source as the editor-options route. An Azure error there is a 503.

No raw IDs reach the model (gotcha 28).

  • Everything the model can name has a request-local handle: task_N, node_N, agent_N, model_N, alert_N, doc_N and ref_N. Shared references are named by alias, and focus is sent as a handle.
  • The server maps handles back and refuses anything else.
  • A new reference gets an id and alias derived from its file name, unique and valid under _name().

Untrusted material (gotchas 5 and 27).

  • The draft, earlier turns, the description and the excerpts go into a JSON data envelope, and the system prompt says they're data, not instructions. None of it is logged.
  • Excerpt limits:
    • at most 5 documents, 6,000 characters each and 24,000 in total
    • before each load, the remaining time is checked; a document is sent as its label only when loading it would leave less than 100 s for the model and a correction round

Operations (a closed allowlist, each op validated against its own JSON schema):

  • name and description
  • manual, interval and calendar schedules
  • alerts
  • runner and agent, by handle
  • tasks: add, remove, reorder, instructions and inputs
  • document placement by intent (§5): shared by all tasks, used by selected tasks through reference_ids, or one task's document_action target with target_mode: selected

An ambiguous placement is answered with a Question that changes nothing.

Validation pipeline.

  1. Parse the model's reply as strictly as the body: finite numbers only, valid Unicode, at most 200 levels deep.
  2. Apply the ops structurally to a deep copy.
  3. Run the Python port of 3a's workflowAssistViolation: authored fields only, no forbidden field, no approval change, and no ID that changes meaning.
  4. Run the Python port of workflowForSave. Whole numbers below 1e21 reach the check as int, as they reach the save route through the editor's JSON.
  5. Run Phase 2's dry_run_personal_workflow.
  6. Allow one correction round with server-authored errors. After that it's 502 assistant_output_invalid, and nothing changes.

Responses are written with json.dumps(allow_nan=False), because the browser's response.json() can't read NaN. A result that can't be written returns 500 assistant_failed and echoes nothing.

Warnings.

  • run_as_reapproval: the Run-as fingerprint changed while m365_run_as_user_id is set
  • email_requires_m365_agent
  • m365_not_connected: read from the stored record only
  • draft_has_errors

Limits.

  • One request in flight, and 20 requests per 10 minutes per user.
  • The count lives in Cosmos, following check_inbound_mcp_tool_rate_limit: a fixed window under etag compare-and-swap, with a 180 s lease.
  • The limiter fails closed: 503 assistant_limit_unavailable.
  • 429 responses carry Retry-After.
  • The whole request, including the correction round, has a 150 s deadline.

Model.

  • It reuses the draft-instructions deployment and client (_resolve_agent_instruction_model and _create_agent_instruction_client).
  • It has its own token limits: max_completion_tokens 16,000 for o1, o3 and gpt-5 (the same family markers as _build_agent_instruction_api_params); otherwise max_tokens 4,000 at temperature 0.2.
  • It asks for strict JSON output with outcome, a plain-text reply and typed operations.

Statuses. 200, 400, 403, 404, 409, 413, 429, 502, 503 and 500. Every error has a closed code and a safe message, and never echoes model output or document text.

Telemetry. One content-free log_event per request. It records:

  • user_id, submission_id, status, outcome, code and the failing stage
  • operation, correction and model-call counts
  • turns received, sent and dropped
  • reference and excerpt counts
  • duration

It never records instructions, drafts, replies or document text.

New modules (all in application/single_app/):

  • functions_workflow_assist.py: the request, envelope, evaluator, warnings and run
  • functions_workflow_assist_operations.py: the handles, the op schemas and the allowlist
  • functions_workflow_assist_editor.py: the Python mirror of 3a's lists, workflowAssistViolation and workflowForSave
  • functions_workflow_assist_limits.py: the Cosmos limiter
  • functions_workflow_assist_runtime.py: the Azure-backed services

Shared files touched (additive only). route_backend_workflows.py (the new route and helpers), functions_settings.py, admin_settings_fields.py, route_frontend_admin_settings.py, templates/admin/_panes/workflow.html, route_backend_v2.py, the route inventory and test_v2_admin_workflow_parity.py's expected dependencies.

Docs.

  • New: docs/explanation/features/WORKFLOW_AI_ASSISTANT.md, covering the contract, operations, limits and security model. 3c will extend it.
  • docs/admin/workflow.md: a settings row, an "AI workflow assistant" section and troubleshooting rows.
  • A features.yml entry with its generated stub.
  • The regenerated app_surface.yml.
  • Release notes for v0.261.208.

VERSION is 0.261.208. #1578 took 0.261.205, and Phase 4 (#1580) took 0.261.207.

Decisions made autonomously

  1. Setting. enable_workflow_ai_assistant, default on. It depends on allow_user_workflows and follows the WorkflowUser role rule.

  2. base uses definition_revision, not the roadmap's modified_at. It's the save route's conflict token, and Phase 2's dry run turns a stale value into workflow_definition_conflict → 409. A deleted base returns 409 workflow_deleted.

  3. # tags are refused with 400 tags_unsupported. reference_inputs hold documents only.

  4. # never narrows a search here (gotcha 60). A # document is a bound workflow input by construction.

  5. No File Sync operations. Trigger operations on a File Sync workflow are refused and explained. Phase 4 (Phase 4: orchestration proposes workflows #1580) made personal File Sync editable, so the Python workflowForSave port now mirrors workflowFileSyncForSave: File Sync settings the author edited reach the dry run as the editor would save them. File Sync operations are a follow-up.

  6. v3 flow drafts. The assistant may change:

    • the workflow's name, description, schedule, alerts and runner
    • each task's name, instructions, runner, shared references and document target

    It can't add, remove or reorder tasks or set their inputs, because those change the flow graph. The flow object must come back unchanged, which is stricter than the editor. For each and If edits are refused and explained.

  7. Projection parity. The Python port mirrors workflowForSave, rather than relying on the dry run accepting the raw draft. Three tests guard it:

    • a text parity test against the TypeScript
    • 24 Python candidates replayed through the real V2 editor code under Node
    • a type-strict corpus of numbers, whitespace and JSON literals that the port must open, save and read exactly as the editor does, compared as json.dumps(sort_keys=True) so 12 and 12.0 differ
  8. Personal workflows off. allow_user_workflows off keeps the shared decorator's existing 400. Changing it to 403 would affect every route. The assistant setting off and the missing role return 403.

  9. The limiter fails closed.

    • It counts well-formed requests, and a failed provider call still counts.
    • It refunds requests that never reached the model.
    • A lease-release failure is swallowed, with one content-free warning.
  10. Provider throttling returns 503 with Retry-After, not 429. The provider's value is clamped to 1–60 s, with 10 s when it sends none.

  11. No time left for a model call returns 503 assistant_timeout.

  12. Advisory lookups (the agent's email capability and the M365 connection) are skipped when less than 5 s remain.

  13. A changed outcome with no effective diff is returned as explained.

  14. A draft that already fails a check before the assistant touches it is tolerated. The response carries a draft_has_errors warning, and only new errors block.

  15. Build failures without a structured message reach the model as a generic "fails a save check" line.

  16. A draft over the prompt budget (360,000 characters) returns 400 assistant_input_too_large rather than being elided.

  17. The limits are fixed constants, not admin settings.

  18. Reference IDs. A new reference's ID is its alias.

  19. Task reference lists. When a new reference is bound to selected tasks:

    • a listed task that reads every reference keeps doing so
    • an unlisted task that reads every reference gets an explicit list of the references it already had, so the new one doesn't reach it
  20. Removing a task drops later tasks' input bindings to it.

  21. Content safety. None beyond the model deployment's own, consistent with the other assists.

  22. Merged V2 three times, never rebased. Every merge used -c rerere.enabled=false, and I resolved each by hand:

    The v0.261.208 release-notes section sits above Phase 4's v0.261.207, which is unchanged. Against V2, only my VERSION line and my own release-notes section differ in config.py and release_notes.md.

  23. Non-string enum values are refused, not coerced. The editor keeps a non-string trigger_type, reference scope_type or runner type whose String() is allowed (['interval'] reads as 'interval'). The request refuses a draft carrying one with 400 draft_invalid before projection, so the server and the editor can't disagree.

  24. Numbers follow JavaScript. Number() is ported with ASCII digits only and JavaScript's trim() set (which includes \ufeff and excludes \x1c and \x85), and every number is rounded as a double. Whole numbers below 1e21 become int and -0 becomes 0, as they do through JSON.stringify and the save route. The only numeric operation fields (the interval value and day_of_month) are range-checked, so an exact large integer can't reach a workflow.

  25. Non-finite numbers are refused at both inputs. The body and the model's reply are parsed with parse_constant, parse_float and parse_int hooks that refuse NaN, Infinity, 1e999 and integers too large for a double. In the reply, that is a correction round, then 502.

Review round 2 (at c3bcd7e)

Item Change
1. Numeric parity _JS_DECIMAL uses [0-9]. _js_double rounds integer and radix input as JavaScript does. json_number and strip_undefined turn whole floats below 1e21 into int and -0.0 into 0. test_workflow_assist_candidate_parity.py adds a type-strict corpus run through the real Number(), normalizeWorkflowDefinition and workflowForSave under Node: 77 numbers, 175 opened records, 175 saved drafts and 34 JSON literals, including every case in the review. Node's JSON.stringify output is read with json.loads, and both sides are compared as json.dumps(sort_keys=True). A table of 29 known JavaScript answers also runs without Node. The dry-run test checks that expected_count given as "12" or 12.0 passes and projects as type(...) is int.
2. Non-finite numbers _strict_json adds parse_float and parse_int hooks to parse_constant, in both parse_assist_body and parse_model_output. _workflow_assist_response writes json.dumps(payload, allow_nan=False) into current_app.response_class(..., mimetype='application/json'). On TypeError, ValueError or RecursionError, it logs one content-free error and returns 500 assistant_failed. Tests cover each case. In the body, NaN, Infinity, -Infinity, 1e999, -1e999, a nested 1.5E400, a 400-digit integer and one past the 4,300-digit limit return 400 invalid_request. In a model reply, NaN, Infinity, -Infinity, 1e999, -1.5E400 and a 400-digit integer each get a correction round, then 502 if they repeat, and a reply corrected on the second try succeeds. A stubbed NaN, Infinity or -Infinity result at the route returns assistant_failed and echoes nothing.
3. Gate helper Unit tests for is_workflow_assistant_enabled_for_user cover every case in the review. Another test checks that the V2 bootstrap reports features.enable_workflow_ai_assistant as False when allow_user_workflows is off.
4. _json_problem on replies A lone-surrogate value, a lone-surrogate key and a reply nested past the depth limit each get a correction round, then 502.
5. finished_log It raises AssertionError when len(records) != 1, so -O keeps the check.

Extras found along the way:

  • _finite_int refuses an integer too large for a double, which JavaScript reads as Infinity. It also refuses one past Python's 4,300-digit int() limit, as invalid_request.
  • _js_number used to raise OverflowError for a radix string or integer past the double range. It now returns Infinity, as JavaScript does.
  • RecursionError while serializing is handled like the other serialization errors.

Mutation check. Each mutant was applied alone, then restored byte for byte:

Mutant Killed by
M5: \d in _JS_DECIMAL 4 parity tests, including the known-answers table
M6: json_number keeps whole floats 6 parity tests and both dry-run expected_count cases
M8: plain json.loads in parse_model_output 7 operations-security tests
M8b: plain json.loads in parse_assist_body 8 request tests
M13a, three variants: drop the personal-workflow gate; truthy instead of is True; default True 8, 5 and 3 policy tests
M19: drop _json_problem in parse_model_output 3 operations-security tests
allow_nan=True in _workflow_assist_response 3 policy tests
_finite_float without its finite check 7 tests across request, operations-security and parity
_finite_int without its overflow check 4 tests across request, operations-security and parity

Phase 4 merge and File Sync port (at 3ebc60c)

Phase 4 (#1580) landed on V2 first, at 0.261.207, so this PR moved to 0.261.208 and merged V2 at 6cc22fc8d.

  • b6934b37a renumbers every 0.261.206 in this PR to 0.261.208. git grep 0.261.206 finds nothing.

  • d4ff642d6 merges V2 with -c rerere.enabled=false. Four files were resolved by hand:

    • config.py: 0.261.208
    • release_notes.md: the v0.261.208 section sits above Phase 4's v0.261.207, which is unchanged
    • test_route_blueprint_policy_inventory.py: both sides' policy entries and tests are kept, with a header line for each phase's coverage
    • app_surface.yml: regenerated with build_docs_inventory.py (122 capabilities), not hand-merged

    Against V2, config.py differs only in the VERSION line, the release notes only add this PR's section, and the inventory test only adds this PR's lines and the Version: bump.

  • 3095cac6f ports Phase 4's personal File Sync save. Phase 4 made workflowForSave spread workflowFileSyncForSave for every scope, so the editor now sends an edited file_sync for a personal workflow. The Node candidate-parity tests caught the gap before the port: for a personal draft with edited File Sync, the editor saved continue_mode: "changed" while the Python projection kept the stored "always".

    • The port adds workflow_file_sync_config and workflow_file_sync_for_save, applied before the alert fields as in the TypeScript.
    • test_workflow_assist_candidate_parity.py gains 11 File Sync saves. Each runs through the port and through the real editor under Node, and each is pinned, so the port is still checked without Node.
    • test_workflow_assist_field_parity.py pins the two File Sync mode lists.
    • Phase 4's other editor changes, to preservedWorkflowFieldLabels and workflowSettingsDraftErrors, have no Python mirror here, so nothing else needed porting.
  • 3ebc60cdb updates the feature doc: the port is done, and File Sync operations remain a follow-up.

Mutation check on the port. Each mutant was applied alone, then restored byte for byte. All ten were killed:

Mutant Killed by
FS1: drop the File Sync override from workflow_for_save 4 candidate-parity tests, including both scenario replays under Node
FS2: always keep sources 2 candidate-parity tests: the corpus save under Node and the pinned answers
FS3: skip the unchanged check the same 2
FS4: send every source key the same 2
FS5: default scope_type to personal the same 2
FS6: truthy enabled the same 2
FS7: truthy use_changed_documents the same 2
FS8: Python strip() for source IDs the same 2
FS9: no id fallback for source_id the same 2
FS10: modes not lowercased the same 2

The user moved this PR out of draft. The two CodeQL review threads on the test fakes were answered and resolved; see the CodeQL section.

CodeQL

CodeQL's check on 69989558c reported 15 new alerts. Seven are existing alerts in code this PR doesn't touch, which the check lists because each now has a path from this route; they need no code change. The other eight were in this PR's own files: 508bfa4bb fixes six and keeps two. CodeQL's re-run on 508bfa4bb lists nine alerts, the seven path alerts and the two kept notes, and marks the six as fixed. Its run on 3ebc60cdb, after the Phase 4 merge and the File Sync port, lists the same nine and nothing new.

Path alerts to dismiss. None of the seven is new to the repo. All seven are still open on paullizer-react-v2-ui at 6cc22fc8d, and the four log-injection alerts are open on Development too. Dismissing an alert closes it on every branch, so dismissing these here also closes them there. That's still the right call, because each reason below holds for any path into the sink, not only this route's. Only the three high alerts (#2847, #3404 and #2849) fail the check; the log-injection alerts are warnings and the two kept notes are notices.

Alert Where Why Recommended dismissal
#2847 and #3404: Polynomial regular expression used on uncontrolled data (high) functions_workflow_alerts.py L157 and L183 _NESTED_QUANTIFIER_PATTERN runs only after the 200-character cap (WORKFLOW_ALERT_REGEX_MAX_LENGTH) is checked. At that cap, the slowest of 3,056 inputs I timed, including the shapes CodeQL names, took about 0.7 ms. False positive
#2849: Regular expression injection (high) functions_workflow_alerts.py L161 Compiling the author's own alert regex is the feature, bounded by the same cap and nested-quantifier check. The assist path reaches it only through the dry run, whose save check (L298 → L188) compiles the pattern and discards it. Only the run-time evaluator (L742–744) searches with it. Won't fix (by design, compile-only here)
#2551 to #2554: Log injection (medium) functions_appinsights.py L419, L515, L531 and L553 The three later sinks are inside log_event. L419 is reached only through debug_print, which emits only when debug logging is on. Both helpers replace \r, \n and \t and redact secrets in the message and in every string property at any depth, then log under a constant format string, so no request value can forge a log line. The events this route writes itself also carry only closed values: the session's user_id, codes and stage names, counts, error types, definition_version (an int from 1 to 3) and a submission_id that must match ^[A-Za-z0-9._:-]{1,128}$. False positive (sanitized, closed values)

Fixed in 508bfa4bb, with no behavior change:

  • Removed the unused ASSIST_REPLY_MAX_LENGTH import from functions_workflow_assist.py and the unused WORKFLOW_OUTPUT_KINDS import from functions_workflow_assist_operations.py.
  • _checked_focus ends with an explicit raise, so it can't fall through to None.
  • Each of the two empty except: pass blocks in the run's telemetry says why it swallows: raising there would replace the request's own result.
  • test_workflow_assist_dry_run_parity.py L309 took a writes snapshot and never checked it. Every other snapshot in that file is checked after its run, so I restored the missing assert harness.writes() == writes after the first run rather than deleting the snapshot.

Kept. "Non-standard exception raised in special method" at test_workflow_assist_limits_runtime.py L761 and L803. The two fake containers' __getattr__ raise AssertionError for a write method they don't define, on purpose: hasattr() and getattr(..., default) swallow AttributeError, so a probe for a write method would otherwise go unnoticed. Any other missing attribute still raises AttributeError. Both review threads on them were answered and resolved.

Validation

Runner. The pinned venv C:\Users\paullizer\.copilot\session-state\aa1bf1e0-b10d-4312-8dbb-143e9e9f9966\files\venv\Scripts\python.exe (Flask 3.1.3, Werkzeug 3.1.6) with PYTHONPATH=application/single_app;functional_tests, one file per process.

  • The new tests use assert, never a returned boolean. Their helpers are in functional_tests/test_support/workflow_assist.py.
  • Nothing calls a live model.
  • Everything below ran at the PR head, 3ebc60cdb, which includes V2 at 6cc22fc8d, both normally and with python -O unless a row says otherwise. V2's functional_tests/conftest.py return-value guard was active, and its own test, test_pytest_return_false_guard.py, passes (7).

pytest (python [-O] -u -m pytest <file> -q -p no:cacheprovider):

Test Normal -O
test_workflow_assist_request.py (validation, strict JSON, sizes, turn counts, time zones, group, foreign, stale and deleted bases) 147 passed 147 passed
test_workflow_assist_operations_security.py (forbidden fields, approval, raw IDs, unknown handles, group, escalation; no raw IDs in captured model messages, with focus set; fenced and bounded excerpts; non-finite, lone-surrogate and over-deep replies) 96 passed 96 passed
test_workflow_assist_scenarios.py ("Done when": 7 AM weekdays with alerts and the Run-as warning; #checklist shared and used by the review task; #incident-report bound to one task; ambiguous → question; un-normalized candidate; correction round → 502; content-free telemetry) 49 passed 49 passed
test_workflow_assist_dry_run_parity.py (the Python workflowForSave projection through Phase 2's dry run, and the integer expected_count) 15 passed 15 passed
test_workflow_assist_limits_runtime.py (in flight, window, Retry-After, refunds, fail closed, model params, provider errors, and no writes except the limiter document) 93 passed 93 passed
test_workflow_assist_field_parity.py (Python lists and violation rules against WorkflowAuthoringHistory.tsx and the editor, read as text, including the File Sync mode lists) 23 passed 23 passed
test_workflow_assist_candidate_parity.py (24 candidates through 3a's real applyAssist and workflowForSave, plus the type-strict corpus and 11 File Sync saves; 8 of the 15 tests run under Node) 15 passed 15 passed
route_tests/test_workflow_assist_policy.py (decorator order, each gate and status code, Retry-After, the gate helper, the V2 flag, strict serialization) 64 passed 64 passed
route_tests/test_route_blueprint_policy_inventory.py 9 passed 9 passed
route_tests/test_route_unauthenticated_policy_contract.py 4 passed 4 passed
route_tests/test_route_policy_test_coverage.py 2 passed 2 passed
  • Normally, the limits file shows one third-party pydantic deprecation warning.
  • -O adds pytest's warning that assertions outside test modules aren't executed. Test-module asserts are rewritten by pytest and still run.

Phase 4 overlap. These pass at this head, normally and with -O:

  • test_orchestration_workflow_proposals_admin.py: 12
  • test_orchestration_workflow_proposal_routes.py: 60
  • test_personal_workflow_file_sync_sources.py: 22
  • test_group_workflow_file_sync_client_parity.py: 179

Playwright ui_tests/test_admin_orchestration_workflow_proposals.py passes 7 of 7, normally only. It needed a rebuild of the local V2 bundle first, which is gitignored and was stale after the merge.

__main__ (python [-O] -u <file>):

Test Normal -O
test_docs_app_surface_coverage.py 7/7 7/7
test_docs_site_quality.py 6/6 6/6
test_docs_link_integrity.py 2/5 2/5
test_docs_release_notes_integrity.py 0/1 0/1
test_v2_admin_capability_placement.py 7/7 7/7
test_v2_admin_workflow_parity.py 8/9 8/9
test_v2_admin_settings_schema.py 10/11 11/11

Notes on the failures. Each fails the same way on V2 at 6cc22fc8d:

  • Link integrity (2/5). Known base failures. The same 11 links are broken on V2, and none is in a file this PR touches.
  • Release-notes integrity (0/1). Known: the generated release-notes pages are stale on the base, and I didn't regenerate them.
  • Workflow parity (8/9). Known base failure test_sub_settings_are_gated_by_their_capability, caused by another field's list-valued depends_on.
  • Settings schema. test_defaults_match_their_field_type fails on core-plugin-toggles.m365_trusted_download_hosts, a default [] on a string field from ad85fc3c9 on the base. It passes under -O only because its check is a bare assert in a __main__ script, which -O strips. Pre-existing and not in this PR.

Other checks.

  • python scripts/build_docs_inventory.py regenerates app_surface.yml with no diff.
  • python scripts/generate_feature_pages.py --check confirms the new stub matches. Four unrelated stubs are stale on the base (chat-memory, chat-orchestration, content-screening and rate-limit-response-message), and I didn't regenerate them.
  • application/v2_ui is identical to V2 at 6cc22fc8d, because this PR changes no V2 source. npm run typecheck and npm run build pass at this head. The TS logic test lives under functional_tests/.

Guardrails. run_v2_guardrails.py <worktree> 6cc22fc8d HEAD 3b, with the venv; 6cc22fc8d is the current V2 merge-base. All six checks passed:

Check Result
broken-access-control PASS (11 files)
xss-sinks PASS (12 files)
swagger-routes PASS (3 route files)
python-syntax PASS (485 files compile)
malicious-pr-review PASS, with no blocker findings; its keyword-marker notes are on test fixtures
v2-ts-sinks PASS (no ts/tsx files under application/)

V2's scripts/check_xss_sinks.py, run the way xss-sink-check.yml runs it (--base-sha 6cc22fc8d --head-sha HEAD over the 12 changed application/** files), also passed.

CI on 3ebc60cdb. Every check passes except CodeQL, which lists the same nine alerts described in the CodeQL section.

Follow-ups for 3c

  1. The # picker offers documents only, because tags return 400 tags_unsupported.
  2. Send time_zone, the browser's IANA zone, so "7 AM" means the author's 7 AM.
  3. Count the instruction's length in code points, the way the server does; the limit is 2,000.
  4. Replay only completed turns as {role, text}. Replay an assistant turn as its reply plus the changes that were applied. Never replay failed or cancelled turns.
  5. On 409, keep the draft and offer to reload the workflow.
  6. On 429, honor Retry-After.
  7. Use a browser deadline of about 170 s; the server's deadline is 150 s.
  8. Gate the tab on features.enable_workflow_ai_assistant. Hide it for group and read-only workflows.
  9. Pass candidate to applyAssist(candidate, {turnId, label}), and use changes[].target.focus_key for Jump to. Show warnings on the turn.
  10. Render reply, error and the shared rate-limit message as plain text. The rate-limit message is the admin's Markdown.
  11. For a new draft, send base: null and a draft without an id.
  12. Later, not in 3c:
    • elide very large drafts
    • add structured build errors from Phase 2's dry run
    • add File Sync operations; Phase 4 made personal File Sync editable, and the Python workflow_for_save port already mirrors its save
    • optionally add a global in-flight cap like Score's (8); provider throttling already maps to 503
    • port flowUnsupportedReason into the Python workflowForSave, so a draft that already has a publication the editor can't save (for example a pdf one, which the save accepts) gets draft_has_errors

Refs #1548
Part of #1543

POST /api/user/workflows/assist turns one instruction into a validated,
un-normalized candidate workflow draft for the V2 editor's applyAssist.
It never writes a workflow, run or document; its only write is the
caller's rate-limit document.

- functions_workflow_assist_editor: Python port of the editor's
  projection, violation and change helpers
- functions_workflow_assist_operations: handles, the operation
  allowlist and schemas, and the structural applier
- functions_workflow_assist: request checks, the model envelope, the
  apply-then-validate loop with one correction round, and warnings
- functions_workflow_assist_limits: a per-user Cosmos limiter (one in
  flight, 20 per 10 minutes) with etag compare-and-swap
- functions_workflow_assist_runtime: the model invoker and the
  Azure-backed services
- enable_workflow_ai_assistant admin setting (default on), gated on
  allow_user_workflows

Refs microsoft#1548

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Covers request validation, authorization, the operation allowlist and
escalation refusals, apply-then-validate with one correction round, the
Phase 2 dry-run parity, the three placement cases, the limiter, the
Azure-backed services, content-free telemetry, the no-writes contract
and the route's gates, body bounds and status mapping. Sets VERSION to
0.261.205 (provisional).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The field parity test reads the V2 editor's TypeScript lists (authored,
forbidden, alert, schedule, runner, flow) and fails when the server's
Python copies drift. The candidate parity test runs 24 real assistant
requests with a scripted model and replays every result through the
editor under node: the opened draft, workflowAssistViolation, the change
list and Jump to targets, the Run as consequence, workflowForSave and
applyAssist all agree with the server, and refused candidates are refused
by both with the same message.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Keeps VERSION 0.261.205 over V2's 0.261.204.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds the feature doc, the Workflow admin settings row, an admin section and troubleshooting rows, the features.yml claim and its generated stub, the regenerated app surface inventory, and the v0.261.205 release notes.

Refs microsoft#1548

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
- Read the request body and the model's reply as the browser's JSON.parse
  does: NaN, Infinity, 1e999 and integers JavaScript reads as Infinity are
  refused (parse_constant, parse_float and parse_int hooks).
- Write assist responses with json.dumps(allow_nan=False); a result that
  cannot be written is a content-free 500 assistant_failed.
- Match JavaScript Number(): ASCII digits only, doubles for radix and
  integer input, and whole numbers below 1e21 reach the dry run as ints,
  as they do through the editor's JSON save.
- Add a type-strict numeric, whitespace and JSON-literal corpus replayed
  through the real V2 editor code under Node, compared with
  json.dumps(sort_keys=True).
- Test is_workflow_assistant_enabled_for_user and the V2 bootstrap flag,
  non-finite, lone-surrogate and over-deep model replies, and a whole
  expected_count checked as an int.
- finished_log raises instead of using a bare assert, so -O keeps it.
- Move VERSION to 0.261.206 and document the changes and the
  flowUnsupportedReason follow-up.

Refs microsoft#1548

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Brings in microsoft#1578, which took 0.261.205. Keeps VERSION 0.261.206 and puts
the v0.261.206 release-notes section above microsoft#1578's v0.261.205 section,
which is unchanged.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Comment thread functional_tests/test_workflow_assist_dry_run_parity.py Fixed
Comment thread application/single_app/functions_workflow_assist.py Fixed
Comment thread application/single_app/functions_workflow_assist.py Fixed
Comment thread application/single_app/functions_workflow_assist.py Fixed
Comment thread application/single_app/functions_workflow_assist.py Fixed
Comment thread application/single_app/functions_workflow_assist_operations.py Fixed
Comment thread functional_tests/test_workflow_assist_limits_runtime.py
Comment thread functional_tests/test_workflow_assist_limits_runtime.py
- Drop the unused ASSIST_REPLY_MAX_LENGTH import from
  functions_workflow_assist.py and the unused WORKFLOW_OUTPUT_KINDS import
  from functions_workflow_assist_operations.py.
- End _checked_focus with an explicit raise, so it can't fall through to None.
- Say why each best-effort telemetry except swallows its error.
- Restore the "nothing written" assert after the first run of the dry-run
  correction test. Its writes snapshot was taken but never checked.

No behavior change. Refs microsoft#1548

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@paullizer
Paul Lizer (paullizer) marked this pull request as ready for review September 30, 2026 02:08
Paul Lizer (paullizer) added a commit that referenced this pull request Sep 30, 2026
Versions now follow the order PRs go ready: #1578 takes 0.261.205 and
3b (#1577) takes 0.261.206, so the coordinator moved Phase 4 to
0.261.207. Move VERSION and the headers, docs, version floors and the
stubbed bootstrap version this branch set to 0.261.206. Only files this
branch changed since V2 2f8dab4 are touched.

Refs #1547

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Phase 4 (microsoft#1580) landed first and took 0.261.207, and microsoft#1578 took 0.261.205, so 0.261.206 is skipped.
This is a byte-level replace of 0.261.206 with 0.261.208 in every file of this PR, with CRLF
preserved; nothing else changes.

Refs microsoft#1548

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Merges paullizer-react-v2-ui at 6cc22fc (microsoft#1580) with rerere off.

- config.py: VERSION 0.261.208.
- release_notes.md: the v0.261.208 section sits above Phase 4's v0.261.207 section, which is unchanged.
- test_route_blueprint_policy_inventory.py: keeps Phase 4's proposal coverage line and test, adds the
  assistant coverage line, and moves Version to 0.261.208.
- docs/_data/app_surface.yml: regenerated with scripts/build_docs_inventory.py (capabilities 122).

Refs microsoft#1548

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Phase 4 (microsoft#1547, microsoft#1580) made personal File Sync editable: workflowForSave now
spreads workflowFileSyncForSave for every scope, so an edited file_sync is sent
as workflowFileSyncConfig normalizes it, with sources reduced to scope_type,
scope_id and source_id and dropped while File Sync is off (unless the trigger
is file_sync). The Python port gains workflow_file_sync_config and
workflow_file_sync_for_save and applies them before the alert fields, so the
server's projection saves exactly what the editor would.

The candidate parity suite gains eleven File Sync save cases checked against
the real TypeScript under Node and against pinned answers, and the field parity
suite now pins the two File Sync mode lists. The assistant still has no File
Sync operations; that remains a follow-up.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Phase 4 has landed, so the doc no longer waits on it: the Python workflowForSave
port sends an edited personal File Sync configuration as the editor does, the
candidate parity suite covers those saves, and adding File Sync operations to
the assistant stays a follow-up.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@paullizer
Paul Lizer (paullizer) merged commit 5edb0da into microsoft:paullizer-react-v2-ui Sep 30, 2026
10 of 11 checks passed
Paul Lizer (paullizer) added a commit to paullizer/simplechat that referenced this pull request Sep 30, 2026
Record 3b (microsoft#1577, v0.261.208) and Phase 4 (microsoft#1580, v0.261.207) as shipped
and mark 3c (the Ask AI tab) in progress. Add what later phases build on,
their follow-ups, the side fixes from microsoft#1576, microsoft#1578 and microsoft#1579, and gotcha 61
(Microsoft 365 pending-action run links). Docs only; no version bump.

Refs microsoft#1543

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants