diff --git a/application/single_app/config.py b/application/single_app/config.py
index 14e7f949f..43ca4b9fe 100644
--- a/application/single_app/config.py
+++ b/application/single_app/config.py
@@ -101,7 +101,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
-VERSION = "0.261.314"
+VERSION = "0.261.315"
IS_DEVELOPMENT = is_development_env_enabled()
# Opt-out for deployments where App Service Easy Auth is active but the platform
diff --git a/application/single_app/functions_orchestration_planner.py b/application/single_app/functions_orchestration_planner.py
index 432661c21..6c5d8dbc8 100644
--- a/application/single_app/functions_orchestration_planner.py
+++ b/application/single_app/functions_orchestration_planner.py
@@ -310,6 +310,42 @@ def resolve_planner_client(settings):
words such as "this week" can stay relative.
- optionally "alerts" {"mode":"every_run"|"failures_only","severity":"info"|"low"}, "run_as"
"self"|"none", and "durable": true.
+ Those legacy alert modes are quiet, bell-only run-status notifications. For native conditional
+ monitoring alerts instead use "alerts":{"mode":"rules","rules":[...]} (1 to 20 rules).
+ Each rule has "severity":"info"|"low"|"medium"|"high"|"critical" and "condition"; optionally
+ "name", "enabled" (boolean), "delivery":"default"|"notify_only"|"popup",
+ "require_acknowledgment" (boolean), "sound":"off"|"once"|"repeat", "size":"small"|"medium"|"large",
+ and "scope":{"type":"final"|"any_task"} or {"type":"task","task":N}, where N is the 1-based
+ position of a task in this blueprint, not a stored id. Default scope is final.
+ Conditions: {"type":"run_status","statuses":[...]} (completed, failed, cancelled,
+ completed_with_task_errors); {"type":"task_status","statuses":[...]} (succeeded, failed);
+ {"type":"text_match","mode":"contains_any"|"contains_all"|"not_contains","values":[...],
+ "case_sensitive":false}, or {"type":"text_match","mode":"regex","pattern":"..."};
+ {"type":"model_evaluation","prompt":"..."}; {"type":"agent_signal","signal_name":"...",
+ "min_severity":"info"|"low"|"medium"|"high"|"critical"}; {"type":"no_output"};
+ or {"type":"file_sync","outcome":"changes_found"|"no_changes"|"sync_failed"}.
+ run_status, file_sync and agent_signal conditions must use final scope.
+ Native alerts require no email action or Microsoft 365 connection. Normally info/low go to the
+ bell, medium/high/critical pop up. Acknowledgment, sound and larger size require pop-up delivery;
+ repeat sound requires acknowledgment. These settings respect device sound preferences.
+ When the user explicitly requests strongest/highest attention, propose critical, delivery popup,
+ require_acknowledgment true, sound repeat, size large, unless their constraints say otherwise.
+ Disclose these attention options for approval; never make them the default for ordinary alerts.
+ A personal workflow alerts its owner, not arbitrary recipients or everyone in a group.
+ Monitor external data using an offered read-capable agent, then alert inside SimpleChat on adverse
+ output. This is different from changing an external system's alarm configuration: do not request
+ external writes unless that is what the user asks. A successful monitoring run may find failures.
+ run_status/task_status failures detect workflow execution errors, not domain problems in telemetry.
+ Prefer model_evaluation for semantic findings, explicitly excluding healthy/negated findings
+ ("no active alerts"); text_match is for defined literal signals, and agent_signal needs an agent
+ known to produce supported signals. Do not infer signal support from an agent's name.
+ For example, ongoing vehicle monitoring can read telemetry and use a critical model_evaluation
+ rule for actual active faults, while "email me the findings" needs a mail-capable agent.
+ "Configure the source system's alarms" is an external configuration request, not native alerting.
+ Use the native rules for "create a workflow that monitors and alerts me"; ask for missing cadence
+ rather than inventing a schedule. Do not turn a one-time alert/configuration request into recurring
+ monitoring. If native workflow proposals are unavailable, explain the limitation rather than
+ claiming an external read-only integration cannot support SimpleChat alerting.
- A task can merge files with code instead of a model by adding "merge": {"kind","files",
"output_format","file_name","options"} and no agent runner. kind "tabular" (the default) appends
the rows of CSV and Excel files into one "csv" or "xlsx" file; "workbook" puts each CSV or Excel
diff --git a/application/single_app/functions_orchestration_registry.py b/application/single_app/functions_orchestration_registry.py
index 21c8622d1..6949779cd 100644
--- a/application/single_app/functions_orchestration_registry.py
+++ b/application/single_app/functions_orchestration_registry.py
@@ -1485,7 +1485,10 @@ def generate_image_arguments_schema(options=None):
),
'when_to_use': (
'Use one step when the user asks for work to repeat on a schedule, run when File Sync '
- 'finds changes, or be saved to run later. Write the blueprint from the request and the '
+ 'finds changes, or be saved to run later, including monitoring external data and issuing '
+ 'native SimpleChat alerts on findings. Native alerts can pop up with critical severity, '
+ 'require acknowledgment, repeat sound and use full-screen size; they are not email or '
+ 'changes to the external source alarm configuration. Write the blueprint from the request and the '
'workflow_planning catalog alone: this step takes no depends_on and no inputs, and no '
'other step may bind its output. When the request also wants a result now, answer it '
'once with the usual steps and select that answer as final_response.'
diff --git a/application/single_app/functions_orchestration_workflows.py b/application/single_app/functions_orchestration_workflows.py
index 952ff91e7..a47bd02d1 100644
--- a/application/single_app/functions_orchestration_workflows.py
+++ b/application/single_app/functions_orchestration_workflows.py
@@ -62,6 +62,7 @@
from functions_workflow_drafts import (
BLUEPRINT_TASK_TITLE_MAX_LENGTH,
WORKFLOW_ORIGIN_SOURCE_ORCHESTRATION,
+ blueprint_alert_summary,
check_workflow_blueprint,
dry_run_workflow_blueprint,
validate_workflow_blueprint,
@@ -500,7 +501,6 @@ def _proposal_summary(blueprint, task_actions, planning, used, request_time_zone
m365_sources.update(entry.get('m365_sources') or ())
can_send = can_send or entry.get('can_send') is True
required = required or entry.get('needs_run_as') is True
- alerts = blueprint.get('alerts') if isinstance(blueprint.get('alerts'), dict) else {}
return {
'name': clean_catalog_text(blueprint.get('name'), NAME_MAX_LENGTH * 2),
'description': clean_catalog_text(blueprint.get('description'), 1000),
@@ -516,7 +516,7 @@ def _proposal_summary(blueprint, task_actions, planning, used, request_time_zone
'run_as': blueprint.get('run_as') or 'none',
'required': required,
},
- 'alerts': {'mode': alerts.get('mode') or 'every_run', 'severity': alerts.get('severity') or 'info'},
+ 'alerts': blueprint_alert_summary(blueprint),
'durable': True,
}, stored_type, normalized
diff --git a/application/single_app/functions_workflow_alert_authoring.py b/application/single_app/functions_workflow_alert_authoring.py
new file mode 100644
index 000000000..6a2945115
--- /dev/null
+++ b/application/single_app/functions_workflow_alert_authoring.py
@@ -0,0 +1,85 @@
+# functions_workflow_alert_authoring.py
+"""Closed, ID-free alert schemas shared by workflow AI authoring surfaces."""
+
+from functions_workflow_alerts import (
+ WORKFLOW_ALERT_DELIVERIES,
+ WORKFLOW_ALERT_EVALUATION_PROMPT_MAX_LENGTH,
+ WORKFLOW_ALERT_FILE_SYNC_OUTCOMES,
+ WORKFLOW_ALERT_MAX_TEXT_VALUES,
+ WORKFLOW_ALERT_REGEX_MAX_LENGTH,
+ WORKFLOW_ALERT_RULE_NAME_MAX_LENGTH,
+ WORKFLOW_ALERT_RUN_STATUSES,
+ WORKFLOW_ALERT_SEVERITY_ORDER,
+ WORKFLOW_ALERT_SIZE_ORDER,
+ WORKFLOW_ALERT_SOUND_ORDER,
+ WORKFLOW_ALERT_TASK_STATUSES,
+ WORKFLOW_ALERT_TEXT_MATCH_MODES,
+ WORKFLOW_ALERT_TEXT_VALUE_MAX_LENGTH,
+)
+
+
+def _text(limit, minimum=1):
+ return {'type': 'string', 'minLength': minimum, 'maxLength': limit, **({'pattern': r'\S'} if minimum else {})}
+
+
+def _values(options):
+ return {'type': 'array', 'minItems': 1, 'maxItems': len(options), 'uniqueItems': True,
+ 'items': {'enum': sorted(options)}}
+
+
+def alert_condition_schema(*, file_sync=True):
+ """Native conditions without storage IDs or free-form fields."""
+ variants = []
+ fields = {
+ 'run_status': ({'statuses': _values(WORKFLOW_ALERT_RUN_STATUSES)}, ['statuses']),
+ 'task_status': ({'statuses': _values(WORKFLOW_ALERT_TASK_STATUSES)}, ['statuses']),
+ 'model_evaluation': ({'prompt': _text(WORKFLOW_ALERT_EVALUATION_PROMPT_MAX_LENGTH)}, ['prompt']),
+ 'agent_signal': ({
+ 'signal_name': _text(WORKFLOW_ALERT_RULE_NAME_MAX_LENGTH, 0),
+ 'min_severity': {'enum': list(WORKFLOW_ALERT_SEVERITY_ORDER)},
+ }, []),
+ 'no_output': ({}, []),
+ }
+ if file_sync:
+ fields['file_sync'] = ({'outcome': {'enum': sorted(WORKFLOW_ALERT_FILE_SYNC_OUTCOMES)}}, ['outcome'])
+ for kind, (properties, required) in fields.items():
+ variants.append({
+ 'type': 'object', 'additionalProperties': False, 'required': ['type', *required],
+ 'properties': {'type': {'const': kind}, **properties},
+ })
+ for mode in sorted(WORKFLOW_ALERT_TEXT_MATCH_MODES):
+ properties = (
+ {'pattern': _text(WORKFLOW_ALERT_REGEX_MAX_LENGTH)} if mode == 'regex' else {
+ 'values': {'type': 'array', 'minItems': 1, 'maxItems': WORKFLOW_ALERT_MAX_TEXT_VALUES,
+ 'items': _text(WORKFLOW_ALERT_TEXT_VALUE_MAX_LENGTH)},
+ 'case_sensitive': {'type': 'boolean'},
+ }
+ )
+ variants.append({
+ 'type': 'object', 'additionalProperties': False,
+ 'required': ['type', 'mode', 'pattern' if mode == 'regex' else 'values'],
+ 'properties': {'type': {'const': 'text_match'}, 'mode': {'const': mode}, **properties},
+ })
+ return {'oneOf': variants}
+
+
+def alert_rule_properties(task_reference, *, file_sync=True):
+ """Editable personal rule fields; the caller supplies its local task-reference schema."""
+ return {
+ 'name': _text(WORKFLOW_ALERT_RULE_NAME_MAX_LENGTH, 0),
+ 'enabled': {'type': 'boolean'},
+ 'severity': {'enum': list(WORKFLOW_ALERT_SEVERITY_ORDER)},
+ 'delivery': {'enum': sorted(WORKFLOW_ALERT_DELIVERIES)},
+ 'require_acknowledgment': {'type': 'boolean'},
+ 'sound': {'enum': list(WORKFLOW_ALERT_SOUND_ORDER)},
+ 'size': {'enum': list(WORKFLOW_ALERT_SIZE_ORDER)},
+ 'scope': {
+ 'oneOf': [
+ {'type': 'object', 'additionalProperties': False, 'required': ['type', 'task'],
+ 'properties': {'type': {'const': 'task'}, 'task': task_reference}},
+ {'type': 'object', 'additionalProperties': False, 'required': ['type'],
+ 'properties': {'type': {'enum': ['final', 'any_task']}}},
+ ],
+ },
+ 'condition': alert_condition_schema(file_sync=file_sync),
+ }
diff --git a/application/single_app/functions_workflow_assist.py b/application/single_app/functions_workflow_assist.py
index 7eb9a1c2e..b89a13672 100644
--- a/application/single_app/functions_workflow_assist.py
+++ b/application/single_app/functions_workflow_assist.py
@@ -495,11 +495,15 @@ def parse_assist_request(body, user_id):
RULES
1. Change only what the instruction asks for, and keep everything else as it is.
-2. You can't change whether the workflow is enabled, who it runs as (Run as), sharing, ownership, URL access, approval settings, IDs or the definition version, and you can't set up File Sync. When asked, explain that the user can change it in the editor.
+2. You can't change whether the workflow is enabled, who it runs as (Run as), sharing, ownership, URL access, task approval settings, IDs or the definition version, and you can't set up File Sync. Alert acknowledgment is different from task approval and IS editable through alert operations. When asked for unsupported changes, explain that the user can change them in the editor.
3. For each and If blocks can't be added, removed or restructured yet. When draft.format is "flow", tasks can't be added, removed, reordered or given inputs, but you can rename a task and change its instructions, runner and documents. Explain anything else.
4. Parts of the draft marked "editable": false, a publication task's runner and documents, and a task's approval can't change. Explain instead.
5. Schedules: "every day at", "on weekdays at", "every Monday at" and "on the 1st at" are calendar schedules (set_schedule_calendar). "Every N minutes" and "every N hours" are intervals (set_schedule_interval). time_of_day is a 24-hour HH:MM time. Leave "timezone" out unless the user names a time zone; the schedule then keeps its current time zone or uses time_zone. An interval can't be shorter than choices.minimum_interval_seconds.
6. Alerts: mode "every_run" alerts after every run at a priority. Mode "rules" alerts only when a rule matches, so "only alert me when..." means mode "rules" plus a rule for that condition: a model_evaluation rule whose prompt states the condition, or a text_match rule for exact words. Match the severity to the wording; urgent or critical means high or critical. Mode "off" turns alerts off.
+ These are native SimpleChat alerts, not email and not changes to an external system's alarms. A task can read external telemetry with a read-only agent and a rule can alert on its findings even when the run succeeds. run_status/task_status failures mean workflow execution failed, not that the monitored system failed. Use model_evaluation for semantic findings, explicitly distinguishing actual problems from "no active alerts" or healthy results; use text_match only for defined literal signals. Use agent_signal only when the agent actually emits supported alert signals.
+ A file_sync alert condition can watch changes_found, no_changes or sync_failed in an existing File Sync workflow; editing that rule does not configure the File Sync trigger. Run status, File Sync and agent signals watch the whole run (final scope).
+ Info/low normally go to the bell; medium/high/critical normally pop up. Rules support require_acknowledgment (boolean), sound ("off", "once", "repeat"), and size ("small", "medium", "large"). Acknowledgment keeps the pop-up returning across pages/devices until acknowledged. Pop-up options need resolved pop-up delivery; repeat sound needs acknowledgment. These settings do not override device sound preferences. Update an existing rule by its alert handle with update_alert_rule, preserving everything not requested. "Require acknowledgment on Rule 2" changes that rule, not sound, size, other rules, or task approvals; also set delivery "popup" if needed and explain it. If the target is ambiguous, ask.
+ When the user explicitly requests the highest/strongest attention possible, propose critical severity, delivery "popup", require_acknowledgment true, sound "repeat", size "large", unless their other constraints say otherwise. Explain the acknowledgment, repeating sound, and full-screen consequences in the reply for review. Otherwise keep quieter defaults and unrequested options unchanged. Do not substitute an email task for native alerting unless email is explicitly requested. These are personal workflows; alerting "us" does not grant group audience or new recipients. Changes remain draft-only until saved.
7. Runners: choose agents and models only from "choices". A task inherits the workflow's runner unless it has its own agent or model. A task that sends email needs an agent with the Microsoft 365 action.
8. "attached_documents" are documents the user attached with #. Place each one by what the instruction asks:
- Read now to shape the workflow ("use #X to design the steps"): don't place it. Say in the reply that it was read as context.
diff --git a/application/single_app/functions_workflow_assist_operations.py b/application/single_app/functions_workflow_assist_operations.py
index 110217683..3597e9a4f 100644
--- a/application/single_app/functions_workflow_assist_operations.py
+++ b/application/single_app/functions_workflow_assist_operations.py
@@ -28,6 +28,8 @@
from jsonschema import Draft202012Validator
+from functions_workflow_alert_authoring import alert_rule_properties
+from functions_workflow_alerts import normalize_alert_rule, normalize_alert_flag
from functions_workflow_assist_editor import (
WORKFLOW_ALIAS_PATTERN,
WORKFLOW_REFERENCE_SCOPES,
@@ -43,7 +45,7 @@
workflow_alert_config,
workflow_schedule_for_editor,
)
-from functions_workflow_definitions import WorkflowPublicValidationError
+from functions_workflow_definitions import WorkflowAlertValidationError, WorkflowPublicValidationError
from functions_workflow_schedules import (
enforce_workflow_schedule_minimum,
normalize_workflow_schedule,
@@ -83,13 +85,13 @@
ASSIST_ALERT_DELIVERIES = ('default', 'notify_only', 'popup')
ASSIST_ALERT_SCOPES = ('final', 'any_task', 'task')
ASSIST_ALERT_PRIORITIES = ('low', 'medium', 'high')
-ASSIST_ALERT_CONDITIONS = ('run_status', 'task_status', 'text_match', 'model_evaluation', 'agent_signal', 'no_output')
+ASSIST_ALERT_CONDITIONS = ('run_status', 'task_status', 'text_match', 'file_sync', 'model_evaluation', 'agent_signal', 'no_output')
ASSIST_ALERT_RUN_STATUSES = ('completed', 'failed', 'cancelled', 'completed_with_task_errors')
ASSIST_ALERT_TASK_STATUSES = ('succeeded', 'failed')
ASSIST_ALERT_TEXT_MODES = ('contains_any', 'contains_all', 'not_contains', 'regex')
# Conditions that read run-level facts. The editor resets their scope to final when one is chosen
# (WORKFLOW_ALERT_SCOPELESS_CONDITIONS in workflowAlerts.ts), so the assistant only writes final.
-ASSIST_SCOPELESS_CONDITIONS = frozenset({'run_status', 'agent_signal'})
+ASSIST_SCOPELESS_CONDITIONS = frozenset({'run_status', 'file_sync', 'agent_signal'})
HANDLE_PATTERN = r'^[A-Za-z][A-Za-z0-9_-]{0,63}$'
TASK_HANDLE_PATTERN = r'^(task|new)_[1-9][0-9]{0,2}$'
@@ -685,6 +687,9 @@ def _alert_rule_view(rule, handle, handles):
'enabled': alert_rule_enabled(rule),
'severity': _string(rule.get('severity')) or 'medium',
'delivery': _string(rule.get('delivery')) or 'default',
+ 'require_acknowledgment': normalize_alert_flag(rule.get('require_acknowledgment')),
+ 'sound': _string(rule.get('sound')) or 'off',
+ 'size': _string(rule.get('size')) or 'small',
'scope': scope_view,
'condition': {key: copy.deepcopy(condition[key]) for key in _ALERT_CONDITION_KEYS if key in condition},
}
@@ -813,51 +818,6 @@ def _operation(name, properties=None, required=(), all_of=()):
_TASK = _handle(TASK_HANDLE_PATTERN)
-_CONDITION_SCHEMAS = {
- 'run_status': {
- 'statuses': {'type': 'array', 'minItems': 1, 'maxItems': 4, 'uniqueItems': True,
- 'items': {'enum': list(ASSIST_ALERT_RUN_STATUSES)}},
- },
- 'task_status': {
- 'statuses': {'type': 'array', 'minItems': 1, 'maxItems': 2, 'uniqueItems': True,
- 'items': {'enum': list(ASSIST_ALERT_TASK_STATUSES)}},
- },
- 'text_match': {
- 'mode': {'enum': list(ASSIST_ALERT_TEXT_MODES)},
- 'values': {'type': 'array', 'minItems': 1, 'maxItems': ASSIST_ALERT_MAX_VALUES,
- 'items': _str(ASSIST_ALERT_VALUE_MAX_LENGTH)},
- 'pattern': _str(ASSIST_ALERT_REGEX_MAX_LENGTH),
- 'case_sensitive': {'type': 'boolean'},
- },
- 'model_evaluation': {'prompt': _str(ASSIST_ALERT_PROMPT_MAX_LENGTH)},
- 'agent_signal': {
- 'signal_name': _str(ASSIST_ALERT_NAME_MAX_LENGTH, min_length=0),
- 'min_severity': {'enum': list(ASSIST_ALERT_SEVERITIES)},
- },
- 'no_output': {},
-}
-_CONDITION_REQUIRED = {
- 'run_status': ['statuses'], 'task_status': ['statuses'], 'text_match': ['mode'], 'model_evaluation': ['prompt'],
-}
-_CONDITION = {
- 'type': 'object',
- 'required': ['type'],
- 'properties': {'type': {'enum': list(ASSIST_ALERT_CONDITIONS)}},
- 'allOf': [
- _when('type', condition_type, {
- 'additionalProperties': False,
- 'required': _CONDITION_REQUIRED.get(condition_type, []),
- 'properties': {'type': {'const': condition_type}, **fields},
- })
- for condition_type, fields in _CONDITION_SCHEMAS.items()
- ] + [
- {'if': {'required': ['type', 'mode'], 'properties': {'type': {'const': 'text_match'}, 'mode': {'const': 'regex'}}},
- 'then': {'required': ['pattern'], **_forbid('values', 'case_sensitive')}},
- {'if': {'required': ['type', 'mode'], 'properties': {'type': {'const': 'text_match'}, 'mode': {'not': {'const': 'regex'}}}},
- 'then': {'required': ['values'], **_forbid('pattern')}},
- ],
-}
-
OPERATION_SCHEMAS = {
'set_name': _operation('set_name', {'name': _str(ASSIST_WORKFLOW_NAME_MAX_LENGTH)}, ['name']),
'set_description': _operation(
@@ -889,20 +849,12 @@ def _operation(name, properties=None, required=(), all_of=()):
}, ['mode'], [
{'if': {'required': ['mode'], 'properties': {'mode': {'not': {'const': 'every_run'}}}}, 'then': _forbid('priority')},
]),
- 'add_alert_rule': _operation('add_alert_rule', {
- 'name': _str(ASSIST_ALERT_NAME_MAX_LENGTH, min_length=0),
- 'severity': {'enum': list(ASSIST_ALERT_SEVERITIES)},
- 'delivery': {'enum': list(ASSIST_ALERT_DELIVERIES)},
- 'scope': {
- 'type': 'object', 'additionalProperties': False, 'required': ['type'],
- 'properties': {'type': {'enum': list(ASSIST_ALERT_SCOPES)}, 'task': _TASK},
- 'allOf': [
- _when('type', 'task', {'required': ['task']}),
- {'if': {'required': ['type'], 'properties': {'type': {'not': {'const': 'task'}}}}, 'then': _forbid('task')},
- ],
- },
- 'condition': _CONDITION,
- }, ['severity', 'condition']),
+ 'add_alert_rule': _operation(
+ 'add_alert_rule', alert_rule_properties(_TASK), ['severity', 'condition'],
+ ),
+ 'update_alert_rule': _operation('update_alert_rule', {
+ 'rule': _handle(ALERT_HANDLE_PATTERN), **alert_rule_properties(_TASK),
+ }, ['rule'], [{'anyOf': [{'required': [field]} for field in alert_rule_properties(_TASK)]}]),
'remove_alert_rule': _operation('remove_alert_rule', {'rule': _handle(ALERT_HANDLE_PATTERN)}, ['rule']),
'set_workflow_runner': _operation('set_workflow_runner', {
'runner': {'enum': ['agent', 'model']},
@@ -1368,6 +1320,8 @@ def _alert_condition(self, raw):
if condition_type == 'model_evaluation':
prompt = model_text(raw['prompt'], 'The condition', ASSIST_ALERT_PROMPT_MAX_LENGTH, multiline=True)
return {'type': condition_type, 'prompt': prompt}
+ if condition_type == 'file_sync':
+ return {'type': condition_type, 'outcome': raw['outcome']}
if condition_type == 'agent_signal':
return {
'type': condition_type,
@@ -1382,20 +1336,43 @@ def op_add_alert_rule(self, op):
condition = self._alert_condition(op['condition'])
scope = op.get('scope') or {'type': 'final'}
if condition['type'] in ASSIST_SCOPELESS_CONDITIONS and scope['type'] != 'final':
- raise _OperationError('Run status and agent signal rules watch the whole run, so their scope must be final.')
+ raise _OperationError('Run status, File Sync and agent signal rules watch the whole run, so their scope must be final.')
task_id = self._task_id(scope['task']) if scope['type'] == 'task' else ''
# newWorkflowAlertRule's shape, with the model's choices.
self.alert_rules.append((None, {
'id': _new_uuid(),
'name': model_text(op.get('name', ''), 'The rule name', ASSIST_ALERT_NAME_MAX_LENGTH, required=False),
- 'enabled': True,
+ 'enabled': op.get('enabled', True),
'severity': op['severity'],
'delivery': op.get('delivery', 'default'),
'scope': {'type': scope['type'], 'task_id': task_id},
'condition': condition,
+ **{key: op[key] for key in ('require_acknowledgment', 'sound', 'size') if key in op},
}))
self.added_rules += 1
+ def op_update_alert_rule(self, op):
+ self._alerts()
+ target = self.handles.resolve(op['rule'])
+ if target is None or target[0] != 'alert':
+ raise _OperationError('It names an alert rule that is not in the draft.')
+ rule = next((rule for index, rule in self.alert_rules if index == target[1]), None)
+ if rule is None:
+ raise _OperationError('It names an alert rule that an earlier operation removed.')
+ for key in ('name', 'enabled', 'severity', 'delivery', 'require_acknowledgment', 'sound', 'size'):
+ if key in op:
+ rule[key] = (
+ model_text(op[key], 'The rule name', ASSIST_ALERT_NAME_MAX_LENGTH, required=False)
+ if key == 'name' else op[key]
+ )
+ if 'condition' in op:
+ rule['condition'] = self._alert_condition(op['condition'])
+ if 'scope' in op:
+ scope = op['scope']
+ rule['scope'] = {'type': scope['type'], 'task_id': self._task_id(scope['task']) if scope['type'] == 'task' else ''}
+ if rule['condition']['type'] in ASSIST_SCOPELESS_CONDITIONS and rule['scope']['type'] != 'final':
+ raise _OperationError('Run status, File Sync and agent signal rules watch the whole run, so their scope must be final.')
+
def op_remove_alert_rule(self, op):
self._alerts()
target = self.handles.resolve(op['rule'])
@@ -1744,6 +1721,20 @@ def _finish_alerts(self):
messages.append('The rules alert mode needs at least one alert rule.')
if len(rules) > ASSIST_ALERT_MAX_RULES:
messages.append(f'A workflow can have at most {ASSIST_ALERT_MAX_RULES} alert rules.')
+ original_rules = workflow_alert_config(self.draft)['alert_rules']
+ for index, rule in enumerate(rules):
+ original_index = self.alert_rules[index][0]
+ if original_index is not None and same_editor_value(rule, original_rules[original_index]):
+ continue
+ try:
+ normalize_alert_rule(
+ rule, task_ids={task['id'] for task in self._tasks()}, index=index, workflow_scope='personal',
+ )
+ except WorkflowAlertValidationError:
+ messages.append(
+ f'Alert rule {index + 1} is invalid. Use pop-up delivery for acknowledgment, sound or size; '
+ 'repeat sound requires acknowledgment. Check its condition and task scope too.'
+ )
final = {
'alert_mode': settings['alert_mode'], 'alert_priority': settings['alert_priority'],
'alert_rules': rules, 'alert_evaluation': settings['alert_evaluation'],
diff --git a/application/single_app/functions_workflow_drafts.py b/application/single_app/functions_workflow_drafts.py
index 7ae8fc467..26e2ad7ad 100644
--- a/application/single_app/functions_workflow_drafts.py
+++ b/application/single_app/functions_workflow_drafts.py
@@ -69,7 +69,15 @@
)
from functions_settings import is_user_workflows_enabled_for_user, read_user_settings_snapshot
from functions_workflow_bindings import authorize_workflow_reference
+from functions_workflow_alert_authoring import alert_rule_properties
+from functions_workflow_alerts import (
+ WORKFLOW_ALERT_MAX_RULES,
+ describe_alert_condition,
+ normalize_alert_rule,
+ resolve_alert_delivery,
+)
from functions_workflow_definitions import (
+ WorkflowAlertValidationError,
WorkflowCadenceError,
WorkflowDefinitionConflict,
WorkflowDefinitionError,
@@ -620,9 +628,24 @@ def _build_blueprint_schema():
'type': 'object',
'additionalProperties': False,
'properties': {
- 'mode': {'enum': list(BLUEPRINT_ALERT_MODES)},
+ 'mode': {'enum': [*BLUEPRINT_ALERT_MODES, 'rules']},
'severity': {'enum': list(BLUEPRINT_ALERT_SEVERITIES)},
+ 'rules': {
+ 'type': 'array', 'minItems': 1, 'maxItems': WORKFLOW_ALERT_MAX_RULES,
+ 'items': {
+ 'type': 'object', 'additionalProperties': False,
+ 'required': ['severity', 'condition'],
+ 'properties': alert_rule_properties(
+ {'type': 'integer', 'minimum': 1, 'maximum': BLUEPRINT_MAX_TASKS},
+ ),
+ },
+ },
},
+ 'allOf': [
+ _when('mode', 'rules', {'required': ['rules'], 'allOf': _forbid('severity')}),
+ {'if': {'not': {'required': ['mode'], 'properties': {'mode': {'const': 'rules'}}}},
+ 'then': {'allOf': _forbid('rules')}},
+ ],
},
'run_as': {'enum': list(BLUEPRINT_RUN_AS)},
'durable': {'const': True},
@@ -782,6 +805,8 @@ def _prepare_blueprint(blueprint, *, validator=None, map_error=None):
validator = validator or _BLUEPRINT_VALIDATOR
map_error = map_error or _map_schema_error
errors = [mapped for error in validator.iter_errors(candidate) for mapped in map_error(error)]
+ if not errors and validator is _BLUEPRINT_VALIDATOR:
+ errors.extend(_blueprint_alert_errors(candidate))
return (None, errors) if errors else (candidate, [])
@@ -1061,7 +1086,71 @@ def _alert_rule(workflow_id, key, name, severity, statuses):
}
-def _alert_fields(alerts, workflow_id):
+def _blueprint_alert_rules(alerts, workflow_id, task_ids):
+ rules = []
+ for index, raw in enumerate(alerts['rules']):
+ rule = copy.deepcopy(raw)
+ scope = rule.get('scope', {'type': 'final'})
+ task = scope.get('task')
+ if scope['type'] == 'task' and task > len(task_ids):
+ raise WorkflowAlertValidationError('The alert scope names a task that is not in the blueprint.')
+ if rule['condition']['type'] in ('run_status', 'file_sync', 'agent_signal') and scope['type'] != 'final':
+ raise WorkflowAlertValidationError('This alert condition must watch the whole run.')
+ rule['scope'] = {
+ 'type': scope['type'], 'task_id': task_ids[task - 1] if scope['type'] == 'task' else '',
+ }
+ rule['id'] = _derived_id(workflow_id, 'alert', index)
+ rules.append(normalize_alert_rule(rule, task_ids=set(task_ids), index=index, workflow_scope='personal'))
+ return rules
+
+
+def _blueprint_alert_errors(blueprint):
+ alerts = blueprint.get('alerts') or {}
+ if alerts.get('mode') != 'rules':
+ return []
+ try:
+ _blueprint_alert_rules(alerts, 'validation', [str(index) for index in range(len(blueprint['tasks']))])
+ except WorkflowAlertValidationError:
+ return [draft_error(
+ 'blueprint_invalid', ('alerts', 'rules'),
+ 'Check alert task scopes and conditions. Acknowledgment, sound and size require pop-up delivery; '
+ 'repeating sound also requires acknowledgment.',
+ )]
+ return []
+
+
+def blueprint_alert_summary(blueprint):
+ """Review disclosures from the same native rules used to build the workflow."""
+ alerts = blueprint.get('alerts') or {}
+ if alerts.get('mode') != 'rules':
+ return {'mode': alerts.get('mode') or 'every_run', 'severity': alerts.get('severity') or 'info'}
+ tasks = blueprint['tasks']
+ rules = _blueprint_alert_rules(alerts, 'summary', [str(index) for index in range(len(tasks))])
+ return {'mode': 'rules', 'rules': [{
+ 'name': rule['name'], 'enabled': rule['enabled'],
+ 'condition': (
+ f"{describe_alert_condition(rule['condition'])} (minimum severity: {rule['condition']['min_severity']})"
+ if rule['condition']['type'] == 'agent_signal'
+ else f"{describe_alert_condition(rule['condition'])} (case-sensitive)"
+ if rule['condition'].get('case_sensitive') else describe_alert_condition(rule['condition'])
+ ),
+ 'scope': (
+ tasks[int(rule['scope']['task_id'])]['title'] if rule['scope']['type'] == 'task'
+ else 'Any task output' if rule['scope']['type'] == 'any_task' else 'Final output / whole run'
+ ),
+ 'severity': rule['severity'], 'delivery': resolve_alert_delivery(rule['severity'], rule['delivery']),
+ 'require_acknowledgment': rule.get('require_acknowledgment', False),
+ 'sound': rule.get('sound', 'off'), 'size': rule.get('size', 'small'),
+ } for rule in rules]}
+
+
+def _alert_fields(alerts, workflow_id, task_ids=()):
+ if alerts.get('mode') == 'rules':
+ return {
+ 'alert_mode': 'rules', 'alert_priority': 'none',
+ 'alert_rules': _blueprint_alert_rules(alerts, workflow_id, task_ids),
+ 'alert_evaluation': {'on_error': 'skip'},
+ }
# The stored every_run mode always opens a pop-up, so a digest uses rules delivered to the bell.
rules = []
if alerts.get('mode', 'every_run') == 'every_run':
@@ -1222,7 +1311,7 @@ def _blueprint_payload(blueprint, handles, *, workflow_id, user_id, settings, en
'tasks': tasks,
'reference_inputs': references,
**_trigger_fields(trigger, handles),
- **_alert_fields(blueprint.get('alerts') or {}, workflow_id),
+ **_alert_fields(blueprint.get('alerts') or {}, workflow_id, [task['id'] for task in tasks]),
'm365_run_as_user_id': str(user_id) if blueprint.get('run_as') == 'self' else '',
}
diff --git a/application/v2_ui/src/components/chat/WorkflowProposalCard.tsx b/application/v2_ui/src/components/chat/WorkflowProposalCard.tsx
index 47dfa8b17..9db5bf790 100644
--- a/application/v2_ui/src/components/chat/WorkflowProposalCard.tsx
+++ b/application/v2_ui/src/components/chat/WorkflowProposalCard.tsx
@@ -124,6 +124,19 @@ function howOftenText(summary: WorkflowProposalSummary): string {
}
function alertsText(summary: WorkflowProposalSummary): string {
+ if (summary.alerts.mode === 'rules') {
+ return summary.alerts.rules.map((rule, index) => {
+ const options = [
+ rule.delivery === 'popup' ? 'Pop-up alert' : 'Notification bell only',
+ rule.require_acknowledgment ? 'Requires acknowledgment; returns until acknowledged' : '',
+ rule.sound === 'repeat' ? 'Sound repeats until acknowledged' : rule.sound === 'once' ? 'Sound plays once' : 'Sound off',
+ rule.delivery === 'popup' ? rule.size === 'large' ? 'Full-screen size'
+ : rule.size === 'medium' ? 'Medium size' : 'Small size' : '',
+ ].filter(Boolean).join('. ');
+ return `Rule ${index + 1}: ${rule.name}${rule.enabled ? '' : ' (disabled)'}. ${rule.condition}. `
+ + `Looks at: ${rule.scope}. Severity: ${rule.severity}. ${options}.`;
+ }).join('\n') + "\nSound follows each device's preferences.";
+ }
const when = summary.alerts.mode === 'failures_only'
? 'A notification only when a run fails'
: 'A notification after every run';
@@ -239,7 +252,7 @@ function ProposalDetails({ proposal }: { proposal: WorkflowProposal }) {
{whenText(summary)}
{howOftenText(summary)}
- {alertsText(summary)}
+ {alertsText(summary)}
{summary.durable ? (
Each run saves checkpoints and can resume after an interruption.
diff --git a/application/v2_ui/src/lib/workflowProposals.ts b/application/v2_ui/src/lib/workflowProposals.ts
index c0fa37c1f..831f928ea 100644
--- a/application/v2_ui/src/lib/workflowProposals.ts
+++ b/application/v2_ui/src/lib/workflowProposals.ts
@@ -9,6 +9,13 @@
import { api } from './apiClient';
import { WORKFLOW_LINK_PARAM } from './workflowRunLink';
+import {
+ WORKFLOW_ALERT_MAX_RULES,
+ WORKFLOW_ALERT_SEVERITIES,
+ WORKFLOW_ALERT_SIZES,
+ WORKFLOW_ALERT_SOUNDS,
+} from './workflowAlerts';
+import type { WorkflowAlertSeverity, WorkflowAlertSize, WorkflowAlertSound } from './workflowAlerts';
/** The orchestration capability whose completed step leaves a proposal on its answer. */
export const WORKFLOW_PROPOSE_CAPABILITY = 'workflow_propose';
@@ -96,10 +103,26 @@ export interface WorkflowProposalSummary {
};
tasks: WorkflowProposalTask[];
file_sync_sources: string[];
- alerts: { mode: typeof ALERT_MODES[number]; severity: typeof ALERT_SEVERITIES[number] };
+ alerts: WorkflowProposalAlerts;
durable: boolean;
}
+export interface WorkflowProposalAlertRule {
+ name: string;
+ enabled: boolean;
+ condition: string;
+ scope: string;
+ severity: WorkflowAlertSeverity;
+ delivery: 'notify_only' | 'popup';
+ require_acknowledgment: boolean;
+ sound: WorkflowAlertSound;
+ size: WorkflowAlertSize;
+}
+
+export type WorkflowProposalAlerts =
+ | { mode: typeof ALERT_MODES[number]; severity: typeof ALERT_SEVERITIES[number] }
+ | { mode: 'rules'; rules: WorkflowProposalAlertRule[] };
+
export interface WorkflowProposalM365 {
required: boolean;
can_send: boolean;
@@ -239,6 +262,36 @@ function taskOf(value: unknown): WorkflowProposalTask {
};
}
+function alertsOf(value: unknown): WorkflowProposalAlerts {
+ if (!isRecord(value)) invalid();
+ if (value.mode !== 'rules') {
+ return { mode: oneOf(ALERT_MODES, value.mode), severity: oneOf(ALERT_SEVERITIES, value.severity) };
+ }
+ if (!Array.isArray(value.rules) || value.rules.length < 1 || value.rules.length > WORKFLOW_ALERT_MAX_RULES) invalid();
+ return {
+ mode: 'rules',
+ rules: value.rules.map((raw): WorkflowProposalAlertRule => {
+ if (!isRecord(raw)) invalid();
+ const rule: WorkflowProposalAlertRule = {
+ name: textOf(raw.name),
+ enabled: flagOf(raw.enabled),
+ condition: textOf(raw.condition),
+ scope: textOf(raw.scope),
+ severity: oneOf(WORKFLOW_ALERT_SEVERITIES, raw.severity),
+ delivery: oneOf(['notify_only', 'popup'] as const, raw.delivery),
+ require_acknowledgment: flagOf(raw.require_acknowledgment),
+ sound: oneOf(WORKFLOW_ALERT_SOUNDS, raw.sound),
+ size: oneOf(WORKFLOW_ALERT_SIZES, raw.size),
+ };
+ if (rule.delivery !== 'popup' && (rule.require_acknowledgment || rule.sound !== 'off' || rule.size !== 'small')) {
+ invalid();
+ }
+ if (rule.sound === 'repeat' && !rule.require_acknowledgment) invalid();
+ return rule;
+ }),
+ };
+}
+
function summaryOf(value: unknown): WorkflowProposalSummary | null {
if (value === null) return null;
if (!isRecord(value) || !isRecord(value.runs_per_month) || !isRecord(value.alerts) || !Array.isArray(value.tasks)) {
@@ -258,10 +311,7 @@ function summaryOf(value: unknown): WorkflowProposalSummary | null {
},
tasks: value.tasks.map(taskOf),
file_sync_sources: textsOf(value.file_sync_sources),
- alerts: {
- mode: oneOf(ALERT_MODES, value.alerts.mode),
- severity: oneOf(ALERT_SEVERITIES, value.alerts.severity),
- },
+ alerts: alertsOf(value.alerts),
durable: flagOf(value.durable),
};
}
diff --git a/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOW_PROPOSALS.md b/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOW_PROPOSALS.md
index c790ef1b6..dcdf6d506 100644
--- a/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOW_PROPOSALS.md
+++ b/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOW_PROPOSALS.md
@@ -4,6 +4,8 @@ Implemented in version: **0.261.207**.
Application version tracking: `application\single_app\config.py`.
+Native alert authoring implemented in version: **0.261.315**.
+
Related issue: #1547, part of #1543. Builds on the
[Workflow draft service](WORKFLOW_DRAFT_SERVICE.md) (#1545) and
[Workflow calendar schedules](WORKFLOW_CALENDAR_SCHEDULES.md) (#1544). The
@@ -23,6 +25,34 @@ under the answer, with everything the workflow would do. Nothing is created unti
the requester chooses **Create & start**, **Create paused**, **Create** or
**Edit** on the card.
+### Monitoring with native alerts
+
+Chat can propose conditional SimpleChat alerts, not just quiet run-completion notifications.
+For example, a scheduled task can read telemetry through an available read-only agent and
+alert when its output reports an actual active fault. Workflow execution failures and faults
+in the monitored system are different conditions; successful data acquisition does not mean
+the source is healthy. Creating this workflow does not configure the source system's alarms.
+
+For an explicit strongest-attention request, planner guidance proposes critical severity,
+pop-up delivery, acknowledgment, repeating sound and full-screen size, unless other requested
+constraints contradict those options. The approval card discloses each rule's condition,
+scope and attention options before creation. Nothing is saved until you approve it, and
+**Edit** retains those options. Sound still follows device preferences.
+
+Other requests retain quieter defaults. Email is a separate task requiring the appropriate
+agent and account connection, not a prerequisite for native alerts. These proposals remain
+personal workflows and alert their owner, not arbitrary recipients or everyone in a group.
+Missing monitoring cadence should be clarified rather than invented.
+
+The closed blueprint accepts `alerts: {mode: "rules", rules: [...]}` with up to 20 rules.
+Rules use native severities, conditions and pop-up options; a task scope uses its 1-based
+position in the blueprint, never a stored task ID. The server checks native alert validation
+and maps positions to deterministic saved IDs. Existing `every_run`/`failures_only` blueprint
+shapes retain their quiet, bell-only behavior. One-shot large-work hand-offs are unchanged.
+
+See [the capability parity fix](../fixes/WORKFLOW_AI_ALERT_CAPABILITY_PARITY_FIX.md) for
+regression coverage and the distinction between contract tests and live model evaluation.
+
What this version adds:
- **The `workflow_propose` capability.** A plan step that describes one personal
diff --git a/docs/explanation/features/WORKFLOW_AI_ASSISTANT.md b/docs/explanation/features/WORKFLOW_AI_ASSISTANT.md
index 7b6cea0ed..05402692e 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.213)
+# Workflow AI Assistant (v0.261.315)
## Overview
@@ -46,6 +46,31 @@ Dependencies:
`#` references in `lib/planReferences.ts`, and 3a's `applyAssist`, `revertTurn` and **Changes**
tab.
+## Native alert editing
+
+Implemented in version: **0.261.315**, tracked in `application/single_app/config.py`.
+
+Ask AI can read and change a rule's **Require acknowledgment**, **Sound**, and **Size**,
+as well as its condition, scope, severity and delivery. For example, “Require acknowledgment
+on Rule 2” updates that rule without recreating it or changing other rules. Alert acknowledgment
+is a notification receipt requirement, not a task approval.
+
+An explicit request for the strongest available attention guides the model to propose critical
+severity, pop-up delivery, acknowledgment, repeating sound and full-screen size. These are not
+the default for ordinary alerts; explicit constraints such as “keep sound off” still apply.
+The reply describes the consequences, and the changes remain undoable drafts until saved.
+Sound continues to respect each device's preferences.
+
+Native SimpleChat alerts do not require an email task or a Microsoft 365 connection. A task
+can read external telemetry and a rule can alert on actual problems in its output, even when
+the task completed successfully. This does not modify the external system's own alarm settings.
+Semantic conditions should exclude healthy and negated findings such as “no active alerts”;
+literal text matching is appropriate only for defined signals.
+
+Pop-up options require pop-up delivery, and repeating sound requires acknowledgment. Personal
+workflows still alert their owner: this does not add group broadcasts or recipients.
+See [the capability parity fix](../fixes/WORKFLOW_AI_ALERT_CAPABILITY_PARITY_FIX.md).
+
## What it does and doesn't do
The endpoint returns a proposal. It never writes a workflow, a run or a document, and it never
@@ -252,7 +277,7 @@ properties) before anything is applied. At most 64 operations are accepted per r
| --- | --- |
| Name and description | `set_name`, `set_description` |
| Trigger and schedule | `set_trigger_manual`, `set_schedule_interval` (seconds, minutes or hours), `set_schedule_calendar` (daily, weekdays, weekly or monthly, at a local `HH:MM`) |
-| Alerts | `set_alert_mode` (`off`, `every_run` with a priority, or `rules`), `add_alert_rule`, `remove_alert_rule` |
+| Alerts | `set_alert_mode` (`off`, `every_run` with a priority, or `rules`), `add_alert_rule`, `update_alert_rule`, `remove_alert_rule`; rules support acknowledgment, sound and size |
| Runner | `set_workflow_runner`, `set_task_runner` (inherit, an agent handle or a model handle) |
| Tasks | `add_task`, `remove_task`, `move_task`, `set_task_name`, `set_task_instructions`, `set_task_inputs` |
| Documents | `bind_reference`, `set_task_references`, `unbind_reference`, `set_task_document_target`, `clear_task_document_target` |
diff --git a/docs/explanation/features/WORKFLOW_DRAFT_SERVICE.md b/docs/explanation/features/WORKFLOW_DRAFT_SERVICE.md
index 2edc1736c..de1838522 100644
--- a/docs/explanation/features/WORKFLOW_DRAFT_SERVICE.md
+++ b/docs/explanation/features/WORKFLOW_DRAFT_SERVICE.md
@@ -4,6 +4,8 @@ Implemented in version: **0.261.202**.
Application version tracking: `application\single_app\config.py`.
+Native rules-mode alert blueprints implemented in version: **0.261.315**.
+
Related issue: #1545, part of #1543. Builds on
[Workflow calendar schedules](WORKFLOW_CALENDAR_SCHEDULES.md) (#1544).
@@ -135,8 +137,9 @@ The schema is JSON Schema Draft 2020-12, `$id`
| `tasks[].instructions` | Required. 1 to 4,000 characters. |
| `tasks[].runner` | Optional. `{"type": "agent", "agent_ref": }` or `{"type": "model"}`. Omitted means the default model. |
| `tasks[].inputs` | Optional. Up to 10 distinct document handles. |
-| `alerts.mode` | Optional. `every_run` (the default) or `failures_only`. |
-| `alerts.severity` | Optional. `info` (the default) or `low`. |
+| `alerts.mode` | Optional. `every_run` (the default), `failures_only`, or explicit `rules`. |
+| `alerts.severity` | Optional for legacy modes. `info` (the default) or `low`; not accepted with `rules`. |
+| `alerts.rules` | Required only in `rules` mode. 1 to 20 bounded, ID-free native personal alert rules. Each requires a condition and severity; task scope uses a 1-based blueprint task position. |
| `run_as` | Optional. `self` or `none`. |
| `durable` | Optional. Only `true`; every workflow created from chat is durable. |
@@ -194,7 +197,7 @@ for this user:
| `runner.type: agent` | `selected_agent` from the agent handle |
| `runner.type: model`, or no runner | `runner: {type: inherit}`, the default model |
| Task `inputs` | `reference_inputs` named by handle, and each task's `reference_ids` |
-| `alerts` | Bell-only alert rules; see below |
+| `alerts` | Legacy modes become bell-only rules; explicit rules retain native conditions and pop-up options |
| `run_as: self` | `m365_run_as_user_id` set to the user, with no approval yet |
Every created workflow also has `definition_version: 2`,
@@ -205,7 +208,7 @@ retry_count: 0}`. It's paused unless the caller passes `enabled=True`.
In a File Sync workflow, the first task analyzes the documents each sync
changed, when the document analysis action is enabled. Later tasks don't.
-**Alerts.** A workflow created from chat is a digest: its results should reach
+**Alerts.** Legacy blueprint alert modes describe a quiet digest: results reach
the notification bell without interrupting anyone. The stored `every_run` mode
always opens a pop-up, so the builder writes `alert_mode: rules` instead:
@@ -217,6 +220,15 @@ always opens a pop-up, so the builder writes `alert_mode: rules` instead:
Both rules deliver `notify_only`, so they never pop up. `alert_evaluation` is
`{on_error: skip}`. With `failures_only`, `severity` is accepted and ignored.
+Since **0.261.315**, explicit `rules` blueprints instead preserve native severity
+(`info` through `critical`), delivery and conditions, plus `require_acknowledgment`,
+`sound` and `size`. Conditions can evaluate output text or semantic findings, run/task
+status, File Sync outcomes, missing output, or supported agent alert signals.
+Pop-up options require pop-up delivery; repeating sound also requires acknowledgment.
+The builder uses native validation, maps task positions to deterministic IDs and rejects
+group audience or free-form recipients. The proposal card discloses the resulting rules.
+See [workflow proposals](CHAT_ORCHESTRATION_WORKFLOW_PROPOSALS.md#monitoring-with-native-alerts).
+
**Determinism.** The workflow id is a UUID 5 of the user and the proposal. Task,
reference and alert rule ids are UUID 5 values of the workflow id and their
position or handle. The builder reads no clock and no random source, so the same
diff --git a/docs/explanation/fixes/WORKFLOW_AI_ALERT_CAPABILITY_PARITY_FIX.md b/docs/explanation/fixes/WORKFLOW_AI_ALERT_CAPABILITY_PARITY_FIX.md
new file mode 100644
index 000000000..d5296c83d
--- /dev/null
+++ b/docs/explanation/fixes/WORKFLOW_AI_ALERT_CAPABILITY_PARITY_FIX.md
@@ -0,0 +1,75 @@
+# Workflow AI alert capability parity fix (v0.261.315)
+
+Fixed/Implemented in version: **0.261.315**, tracked in
+`application/single_app/config.py`.
+
+## Issue and root cause
+
+Ask AI could incorrectly describe alert acknowledgment as unsupported even while the workflow
+editor displayed it. Its draft projection omitted acknowledgment, sound and size, and its
+allowlist offered no update-existing-rule operation or pop-up option fields. Chat's proposal
+blueprint separately restricted alerts to quiet info/low run-status notifications. Its builder
+forced notification-bell delivery, and the approval card promised alerts never popped up.
+Prompt changes alone could not overcome these closed contracts.
+
+The reported email/external-system configuration detour is consistent with missing native
+capability information, but its original deployed model trace was not inspected; the exact
+cause of that individual response is not proven.
+
+## Technical changes and impact
+
+- `functions_workflow_alert_authoring.py` provides bounded, ID-free personal rule schemas using
+ the existing native alert vocabulary and limits. Both authoring paths reuse them.
+- `functions_workflow_assist_operations.py` exposes pop-up options and supports targeted
+ `update_alert_rule` changes, retaining IDs, order and fields not requested. Changed rules
+ are checked with native validation; an untouched pre-existing invalid rule is not newly rejected.
+- `functions_workflow_drafts.py` accepts explicit rules-mode blueprints, checks task references
+ and option compatibility, and maps task positions to deterministic saved IDs. Legacy quiet
+ proposals and one-shot hand-off defaults retain their behavior.
+- `functions_workflow_assist.py`, `functions_orchestration_planner.py` and
+ `functions_orchestration_registry.py` explain native alerts versus email and external alarm
+ configuration. Capability selection stays model-driven, with no keyword-routing heuristics.
+- `functions_orchestration_workflows.py`, `workflowProposals.ts` and `WorkflowProposalCard.tsx`
+ carry and disclose native rule settings before approval, while continuing to read old summaries.
+
+Explicit strongest-attention requests guide the model toward critical pop-ups, acknowledgment,
+repeating sound and full-screen size for review. Ordinary alerts keep quiet defaults; explicit
+constraints still apply. Sound preferences, ownership, proposal approval, draft-only assistance,
+and personal-workflow audience limits are unchanged.
+
+## Validation and limitations
+
+`functional_tests/test_workflow_ai_alert_capability_parity.py` exercises rule targeting, explicit
+clearing, invalid handles, incompatible options, deterministic dry-run/create, disclosure and
+adverse findings after successful acquisition. Its evaluator/model replies are scripted.
+`test_workflow_draft_v2_round_trip.py` exercises the production TypeScript editor and real save
+path. Proposal route round-trip coverage also includes acknowledged critical rules.
+`test_v2_workflow_proposal_alerts.mjs` covers old/new client contracts and malformed responses.
+`ui_tests/test_v2_orchestration_workflow_proposal_card.py` checks review disclosure and inert text
+in desktop/light and mobile/dark layouts.
+
+Before: AI authors could not express these options, and a chat proposal could only create quiet
+run-status alerts. After: they can propose, edit and preserve native conditional pop-up rules,
+with the consequences visible before persistence.
+
+Offline contract tests do not prove a live model will choose correctly on every paraphrase.
+Provider-backed evaluation of monitoring, explicit email, negated findings and strongest-attention
+requests remains necessary in a configured deployment. No private chat or telemetry is used by
+the regression suite.
+
+### Local validation results
+
+| Check | Result |
+| --- | --- |
+| Targeted assistant, draft service, production editor save parity and alert runtime suites | 381 passed |
+| Complete proposal-card browser suite, using the shared local/Azure Playwright connection fixture | 49 passed locally, including desktop/light and mobile/dark alert disclosures |
+| Proposal alert and merge response parser tests | 6 passed |
+| V2 TypeScript check and production build into UI test artifacts | Passed |
+| Documentation inventory coverage and site quality | 7/7 and 6/6 passed; inventory unchanged |
+
+The orchestration route integration suites could not complete in this environment: their fixture
+hits a `functions_public_workspaces` / `functions_search` import cycle. Repeating an existing
+round-trip test with the original application modules from `HEAD` reproduced that failure.
+Bootstrapping authentication first then exposes an unrelated installed OpenSSL/cryptography
+incompatibility (`GEN_EMAIL` missing). Neither issue was changed or suppressed. The real workflow
+builder, production TypeScript editor and personal save round-trip tests do pass independently.
diff --git a/docs/guides/create-a-workflow.md b/docs/guides/create-a-workflow.md
index 9061bf15f..9f60e2d48 100644
--- a/docs/guides/create-a-workflow.md
+++ b/docs/guides/create-a-workflow.md
@@ -708,6 +708,18 @@ 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.
+From **0.261.315**, you can also ask “Require acknowledgment on Rule 2” or change a rule's
+sound and size. Acknowledgment is separate from task approvals. For an explicit request
+for the strongest available attention, AI guidance proposes critical severity, a pop-up,
+required acknowledgment, repeating sound and full-screen size for your review; quieter
+requests retain their defaults, and device sound preferences still apply.
+
+Chat-created monitoring proposals can use these same native conditional alerts. A read-only
+agent can fetch external telemetry and SimpleChat can alert on actual adverse findings without
+changing the source system's alarm configuration or sending email. Review each rule's condition
+and attention options on the proposal card before creating it. Personal workflows alert their
+owner, not a whole group.
+
**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.
diff --git a/functional_tests/test_orchestration_workflow_proposal_editor_round_trip.py b/functional_tests/test_orchestration_workflow_proposal_editor_round_trip.py
index b1b63edc9..fe03c6909 100644
--- a/functional_tests/test_orchestration_workflow_proposal_editor_round_trip.py
+++ b/functional_tests/test_orchestration_workflow_proposal_editor_round_trip.py
@@ -2,7 +2,7 @@
# test_orchestration_workflow_proposal_editor_round_trip.py
"""
Functional test for editing a workflow proposal in the V2 workflow editor before accepting it.
-Version: 0.261.207
+Version: 0.261.315
Implemented in: 0.261.207
The proposal card's Edit opens the draft route's workflow in the V2 workflow editor, and Save
@@ -141,5 +141,30 @@ def test_a_renamed_proposal_is_recorded_as_edited_and_stored_as_typed(h):
assert stored["origin"]["source"] == "orchestration"
+@pytest.mark.parametrize("edit", [False, True])
+def test_native_critical_alerts_survive_editor_and_accept(h, edit):
+ blueprint = blueprint_with(TRIGGERS["manual"])
+ blueprint["alerts"] = {"mode": "rules", "rules": [{
+ "name": "Critical finding", "severity": "critical", "delivery": "popup",
+ "require_acknowledgment": True, "sound": "repeat", "size": "large",
+ "scope": {"type": "task", "task": 1},
+ "condition": {"type": "model_evaluation", "prompt": "An actual active fault was reported."},
+ }]}
+ seed_run(h, blueprint=blueprint)
+ login(h)
+ workflow = draft_workflow(h)
+ [payload] = run_probe([{"workflow": workflow, **({"name": "Changed monitor"} if edit else {})}])
+ response = accept(h, workflow=payload)
+ assert response.status_code == 201, response.get_data(as_text=True)
+ stored = stored_workflow(h)
+ assert stored["alert_rules"] == workflow["alert_rules"]
+ assert stored["origin"]["edited"] is edit
+ rule = stored["alert_rules"][0]
+ assert (rule["severity"], rule["require_acknowledgment"], rule["sound"], rule["size"]) == (
+ "critical", True, "repeat", "large",
+ )
+ assert rule["scope"]["task_id"] == stored["tasks"][0]["id"]
+
+
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-q"]))
diff --git a/functional_tests/test_orchestration_workflow_propose_capability.py b/functional_tests/test_orchestration_workflow_propose_capability.py
index 9577d501f..24393ca4c 100644
--- a/functional_tests/test_orchestration_workflow_propose_capability.py
+++ b/functional_tests/test_orchestration_workflow_propose_capability.py
@@ -2,7 +2,7 @@
# test_orchestration_workflow_propose_capability.py
"""
Functional test for the workflow_propose orchestration capability.
-Version: 0.261.242
+Version: 0.261.315
Implemented in: 0.261.207
Merge tasks added in: 0.261.241
@@ -1293,7 +1293,7 @@ def test_the_proposal_module_creates_nothing_and_never_imports_flask():
assert not any(name.split(".")[0] == "flask" for name in imported)
assert imported["functions_workflow_drafts"] == {
"BLUEPRINT_TASK_TITLE_MAX_LENGTH", "WORKFLOW_ORIGIN_SOURCE_ORCHESTRATION", "check_workflow_blueprint",
- "dry_run_workflow_blueprint", "validate_workflow_blueprint",
+ "dry_run_workflow_blueprint", "validate_workflow_blueprint", "blueprint_alert_summary",
}
assert not any(name.startswith(("functions_workflows", "functions_personal")) for name in imported)
assert "cosmos_" not in source and "create_personal_workflow" not in source
diff --git a/functional_tests/test_v2_workflow_proposal_alerts.mjs b/functional_tests/test_v2_workflow_proposal_alerts.mjs
new file mode 100644
index 000000000..9679495dd
--- /dev/null
+++ b/functional_tests/test_v2_workflow_proposal_alerts.mjs
@@ -0,0 +1,59 @@
+// test_v2_workflow_proposal_alerts.mjs
+// Version: 0.261.315
+// Implemented in: 0.261.315
+// Validates quiet legacy summaries and native alert review contracts without network access.
+
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import './test_support/tsResolve.mjs';
+
+const { parseWorkflowProposalList } = await import('../application/v2_ui/src/lib/workflowProposals.ts');
+
+const rule = {
+ name: 'Active fault', enabled: true, condition: 'An actual active fault was reported.',
+ scope: 'Read telemetry', severity: 'critical', delivery: 'popup',
+ require_acknowledgment: true, sound: 'repeat', size: 'large',
+};
+
+function response(alerts) {
+ return {
+ run_id: 'run-1',
+ proposals: [{
+ proposal_id: 'proposal-1', step_id: 'propose', state: 'pending', reason: null,
+ created_at: null, expires_at: null,
+ actions: { accept: true, edit: true, deny: true, create_again: false, open_workflow: false },
+ summary: {
+ name: 'Monitor', description: '', trigger_type: 'manual', schedule_label: '',
+ time_zone: 'UTC', runs_per_month: { kind: 'manual', value: null },
+ tasks: [], file_sync_sources: [], alerts, durable: true,
+ },
+ similar_workflows: [], workflow: null,
+ m365: { required: false, can_send: false, run_as: 'none', sources: [], connected: null, approval_state: null },
+ }],
+ };
+}
+
+test('legacy quiet summaries and explicit native rules retain all review fields', () => {
+ for (const alerts of [
+ { mode: 'every_run', severity: 'info' },
+ { mode: 'failures_only', severity: 'low' },
+ { mode: 'rules', rules: [rule] },
+ ]) {
+ const parsed = parseWorkflowProposalList(response(alerts), 'run-1');
+ assert.deepEqual(parsed.proposals[0].summary.alerts, alerts);
+ }
+});
+
+test('unrenderable or incompatible alert options fail closed', () => {
+ for (const alerts of [
+ { mode: 'rules', rules: [] },
+ { mode: 'rules', rules: Array(21).fill(rule) },
+ ...[
+ { sound: 'continuous' }, { size: 'huge' }, { severity: 'maximum' },
+ { require_acknowledgment: 'true' }, { delivery: 'notify_only' },
+ { require_acknowledgment: false }, { condition: null },
+ ].map(change => ({ mode: 'rules', rules: [{ ...rule, ...change }] })),
+ ]) {
+ assert.throws(() => parseWorkflowProposalList(response(alerts), 'run-1'), /invalid response/);
+ }
+});
diff --git a/functional_tests/test_workflow_ai_alert_capability_parity.py b/functional_tests/test_workflow_ai_alert_capability_parity.py
new file mode 100644
index 000000000..c9174c738
--- /dev/null
+++ b/functional_tests/test_workflow_ai_alert_capability_parity.py
@@ -0,0 +1,301 @@
+# test_workflow_ai_alert_capability_parity.py
+"""
+Functional tests for native alert authoring through chat and AI Assist.
+Version: 0.261.315
+Implemented in: 0.261.315
+
+Real draft/save and assistant operation paths run offline with scoped service doubles.
+Scripted model replies prove contract plumbing, not live model selection quality.
+"""
+
+import copy
+import json
+import sys
+from pathlib import Path
+
+import pytest
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "application" / "single_app"))
+
+from functions_workflow_alerts import build_workflow_alert_facts, evaluate_workflow_alert_rules
+from functions_workflow_assist import ASSIST_SYSTEM_PROMPT
+from test_support import workflow_assist as wa
+from test_workflow_draft_service import harness # noqa: F401
+
+
+RULE = {
+ "name": "Active telemetry faults", "severity": "critical", "delivery": "popup",
+ "require_acknowledgment": True, "sound": "repeat", "size": "large",
+ "scope": {"type": "task", "task": 1},
+ "condition": {
+ "type": "model_evaluation",
+ "prompt": "An actual active fault or unhealthy state was reported. Exclude healthy results and no active alerts.",
+ },
+}
+BLUEPRINT = {
+ "name": "Telemetry monitor", "trigger": {"type": "interval", "unit": "hours", "value": 1},
+ "tasks": [{"title": "Read telemetry", "instructions": "Read current telemetry and report actual active faults."}],
+ "alerts": {"mode": "rules", "rules": [RULE]},
+}
+
+
+def _assist(operations, *, rules=None):
+ stored = wa.stored_workflow(
+ alert_mode="rules", alert_priority="none",
+ alert_rules=rules or [
+ {"id": "alert-first", "name": "Same name", "enabled": True, "severity": "low",
+ "delivery": "notify_only", "scope": {"type": "final", "task_id": ""},
+ "condition": {"type": "run_status", "statuses": ["failed"]}},
+ {"id": "alert-second", "name": "Same name", "enabled": True, "severity": "critical",
+ "delivery": "default", "scope": {"type": "task", "task_id": wa.REVIEW_ID},
+ "condition": {"type": "task_status", "statuses": ["failed"]}},
+ ],
+ )
+ body = wa.request_body(stored=stored, instruction="Require acknowledgment on Rule 2.")
+ reply = wa.reply("changed", "Updated the requested rule.", operations)
+ model = wa.ScriptedModel(reply, reply)
+ services = wa.services(model, stored=stored)
+ return body, model, services
+
+
+def test_acknowledgment_updates_only_the_requested_rule_and_exposes_options():
+ body, model, services = _assist([
+ {"op": "update_alert_rule", "rule": "alert_2", "require_acknowledgment": True},
+ ])
+ result = wa.run(body, services)
+ expected = copy.deepcopy(body["draft"])
+ expected["alert_evaluation"] = {"on_error": "skip"}
+ expected["alert_rules"][1]["require_acknowledgment"] = True
+ assert result["candidate"] == expected
+ assert result["warnings"] == []
+ assert [change["key"] for change in result["changes"]] == ["alerts"]
+ visible = model.envelope()["draft"]["alerts"]["rules"][1]
+ assert (visible["require_acknowledgment"], visible["sound"], visible["size"]) == (False, "off", "small")
+ assert "alert-second" not in model.text()
+
+
+def test_pop_up_options_can_be_explicitly_cleared_without_changing_identity():
+ rules = [{
+ "id": "alert-existing", **{key: value for key, value in RULE.items() if key != "scope"},
+ "scope": {"type": "final", "task_id": ""}, "enabled": True,
+ }]
+ body, model, services = _assist([
+ {"op": "update_alert_rule", "rule": "alert_1",
+ "require_acknowledgment": False, "sound": "off", "size": "small"},
+ ], rules=rules)
+ result = wa.run(body, services)
+ candidate = result["candidate"]["alert_rules"][0]
+ assert candidate == {**body["draft"]["alert_rules"][0],
+ "require_acknowledgment": False, "sound": "off", "size": "small"}
+ visible = model.envelope()["draft"]["alerts"]["rules"][0]
+ assert (visible["require_acknowledgment"], visible["sound"], visible["size"]) == (True, "repeat", "large")
+
+
+def test_assist_adds_the_strongest_supported_rule_and_preserves_other_rules():
+ body, _model, services = _assist([{
+ "op": "add_alert_rule", **{key: value for key, value in RULE.items() if key != "scope"},
+ "scope": {"type": "task", "task": "task_2"},
+ }])
+ result = wa.run(body, services)
+ rules = result["candidate"]["alert_rules"]
+ assert rules[:2] == body["draft"]["alert_rules"]
+ assert rules[2]["scope"] == {"type": "task", "task_id": wa.REVIEW_ID}
+ for key in ("severity", "delivery", "require_acknowledgment", "sound", "size", "condition"):
+ assert rules[2][key] == RULE[key]
+
+
+def test_assist_can_edit_file_sync_alerts_without_configuring_the_trigger():
+ rules = [{
+ "id": "alert-sync", "name": "Sync failed", "enabled": True, "severity": "critical",
+ "delivery": "default", "scope": {"type": "final", "task_id": ""},
+ "condition": {"type": "file_sync", "outcome": "sync_failed"},
+ }]
+ body, model, services = _assist([
+ {"op": "update_alert_rule", "rule": "alert_1", "require_acknowledgment": True},
+ ], rules=rules)
+ result = wa.run(body, services)
+ assert result["candidate"]["alert_rules"][0]["condition"] == rules[0]["condition"]
+ assert result["candidate"]["alert_rules"][0]["require_acknowledgment"] is True
+ assert result["candidate"]["trigger_type"] == body["draft"]["trigger_type"]
+ assert model.envelope()["draft"]["alerts"]["rules"][0]["condition"] == rules[0]["condition"]
+ body, _model, services = _assist([{
+ "op": "add_alert_rule", "severity": "high",
+ "condition": {"type": "file_sync", "outcome": "sync_failed"},
+ }])
+ result = wa.run(body, services)
+ assert result["candidate"]["alert_rules"][-1]["condition"] == rules[0]["condition"]
+
+
+def test_untouched_pre_existing_invalid_rules_do_not_block_a_valid_option_edit():
+ rules = [
+ {"id": "alert-old", "name": "Old invalid rule", "enabled": True, "severity": "critical",
+ "delivery": "notify_only", "require_acknowledgment": True,
+ "scope": {"type": "final", "task_id": ""}, "condition": {"type": "run_status", "statuses": ["failed"]}},
+ {"id": "alert-good", "name": "New rule", "enabled": True, "severity": "critical", "delivery": "popup",
+ "scope": {"type": "final", "task_id": ""}, "condition": {"type": "run_status", "statuses": ["failed"]}},
+ ]
+ body, _model, services = _assist([
+ {"op": "update_alert_rule", "rule": "alert_2", "require_acknowledgment": True},
+ ], rules=rules)
+ result = wa.run(body, services)
+ assert result["candidate"]["alert_rules"][0] == body["draft"]["alert_rules"][0]
+ assert result["candidate"]["alert_rules"][1]["require_acknowledgment"] is True
+
+
+@pytest.mark.parametrize("change,expected", [
+ ({"name": "Updated name"}, {"name": "Updated name"}),
+ ({"enabled": False}, {"enabled": False}),
+ ({"severity": "high", "delivery": "popup"}, {"severity": "high", "delivery": "popup"}),
+ ({"scope": {"type": "task", "task": "task_1"}},
+ {"scope": {"type": "task", "task_id": wa.COLLECT_ID}}),
+ ({"condition": {"type": "text_match", "mode": "contains_any", "values": ["FAULT"]}},
+ {"condition": {"type": "text_match", "mode": "contains_any", "values": ["FAULT"], "case_sensitive": False}}),
+])
+def test_existing_rule_fields_are_updated_in_place(change, expected):
+ body, _model, services = _assist([{"op": "update_alert_rule", "rule": "alert_2", **change}])
+ result = wa.run(body, services)
+ assert result["candidate"]["alert_rules"][1] == {**body["draft"]["alert_rules"][1], **expected}
+ assert result["candidate"]["alert_rules"][0] == body["draft"]["alert_rules"][0]
+
+
+@pytest.mark.parametrize("operation", [
+ {"rule": "alert_99", "require_acknowledgment": True},
+ {"rule": "alert_1", "require_acknowledgment": True},
+ {"rule": "alert_2", "sound": "repeat"},
+ {"rule": "alert_2", "size": "huge"},
+ {"rule": "alert_2", "audience": "group"},
+ {"rule": "alert_2"},
+ {"rule": "alert-second", "require_acknowledgment": True},
+])
+def test_invalid_assist_edits_are_corrected_then_refused_without_a_write(operation):
+ body, model, services = _assist([{"op": "update_alert_rule", **operation}])
+ error = wa.refusal(body, services)
+ assert error.code == "assistant_output_invalid"
+ assert len(model.calls) == 2
+ assert model.envelope(1)["previous_attempt_errors"]
+ assert services.recorder.named("dry_run") == []
+
+
+def test_an_update_cannot_target_a_rule_removed_earlier_in_the_reply():
+ body, _model, services = _assist([
+ {"op": "remove_alert_rule", "rule": "alert_2"},
+ {"op": "update_alert_rule", "rule": "alert_2", "require_acknowledgment": True},
+ ])
+ error = wa.refusal(body, services)
+ assert error.code == "assistant_output_invalid"
+
+
+def test_rules_survive_real_dry_run_create_and_review_summary(harness):
+ preview = harness.dry_run(BLUEPRINT)
+ assert preview["ok"], preview["errors"]
+ assert harness.writes() == {}
+ again = harness.dry_run(BLUEPRINT)
+ assert again["workflow"]["alert_rules"] == preview["workflow"]["alert_rules"]
+ rule = preview["workflow"]["alert_rules"][0]
+ assert rule["scope"]["task_id"] == preview["workflow"]["tasks"][0]["id"]
+ for key in ("severity", "delivery", "require_acknowledgment", "sound", "size", "condition"):
+ assert rule[key] == RULE[key]
+ summary = harness.call("blueprint_alert_summary", BLUEPRINT)
+ disclosure = summary["rules"][0]
+ assert disclosure["condition"].endswith(RULE["condition"]["prompt"])
+ assert disclosure["scope"] == "Read telemetry"
+ assert disclosure["delivery"] == "popup"
+ created = harness.create(BLUEPRINT)
+ assert created["ok"], created["errors"]
+ assert created["workflow"]["alert_rules"] == preview["workflow"]["alert_rules"]
+
+
+@pytest.mark.parametrize("condition,scope", [
+ ({"type": "run_status", "statuses": ["failed"]}, {"type": "final"}),
+ ({"type": "task_status", "statuses": ["failed"]}, {"type": "task", "task": 1}),
+ ({"type": "text_match", "mode": "contains_all", "values": ["ACTIVE", "FAULT"], "case_sensitive": True},
+ {"type": "any_task"}),
+ ({"type": "text_match", "mode": "regex", "pattern": "^FAULT:"}, {"type": "final"}),
+ ({"type": "file_sync", "outcome": "sync_failed"}, {"type": "final"}),
+ ({"type": "no_output"}, {"type": "any_task"}),
+ ({"type": "agent_signal", "signal_name": "fault", "min_severity": "high"}, {"type": "final"}),
+])
+def test_native_condition_variants_share_validation_and_accurate_disclosure(harness, condition, scope):
+ blueprint = copy.deepcopy(BLUEPRINT)
+ blueprint["alerts"]["rules"][0].update(condition=condition, scope=scope)
+ result = harness.dry_run(blueprint)
+ assert result["ok"], result["errors"]
+ summary = harness.call("blueprint_alert_summary", blueprint)
+ text = summary["rules"][0]["condition"]
+ if condition.get("case_sensitive"):
+ assert "case-sensitive" in text
+ if condition["type"] == "agent_signal":
+ assert "minimum severity: high" in text
+
+
+@pytest.mark.parametrize("change", [
+ {"delivery": "notify_only"},
+ {"require_acknowledgment": False},
+ {"scope": {"type": "task", "task": 2}},
+ {"scope": {"type": "task", "task": "raw-id"}},
+ {"scope": {"type": "any_task"}, "condition": {"type": "run_status", "statuses": ["failed"]}},
+ {"audience": "group"},
+ {"id": "raw-rule-id"},
+ {"sound": "louder"},
+ {"condition": {"type": "text_match", "mode": "regex", "pattern": "["}},
+])
+def test_invalid_blueprint_rules_fail_before_any_write(harness, change):
+ blueprint = copy.deepcopy(BLUEPRINT)
+ blueprint["alerts"]["rules"][0].update(change)
+ result = harness.dry_run(blueprint)
+ assert not result["ok"]
+ assert result["errors"]
+ assert harness.writes() == {}
+
+
+@pytest.mark.parametrize("alerts", [
+ {"mode": "rules", "rules": []},
+ {"mode": "rules", "rules": [RULE] * 21},
+ {"mode": "rules", "rules": [RULE], "severity": "low"},
+ {"mode": "every_run", "rules": [RULE]},
+ {"rules": [RULE]},
+])
+def test_rules_mode_is_explicit_bounded_and_not_mixed_with_legacy_options(harness, alerts):
+ result = harness.dry_run({**BLUEPRINT, "alerts": alerts})
+ assert not result["ok"]
+ assert harness.writes() == {}
+
+
+@pytest.mark.parametrize("matched", [True, False])
+def test_domain_faults_can_alert_after_successful_telemetry_acquisition(harness, matched):
+ preview = harness.dry_run(BLUEPRINT)
+ assert preview["ok"]
+ workflow = preview["workflow"]
+ rule_id = workflow["alert_rules"][0]["id"]
+ output = "Active fault detected" if matched else "No active alerts"
+ facts = build_workflow_alert_facts(
+ workflow, {"status": "completed", "success": True},
+ {"task_results": [{"task": workflow["tasks"][0], "status": "succeeded", "result": {"reply": output}}]},
+ )
+ prompts = []
+
+ def evaluate(prompt):
+ prompts.append(prompt)
+ return json.dumps({"results": [{"rule_id": rule_id, "matched": matched, "reason": "Synthetic evaluation"}]})
+
+ decision = evaluate_workflow_alert_rules(
+ workflow, facts, model_evaluator=evaluate,
+ )
+ assert len(prompts) == 1 and output in prompts[0]
+ assert decision["should_alert"] is matched
+ if matched:
+ assert decision["severity"] == "critical"
+ assert decision["require_acknowledgment"] is True
+ assert (decision["sound"], decision["size"], decision["delivery"]) == ("repeat", "large", "popup")
+
+
+def test_assist_prompt_distinguishes_supported_alerts_from_email_and_task_approval():
+ assert "update_alert_rule" in ASSIST_SYSTEM_PROMPT
+ assert "Alert acknowledgment is different from task approval" in ASSIST_SYSTEM_PROMPT
+ assert "not email" in ASSIST_SYSTEM_PROMPT
+ assert "read-only agent" in ASSIST_SYSTEM_PROMPT
+ assert 'size "large"' in ASSIST_SYSTEM_PROMPT
+
+
+if __name__ == "__main__":
+ raise SystemExit(pytest.main([__file__, "-q"]))
diff --git a/functional_tests/test_workflow_assist_field_parity.py b/functional_tests/test_workflow_assist_field_parity.py
index 069fe4043..3b5afb7d9 100644
--- a/functional_tests/test_workflow_assist_field_parity.py
+++ b/functional_tests/test_workflow_assist_field_parity.py
@@ -2,7 +2,7 @@
# test_workflow_assist_field_parity.py
"""
Functional test for the parity of the AI workflow assistant's field lists with the V2 editor.
-Version: 0.261.208
+Version: 0.261.315
Implemented in: 0.261.208
The assistant (functions_workflow_assist_editor.py) keeps its own copy of the lists that decide
@@ -239,11 +239,8 @@ def test_alert_operation_vocabulary_is_the_editors():
assert list(operations.ASSIST_ALERT_TEXT_MODES) == _const_list(source, "WORKFLOW_ALERT_TEXT_MATCH_MODES")
assert set(operations.ASSIST_ALERT_RUN_STATUSES) == set(_const_list(source, "WORKFLOW_ALERT_RUN_STATUSES"))
assert set(operations.ASSIST_ALERT_TASK_STATUSES) == set(_const_list(source, "WORKFLOW_ALERT_TASK_STATUSES"))
- # Every condition but File Sync's. Phase 4 (#1547) made personal File Sync editable, but the
- # assistant has no File Sync operations yet.
conditions = _const_list(source, "WORKFLOW_ALERT_CONDITION_TYPES")
- assert set(conditions) - set(operations.ASSIST_ALERT_CONDITIONS) == {"file_sync"}
- assert set(operations.ASSIST_ALERT_CONDITIONS) <= set(conditions)
+ assert set(operations.ASSIST_ALERT_CONDITIONS) == set(conditions)
scopeless = _string_set(source, "WORKFLOW_ALERT_SCOPELESS_CONDITIONS")
assert set(operations.ASSIST_SCOPELESS_CONDITIONS) == scopeless & set(operations.ASSIST_ALERT_CONDITIONS)
# An every-run alert names a real priority; 'none' is what "off" stores.
diff --git a/functional_tests/test_workflow_draft_v2_round_trip.py b/functional_tests/test_workflow_draft_v2_round_trip.py
index b5a23feb7..b135b68c6 100644
--- a/functional_tests/test_workflow_draft_v2_round_trip.py
+++ b/functional_tests/test_workflow_draft_v2_round_trip.py
@@ -2,7 +2,7 @@
#!/usr/bin/env python3
"""
Functional test for opening and re-saving workflows created from chat in the V2 editor.
-Version: 0.261.207
+Version: 0.261.315
Implemented in: 0.261.202
This test ensures that each workflow the draft service creates from a blueprint (a weekly
@@ -85,6 +85,18 @@
"sources": {"finance": {"scope_type": "group", "scope_id": GROUP_ID, "source_id": "finance-share"}},
}
+CRITICAL_MONITOR = {
+ "name": "Telemetry fault monitor",
+ "trigger": {"type": "interval", "unit": "hours", "value": 1},
+ "tasks": [{"title": "Read telemetry", "instructions": "Read telemetry and report actual active faults."}],
+ "alerts": {"mode": "rules", "rules": [{
+ "name": "Actual faults", "severity": "critical", "delivery": "popup",
+ "scope": {"type": "task", "task": 1},
+ "condition": {"type": "model_evaluation", "prompt": "Actual faults, not healthy or negated findings."},
+ "require_acknowledgment": True, "sound": "repeat", "size": "large",
+ }]},
+}
+
CASES = {
"weekly_agent_digest": (EMAIL_DIGEST, EMAIL_HANDLES),
"interval_model_check": (INTERVAL_CHECK, {}),
@@ -92,6 +104,7 @@
"personal_file_sync_review": (DOCUMENT_REVIEW, REVIEW_HANDLES),
"monthly_model_report": (MONTHLY_REPORT, {}),
"personal_file_sync_group_source": (GROUP_SOURCE_WATCH, GROUP_SOURCE_HANDLES),
+ "critical_monitor": (CRITICAL_MONITOR, {}),
}
# A save always records when it happened (both timestamps) and gets a new container etag; nothing
# else may change when nothing was edited.
@@ -159,6 +172,18 @@ def test_a_workflow_created_from_chat_opens_editable_in_v2(round_trip, name):
assert client[name]["readonly"] == ""
+def test_critical_monitor_retains_pop_up_options_and_task_scope_after_save(round_trip):
+ _client, saves = round_trip
+ before, after, error = saves["critical_monitor"]
+ assert error is None, error
+ assert after["alert_rules"] == before["alert_rules"]
+ rule = after["alert_rules"][0]
+ assert (rule["severity"], rule["delivery"], rule["require_acknowledgment"], rule["sound"], rule["size"]) == (
+ "critical", "popup", True, "repeat", "large",
+ )
+ assert rule["scope"]["task_id"] == after["tasks"][0]["id"]
+
+
@pytest.mark.parametrize("name", sorted(CASES))
def test_an_unchanged_v2_save_changes_nothing_but_the_save_time(round_trip, name):
_client, saves = round_trip
diff --git a/ui_tests/test_v2_orchestration_workflow_proposal_card.py b/ui_tests/test_v2_orchestration_workflow_proposal_card.py
index ae15b1512..4b31e45ab 100644
--- a/ui_tests/test_v2_orchestration_workflow_proposal_card.py
+++ b/ui_tests/test_v2_orchestration_workflow_proposal_card.py
@@ -1,7 +1,7 @@
# test_v2_orchestration_workflow_proposal_card.py
"""
Real-component browser tests for the workflow proposal card under an orchestration answer.
-Version: 0.261.302
+Version: 0.261.315
Implemented in: 0.261.207; merge task wording added in 0.261.241; merge kinds in 0.261.242; Word in 0.261.243; PowerPoint in 0.261.244
Next and last run on a created card: 0.261.251 (microsoft/simplechat#1546)
Microsoft 365 links stay in V2 Settings and V2 Approvals: 0.261.302
@@ -484,6 +484,32 @@ def text_of(locator):
return locator.evaluate("(element) => element.textContent")
+@pytest.mark.parametrize("theme,width", [("light", 1440), ("dark", 390)])
+def test_native_alert_attention_options_are_disclosed_before_approval(card_ui, theme, width):
+ page, api = card_ui
+ page.set_viewport_size({"width": width, "height": 900})
+ api.proposal["summary"]["alerts"] = {"mode": "rules", "rules": [{
+ "name": "Active fault", "enabled": True,
+ "condition": "Actual active faults; exclude healthy results and no active alerts.",
+ "scope": "Read telemetry", "severity": "critical", "delivery": "popup",
+ "require_acknowledgment": True, "sound": "repeat", "size": "large",
+ }]}
+ mount(page, api, theme=theme)
+ article = card(page)
+ for text in (
+ "Active fault", "Severity: critical", "Pop-up alert",
+ "Requires acknowledgment; returns until acknowledged",
+ "Sound repeats until acknowledged", "Full-screen size", "Looks at: Read telemetry",
+ "Sound follows each device's preferences.",
+ ):
+ expect(article).to_contain_text(text)
+ expect(article).not_to_contain_text("never pops up")
+ expect(article.locator("b")).to_have_count(0)
+ expect(article.get_by_role("button", name="Create paused", exact=True)).to_be_enabled()
+ assert api.writes() == []
+ assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
+
+
@pytest.mark.parametrize("theme,width", [("light", 1440), ("dark", 390)])
def test_pending_card_discloses_everything_and_renders_planner_text_inert(card_ui, theme, width):
page, api = card_ui