From bb362ebdedb9dd600cfbd8b64e1f15557dfde85a Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 8 Oct 2026 11:24:29 -0400 Subject: [PATCH 1/3] Decide Microsoft 365 approvals in the chat and tidy orchestration retries A plan step that had to read more of a SharePoint or OneDrive file than a quick read covers stopped for the user's extended-analysis approval and only linked to Approvals. The stopped message now offers the decision inline (Allow this time, Always allow, Quick read only) and continues the plan once it is saved. - Carry the pending approval id on m365_approval_required failures, keeping only application-minted ids. - Retry a Use an action step that stopped for Microsoft 365 without the external- effect confirmation; plan steps only read Microsoft 365 data, and the server enforces it. Agent steps and other failures still ask. - Close a retry confirmation as soon as the retry is admitted. - Hide an attempt that a later retry replaced, leave it out of model history and exports, and drop the attempt notice from successful retries; the attempt links move to Message details. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../functions_orchestration_attempts.py | 62 ++++ .../functions_orchestration_context.py | 12 +- .../functions_orchestration_recovery.py | 38 ++- .../functions_orchestration_schema.py | 30 +- application/single_app/route_backend_chats.py | 4 + .../route_backend_conversation_export.py | 3 + .../chat/M365ApprovalInlineCard.tsx | 250 +++++++++++++++ .../src/components/chat/MessageInspector.tsx | 39 ++- .../v2_ui/src/components/chat/MessageList.tsx | 11 +- .../chat/OrchestrationRecoveryNotice.tsx | 73 ++++- application/v2_ui/src/lib/orchestration.ts | 45 +++ .../v2_ui/src/lib/orchestrationController.ts | 26 +- docs/admin/orchestration.md | 30 +- ...HESTRATION_M365_APPROVAL_RETRY_FLOW_FIX.md | 213 +++++++++++++ docs/explanation/release_notes.md | 20 ++ docs/reference/chat-controls.md | 12 +- .../test_analyze_backend_saved_integration.py | 2 + .../test_content_screening_history.py | 3 + ..._orchestration_m365_approval_retry_flow.py | 256 +++++++++++++++ ...lts_container_access_chat_orchestration.py | 3 + ...t_v2_orchestration_m365_inline_approval.py | 295 ++++++++++++++++++ .../test_v2_orchestration_recovery_backend.py | 17 +- 23 files changed, 1404 insertions(+), 42 deletions(-) create mode 100644 application/single_app/functions_orchestration_attempts.py create mode 100644 application/v2_ui/src/components/chat/M365ApprovalInlineCard.tsx create mode 100644 docs/explanation/fixes/ORCHESTRATION_M365_APPROVAL_RETRY_FLOW_FIX.md create mode 100644 functional_tests/test_orchestration_m365_approval_retry_flow.py create mode 100644 ui_tests/test_v2_orchestration_m365_inline_approval.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 143f847b8..ef6a7d47c 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.301" +VERSION = "0.261.302" 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_attempts.py b/application/single_app/functions_orchestration_attempts.py new file mode 100644 index 000000000..15038bd26 --- /dev/null +++ b/application/single_app/functions_orchestration_attempts.py @@ -0,0 +1,62 @@ +# functions_orchestration_attempts.py +"""Which saved orchestration attempts a later retry replaced. + +Retrying a failed plan saves the retry's answer as a new assistant message and keeps the +earlier attempt's message, so the conversation still records what happened. Once the +retry's message exists, the earlier attempt no longer describes the request: its "could +not be completed" text is history, not an answer. Readers that send stored messages to a +model, or export them for the user, drop it with these helpers. The V2 thread hides it the +same way (``lib/orchestration.ts`` ``supersededOrchestrationRunIds``). + +Only the standard library is imported, so any reader can use this module without pulling +orchestration storage into its import graph. + +Version: 0.261.302 +Implemented in: 0.261.302 +""" + +from collections.abc import Mapping + + +def _attempt_lineage(message): + """The run id and the retried run id a saved message records, either of which may be None.""" + metadata = message.get('metadata') if isinstance(message, Mapping) else None + orchestration = metadata.get('orchestration') if isinstance(metadata, Mapping) else None + if not isinstance(orchestration, Mapping): + return None, None + run_id = orchestration.get('run_id') + retry_of = orchestration.get('retry_of_run_id') + return ( + run_id if isinstance(run_id, str) and run_id else None, + retry_of if isinstance(retry_of, str) and retry_of else None, + ) + + +def superseded_orchestration_run_ids(messages): + """The run ids of attempts that a later attempt among ``messages`` retried.""" + superseded = set() + for message in messages or (): + run_id, retry_of = _attempt_lineage(message) + if retry_of and retry_of != run_id: + superseded.add(retry_of) + return superseded + + +def is_superseded_orchestration_attempt(message, superseded): + """Whether ``message`` is the assistant answer of an attempt in ``superseded``.""" + if not superseded or not isinstance(message, Mapping) or message.get('role') != 'assistant': + return False + run_id, _retry_of = _attempt_lineage(message) + return bool(run_id and run_id in superseded) + + +def exclude_superseded_orchestration_attempts(messages): + """Return the messages without replaced attempts' answers, preserving their order.""" + messages = list(messages or ()) + superseded = superseded_orchestration_run_ids(messages) + if not superseded: + return messages + return [ + message for message in messages + if not is_superseded_orchestration_attempt(message, superseded) + ] diff --git a/application/single_app/functions_orchestration_context.py b/application/single_app/functions_orchestration_context.py index e42f1270b..e8cefb233 100644 --- a/application/single_app/functions_orchestration_context.py +++ b/application/single_app/functions_orchestration_context.py @@ -43,6 +43,9 @@ from functions_message_block_revisions import resolve_block_sources_in_content from functions_message_deletion import is_soft_deleted_message from functions_message_masking import remove_masked_content +from functions_orchestration_attempts import ( + is_superseded_orchestration_attempt, superseded_orchestration_run_ids, +) from functions_orchestration_registry import ( CAPABILITY_ACTION_INVOKE, CAPABILITY_AGENT_INVOKE, @@ -1838,8 +1841,13 @@ def conversation_snapshot_size(snapshot): def build_conversation_snapshot(messages, settings=None, *, turn_id=None, truncated=False): - """Keep the recent eligible conversation, bounded independently of the current request.""" + """Keep the recent eligible conversation, bounded independently of the current request. + + An attempt a later retry replaced is left out: its failure text no longer describes the + request, and the retry's own answer is what the conversation now says. + """ eligible = [] + superseded = superseded_orchestration_run_ids(messages) for message in messages or (): if not isinstance(message, dict): continue @@ -1847,6 +1855,8 @@ def build_conversation_snapshot(messages, settings=None, *, turn_id=None, trunca orchestration = (metadata.get('orchestration') or {}) if isinstance(metadata, dict) else {} if turn_id and isinstance(orchestration, dict) and orchestration.get('turn_id') == turn_id: continue + if is_superseded_orchestration_attempt(message, superseded): + continue normalized = normalize_history_message(message) if normalized is not None: if not normalized['id']: diff --git a/application/single_app/functions_orchestration_recovery.py b/application/single_app/functions_orchestration_recovery.py index f47e7cefd..18efe92d8 100644 --- a/application/single_app/functions_orchestration_recovery.py +++ b/application/single_app/functions_orchestration_recovery.py @@ -1,7 +1,7 @@ # functions_orchestration_recovery.py """Execution leases and explicitly requested, checkpoint-only retry attempts. -Version: 0.261.293 +Version: 0.261.302 Retry publication is one transactional parent CAS + child create. It never replans, invokes an adapter, or changes plan-revision lineage. Terminal publication preserves an administrator's reply retraction; its probe @@ -11,6 +11,8 @@ file admissions, because preparing the retry supersedes the parent's files. A retry runs again a step that finished without the chart or diagram the plan asked of it, and asks for confirmation when that step can have external effects (0.261.293). +An action step that stopped for Microsoft 365 sign-in, approval or policy is retried without +that confirmation: a plan step only reads Microsoft 365 data (0.261.302). A retry that could only resend requests a service declined is not offered. A run from the removed legacy contract is never retried, resumed or continued; only conversation deletion still reads it, to remove its saved data. @@ -37,11 +39,12 @@ from functions_orchestration_plan_revisions import PlanRevisionError, read_revision_run from functions_orchestration_output_store import build_output_cleanup_intent from functions_orchestration_registry import ( - CAPABILITY_WORKFLOW_RESULTS, admitted_export_pairs, external_effect_capability_ids, get_capability, + CAPABILITY_ACTION_INVOKE, CAPABILITY_WORKFLOW_RESULTS, admitted_export_pairs, external_effect_capability_ids, + get_capability, ) from functions_orchestration_schema import ( - LEGACY_PLAN_CODE, LEGACY_PLAN_MESSAGE, PlanValidationError, build_failure, build_step_result, - failure_repeats_on_retry, is_legacy_plan, safe_failure, step_input_specs, summarize_plan, + LEGACY_PLAN_CODE, LEGACY_PLAN_MESSAGE, M365_STEP_FAILURE_CODES, PlanValidationError, build_failure, + build_step_result, failure_repeats_on_retry, is_legacy_plan, safe_failure, step_input_specs, summarize_plan, ) from functions_orchestration_result_contracts import ProducerIdentity, ResultContractError, ResultRef, TaskResult from functions_orchestration_result_runtime import ( @@ -71,6 +74,28 @@ def _retained_statuses(record): return {'completed', 'partial'} +def _stopped_without_effects(step): + """Whether a failed step is known to have changed nothing outside the plan. + + An action step that stopped for Microsoft 365 sign-in, approval or policy ran only its + one Microsoft 365 action, and a plan step can only read Microsoft 365 data: + ``functions_m365_runtime.step_m365_context`` removes the send, invite and read-state + functions. Retrying it repeats no change, so it needs no confirmation. + """ + failure = step.get('failure') + return ( + step.get('capability_id') == CAPABILITY_ACTION_INVOKE + and isinstance(failure, dict) and failure.get('code') in M365_STEP_FAILURE_CODES + ) + + +def _effects_uncertain(step): + return ( + step.get('capability_id') in EFFECT_CAPABILITIES and bool(step.get('effects_uncertain')) + and not _stopped_without_effects(step) + ) + + def _redraw_step_ids(record): """Steps that finished without the chart or diagram the plan asked of them. @@ -461,10 +486,7 @@ def recovery_projection(record): if step.get('enabled', True) and step['step_id'] not in reused ] repeats_refusal = _retry_repeats_refusal(record, steps, retry) - uncertain = any( - step.get('capability_id') in EFFECT_CAPABILITIES and step.get('effects_uncertain') - for step in steps - ) or any( + uncertain = any(_effects_uncertain(step) for step in steps) or any( # A step run again to draw a missing chart repeats what it did the first time. step.get('capability_id') in EFFECT_CAPABILITIES and step['step_id'] in retry and step['step_id'] in _redraw_step_ids(record) diff --git a/application/single_app/functions_orchestration_schema.py b/application/single_app/functions_orchestration_schema.py index b2024fd71..9663f2cbd 100644 --- a/application/single_app/functions_orchestration_schema.py +++ b/application/single_app/functions_orchestration_schema.py @@ -33,9 +33,10 @@ render through the very same card. Our own paging lives in a sibling ``ui_hints`` field rather than inside the schema, which keeps the schema itself MCP-clean. -Version: 0.261.238 +Version: 0.261.302 Missing signed-in session reported as its own step failure in: 0.261.209 Microsoft 365 step failures, with their sources, added in: 0.261.238 +Microsoft 365 approval stops carry their pending approval id in: 0.261.302 """ import hashlib @@ -1881,8 +1882,23 @@ def summarize_plan(plan): INTEGRATION_CAPABILITIES = frozenset({'action_invoke', 'agent_invoke'}) +_M365_APPROVAL_ID_PREFIX = 'm365-' +_M365_APPROVAL_ID_DIGITS = frozenset('0123456789abcdef') + + +def _m365_approval_id(value): + """An application-minted Microsoft 365 approval id (``m365-`` and a SHA-256 hex digest), or None.""" + if type(value) is not str or len(value) != len(_M365_APPROVAL_ID_PREFIX) + 64: + return None + if not value.startswith(_M365_APPROVAL_ID_PREFIX): + return None + digest = value[len(_M365_APPROVAL_ID_PREFIX):] + return value if set(digest) <= _M365_APPROVAL_ID_DIGITS else None + + def build_failure( code='step_failed', *, step_id=None, capability_id=None, provider_status=None, m365_sources=None, + approval_id=None, ): """Only application-owned text may cross a failure boundary.""" code = code if code in FAILURE_MESSAGES else 'step_failed' @@ -1900,6 +1916,12 @@ def build_failure( sources = sorted({source for source in m365_sources if source in M365_SOURCES}) if sources: result['m365_sources'] = sources + if code == 'm365_approval_required': + # The chat offers this pending approval inline. The approvals API still checks that + # the signed-in user is its subject before showing or deciding it. + approval = _m365_approval_id(approval_id) + if approval: + result['approval_id'] = approval return result @@ -1910,6 +1932,7 @@ def safe_failure(value, *, step_id=None, capability_id=None): capability_id=capability_id or value.get('capability_id'), provider_status=value.get('provider_status'), m365_sources=value.get('m365_sources'), + approval_id=value.get('approval_id'), ) @@ -1917,7 +1940,10 @@ def failure_from_exception(exc, *, answering=False, _depth=0): """Use types and structured status, never diagnostic prose or model content.""" code = getattr(exc, 'orchestration_failure_code', None) if type(code) is str and code in EXCEPTION_FAILURE_CODES: - return build_failure(code, m365_sources=getattr(exc, 'm365_sources', None)) + return build_failure( + code, m365_sources=getattr(exc, 'm365_sources', None), + approval_id=getattr(exc, 'approval_id', None), + ) if isinstance(exc, ModelCatalogError): return build_failure('model_routing_changed') status = getattr(exc, 'status_code', None) diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index 8c17a76f0..d238140f5 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -352,6 +352,7 @@ exclude_soft_deleted_messages, is_soft_deleted_message, ) +from functions_orchestration_attempts import exclude_superseded_orchestration_attempts from functions_message_visual_styles import ( UNSET as VISUAL_STYLE_HEIGHT_UNSET, VisualStyleError, @@ -28379,6 +28380,9 @@ def build_conversation_history_segments( # them. A deleted message must not reach the summary or the recent window, nor take a slot # in the history limit; its mask is only a fail-safe, not what keeps it out. ordered_messages = exclude_soft_deleted_messages(ordered_messages) + # An orchestration attempt a later retry replaced is history, not the conversation: its + # failure text would otherwise reach the model on every later turn. + ordered_messages = exclude_superseded_orchestration_attempts(ordered_messages) total_messages = len(ordered_messages) num_recent_messages = min(total_messages, conversation_history_limit) diff --git a/application/single_app/route_backend_conversation_export.py b/application/single_app/route_backend_conversation_export.py index 1b2802ab2..861c1ff06 100644 --- a/application/single_app/route_backend_conversation_export.py +++ b/application/single_app/route_backend_conversation_export.py @@ -40,6 +40,7 @@ ) from functions_mermaid_export import extract_mermaid_sources from functions_message_block_revisions import resolve_block_sources_in_content +from functions_orchestration_attempts import exclude_superseded_orchestration_attempts from functions_mermaid_server_render import ( is_mermaid_server_rendering_available, render_mermaid_visual_assets, @@ -673,6 +674,8 @@ def _build_export_entry( filtered_messages = sanitize_saved_analysis_messages(filtered_messages, user_id) filtered_messages = hydrate_agent_citations_from_artifacts(filtered_messages, artifact_payload_map) filtered_messages = public_history_messages(filtered_messages, user_id) + # The thread shows only the latest attempt of a retried plan; the export matches it. + filtered_messages = exclude_superseded_orchestration_attempts(filtered_messages) ordered_messages = sort_messages_by_thread(filtered_messages) raw_thoughts = [] if is_collaboration_conversation(conversation) else get_thoughts_for_conversation(conversation.get('id'), user_id) diff --git a/application/v2_ui/src/components/chat/M365ApprovalInlineCard.tsx b/application/v2_ui/src/components/chat/M365ApprovalInlineCard.tsx new file mode 100644 index 000000000..4fd1d8d58 --- /dev/null +++ b/application/v2_ui/src/components/chat/M365ApprovalInlineCard.tsx @@ -0,0 +1,250 @@ +// M365ApprovalInlineCard.tsx +// The Microsoft 365 approval a stopped plan step is waiting on, decided in the conversation. +// +// A plan step that has to read more of a SharePoint or OneDrive file than a quick read covers +// stops for the user's extended-analysis approval. Before this card, the user had to leave for +// Approvals, decide there, come back and retry. Here the same decision is posted to the same +// approvals API, and choosing an option continues the plan: the retry reuses every completed +// step and runs only the work that stopped. The server still decides who may approve, and the +// retry is matched to this approval by the step's stable request id. + +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Loader2 } from 'lucide-react'; +import { GlassButton } from '../ui/primitives'; +import { + canDecideM365, + decideM365Approval, + errorMessage, + fetchM365Approval, + type M365Approval, +} from '../../lib/approvalsApi'; +import { M365_APPROVALS_HREF } from '../../lib/m365Connect'; + +const SOURCE_NAMES: Record = { + calendar: 'Calendar', + email: 'Email', + onedrive: 'OneDrive', + spo: 'SharePoint', +}; + +/** Where the personal "Extended analysis" preference for each file source is saved. */ +const PREFERENCES_PATH = '/settings?tab=preferences'; + +type Phase = 'loading' | 'ready' | 'saving' | 'continuing' | 'unavailable'; + +function sourceName(approval: M365Approval): string { + const sources = Object.keys(approval.sources ?? {}); + return sources.length === 1 ? SOURCE_NAMES[sources[0]] ?? 'Microsoft 365' : 'Microsoft 365'; +} + +/** A proposal count the server recorded, or null when it is missing or not a safe count. */ +function proposalCount(approval: M365Approval, key: string): number | null { + const value = approval.proposal?.[key]; + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : null; +} + +function formatBytes(bytes: number): string { + const megabytes = bytes / (1024 * 1024); + return megabytes >= 1 ? `${megabytes.toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB`; +} + +/** Why the step stopped, in the user's terms, from the counts the approval recorded. */ +function readingSummary(approval: M365Approval): string { + const tokens = proposalCount(approval, 'context_tokens'); + const files = proposalCount(approval, 'file_count'); + const bytes = proposalCount(approval, 'total_bytes'); + const parts: string[] = []; + if (tokens) parts.push(`about ${tokens.toLocaleString()} tokens of file content`); + if (files) parts.push(`${files.toLocaleString()} ${files === 1 ? 'file' : 'files'}`); + if (bytes) parts.push(formatBytes(bytes)); + const detail = parts.length ? ` (${parts.join(', ')})` : ''; + return `To answer, this step needs to read more${detail} than a quick read covers.`; +} + +function savedChoice(approval: M365Approval, source: string): string { + if (approval.analysis_choice === 'always') return `Deeper reads are always allowed for ${source}.`; + if (approval.analysis_choice === 'fast') return 'A quick read was chosen for this request.'; + if (approval.analysis_choice === 'request' || approval.status === 'approved') { + return 'A deeper read was allowed for this request.'; + } + return 'Your choice for this request is saved.'; +} + +export function M365ApprovalInlineCard({ + approvalId, + stepTitle, + canContinue, + continuing, + onContinue, +}: { + approvalId: string; + /** The plan step that stopped, for the card's explanation. */ + stepTitle?: string; + /** Whether the stopped attempt can be continued now. */ + canContinue: boolean; + /** True while a retry is being prepared or the conversation is busy. */ + continuing: boolean; + /** Continue the plan from the step that stopped. */ + onContinue: () => Promise | void; +}) { + const [approval, setApproval] = useState(null); + const [phase, setPhase] = useState('loading'); + const [error, setError] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + setPhase('loading'); + setError(null); + fetchM365Approval(approvalId, controller.signal).then( + (next) => { + if (controller.signal.aborted) return; + setApproval(next); + setPhase('ready'); + }, + () => { + if (!controller.signal.aborted) setPhase('unavailable'); + }, + ); + return () => controller.abort(); + }, [approvalId]); + + const proceed = async () => { + setPhase('continuing'); + try { + await onContinue(); + } finally { + setPhase('ready'); + } + }; + + const choose = async (choice: 'request' | 'always' | 'fast') => { + setError(null); + setPhase('saving'); + try { + setApproval(await decideM365Approval(approvalId, { choice })); + } catch (cause) { + setError(errorMessage(cause, 'Your choice could not be saved. Try again, or decide it in Approvals.')); + setPhase('ready'); + return; + } + if (canContinue) { + await proceed(); + } else { + setPhase('ready'); + } + }; + + const approvalsLink = ( + + Review the Microsoft 365 approval + + ); + + if (phase === 'loading') { + return ( +

+ + Loading the Microsoft 365 approval... +

+ ); + } + + if (phase === 'unavailable' || !approval || approval.request_type !== 'm365_extended_analysis') { + // Another kind of approval, or one this card can't open: decide it in Approvals. + return ( +
+

{approvalsLink}, then continue.

+ {canContinue ? ( + void proceed()}> + Retry from failed step + + ) : null} +
+ ); + } + + const source = sourceName(approval); + const busy = phase === 'saving' || phase === 'continuing' || continuing; + const pending = canDecideM365(approval); + const decided = !pending && ['approved', 'denied', 'executed'].includes(String(approval.status)); + + return ( +
+

+ {pending ? `${source} needs your OK to read more` : `${source} approval`} +

+ {pending ? ( + <> +

+ {stepTitle ? <>{stepTitle}: : null} + {readingSummary(approval)} +

+
+ {approval.can_approve === true ? ( + <> + void choose('request')} + > + Allow this time + + void choose('always')} + > + Always allow for {source} + + + ) : null} + {approval.can_deny === true ? ( + void choose('fast')} + > + Quick read only + + ) : null} +
+

+ A quick read answers from what fits and says what it left out. You can change this + any time in{' '} + + Settings + + . +

+ + ) : decided ? ( +
+ {savedChoice(approval, source)} + {canContinue ? ( + void proceed()} + > + Continue + + ) : null} +
+ ) : ( +

This approval is no longer pending. {approvalsLink} or ask again.

+ )} + {busy ? ( +

+ + {phase === 'saving' ? 'Saving your choice...' : 'Continuing the plan...'} +

+ ) : null} + {error ?

{error}

: null} +
+ ); +} diff --git a/application/v2_ui/src/components/chat/MessageInspector.tsx b/application/v2_ui/src/components/chat/MessageInspector.tsx index f5598db7d..ef2c7e065 100644 --- a/application/v2_ui/src/components/chat/MessageInspector.tsx +++ b/application/v2_ui/src/components/chat/MessageInspector.tsx @@ -25,6 +25,8 @@ import { type DetailGroup, type MessageSources, } from '../../lib/messageDetails'; +import { normalizeOrchestrationAttempt } from '../../lib/orchestration'; +import { openOrchestrationRecovery } from '../../lib/orchestrationController'; import type { ChatMessage, Json, PersistedThought } from '../../lib/types'; import { buildToolResultView, type RowMode } from '../../lib/agentCitationRows'; import { GlassButton } from '../ui/primitives'; @@ -61,6 +63,36 @@ function Failed({ message }: { message: string }) { ); } +/** + * Where a retried plan's answer came from. + * + * The thread shows only the latest attempt of a retried plan, and its answer carries no + * retry notice. The saved attempts stay reachable here, for anyone who wants the history. + */ +function AttemptDetails({ message }: { message: ChatMessage }) { + const attempt = normalizeOrchestrationAttempt(message); + const conversationId = String(message.conversation_id ?? '').trim(); + const previousRunId = attempt.retry_of_run_id; + if (!attempt.run_id || !previousRunId || !conversationId) return null; + const runId = attempt.run_id; + return ( +
+ + {attempt.attempt_index ? `Attempt ${attempt.attempt_index}. ` : ''} + This answer came from retrying a plan that stopped earlier. + + openOrchestrationRecovery(conversationId, previousRunId)}> + View previous attempt + + openOrchestrationRecovery(conversationId, runId)}> + Review saved attempt + +
+ ); +} + function DetailsSection({ message }: { message: ChatMessage }) { const [groups, setGroups] = useState(null); const [error, setError] = useState(null); @@ -404,7 +436,12 @@ export function MessageInspector({
- {active === 'details' && } + {active === 'details' && ( + <> + + + + )} {active === 'sources' && upload && ( conversationId && fileId ? ( { // A workflow run's own post is replaced by its mirrored reply, which says the same // with the maps and sources attached. It stays loaded, so a reply quoting it still - // resolves. - const shown = messages.filter((message) => !isSupersededByWorkflowReply(message)); + // resolves. An orchestration attempt a later retry replaced is hidden the same way: + // the thread shows the latest attempt, and the earlier one stays saved for the record. + const supersededRuns = supersededOrchestrationRunIds(messages); + const shown = messages.filter((message) => !isSupersededByWorkflowReply(message) + && !isSupersededOrchestrationAttempt(message, supersededRuns)); const grouped = groupProposalImages(shown); if (grouped.size === 0) { return { threadMessages: shown, proposalImagesByMessage: grouped }; diff --git a/application/v2_ui/src/components/chat/OrchestrationRecoveryNotice.tsx b/application/v2_ui/src/components/chat/OrchestrationRecoveryNotice.tsx index aac5ef04d..c46dc78f0 100644 --- a/application/v2_ui/src/components/chat/OrchestrationRecoveryNotice.tsx +++ b/application/v2_ui/src/components/chat/OrchestrationRecoveryNotice.tsx @@ -17,6 +17,7 @@ import { type OrchestrationPlan, type PlanStatus, } from '../../lib/orchestration'; import { legacyPlanErrorMessage } from '../../lib/orchestrationErrors'; +import { M365ApprovalInlineCard } from './M365ApprovalInlineCard'; import { OrchestrationM365Notice } from './OrchestrationM365Notice'; function RecoveryConfirmation({ @@ -89,8 +90,12 @@ export function OrchestrationRecoveryNotice({ const failed = !waiting && (outcome === 'failed' || outcome === 'partial' || outcome === 'cancelled'); const newer = attempt.recovery?.current_run_id || attempt.latest_attempt_run_id; const previousRunId = attempt.retry_of_run_id; + // A retry that finished needs no notice: its answer is the reply, and the attempt it + // replaced is hidden from the thread. Its saved attempts stay reachable from the message's + // details. A prepared retry that has not run yet still offers to run it. + const preparedRetry = Boolean(attempt.retry_of_run_id) && (outcome === 'awaiting_approval' || outcome === 'approved'); const relevant = failed || waiting || saved?.transportUnknown || saved?.error - || Boolean(newer && newer !== runId) || Boolean(attempt.retry_of_run_id); + || Boolean(newer && newer !== runId) || preparedRetry; const currentPlan = saved?.plan ?? plan; const recovery = attempt.recovery; const fileOutputs = Boolean(attempt.outputs?.length); @@ -120,6 +125,56 @@ export function OrchestrationRecoveryNotice({ setConfirmationVersion(result.confirmationRequired && result.version ? result.version : null); }; const titleFor = (stepId: string) => currentPlan?.steps.find((step) => step.step_id === stepId)?.title || stepId; + const allFailures = [...(attempt.failure ? [attempt.failure] : []), ...(attempt.failures ?? [])]; + const replaced = Boolean(newer && newer !== runId); + const approvalFailure = allFailures.find( + (failure) => failure.code === 'm365_approval_required' && Boolean(failure.approval_id), + ); + const inlineApproval = failed && !replaced && !fileOutputs && !saved?.transportUnknown && !legacy + ? approvalFailure : undefined; + const confirmation = confirmationVersion ? ( + void retry(confirmationVersion)} + onClose={() => setConfirmationVersion(null)} + /> + ) : null; + + if (replaced && !waiting && !saved?.transportUnknown && !saved?.error) { + // The newer attempt carries the outcome now; its message replaces this one once saved. + return ( +
+

A newer attempt of this plan exists.

+ openOrchestrationRecovery(conversationId, newer as string)}> + View current attempt + +
+ ); + } + + if (inlineApproval?.approval_id) { + const stepTitle = inlineApproval.step_id + ? currentPlan?.steps.find((step) => step.step_id === inlineApproval.step_id)?.title + : undefined; + return ( +
+ retry()} + /> + {saved?.error ?

{saved.error}

: null} + openOrchestrationRecovery(conversationId, runId)}> + Review saved attempt + + {confirmation} +
+ ); + } return (
{attempt.failure?.message ?

{attempt.failure.message}

: null} {saved?.error ?

{saved.error}

: null} - {failed && !saved?.transportUnknown && !legacy && (!newer || newer === runId) ? ( - + {failed && !saved?.transportUnknown && !legacy && !replaced ? ( + ) : null} {waiting ? (

@@ -162,7 +215,7 @@ export function OrchestrationRecoveryNotice({

Execute on retry: {recovery.retry_step_ids.map(titleFor).join(', ')}.

) : null}
- {!fileOutputs && attempt.retry_of_run_id && (outcome === 'awaiting_approval' || outcome === 'approved') ? ( + {!fileOutputs && preparedRetry ? ( void runPreparedOrchestrationRetry(conversationId, runId)}> Run prepared retry @@ -205,13 +258,7 @@ export function OrchestrationRecoveryNotice({ ) : null}
- {confirmationVersion ? ( - void retry(confirmationVersion)} - onClose={() => setConfirmationVersion(null)} - /> - ) : null} + {confirmation}
); } diff --git a/application/v2_ui/src/lib/orchestration.ts b/application/v2_ui/src/lib/orchestration.ts index 1cd598bf5..810c6763d 100644 --- a/application/v2_ui/src/lib/orchestration.ts +++ b/application/v2_ui/src/lib/orchestration.ts @@ -70,6 +70,8 @@ export interface OrchestrationFailure { provider_status?: number; /** The Microsoft 365 sources a sign-in or approval stop needs, when the step reported them. */ m365_sources?: string[]; + /** The pending Microsoft 365 approval an `m365_approval_required` stop is waiting on. */ + approval_id?: string; } export interface OrchestrationRecovery { @@ -114,6 +116,9 @@ function recordOf(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } +/** Microsoft 365 approval ids are minted by the server as `m365-` and a SHA-256 hex digest. */ +const M365_APPROVAL_ID = /^m365-[0-9a-f]{64}$/; + export function normalizeOrchestrationFailure(value: unknown): OrchestrationFailure | null { const data = recordOf(value); if (typeof data.code !== 'string' || typeof data.message !== 'string') return null; @@ -126,6 +131,9 @@ export function normalizeOrchestrationFailure(value: unknown): OrchestrationFail ...(Array.isArray(data.m365_sources) ? { m365_sources: data.m365_sources.filter((source): source is string => typeof source === 'string') } : {}), + ...(data.code === 'm365_approval_required' && typeof data.approval_id === 'string' + && M365_APPROVAL_ID.test(data.approval_id) + ? { approval_id: data.approval_id } : {}), }; } @@ -194,6 +202,43 @@ export function normalizeOrchestrationAttempt(value: unknown): OrchestrationAtte }; } +/** + * The run id and the retried run id an assistant message records, read the way + * `normalizeOrchestrationAttempt` reads them: top-level fields win over `metadata.orchestration`. + */ +function attemptLineage(message: unknown): { runId?: string; retryOf?: string } { + const outer = recordOf(message); + const saved = recordOf(recordOf(outer.metadata).orchestration); + const text = (key: string): string | undefined => { + const value = outer[key] ?? saved[key]; + return typeof value === 'string' && value ? value : undefined; + }; + return { runId: text('run_id'), retryOf: text('retry_of_run_id') }; +} + +/** + * The runs a later attempt in this thread replaced. + * + * A retry saves its answer as a new assistant message and keeps the earlier attempt's message + * for the record. Once the retry's message is in the thread, the earlier attempt no longer + * describes the request: its failure and its retry controls are history, not the answer. + */ +export function supersededOrchestrationRunIds(messages: readonly unknown[]): Set { + const superseded = new Set(); + for (const message of messages) { + const { runId, retryOf } = attemptLineage(message); + if (retryOf && retryOf !== runId) superseded.add(retryOf); + } + return superseded; +} + +/** Whether an assistant message is an attempt that a later retry in the thread replaced. */ +export function isSupersededOrchestrationAttempt(message: unknown, superseded: ReadonlySet): boolean { + if (superseded.size === 0 || recordOf(message).role !== 'assistant') return false; + const { runId } = attemptLineage(message); + return Boolean(runId && superseded.has(runId)); +} + /** A terminal execution status can precede durable final-message publication. */ export function isOrchestrationRunPending(run: OrchestrationAttempt & { status?: string | null }): boolean { return run.status === 'running' || isOrchestrationRunWaiting(run) || run.finalization_status === 'pending' diff --git a/application/v2_ui/src/lib/orchestrationController.ts b/application/v2_ui/src/lib/orchestrationController.ts index 68ef2d707..f1ba41836 100644 --- a/application/v2_ui/src/lib/orchestrationController.ts +++ b/application/v2_ui/src/lib/orchestrationController.ts @@ -950,6 +950,24 @@ const reconciliationTimers = new Map>(); const recoverySubmissions = new Map(); const recoveryLocks = new Set(); +/** + * Start a prepared retry without waiting for it to finish. + * + * `executeSavedPlan` claims the run and enters the streaming state before its first await, so + * the conversation stays busy, and no second retry can start, while the run streams into the + * thread. The retry's confirmation and its button are released as soon as the run is admitted + * instead of staying open until the whole run finishes. + */ +function launchSavedPlan(conversationId: string, plan: OrchestrationPlan): void { + void executeSavedPlan(conversationId, plan.turn_id, plan).catch(() => { + useOrchestrationStore.getState().updateRunRecovery(plan.run_id, { + transportUnknown: true, + error: 'The retry started, but its progress could not be followed here. Checking saved status; no retry will start again.', + }); + void reconcileOrchestrationRun(conversationId, plan.run_id); + }); +} + /** * Stop tracking a run the server refuses as an earlier orchestration version. * @@ -1093,7 +1111,7 @@ export async function retryOrchestrationRun( conversationId: string, runId: string, confirmedVersion?: string, -): Promise<{ confirmationRequired?: boolean; version?: string }> { +): Promise<{ confirmationRequired?: boolean; version?: string; started?: boolean }> { const key = scopeKey(conversationId, runId); const store = useOrchestrationStore.getState(); if (recoveryLocks.has(key)) return {}; @@ -1168,8 +1186,8 @@ export async function retryOrchestrationRun( current.setActiveTurn(conversationId, plan.turn_id); current.pinRun(null); useOrchestrationStore.setState({ recoveryTarget: null }); - await executeSavedPlan(conversationId, plan.turn_id, plan); - return {}; + launchSavedPlan(conversationId, plan); + return { started: true }; } catch (error) { const legacyMessage = legacyPlanErrorMessage(error); if (legacyMessage) { @@ -1228,7 +1246,7 @@ export async function runPreparedOrchestrationRetry(conversationId: string, runI current.setPlan(conversationId, plan.turn_id, plan); current.setActiveTurn(conversationId, plan.turn_id); useOrchestrationStore.setState({ recoveryTarget: null }); - await executeSavedPlan(conversationId, plan.turn_id, plan); + launchSavedPlan(conversationId, plan); } catch (error) { const legacyMessage = legacyPlanErrorMessage(error); if (legacyMessage) { diff --git a/docs/admin/orchestration.md b/docs/admin/orchestration.md index 30d890169..ee9e289a0 100644 --- a/docs/admin/orchestration.md +++ b/docs/admin/orchestration.md @@ -447,10 +447,18 @@ limits: Each request and source is recorded as a `shared_by_request` audit event. Workflow Run as approvals, and approvals to share earlier answers' Microsoft 365 history, still ask. Earlier versions stopped the step before it read anything. -- **Approvals don't resume plans.** A step that needs an approval, such as deeper file - analysis, stops and links to Approvals. The user decides there and then selects **Retry - from failed step**. A retried step reuses its request, so the decision applies while - the action is unchanged. +- **Approvals are decided in the conversation.** A step that needs an approval, such as + deeper file analysis, stops. Since **0.261.302**, the stopped message in the V2 chat + shows the approval itself: why the step stopped (for example, about 18,000 tokens of + file content, more than a quick read covers) and **Allow this time**, **Always allow + for SharePoint** (or OneDrive) or **Quick read only**. Choosing one saves the decision + through the same approvals API as the Approvals page and continues the plan at once. + An approval already decided in Approvals offers **Continue**. Earlier versions only + linked to Approvals and asked the user to come back and select **Retry from failed + step**, which still works. A retried step reuses its request, so the decision applies + while the action is unchanged. Each user can preset deeper analysis per source under + **Settings > Preferences > Connected accounts > Microsoft 365 sharing > Extended + analysis**, which avoids the prompt entirely. - **SharePoint and OneDrive use the step's own model.** File searches and reads limit the excerpts and content they return to what the model can take. Chat measures that against the selected agent's model. Since **0.261.300**, a plan step measures it @@ -688,6 +696,20 @@ identifier derived from the plan's first attempt and the step, so the retry find that attempt started and links it. Stopping a plan does not stop a workflow it already started; the user cancels that run in Workflows. +From **0.261.302**, a **Use an action** step that stopped for Microsoft 365 sign-in, +approval or policy retries without that confirmation. It ran only its one Microsoft 365 +action, and a plan step can only read Microsoft 365 data, so a retry repeats no change. +The server decides this when it prepares the retry, so a browser can't skip a confirmation +another step still needs. **Ask an agent** steps keep the confirmation: an agent can load +actions that send or change data. + +Also from **0.261.302**, the confirmation closes as soon as the retry starts instead of +staying open until the whole retry finishes. The retry's answer then replaces the stopped +attempt in the V2 thread. The stopped attempt stays saved for the record, but it is hidden, +it is left out of the history the model reads on later turns and out of conversation +exports, and a successful retry shows no attempt notice. **Message details** on the +retried answer offers **View previous attempt** and **Review saved attempt**. + A live execution cannot be retried. Missing, incompatible, or unauthorized checkpoints block recovery rather than causing completed actions to run again. Older runs without full checkpoints remain readable but require a new plan. diff --git a/docs/explanation/fixes/ORCHESTRATION_M365_APPROVAL_RETRY_FLOW_FIX.md b/docs/explanation/fixes/ORCHESTRATION_M365_APPROVAL_RETRY_FLOW_FIX.md new file mode 100644 index 000000000..1d21d1262 --- /dev/null +++ b/docs/explanation/fixes/ORCHESTRATION_M365_APPROVAL_RETRY_FLOW_FIX.md @@ -0,0 +1,213 @@ +# Orchestration Microsoft 365 Approval and Retry Flow Fix + +**Version: 0.261.302** + +Fixed in version: **0.261.302**, recorded in +`application/single_app/config.py`. + +This affects the React V2 branch (`paullizer-react-v2-ui`) and deployments built +from it. + +## Issue + +With **Orchestrate** on, a user asked "using my sharepoint documents, what is EVA +Swab Tool?". The plan's **Use an action** step with the user's **M365 SharePoint** +action stopped with "This step needs your approval before it can use Microsoft 365. +Review it in Approvals, then select Retry from failed step", and the answering step +was skipped. To get an answer, the user had to: + +1. Leave the conversation for **Approvals**, decide there, and come back. +2. Select **Retry from failed step** and confirm "Retry this failed step?". The + confirmation stayed on screen, busy, until the whole retry finished. +3. Read the answer under the original "The request could not be completed." message, + which stayed in the thread. +4. Skip past a "Saved execution attempt - Attempt 2" box with **View previous attempt** + and **Review saved attempt** under the answer. + +A new conversation asking the same question later didn't stop at all, which made the +first approval look arbitrary. + +## Root cause + +### Why the step asked for approval + +The conversation was private, so it wasn't a source-sharing approval: +`authorize_sources()` returns immediately for a conversation that isn't shared. The +stop was the **extended-analysis** guard. A SharePoint or OneDrive read that goes past +a quick read needs the user's approval while their per-source preference is **Ask**, +which is the default. A quick read covers at most 3 downloads, a 25 MB file and 12,000 +tokens of file content (`M365_FAST_DOWNLOADS`, `M365_FAST_FILE_BYTES` and +`M365_FAST_CONTEXT_TOKENS` in `functions_m365_retrieval.py`). A NASA technical report +easily exceeds 12,000 tokens. + +Choosing **Always allow deeper analysis** in Approvals saves that preference for the +source, which is why the next conversation didn't ask. The preference can also be set +ahead of time under **Settings > Preferences > Connected accounts > Microsoft 365 +sharing > Extended analysis**. Nothing in the chat said any of this. + +### Why the chat could only send the user to Approvals + +`OrchestrationM365Error` kept the pending approval's id, but `build_failure()` and +`safe_failure()` kept only the failure's code, step, capability, provider status and +sources. The browser never learned which approval the step was waiting on. + +### Why the confirmation appeared and stayed open + +- `action_invoke` is an external-effect capability, so every failed action step was + `effects_uncertain`, and `recovery_projection()` required confirmation. That was + needed even for a Microsoft 365 step, although `step_m365_context()` removes the + send, invite and mark-as-read functions from every plan step, so a Microsoft 365 + plan step can only read data. +- `retryOrchestrationRun()` and `runPreparedOrchestrationRetry()` awaited + `executeSavedPlan()`, the whole streamed run, before returning. The dialog's + `busy` state, and the dialog with it, lasted until the retry finished. + +### Why the stopped attempt and the attempt box stayed + +A retry saves its answer as a new assistant message and keeps the earlier attempt's +message, linked by `metadata.orchestration.retry_of_run_id`. Nothing hid the earlier +message, so it stayed in the thread. It was also sent to the model as history on every +later turn, in both `build_conversation_snapshot()` and +`build_conversation_history_segments()`. `OrchestrationRecoveryNotice` treated any +message with a `retry_of_run_id` as needing a notice, so a successful retry showed +"Saved execution attempt". + +## Changes + +### The failure carries the approval + +`build_failure()` keeps an `approval_id` on an `m365_approval_required` failure, and +`safe_failure()` and `failure_from_exception()` carry it through. Only an id in the +shape the application mints, `m365-` and a 64-character lowercase SHA-256 digest, is +kept; anything else is dropped. The V2 normalizer applies the same check. The +approvals API still checks that the signed-in user is the approval's subject before +showing or deciding it. + +### The approval is decided in the chat + +The stopped message's recovery notice renders `M365ApprovalInlineCard` for an +`m365_approval_required` failure with an approval id. The card: + +- Loads the approval with `GET /api/m365/approvals/`. +- For a pending extended-analysis approval, explains why the step stopped from the + approval's recorded counts, for example "To answer, this step needs to read more + (about 18,400 tokens of file content, 1 file, 2.4 MB) than a quick read covers". +- Offers **Allow this time**, **Always allow for SharePoint** (or OneDrive) and + **Quick read only**. Each choice is posted to + `POST /api/m365/approvals//decision` with the Microsoft 365 CSRF token, and the + plan continues only after the decision is saved. +- For an approval already decided, for example on the Approvals page, offers + **Continue**. +- Links to the Settings preference. +- Falls back to the Approvals link, and **Retry from failed step**, for any other + approval type or an approval it can't open. + +The card replaces the notice's generic failure text, recovery lines and retry button, +so there is one clear next step. + +### Microsoft 365 stops retry without confirmation + +`recovery_projection()` no longer counts a failed step as effect-uncertain when it is +an `action_invoke` step whose failure is in `M365_STEP_FAILURE_CODES`. Such a step ran +only its one Microsoft 365 action, which can only read data in a plan. +`prepare_retry()` recomputes the same projection, so the server, not the browser, +decides that no confirmation is needed. **Ask an agent** steps keep the confirmation, +because an agent can load actions that send or change data. So do action steps that +failed for any other reason. + +### The confirmation closes when the retry starts + +`launchSavedPlan()` starts `executeSavedPlan()` without awaiting it. +`executeSavedPlan()` claims the run and enters the streaming state before its first +await, so the conversation stays busy and no second retry can start, while the +confirmation and the retry button are released as soon as the retry is admitted. A +retry whose progress can't be followed is reconciled with the saved run. + +### The retry's answer replaces the stopped attempt + +- `supersededOrchestrationRunIds()` and `isSupersededOrchestrationAttempt()` in + `lib/orchestration.ts` find the runs a later attempt in the thread retried. + `MessageList` hides those assistant messages, as it hides a workflow post replaced + by its reply. They are read from saved message metadata, so they stay hidden after a + reload. +- `functions_orchestration_attempts.py`, which imports only the standard library, does + the same on the server. `build_conversation_snapshot()`, + `build_conversation_history_segments()` and `_build_export_entry()` leave replaced + attempts out of the model's history and out of exports. The messages stay stored. +- A completed retry shows no recovery notice. While a newer attempt has no saved + answer yet, the earlier attempt shows one line, "A newer attempt of this plan + exists.", with **View current attempt**. +- **Message details** on a retried answer shows the attempt number with **View + previous attempt** and **Review saved attempt**. + +## Files modified + +| File | Change | +| --- | --- | +| `functions_orchestration_schema.py` | `approval_id` on `m365_approval_required` failures, validated by `_m365_approval_id()`. | +| `functions_orchestration_recovery.py` | `_stopped_without_effects()` and `_effects_uncertain()` exempt Microsoft 365 action stops from confirmation. | +| `functions_orchestration_attempts.py` | New: finds and excludes replaced attempts' messages. | +| `functions_orchestration_context.py` | `build_conversation_snapshot()` leaves replaced attempts out. | +| `route_backend_chats.py` | `build_conversation_history_segments()` leaves replaced attempts out. | +| `route_backend_conversation_export.py` | `_build_export_entry()` leaves replaced attempts out. | +| `v2_ui/src/components/chat/M365ApprovalInlineCard.tsx` | New inline approval card. | +| `v2_ui/src/components/chat/OrchestrationRecoveryNotice.tsx` | Renders the card, the one-line notice for a replaced attempt, and no notice for a completed retry. | +| `v2_ui/src/components/chat/MessageList.tsx` | Hides replaced attempts. | +| `v2_ui/src/components/chat/MessageInspector.tsx` | Attempt links in **Message details**. | +| `v2_ui/src/lib/orchestration.ts` | `approval_id` on failures, and the replaced-attempt helpers. | +| `v2_ui/src/lib/orchestrationController.ts` | `launchSavedPlan()`; retries resolve when admitted. | +| `config.py` | Version `0.261.302`. | + +## Validation + +### Tests + +- `functional_tests/test_orchestration_m365_approval_retry_flow.py`: + - Approval ids survive failure building and re-reading only in the minted shape. + - Through the real retry route, a Microsoft 365 action stop retries with + `confirm_external_effects: false` and runs only the stopped work. + - Agent steps, and action steps that failed otherwise, are still refused with + `confirmation_required`. + - Replaced attempts are left out of orchestration history, chat history and + exports. +- `ui_tests/test_v2_orchestration_m365_inline_approval.py` runs the real notice, + card, controller, stores, message list, inspector and Review drawer in Chromium. It + checks that: + - The card explains the stop and each choice is saved before the plan continues. + - No confirmation appears, and the answer replaces the stopped attempt with no + attempt notice. + - A decided approval offers **Continue**, and an approval that can't be opened + falls back to Approvals. + - **Message details** keeps the attempt links. + - A confirmed retry's dialog closes while the retry is still running. +- `ui_tests/test_v2_orchestration_recovery_backend.py` now checks that, after a + reload, the thread shows only the retry's answer. +- Hand-built test namespaces for `_build_export_entry()` and + `build_conversation_history_segments()` gained the new helper. + +### Before and after + +| Before | After | +| --- | --- | +| The stopped message linked to Approvals; the user decided there, came back and retried. | The stopped message explains why and offers the decision; choosing continues the plan. | +| Retrying a Microsoft 365 stop asked "Retry this failed step?". | No confirmation for a Microsoft 365 action stop, enforced by the server. | +| A confirmation stayed open, busy, until the whole retry finished. | It closes as soon as the retry starts. | +| The stopped attempt's message stayed in the thread and in the model's history. | The retry's answer replaces it in the thread, history and exports; it stays stored. | +| A successful retry showed "Saved execution attempt - Attempt 2". | No notice; the attempt links are in **Message details**. | + +## Limitations + +- The card explains the stop with the counts the approval records. It doesn't name + the files: the retrieval budget that requests the approval tracks counts, not file + names. +- Source-sharing and Run as approvals still link to Approvals. Plan steps don't ask + for source sharing; the user's own request is their consent in a shared + conversation. +- Normal chat with Orchestrate off still decides Microsoft 365 approvals in + Approvals. Its request resumes on its own after the decision. + +## Related + +- [Orchestration Microsoft 365 file action evidence fix](ORCHESTRATION_M365_FILE_ACTION_EVIDENCE_FIX.md) +- [Microsoft 365 actions in plans](../../admin/orchestration.md#microsoft-365-actions-in-plans) +- [Orchestration failure recovery controls](../../reference/chat-controls.md#orchestration-failure-recovery-v2-interface) diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index f4b604495..ffef0536e 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,26 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.261.302)** + +#### Bug Fixes + +* **Microsoft 365 Approvals In A Plan Are Decided In The Chat** + * With Orchestrate on, a SharePoint or OneDrive step that had to read more of a file than a quick read covers stopped for the user's extended-analysis approval and only linked to **Approvals**. The user had to decide there, come back and retry. The stop wasn't about access: a quick read covers 3 downloads, a 25 MB file and 12,000 tokens of file content, and the per-source preference defaults to **Ask**. + * The stopped message now shows the approval itself. It says why the step stopped, from the approval's recorded counts, and offers **Allow this time**, **Always allow for SharePoint** (or OneDrive) and **Quick read only**. A choice is saved through the approvals API, and the plan continues straight away. An approval already decided in Approvals offers **Continue**. Other approvals still link to Approvals. + * The step's failure now carries its pending approval's id. Only an id in the shape the application mints is kept, and the approvals API still checks the signed-in user. + * (Ref: `M365ApprovalInlineCard.tsx`, `OrchestrationRecoveryNotice.tsx`, `functions_orchestration_schema.py` `build_failure`, `lib/orchestration.ts`, [Orchestration M365 Approval and Retry Flow Fix](fixes/ORCHESTRATION_M365_APPROVAL_RETRY_FLOW_FIX.md)) + +* **Retrying A Microsoft 365 Stop Needs No Confirmation, And A Confirmation Closes When The Retry Starts** + * A **Use an action** step that stopped for Microsoft 365 sign-in, approval or policy is retried without "Retry this failed step?". It ran only its one Microsoft 365 action, and a plan step can only read Microsoft 365 data. The server decides this when it prepares the retry. Agent steps, and action steps that failed for other reasons, still ask. + * A confirmation that is still needed now closes as soon as the retry is admitted. Before, it stayed open and busy until the whole retry finished. + * (Ref: `functions_orchestration_recovery.py` `_stopped_without_effects`, `orchestrationController.ts` `launchSavedPlan`) + +* **A Retry's Answer Replaces The Stopped Attempt** + * After a successful retry, the stopped attempt's "The request could not be completed." message stayed in the thread, the answer carried a "Saved execution attempt - Attempt 2" box, and the stopped attempt was sent to the model on every later turn. + * The retry's answer now replaces the attempt it retried in the thread, including after a reload. The stopped attempt is left out of the model's history and of conversation exports, but stays stored. A completed retry shows no attempt notice, and **Message details** offers **View previous attempt** and **Review saved attempt**. + * (Ref: `functions_orchestration_attempts.py`, `functions_orchestration_context.py`, `route_backend_chats.py` `build_conversation_history_segments`, `route_backend_conversation_export.py`, `MessageList.tsx`, `MessageInspector.tsx`) + ### **(v0.261.301)** #### New Features diff --git a/docs/reference/chat-controls.md b/docs/reference/chat-controls.md index 7d0c72509..ca01f9825 100644 --- a/docs/reference/chat-controls.md +++ b/docs/reference/chat-controls.md @@ -531,10 +531,12 @@ saved effective plan, not the current composer selections or a new planner call. | Control | What it does | Why you would use it | Enabled by | | --- | --- | --- | --- | | Retry from failed step | Creates a linked attempt that restores valid completed-step results and executes the incomplete work. | Recover after a failure without repeating successful plan steps or duplicating the question. | `enable_chat_orchestration`, current access, and recoverable saved checkpoints; not a substitute for individual file recovery | -| Confirm retry / Cancel | Confirms the possible external effects of retrying a failed agent/action, or dismisses the confirmation without executing it. Since **0.261.212** it also appears for a step that starts a saved workflow; its retry never starts the workflow twice, and links the run when the plan already started it. | Decide whether it is safe to repeat the failed step's internal tool activity. | A recoverable attempt that requires external-effect confirmation | +| Confirm retry / Cancel | Confirms the possible external effects of retrying a failed agent/action, or dismisses the confirmation without executing it. Since **0.261.212** it also appears for a step that starts a saved workflow; its retry never starts the workflow twice, and links the run when the plan already started it. Since **0.261.302** it closes as soon as the retry starts, and it doesn't appear for a **Use an action** step that stopped for Microsoft 365 sign-in, approval or policy, which only reads data. | Decide whether it is safe to repeat the failed step's internal tool activity. | A recoverable attempt that requires external-effect confirmation | +| Allow this time / Always allow for SharePoint (or OneDrive) / Quick read only | Since **0.261.302**, shown in the stopped message when a step needs the user's approval to read more of a SharePoint or OneDrive file than a quick read covers, with the amount it needs to read. Each choice saves the decision through the approvals API and continues the plan immediately. **Always allow** also saves the per-source preference; **Quick read only** answers from what fits and says what it left out. | Approve or decline deeper file analysis without leaving the conversation or retrying by hand. | A **Use an action** step stopped with `m365_approval_required` for extended analysis that the user can still decide; other approvals link to **Approvals** | +| Continue | Since **0.261.302**, continues a stopped plan whose Microsoft 365 approval was already decided, for example on the **Approvals** page. | Pick up the plan after deciding elsewhere, without a confirmation. | A recoverable attempt stopped for an approval that is no longer pending | | Run prepared retry | Starts a recovery attempt that was already prepared but has not executed. | Continue after preparation was saved but execution was interrupted by navigation or connection loss. | An unstarted saved recovery attempt | | Review saved attempt | Opens the selected attempt in the Plan/Run view. | Inspect the failure, completed steps, and remaining work without editing or rerunning history. | A saved orchestration attempt | -| View current attempt / View previous attempt | Opens a linked execution attempt rather than starting another one. | Follow recovery history and avoid retrying an older attempt that already has a successor. | Linked recovery attempts | +| View current attempt / View previous attempt | Opens a linked execution attempt rather than starting another one. Since **0.261.302**, a retried answer offers **View previous attempt** and **Review saved attempt** under **Message details** instead of an attempt notice below the answer. | Follow recovery history and avoid retrying an older attempt that already has a successor. | Linked recovery attempts | | Check saved status | Reconciles the displayed state with the existing server execution, without running or retrying work. | Check a waiting computation, or find out whether work finished after connection loss. | A waiting attempt, execution-status error, or recovery-detail error | | Stop execution | Requests server cancellation of the potentially active attempt. | Stop work even when it is waiting for retained computation or its stream was interrupted. | A waiting or interrupted connection with a tracked in-flight attempt | @@ -542,6 +544,12 @@ Steps restored from checkpoints show **Reused saved result**. Retry is always manual, including when normal approval is Auto or timed. An older attempt cannot create a competing retry after a newer attempt has been prepared. +Since **0.261.302**, a retry's answer replaces the attempt it retried in the thread. +The earlier attempt stays saved for the record, but it is hidden, and it is left out +of what the model reads on later turns and out of conversation exports. While a +prepared or running retry has no saved answer yet, the earlier attempt shows only +"A newer attempt of this plan exists." with **View current attempt**. + Since **0.261.127**, **Waiting for required results** keeps the same producing attempt active. Reload and status checks do not execute it again, and waiting does not expose a run-retry button or a completed-file link. File-specific diff --git a/functional_tests/test_analyze_backend_saved_integration.py b/functional_tests/test_analyze_backend_saved_integration.py index 668208f91..53626f4fe 100644 --- a/functional_tests/test_analyze_backend_saved_integration.py +++ b/functional_tests/test_analyze_backend_saved_integration.py @@ -59,6 +59,7 @@ result_storage = import_app_module("functions_workflow_result_store") tabular = import_app_module("functions_tabular_orchestration") deletion = import_app_module("functions_message_deletion") +attempts = import_app_module("functions_orchestration_attempts") def load_m365_runtime(): @@ -483,6 +484,7 @@ def read_m365_conversation(user_id, conversation_id): "extract_chat_completion_response_text": lambda value: value.choices[0].message.content, "filter_assistant_artifact_items": lambda values: list(values), "exclude_soft_deleted_messages": deletion.exclude_soft_deleted_messages, + "exclude_superseded_orchestration_attempts": attempts.exclude_superseded_orchestration_attempts, "build_message_artifact_payload_map": lambda values: {}, "hydrate_agent_citations_from_artifacts": lambda values, payloads: values, "sort_messages_by_thread": lambda values: values, diff --git a/functional_tests/test_content_screening_history.py b/functional_tests/test_content_screening_history.py index 4cf2446a8..5e6768806 100644 --- a/functional_tests/test_content_screening_history.py +++ b/functional_tests/test_content_screening_history.py @@ -69,6 +69,9 @@ def collaboration_thoughts(*args): "sanitize_saved_analysis_messages": lambda messages, user_id: messages, "hydrate_agent_citations_from_artifacts": lambda messages, payloads: messages, "public_history_messages": lambda messages, user_id: messages, + "exclude_superseded_orchestration_attempts": import_app_module( + "functions_orchestration_attempts", + ).exclude_superseded_orchestration_attempts, "sort_messages_by_thread": list, "is_collaboration_conversation": lambda conversation: collaboration, "get_thoughts_for_conversation": lambda *args: [thought], diff --git a/functional_tests/test_orchestration_m365_approval_retry_flow.py b/functional_tests/test_orchestration_m365_approval_retry_flow.py new file mode 100644 index 000000000..1ac658604 --- /dev/null +++ b/functional_tests/test_orchestration_m365_approval_retry_flow.py @@ -0,0 +1,256 @@ +# test_orchestration_m365_approval_retry_flow.py +#!/usr/bin/env python3 +""" +Functional test for the orchestration Microsoft 365 approval and retry flow. +Version: 0.261.302 +Implemented in: 0.261.302 + +A plan step that had to read more of a SharePoint or OneDrive file than a quick read covers +stops for the user's extended-analysis approval. These tests cover the server half of letting +the user decide that approval in the chat and continue at once: + +* the step failure carries the pending approval's id, and only an id the application minted; +* an action step that stopped for Microsoft 365 retries without the "may already have acted + outside this chat" confirmation, enforced by the real retry route, while agent steps and + other action failures still need it; +* once a retry exists, the attempt it replaced stays saved but is left out of what the model + reads on later turns, in both orchestration and normal chat, and out of exports. +""" + +import ast +import sys +from collections import Counter, defaultdict +from collections.abc import Mapping +from copy import deepcopy +from pathlib import Path +from typing import Any, Dict, List + +from flask import Flask + +TESTS = Path(__file__).resolve().parent +APP = TESTS.parent / "application" / "single_app" +sys.path.insert(0, str(APP)) +sys.path.insert(0, str(TESTS)) + +from test_support.offline_bootstrap import offline_app_imports # noqa: E402 - the test path is set above +from test_support.orchestration_recovery import RecoveryFixture, frames # noqa: E402 +from test_support.versioning import assert_app_version_at_least # noqa: E402 + +import functions_orchestration_attempts as attempts # noqa: E402 - standard library only + +APPROVAL_ID = "m365-" + "0123456789abcdef" * 4 + + +def test_application_version(): + assert_app_version_at_least("0.261.302") + + +# -------------------------------------------------------------------------------------- +# The failure carries the pending approval +# -------------------------------------------------------------------------------------- + +def test_approval_stops_carry_only_minted_approval_ids(): + with offline_app_imports(): + import functions_orchestration_m365 as orchestration_m365 + import functions_orchestration_schema as schema + + failure = schema.build_failure("m365_approval_required", step_id="search", approval_id=APPROVAL_ID) + assert failure["approval_id"] == APPROVAL_ID + assert schema.safe_failure(failure) == failure, "A saved failure must keep its approval through a re-read." + + for invalid in ( + None, "", 42, "m365-" + "A" * 64, "m365-" + "a" * 63, "m365-" + "a" * 65, + "x365-" + "a" * 64, "m365-" + "g" * 64, f"{APPROVAL_ID}\n", "javascript:alert(1)", + ): + assert "approval_id" not in schema.build_failure("m365_approval_required", approval_id=invalid), invalid + for code in ("m365_sign_in_required", "m365_unavailable", "step_failed"): + assert "approval_id" not in schema.build_failure(code, approval_id=APPROVAL_ID), code + + stop = orchestration_m365.OrchestrationM365Error( + "m365_approval_required", m365_code="m365_approval_required", sources=("spo",), + approval_id=APPROVAL_ID, + ) + wrapped = RuntimeError("An action function could not complete.") + wrapped.__cause__ = stop + for error in (stop, wrapped): + reported = schema.failure_from_exception(error) + assert reported["code"] == "m365_approval_required" + assert reported["approval_id"] == APPROVAL_ID + assert reported["m365_sources"] == ["spo"] + + +# -------------------------------------------------------------------------------------- +# A Microsoft 365 stop retries without external-effect consent, through the real route +# -------------------------------------------------------------------------------------- + +def _stop_step_b(fixture, run_id, *, capability_id, failure): + """Record the failed step as the kind of step and stop under test, as the executor saves it.""" + record = fixture.runs.items[(fixture.conversation_id, run_id)] + step = next(step for step in record["execution_steps"] if step["step_id"] == "b") + assert step["status"] == "failed" and step["effects_uncertain"] is True + step.update(capability_id=capability_id, failure=failure) + + +def test_microsoft_365_action_stop_retries_without_external_effect_consent(): + with RecoveryFixture() as fixture: + plan = fixture.plan_attempt() + frames(fixture.run_attempt(plan)) + schema = fixture.modules.schema + _stop_step_b( + fixture, plan["run_id"], capability_id="action_invoke", + failure=schema.build_failure("m365_approval_required", step_id="b", approval_id=APPROVAL_ID), + ) + + recovery = fixture.detail(plan["run_id"])["recovery"] + assert recovery["eligible"] is True + assert recovery["requires_confirmation"] is False + + response = fixture.retry(plan["run_id"], confirm_external_effects=False) + assert response.status_code == 200, response.get_data(as_text=True) + child = response.get_json()["run"] + assert child["recovery"]["reused_step_ids"] == ["a"] + fixture.fail_b = False + frames(fixture.run_attempt(child["plan"])) + assert fixture.calls == ["a", "b", "b", "c"], "Only the stopped work runs again." + + +def test_other_failed_effect_steps_still_require_consent(): + cases = ( + # An agent may load actions that send or change data, so its stop proves nothing. + ("agent_invoke", "m365_approval_required"), + # An action step that failed for another reason may already have acted. + ("action_invoke", "step_timeout"), + ) + for capability_id, code in cases: + with RecoveryFixture() as fixture: + plan = fixture.plan_attempt() + frames(fixture.run_attempt(plan)) + schema = fixture.modules.schema + _stop_step_b( + fixture, plan["run_id"], capability_id=capability_id, + failure=schema.build_failure(code, step_id="b", approval_id=APPROVAL_ID), + ) + assert fixture.detail(plan["run_id"])["recovery"]["requires_confirmation"] is True, capability_id + + refused = fixture.retry(plan["run_id"], confirm_external_effects=False) + assert refused.status_code == 409, (capability_id, refused.get_data(as_text=True)) + assert refused.get_json()["code"] == "confirmation_required" + assert not [row for row in fixture.runs.items.values() if row.get("retry_of_run_id")] + + +# -------------------------------------------------------------------------------------- +# A replaced attempt leaves model history and exports +# -------------------------------------------------------------------------------------- + +def _message(message_id, role, content, timestamp, *, run_id=None, retry_of=None, turn_id=None): + metadata = {} + if run_id: + metadata["orchestration"] = { + "run_id": run_id, "turn_id": turn_id, "retry_of_run_id": retry_of, + "outcome": "completed" if retry_of else "failed", + } + return { + "id": message_id, "conversation_id": "conversation-1", "role": role, "content": content, + "timestamp": timestamp, "metadata": metadata, + } + + +def _retried_conversation(): + return [ + _message("q1", "user", "What is the EVA Swab Tool?", "2026-10-08T10:00:00+00:00"), + _message( + "a1", "assistant", "STOPPED_ATTEMPT_CANARY The request could not be completed.", + "2026-10-08T10:00:10+00:00", run_id="run-1", turn_id="turn-1", + ), + _message( + "a2", "assistant", "The EVA Swab Tool is a NASA sampling kit.", + "2026-10-08T10:02:00+00:00", run_id="run-2", retry_of="run-1", turn_id="turn-1", + ), + _message("q2", "user", "Who designed it?", "2026-10-08T10:05:00+00:00"), + ] + + +def test_replaced_attempts_are_found_from_saved_message_lineage(): + messages = _retried_conversation() + assert attempts.superseded_orchestration_run_ids(messages) == {"run-1"} + kept = attempts.exclude_superseded_orchestration_attempts(messages) + assert [message["id"] for message in kept] == ["q1", "a2", "q2"] + + # A chain of retries keeps only the latest attempt. + third = _message("a3", "assistant", "Third", "2026-10-08T10:03:00+00:00", run_id="run-3", retry_of="run-2") + assert [m["id"] for m in attempts.exclude_superseded_orchestration_attempts(messages + [third])] == [ + "q1", "q2", "a3", + ] + # Only assistant answers are replaced; malformed lineage and self-references change nothing. + odd = [ + {"id": "u", "role": "user", "metadata": {"orchestration": {"run_id": "run-1"}}}, + {"id": "s", "role": "assistant", "metadata": {"orchestration": {"run_id": "run-9", "retry_of_run_id": "run-9"}}}, + {"id": "m", "role": "assistant", "metadata": {"orchestration": ["not", "a", "mapping"]}}, + {"id": "n", "role": "assistant", "metadata": None}, + ] + assert [m["id"] for m in attempts.exclude_superseded_orchestration_attempts(messages + odd)] == [ + "q1", "a2", "q2", "u", "s", "m", "n", + ] + assert attempts.exclude_superseded_orchestration_attempts(None) == [] + + +def test_orchestration_history_leaves_out_a_replaced_attempt(): + with offline_app_imports(): + import functions_orchestration_context as context + + snapshot = context.build_conversation_snapshot(_retried_conversation()) + ids = [message["id"] for message in snapshot["messages"]] + assert ids == ["q1", "a2", "q2"], ids + assert "STOPPED_ATTEMPT_CANARY" not in str(snapshot) + + +def test_chat_history_leaves_out_a_replaced_attempt(): + with offline_app_imports(): + import route_backend_chats as chats + + with Flask("superseded-attempt-history").test_request_context("/"): + segments = chats.build_conversation_history_segments( + _retried_conversation(), 10, user_message_id="q2", fallback_user_message="Who designed it?", + ) + history = str(segments["history_messages"]) + assert "STOPPED_ATTEMPT_CANARY" not in history, "A replaced attempt reached the model history." + assert "NASA sampling kit" in history and "Who designed it?" in history + + +def _load_function(file_name, function_name, namespace): + tree = ast.parse((APP / file_name).read_text(encoding="utf-8")) + node = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == function_name) + exec(compile(ast.Module(body=[node], type_ignores=[]), str(APP / file_name), "exec"), namespace) + return namespace[function_name] + + +def test_export_leaves_out_a_replaced_attempt(): + namespace = { + "Any": Any, "Dict": Dict, "List": List, "Mapping": Mapping, + "Counter": Counter, "defaultdict": defaultdict, + "TRANSCRIPT_ROLES": {"user", "assistant"}, + "build_message_artifact_payload_map": lambda messages: {}, + "_filter_messages_for_export": list, + "sanitize_saved_analysis_messages": lambda messages, user_id: messages, + "hydrate_agent_citations_from_artifacts": lambda messages, payloads: messages, + "public_history_messages": lambda messages, user_id: messages, + "exclude_superseded_orchestration_attempts": attempts.exclude_superseded_orchestration_attempts, + "sort_messages_by_thread": list, + "is_collaboration_conversation": lambda conversation: False, + "get_thoughts_for_conversation": lambda *args: [], + "get_accessible_collaboration_message_thoughts": lambda *args: [], + "is_saved_analysis_unavailable": lambda message: False, + "_sanitize_thought": deepcopy, + "_sanitize_message": lambda message, **kwargs: {"id": message["id"], "content": message["content"]}, + "_sanitize_conversation": lambda conversation, **kwargs: conversation, + "_build_summary_intro": lambda **kwargs: None, + } + export = _load_function("route_backend_conversation_export.py", "_build_export_entry", namespace) + entry = export({"id": "conversation-1"}, _retried_conversation(), "user-1", {}) + assert [message["id"] for message in entry["messages"]] == ["q1", "a2", "q2"] + + +if __name__ == "__main__": + import pytest + + sys.exit(pytest.main([__file__, "-q"])) diff --git a/functional_tests/test_saved_results_container_access_chat_orchestration.py b/functional_tests/test_saved_results_container_access_chat_orchestration.py index 98129a844..c755ec18a 100644 --- a/functional_tests/test_saved_results_container_access_chat_orchestration.py +++ b/functional_tests/test_saved_results_container_access_chat_orchestration.py @@ -411,6 +411,9 @@ def export(self, messages): "sanitize_saved_analysis_messages": saved.sanitize_saved_analysis_messages, "hydrate_agent_citations_from_artifacts": lambda raw_messages, payloads: raw_messages, "public_history_messages": screening_access.public_history_messages, + "exclude_superseded_orchestration_attempts": import_app_module( + "functions_orchestration_attempts", + ).exclude_superseded_orchestration_attempts, "sort_messages_by_thread": list, "is_collaboration_conversation": lambda conversation: False, "get_thoughts_for_conversation": lambda *args: [], diff --git a/ui_tests/test_v2_orchestration_m365_inline_approval.py b/ui_tests/test_v2_orchestration_m365_inline_approval.py new file mode 100644 index 000000000..2e9644728 --- /dev/null +++ b/ui_tests/test_v2_orchestration_m365_inline_approval.py @@ -0,0 +1,295 @@ +# test_v2_orchestration_m365_inline_approval.py +""" +Real-component browser coverage for deciding a Microsoft 365 approval inside the chat. +Version: 0.261.302 +Implemented in: 0.261.302 + +A plan step that had to read more of a SharePoint file than a quick read covers stopped for +the user's extended-analysis approval. The stopped attempt's message now offers that decision +inline. Choosing an option saves it through the approvals API and continues the plan at once: +no trip to Approvals, and no "Retry this failed step?" confirmation, because a Microsoft 365 +plan step only reads data. The retry's answer then replaces the stopped attempt in the thread, +with no attempt notice; the saved attempts stay reachable from the answer's message details. + +The production notice, inline card, controller, stores, message list, inspector and Review +drawer run in Chromium. Only HTTP responses are deterministic. + +Build CSS with the existing V2 build, keeping outputs in UI test artifacts: +npm --prefix .\\application\\v2_ui run build -- --outDir ..\\..\\ui_tests\\artifacts\\orchestration-plan-editor +Run: python -m pytest .\\ui_tests\\test_v2_orchestration_m365_inline_approval.py -q +""" + +import copy +from urllib.parse import urlsplit + +import pytest +from playwright.sync_api import expect + +import test_v2_orchestration_plan_editor as editor_tests +import test_v2_orchestration_recovery as recovery_tests +from test_v2_orchestration_plan_editor import ( # noqa: F401 + connect_options, + editor_assets, + editor_browser, +) + + +pytestmark = pytest.mark.ui +APPROVAL_ID = "m365-" + "a" * 64 +CSRF_TOKEN = "m365-inline-approval-test-csrf-token-0001" +# The server's own message for this failure, from FAILURE_MESSAGES. +APPROVAL_MESSAGE = ( + "This step needs your approval before it can use Microsoft 365. Review it in Approvals, " + "then select Retry from failed step." +) +ANSWER = "Comparison from saved reports and the recovered agent." + + +class InlineApprovalApi(recovery_tests.RecoveryApi): + """The recovery API, a pending extended-analysis approval, and an optionally held retry run.""" + + def __init__(self, assets): + super().__init__(assets) + self.failure = { + "code": "m365_approval_required", "message": APPROVAL_MESSAGE, + "m365_sources": ["spo"], "approval_id": APPROVAL_ID, + } + self.approval = { + "id": APPROVAL_ID, "request_type": "m365_extended_analysis", "approval_scope": "user", + "status": "pending", "group_name": "Microsoft 365", "context": {"shared": False}, + "sources": {"spo": { + "maximum_sharing_acknowledgement": "always", + "allowed_durations": ["request", "today", "always"], "generation": 1, + }}, + "proposal": {"file_count": 1, "download_count": 1, "total_bytes": 2516582, "context_tokens": 18400}, + "can_approve": True, "can_deny": True, + } + self.approval_status = 200 + self.decisions = [] + self.hold_retry_run = False + self.metadata_reads = [] + + def recovery(self, run_id): + recovery = super().recovery(run_id) + # The server's projection: an action step that stopped for Microsoft 365 changed nothing. + recovery["requires_confirmation"] = False + return recovery + + def handle(self, route): + request = route.request + path = urlsplit(request.url).path + body = request.post_data_json if request.post_data else None + if path == f"/api/m365/approvals/{APPROVAL_ID}" and request.method == "GET": + self.requests.append({"path": path, "method": "GET", "body": None}) + if self.approval_status != 200: + route.fulfill(status=self.approval_status, json={"error": "not_found"}) + return + route.fulfill(json={"approval": copy.deepcopy(self.approval), "csrf_token": CSRF_TOKEN}) + return + if path == f"/api/m365/approvals/{APPROVAL_ID}/decision" and request.method == "POST": + self.requests.append({"path": path, "method": "POST", "body": body}) + self.decisions.append({"body": body, "csrf": request.headers.get("x-m365-csrf-token")}) + self.approval.update( + status="denied" if body.get("choice") == "fast" else "approved", + analysis_choice=body.get("choice"), can_approve=False, can_deny=False, + ) + route.fulfill(json={"approval": copy.deepcopy(self.approval), "csrf_token": CSRF_TOKEN}) + return + if path.startswith("/api/message/") and path.endswith("/metadata"): + self.requests.append({"path": path, "method": request.method, "body": None}) + self.metadata_reads.append(path) + route.fulfill(json={}) + return + if path.endswith("/retry") and request.method == "POST": + self.requests.append({"path": path, "method": "POST", "body": body}) + # No external-effect consent is sent, or needed, for a Microsoft 365 stop. + assert body["confirm_external_effects"] is False, body + run_id = path.split("/")[-2] + child_plan = copy.deepcopy(self.records[run_id]["plan"]) + child_plan.update( + run_id="retry-attempt", plan_id="retry-plan", status="awaiting_approval", + edit_version="retry-plan-version", + ) + child_plan["approval"] = {"mode": "manual", "timeout_seconds": 0, "state": "pending"} + child = self.add_record(child_plan, 2, run_id) + self.records[run_id].update(latest_attempt_run_id=child["run_id"]) + self.records[run_id]["recovery"].update(eligible=False, current_run_id=child["run_id"]) + route.fulfill(json={"run": child}) + return + if (path == editor_tests.RUN and self.hold_retry_run and body + and self.records.get(body.get("run_id"), {}).get("retry_of_run_id")): + self.requests.append({"path": path, "method": "POST", "body": body}) + run_id = body["run_id"] + self.records[run_id].update(status="running", started_at="2026-09-08T12:00:30Z") + + def finish_later(): + event = self.finish(run_id, "completed") + steps = [{"type": "orchestration_step", **step} for step in self.steps[run_id]] + self.stream(route, steps + [event]) + + self.waiting.append(finish_later) + return + super().handle(route) + + +class HeldRetryApi(InlineApprovalApi): + """A failed agent step: its retry needs confirmation, and its run streams only when released.""" + + def __init__(self, assets): + super().__init__(assets) + self.failure = {"code": "step_timeout", "message": recovery_tests.FAILURE} + self.hold_retry_run = True + + def recovery(self, run_id): + return recovery_tests.RecoveryApi.recovery(self, run_id) + + def handle(self, route): + path = urlsplit(route.request.url).path + if path.endswith("/retry"): + recovery_tests.RecoveryApi.handle(self, route) + return + super().handle(route) + + +def _ui(editor_browser, editor_assets, api_class): + context = editor_browser.new_context(viewport={"width": 1440, "height": 900}) + page = context.new_page() + api = api_class(editor_assets) + page.route("**/*", api.handle) + page.on("pageerror", lambda error: api.errors.append(str(error))) + return context, page, api + + +@pytest.fixture +def inline_ui(editor_browser, editor_assets): + context, page, api = _ui(editor_browser, editor_assets, InlineApprovalApi) + try: + yield page, api + finally: + api.release() + context.close() + assert not api.errors, api.errors + + +@pytest.fixture +def held_ui(editor_browser, editor_assets): + context, page, api = _ui(editor_browser, editor_assets, HeldRetryApi) + try: + yield page, api + finally: + api.release() + context.close() + assert not api.errors, api.errors + + +def failed_message(api): + return f'[id="message-assistant-{api.plan["run_id"]}"]' + + +def start_stopped_plan(page, api): + recovery_tests.mount_recovery(page, api) + page.get_by_role("button", name="Approve and run the plan").click() + card = page.get_by_test_id("v2-m365-inline-approval") + expect(card).to_be_visible() + return card + + +def test_extended_analysis_stop_is_decided_inline_and_continues_without_a_dialog(inline_ui): + page, api = inline_ui + card = start_stopped_plan(page, api) + expect(card.get_by_text("SharePoint needs your OK to read more", exact=True)).to_be_visible() + expect(card).to_contain_text("Ask the selected agent") + expect(card).to_contain_text("about 18,400 tokens of file content, 1 file, 2.4 MB") + expect(card.get_by_role("link", name="Settings")).to_have_attribute("href", "/settings?tab=preferences") + # The card owns the next step: no separate retry button competes with it. + expect(page.get_by_role("button", name="Retry from failed step")).to_have_count(0) + + card.get_by_role("button", name="Allow this time").click() + + expect(page.get_by_text(ANSWER, exact=True)).to_be_visible() + assert api.decisions == [{"body": {"choice": "request"}, "csrf": CSRF_TOKEN}] + assert len(api.calls("/retry")) == 1 + assert [entry["body"]["run_id"] for entry in api.calls("/run")] == [api.plan["run_id"], "retry-attempt"] + expect(page.get_by_role("dialog")).to_have_count(0) + # The retry's answer replaces the stopped attempt, without an attempt notice. + expect(page.locator(failed_message(api))).to_have_count(0) + expect(page.get_by_text(APPROVAL_MESSAGE, exact=True)).to_have_count(0) + expect(page.get_by_text("Saved execution attempt", exact=False)).to_have_count(0) + expect(page.get_by_role("button", name="View previous attempt")).to_have_count(0) + + +@pytest.mark.parametrize(("label", "choice"), [ + ("Always allow for SharePoint", "always"), + ("Quick read only", "fast"), +]) +def test_each_inline_choice_is_saved_before_the_plan_continues(inline_ui, label, choice): + page, api = inline_ui + card = start_stopped_plan(page, api) + card.get_by_role("button", name=label).click() + expect(page.get_by_text(ANSWER, exact=True)).to_be_visible() + assert api.decisions == [{"body": {"choice": choice}, "csrf": CSRF_TOKEN}] + decision_index = next(i for i, entry in enumerate(api.requests) if entry["path"].endswith("/decision")) + retry_index = next(i for i, entry in enumerate(api.requests) if entry["path"].endswith("/retry")) + assert decision_index < retry_index, "The plan must continue only after the choice is saved." + + +def test_an_approval_decided_in_approvals_offers_continue(inline_ui): + page, api = inline_ui + api.approval.update(status="approved", analysis_choice="always", can_approve=False, can_deny=False) + card = start_stopped_plan(page, api) + expect(card.get_by_text("Deeper reads are always allowed for SharePoint.", exact=True)).to_be_visible() + expect(card.get_by_role("button", name="Allow this time")).to_have_count(0) + card.get_by_role("button", name="Continue").click() + expect(page.get_by_text(ANSWER, exact=True)).to_be_visible() + assert not api.decisions + assert len(api.calls("/retry")) == 1 + + +def test_an_approval_that_cannot_be_opened_falls_back_to_approvals(inline_ui): + page, api = inline_ui + api.approval_status = 404 + recovery_tests.mount_recovery(page, api) + page.get_by_role("button", name="Approve and run the plan").click() + section = page.get_by_label("Microsoft 365 approval").first + expect(section.get_by_role("link", name="Review the Microsoft 365 approval")).to_have_attribute( + "href", "/approvals", + ) + section.get_by_role("button", name="Retry from failed step").click() + expect(page.get_by_text(ANSWER, exact=True)).to_be_visible() + assert not api.decisions + + +def test_retried_answer_keeps_its_attempts_in_message_details(inline_ui): + page, api = inline_ui + card = start_stopped_plan(page, api) + card.get_by_role("button", name="Allow this time").click() + expect(page.get_by_text(ANSWER, exact=True)).to_be_visible() + + answer = page.locator('[id="message-assistant-retry-attempt"]') + answer.get_by_role("button", name="Message details").click() + expect(page.get_by_text("This answer came from retrying a plan that stopped earlier.", exact=False)).to_be_visible() + page.get_by_role("button", name="Review saved attempt").click() + drawer = page.get_by_role("complementary", name="Review drawer") + expect(drawer.get_by_text("Reused saved result", exact=True).first).to_be_visible() + # Only the retried answer's details were read (StrictMode mounts can read them twice). + assert set(api.metadata_reads) == {"/api/message/assistant-retry-attempt/metadata"} + + +def test_confirmed_retry_closes_its_dialog_while_the_retry_runs(held_ui): + page, api = held_ui + recovery_tests.start_failure(page, api) + page.get_by_role("button", name="Retry from failed step").click() + dialog = page.get_by_role("dialog", name="Retry this failed step?") + expect(dialog).to_be_visible() + with page.expect_request(f"**{editor_tests.RUN}"): + dialog.get_by_role("button", name="Confirm retry").click() + # Admitted: the confirmation is gone although the retry's run is still streaming. + expect(dialog).to_have_count(0) + assert api.waiting, "The retry's run should still be in progress." + expect(page.get_by_text("A newer attempt of this plan exists.", exact=True)).to_be_visible() + + api.release() + expect(page.get_by_text(ANSWER, exact=True)).to_be_visible() + expect(page.locator(failed_message(api))).to_have_count(0) + expect(page.get_by_text("Saved execution attempt", exact=False)).to_have_count(0) + assert len(api.calls("/retry")) == 1 diff --git a/ui_tests/test_v2_orchestration_recovery_backend.py b/ui_tests/test_v2_orchestration_recovery_backend.py index 3a6748f9b..82ba517ea 100644 --- a/ui_tests/test_v2_orchestration_recovery_backend.py +++ b/ui_tests/test_v2_orchestration_recovery_backend.py @@ -1,10 +1,11 @@ # test_v2_orchestration_recovery_backend.py """ Browser-to-Flask checkpoint recovery regressions. -Version: 0.261.140 +Version: 0.261.302 Implemented in: 0.261.105 Single orchestration contract updated in: 0.261.139 Cosmos SDK response coverage added in: 0.261.140 +A reloaded thread shows only the retry's answer, not the attempt it replaced, in: 0.261.302 Real orchestration routes, executor, durable checkpoint codec, conditional attempts, and message persistence run in the shared backend fixture. Only Azure/model/service @@ -161,8 +162,18 @@ def test_real_failed_agent_resume_reuses_checkpoints_and_survives_reload(integra row for row in backend.messages.items.values() if row.get("role") in ("user", "assistant") ] recovery_tests.mount_recovery(page, mounted, saved=True) - expect(page.get_by_role("button", name="View current attempt").first).to_be_visible() - page.get_by_role("button", name="View current attempt").first.click() + # After a reload the thread shows only the retry's answer: the attempt it replaced stays + # saved but is hidden, and the successful retry carries no attempt notice. + current_run_id = attempts[1]["metadata"]["orchestration"]["run_id"] + expect(page.locator(f'[id="message-{attempts[1]["id"]}"]')).to_be_visible() + expect(page.locator(f'[id="message-{attempts[0]["id"]}"]')).to_have_count(0) + expect(page.get_by_text(record["failure"]["message"], exact=True)).to_have_count(0) + expect(page.get_by_text("Saved execution attempt", exact=False)).to_have_count(0) + expect(page.get_by_role("button", name="View current attempt")).to_have_count(0) + page.evaluate( + "(spec) => window.OrchHarness.controller.openOrchestrationRecovery(spec.conversation, spec.run)", + {"conversation": "conv1", "run": current_run_id}, + ) try: expect(page.get_by_role("complementary", name="Review drawer") .get_by_text("Reused saved result", exact=True).first).to_be_visible() From fb76e560ebc6d35f98f23e15057a84660f88414e Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 8 Oct 2026 12:47:57 -0400 Subject: [PATCH 2/3] Import the plan editor test module once in the inline approval UI test CodeQL flagged test_v2_orchestration_plan_editor as imported with both 'import' and 'import from'. Take RUN from the same from-import as the shared fixtures instead of a second module alias. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ui_tests/test_v2_orchestration_m365_inline_approval.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui_tests/test_v2_orchestration_m365_inline_approval.py b/ui_tests/test_v2_orchestration_m365_inline_approval.py index 2e9644728..788d0c704 100644 --- a/ui_tests/test_v2_orchestration_m365_inline_approval.py +++ b/ui_tests/test_v2_orchestration_m365_inline_approval.py @@ -25,9 +25,9 @@ import pytest from playwright.sync_api import expect -import test_v2_orchestration_plan_editor as editor_tests import test_v2_orchestration_recovery as recovery_tests from test_v2_orchestration_plan_editor import ( # noqa: F401 + RUN, connect_options, editor_assets, editor_browser, @@ -116,7 +116,7 @@ def handle(self, route): self.records[run_id]["recovery"].update(eligible=False, current_run_id=child["run_id"]) route.fulfill(json={"run": child}) return - if (path == editor_tests.RUN and self.hold_retry_run and body + if (path == RUN and self.hold_retry_run and body and self.records.get(body.get("run_id"), {}).get("retry_of_run_id")): self.requests.append({"path": path, "method": "POST", "body": body}) run_id = body["run_id"] @@ -281,7 +281,7 @@ def test_confirmed_retry_closes_its_dialog_while_the_retry_runs(held_ui): page.get_by_role("button", name="Retry from failed step").click() dialog = page.get_by_role("dialog", name="Retry this failed step?") expect(dialog).to_be_visible() - with page.expect_request(f"**{editor_tests.RUN}"): + with page.expect_request(f"**{RUN}"): dialog.get_by_role("button", name="Confirm retry").click() # Admitted: the confirmation is gone although the retry's run is still streaming. expect(dialog).to_have_count(0) From 5d615850ae6512c544cfa61f92dc85d188fb3faa Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 8 Oct 2026 15:27:42 -0400 Subject: [PATCH 3/3] Renumber the approval and retry flow fix to 0.261.304 #1716 shipped as 0.261.302, the version this branch used, and the open Microsoft 365 citations PR (#1718) holds 0.261.303. Move this branch to 0.261.304: config.py, its own release-notes section above v0.261.302, and the version headers and "Since" notes in its code, tests and docs. The fix doc also lists the sharing-preferences link and guardrail pin added when the base was merged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../single_app/functions_orchestration_attempts.py | 4 ++-- .../single_app/functions_orchestration_recovery.py | 4 ++-- .../single_app/functions_orchestration_schema.py | 4 ++-- docs/admin/orchestration.md | 6 +++--- .../ORCHESTRATION_M365_APPROVAL_RETRY_FLOW_FIX.md | 14 +++++++++----- docs/explanation/release_notes.md | 6 +++++- docs/reference/chat-controls.md | 10 +++++----- .../test_orchestration_m365_approval_retry_flow.py | 6 +++--- .../test_v2_workflow_run_tracking_xss_guardrail.py | 3 ++- .../test_v2_orchestration_m365_inline_approval.py | 4 ++-- ui_tests/test_v2_orchestration_recovery_backend.py | 4 ++-- 12 files changed, 38 insertions(+), 29 deletions(-) diff --git a/application/single_app/config.py b/application/single_app/config.py index ef6a7d47c..10d13658b 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.302" +VERSION = "0.261.304" 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_attempts.py b/application/single_app/functions_orchestration_attempts.py index 15038bd26..014f6878f 100644 --- a/application/single_app/functions_orchestration_attempts.py +++ b/application/single_app/functions_orchestration_attempts.py @@ -11,8 +11,8 @@ Only the standard library is imported, so any reader can use this module without pulling orchestration storage into its import graph. -Version: 0.261.302 -Implemented in: 0.261.302 +Version: 0.261.304 +Implemented in: 0.261.304 """ from collections.abc import Mapping diff --git a/application/single_app/functions_orchestration_recovery.py b/application/single_app/functions_orchestration_recovery.py index 18efe92d8..9f524c90d 100644 --- a/application/single_app/functions_orchestration_recovery.py +++ b/application/single_app/functions_orchestration_recovery.py @@ -1,7 +1,7 @@ # functions_orchestration_recovery.py """Execution leases and explicitly requested, checkpoint-only retry attempts. -Version: 0.261.302 +Version: 0.261.304 Retry publication is one transactional parent CAS + child create. It never replans, invokes an adapter, or changes plan-revision lineage. Terminal publication preserves an administrator's reply retraction; its probe @@ -12,7 +12,7 @@ A retry runs again a step that finished without the chart or diagram the plan asked of it, and asks for confirmation when that step can have external effects (0.261.293). An action step that stopped for Microsoft 365 sign-in, approval or policy is retried without -that confirmation: a plan step only reads Microsoft 365 data (0.261.302). +that confirmation: a plan step only reads Microsoft 365 data (0.261.304). A retry that could only resend requests a service declined is not offered. A run from the removed legacy contract is never retried, resumed or continued; only conversation deletion still reads it, to remove its saved data. diff --git a/application/single_app/functions_orchestration_schema.py b/application/single_app/functions_orchestration_schema.py index 9663f2cbd..c321baf8c 100644 --- a/application/single_app/functions_orchestration_schema.py +++ b/application/single_app/functions_orchestration_schema.py @@ -33,10 +33,10 @@ render through the very same card. Our own paging lives in a sibling ``ui_hints`` field rather than inside the schema, which keeps the schema itself MCP-clean. -Version: 0.261.302 +Version: 0.261.304 Missing signed-in session reported as its own step failure in: 0.261.209 Microsoft 365 step failures, with their sources, added in: 0.261.238 -Microsoft 365 approval stops carry their pending approval id in: 0.261.302 +Microsoft 365 approval stops carry their pending approval id in: 0.261.304 """ import hashlib diff --git a/docs/admin/orchestration.md b/docs/admin/orchestration.md index ee9e289a0..a2e7e4399 100644 --- a/docs/admin/orchestration.md +++ b/docs/admin/orchestration.md @@ -448,7 +448,7 @@ limits: as approvals, and approvals to share earlier answers' Microsoft 365 history, still ask. Earlier versions stopped the step before it read anything. - **Approvals are decided in the conversation.** A step that needs an approval, such as - deeper file analysis, stops. Since **0.261.302**, the stopped message in the V2 chat + deeper file analysis, stops. Since **0.261.304**, the stopped message in the V2 chat shows the approval itself: why the step stopped (for example, about 18,000 tokens of file content, more than a quick read covers) and **Allow this time**, **Always allow for SharePoint** (or OneDrive) or **Quick read only**. Choosing one saves the decision @@ -696,14 +696,14 @@ identifier derived from the plan's first attempt and the step, so the retry find that attempt started and links it. Stopping a plan does not stop a workflow it already started; the user cancels that run in Workflows. -From **0.261.302**, a **Use an action** step that stopped for Microsoft 365 sign-in, +From **0.261.304**, a **Use an action** step that stopped for Microsoft 365 sign-in, approval or policy retries without that confirmation. It ran only its one Microsoft 365 action, and a plan step can only read Microsoft 365 data, so a retry repeats no change. The server decides this when it prepares the retry, so a browser can't skip a confirmation another step still needs. **Ask an agent** steps keep the confirmation: an agent can load actions that send or change data. -Also from **0.261.302**, the confirmation closes as soon as the retry starts instead of +Also from **0.261.304**, the confirmation closes as soon as the retry starts instead of staying open until the whole retry finishes. The retry's answer then replaces the stopped attempt in the V2 thread. The stopped attempt stays saved for the record, but it is hidden, it is left out of the history the model reads on later turns and out of conversation diff --git a/docs/explanation/fixes/ORCHESTRATION_M365_APPROVAL_RETRY_FLOW_FIX.md b/docs/explanation/fixes/ORCHESTRATION_M365_APPROVAL_RETRY_FLOW_FIX.md index 1d21d1262..ee88446ce 100644 --- a/docs/explanation/fixes/ORCHESTRATION_M365_APPROVAL_RETRY_FLOW_FIX.md +++ b/docs/explanation/fixes/ORCHESTRATION_M365_APPROVAL_RETRY_FLOW_FIX.md @@ -1,8 +1,8 @@ # Orchestration Microsoft 365 Approval and Retry Flow Fix -**Version: 0.261.302** +**Version: 0.261.304** -Fixed in version: **0.261.302**, recorded in +Fixed in version: **0.261.304**, recorded in `application/single_app/config.py`. This affects the React V2 branch (`paullizer-react-v2-ui`) and deployments built @@ -98,8 +98,8 @@ The stopped message's recovery notice renders `M365ApprovalInlineCard` for an plan continues only after the decision is saved. - For an approval already decided, for example on the Approvals page, offers **Continue**. -- Links to the Settings preference. -- Falls back to the Approvals link, and **Retry from failed step**, for any other +- Links to the Settings card that holds the Extended analysis preference, and falls + back to the V2 Approvals link, with **Retry from failed step**, for any other approval type or an approval it can't open. The card replaces the notice's generic failure text, recovery lines and retry button, @@ -156,7 +156,8 @@ retry whose progress can't be followed is reconciled with the saved run. | `v2_ui/src/components/chat/MessageInspector.tsx` | Attempt links in **Message details**. | | `v2_ui/src/lib/orchestration.ts` | `approval_id` on failures, and the replaced-attempt helpers. | | `v2_ui/src/lib/orchestrationController.ts` | `launchSavedPlan()`; retries resolve when admitted. | -| `config.py` | Version `0.261.302`. | +| `v2_ui/src/lib/m365Links.ts` | `M365_SHARING_PREFERENCES_HREF`, the card's in-app link to the Microsoft 365 sharing card in Settings. | +| `config.py` | Version `0.261.304`. | ## Validation @@ -184,6 +185,9 @@ retry whose progress can't be followed is reconciled with the saved run. reload, the thread shows only the retry's answer. - Hand-built test namespaces for `_build_export_entry()` and `build_conversation_history_segments()` gained the new helper. +- `functional_tests/test_v2_workflow_run_tracking_xss_guardrail.py` pins + `M365_SHARING_PREFERENCES_HREF` with the other Microsoft 365 links, which the XSS + checker trusts by name. ### Before and after diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 03cf4612f..30e6c8769 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,7 +2,7 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). -### **(v0.261.302)** +### **(v0.261.304)** #### Bug Fixes @@ -22,6 +22,10 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Ver * The retry's answer now replaces the attempt it retried in the thread, including after a reload. The stopped attempt is left out of the model's history and of conversation exports, but stays stored. A completed retry shows no attempt notice, and **Message details** offers **View previous attempt** and **Review saved attempt**. * (Ref: `functions_orchestration_attempts.py`, `functions_orchestration_context.py`, `route_backend_chats.py` `build_conversation_history_segments`, `route_backend_conversation_export.py`, `MessageList.tsx`, `MessageInspector.tsx`) +### **(v0.261.302)** + +#### Bug Fixes + * **Microsoft 365 Sign-In No Longer Fails With `m365_auth_state_invalid`** * Reconnecting Microsoft 365 for chat could fail seconds after the user signed in, leaving raw JSON in the popup. The session store wrote the whole session back at the end of every request, so the request that finished last won. In V2, the startup-data refresh that runs when the window regains focus took about five seconds, loaded the session before the connect call saved the pending sign-in, and wrote its stale copy back afterwards. * A request now saves only the session keys it changed, merged into the latest stored copy, and a request that changed nothing no longer writes its copy back. A session deleted during a request stays deleted. This also stops overlapping requests from dropping token-cache refreshes, the Microsoft 365 CSRF token and other session writes. diff --git a/docs/reference/chat-controls.md b/docs/reference/chat-controls.md index ca01f9825..5b99f977d 100644 --- a/docs/reference/chat-controls.md +++ b/docs/reference/chat-controls.md @@ -531,12 +531,12 @@ saved effective plan, not the current composer selections or a new planner call. | Control | What it does | Why you would use it | Enabled by | | --- | --- | --- | --- | | Retry from failed step | Creates a linked attempt that restores valid completed-step results and executes the incomplete work. | Recover after a failure without repeating successful plan steps or duplicating the question. | `enable_chat_orchestration`, current access, and recoverable saved checkpoints; not a substitute for individual file recovery | -| Confirm retry / Cancel | Confirms the possible external effects of retrying a failed agent/action, or dismisses the confirmation without executing it. Since **0.261.212** it also appears for a step that starts a saved workflow; its retry never starts the workflow twice, and links the run when the plan already started it. Since **0.261.302** it closes as soon as the retry starts, and it doesn't appear for a **Use an action** step that stopped for Microsoft 365 sign-in, approval or policy, which only reads data. | Decide whether it is safe to repeat the failed step's internal tool activity. | A recoverable attempt that requires external-effect confirmation | -| Allow this time / Always allow for SharePoint (or OneDrive) / Quick read only | Since **0.261.302**, shown in the stopped message when a step needs the user's approval to read more of a SharePoint or OneDrive file than a quick read covers, with the amount it needs to read. Each choice saves the decision through the approvals API and continues the plan immediately. **Always allow** also saves the per-source preference; **Quick read only** answers from what fits and says what it left out. | Approve or decline deeper file analysis without leaving the conversation or retrying by hand. | A **Use an action** step stopped with `m365_approval_required` for extended analysis that the user can still decide; other approvals link to **Approvals** | -| Continue | Since **0.261.302**, continues a stopped plan whose Microsoft 365 approval was already decided, for example on the **Approvals** page. | Pick up the plan after deciding elsewhere, without a confirmation. | A recoverable attempt stopped for an approval that is no longer pending | +| Confirm retry / Cancel | Confirms the possible external effects of retrying a failed agent/action, or dismisses the confirmation without executing it. Since **0.261.212** it also appears for a step that starts a saved workflow; its retry never starts the workflow twice, and links the run when the plan already started it. Since **0.261.304** it closes as soon as the retry starts, and it doesn't appear for a **Use an action** step that stopped for Microsoft 365 sign-in, approval or policy, which only reads data. | Decide whether it is safe to repeat the failed step's internal tool activity. | A recoverable attempt that requires external-effect confirmation | +| Allow this time / Always allow for SharePoint (or OneDrive) / Quick read only | Since **0.261.304**, shown in the stopped message when a step needs the user's approval to read more of a SharePoint or OneDrive file than a quick read covers, with the amount it needs to read. Each choice saves the decision through the approvals API and continues the plan immediately. **Always allow** also saves the per-source preference; **Quick read only** answers from what fits and says what it left out. | Approve or decline deeper file analysis without leaving the conversation or retrying by hand. | A **Use an action** step stopped with `m365_approval_required` for extended analysis that the user can still decide; other approvals link to **Approvals** | +| Continue | Since **0.261.304**, continues a stopped plan whose Microsoft 365 approval was already decided, for example on the **Approvals** page. | Pick up the plan after deciding elsewhere, without a confirmation. | A recoverable attempt stopped for an approval that is no longer pending | | Run prepared retry | Starts a recovery attempt that was already prepared but has not executed. | Continue after preparation was saved but execution was interrupted by navigation or connection loss. | An unstarted saved recovery attempt | | Review saved attempt | Opens the selected attempt in the Plan/Run view. | Inspect the failure, completed steps, and remaining work without editing or rerunning history. | A saved orchestration attempt | -| View current attempt / View previous attempt | Opens a linked execution attempt rather than starting another one. Since **0.261.302**, a retried answer offers **View previous attempt** and **Review saved attempt** under **Message details** instead of an attempt notice below the answer. | Follow recovery history and avoid retrying an older attempt that already has a successor. | Linked recovery attempts | +| View current attempt / View previous attempt | Opens a linked execution attempt rather than starting another one. Since **0.261.304**, a retried answer offers **View previous attempt** and **Review saved attempt** under **Message details** instead of an attempt notice below the answer. | Follow recovery history and avoid retrying an older attempt that already has a successor. | Linked recovery attempts | | Check saved status | Reconciles the displayed state with the existing server execution, without running or retrying work. | Check a waiting computation, or find out whether work finished after connection loss. | A waiting attempt, execution-status error, or recovery-detail error | | Stop execution | Requests server cancellation of the potentially active attempt. | Stop work even when it is waiting for retained computation or its stream was interrupted. | A waiting or interrupted connection with a tracked in-flight attempt | @@ -544,7 +544,7 @@ Steps restored from checkpoints show **Reused saved result**. Retry is always manual, including when normal approval is Auto or timed. An older attempt cannot create a competing retry after a newer attempt has been prepared. -Since **0.261.302**, a retry's answer replaces the attempt it retried in the thread. +Since **0.261.304**, a retry's answer replaces the attempt it retried in the thread. The earlier attempt stays saved for the record, but it is hidden, and it is left out of what the model reads on later turns and out of conversation exports. While a prepared or running retry has no saved answer yet, the earlier attempt shows only diff --git a/functional_tests/test_orchestration_m365_approval_retry_flow.py b/functional_tests/test_orchestration_m365_approval_retry_flow.py index 1ac658604..2c66109d7 100644 --- a/functional_tests/test_orchestration_m365_approval_retry_flow.py +++ b/functional_tests/test_orchestration_m365_approval_retry_flow.py @@ -2,8 +2,8 @@ #!/usr/bin/env python3 """ Functional test for the orchestration Microsoft 365 approval and retry flow. -Version: 0.261.302 -Implemented in: 0.261.302 +Version: 0.261.304 +Implemented in: 0.261.304 A plan step that had to read more of a SharePoint or OneDrive file than a quick read covers stops for the user's extended-analysis approval. These tests cover the server half of letting @@ -42,7 +42,7 @@ def test_application_version(): - assert_app_version_at_least("0.261.302") + assert_app_version_at_least("0.261.304") # -------------------------------------------------------------------------------------- diff --git a/functional_tests/test_v2_workflow_run_tracking_xss_guardrail.py b/functional_tests/test_v2_workflow_run_tracking_xss_guardrail.py index 1d54d29f0..a47fbe95a 100644 --- a/functional_tests/test_v2_workflow_run_tracking_xss_guardrail.py +++ b/functional_tests/test_v2_workflow_run_tracking_xss_guardrail.py @@ -2,8 +2,9 @@ # test_v2_workflow_run_tracking_xss_guardrail.py """ Functional test for the V2 chat workflow run tracking passing the XSS sink guardrail. -Version: 0.261.302 +Version: 0.261.304 Implemented in: 0.261.251 +Microsoft 365 sharing preferences link pinned in: 0.261.304 This test ensures that the files behind a chat-started workflow run in V2 pass scripts/check_xss_sinks.py in full: the run card under a plan's answer, the diff --git a/ui_tests/test_v2_orchestration_m365_inline_approval.py b/ui_tests/test_v2_orchestration_m365_inline_approval.py index e7b265e5b..6ed1a3fde 100644 --- a/ui_tests/test_v2_orchestration_m365_inline_approval.py +++ b/ui_tests/test_v2_orchestration_m365_inline_approval.py @@ -1,8 +1,8 @@ # test_v2_orchestration_m365_inline_approval.py """ Real-component browser coverage for deciding a Microsoft 365 approval inside the chat. -Version: 0.261.302 -Implemented in: 0.261.302 +Version: 0.261.304 +Implemented in: 0.261.304 A plan step that had to read more of a SharePoint file than a quick read covers stopped for the user's extended-analysis approval. The stopped attempt's message now offers that decision diff --git a/ui_tests/test_v2_orchestration_recovery_backend.py b/ui_tests/test_v2_orchestration_recovery_backend.py index 82ba517ea..946e7947d 100644 --- a/ui_tests/test_v2_orchestration_recovery_backend.py +++ b/ui_tests/test_v2_orchestration_recovery_backend.py @@ -1,11 +1,11 @@ # test_v2_orchestration_recovery_backend.py """ Browser-to-Flask checkpoint recovery regressions. -Version: 0.261.302 +Version: 0.261.304 Implemented in: 0.261.105 Single orchestration contract updated in: 0.261.139 Cosmos SDK response coverage added in: 0.261.140 -A reloaded thread shows only the retry's answer, not the attempt it replaced, in: 0.261.302 +A reloaded thread shows only the retry's answer, not the attempt it replaced, in: 0.261.304 Real orchestration routes, executor, durable checkpoint codec, conditional attempts, and message persistence run in the shared backend fixture. Only Azure/model/service