diff --git a/application/single_app/config.py b/application/single_app/config.py index 9eba2c3d2..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.303" +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 new file mode 100644 index 000000000..014f6878f --- /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.304 +Implemented in: 0.261.304 +""" + +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 d5204555b..d001d0d63 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.303 +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 @@ -13,6 +13,8 @@ and asks for confirmation when that step can have external effects (0.261.293). A wait left unfinished by an attempt that failed or was stopped no longer blocks a retry; the retry runs the waiting step again (0.261.303). +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.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. @@ -39,11 +41,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 ( @@ -73,6 +76,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. @@ -463,10 +488,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 66e235b9a..61cbd2d35 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.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.304 """ import hashlib @@ -1886,8 +1887,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' @@ -1905,6 +1921,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 @@ -1915,6 +1937,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'), ) @@ -1922,7 +1945,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 a0b3c3cc7..77adc4a71 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -357,6 +357,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, @@ -28462,6 +28463,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 98db27157..a38e4fed6 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, @@ -674,6 +675,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..78a84fd0e --- /dev/null +++ b/application/v2_ui/src/components/chat/M365ApprovalInlineCard.tsx @@ -0,0 +1,247 @@ +// 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, M365_SHARING_PREFERENCES_HREF } from '../../lib/m365Links'; + +const SOURCE_NAMES: Record = { + calendar: 'Calendar', + email: 'Email', + onedrive: 'OneDrive', + spo: 'SharePoint', +}; + +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 2bc77da8f..8f1311a3e 100644 --- a/application/v2_ui/src/components/chat/MessageInspector.tsx +++ b/application/v2_ui/src/components/chat/MessageInspector.tsx @@ -26,6 +26,8 @@ import { type DetailGroup, type MessageSources, } from '../../lib/messageDetails'; +import { normalizeOrchestrationAttempt } from '../../lib/orchestration'; +import { openOrchestrationRecovery } from '../../lib/orchestrationController'; import { m365ChipLabel, m365SecondaryLine } from '../../lib/m365Citations'; import type { ChatMessage, Json, PersistedThought } from '../../lib/types'; import { buildToolResultView, type RowMode } from '../../lib/agentCitationRows'; @@ -64,6 +66,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); @@ -441,7 +473,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 59a2128d6..991bdb497 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 outputs = attempt.outputs ?? []; @@ -129,6 +134,57 @@ 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), + ); + // The card continues the plan, so it is offered wherever a plan retry is. + const inlineApproval = failed && !replaced && planRetryOffered && !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 ? (

@@ -173,7 +227,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 @@ -216,13 +270,7 @@ export function OrchestrationRecoveryNotice({ ) : null}
- {confirmationVersion ? ( - void retry(confirmationVersion)} - onClose={() => setConfirmationVersion(null)} - /> - ) : null} + {confirmation}
); } diff --git a/application/v2_ui/src/lib/conversationGeneratedFiles.ts b/application/v2_ui/src/lib/conversationGeneratedFiles.ts index b98b1392b..78b61ef72 100644 --- a/application/v2_ui/src/lib/conversationGeneratedFiles.ts +++ b/application/v2_ui/src/lib/conversationGeneratedFiles.ts @@ -9,8 +9,8 @@ // reply that plainly made one. // // The thread's own readers are reused, so the drawer lists exactly what the thread shows: the -// same artifact normaliser, the same committed-output check, the same masking, saved-analysis and -// workflow-reply rules, and the live orchestration state the file cards poll. +// same artifact normaliser, the same committed-output check, the same masking, saved-analysis, +// workflow-reply and replaced-attempt rules, and the live orchestration state the file cards poll. // // Documents an agent created with the SimpleChat upload actions are listed by the server instead // (generatedDocuments.ts), because the server reads their ids from the tool results it stored. @@ -28,7 +28,11 @@ import { type GeneratedArtifact, } from './generatedArtifacts'; import { readMaskState } from './masking'; -import { normalizeOrchestrationAttempt } from './orchestration'; +import { + isSupersededOrchestrationAttempt, + normalizeOrchestrationAttempt, + supersededOrchestrationRunIds, +} from './orchestration'; import { committedOrchestrationArtifact, isOrchestrationOutputArtifact, @@ -340,9 +344,14 @@ export function collectConversationGeneratedFiles( messages: readonly ChatMessage[], sources: GeneratedFileSources = {}, ): ConversationGeneratedFile[] { + // The thread hides a plan attempt that a later retry replaced, so its files are not listed. + const superseded = supersededOrchestrationRunIds(messages); const seen = new Set(); const files: ConversationGeneratedFile[] = []; for (const message of messages) { + if (isSupersededOrchestrationAttempt(message, superseded)) { + continue; + } for (const file of messageGeneratedFiles(message, sources)) { if (!seen.has(file.key)) { seen.add(file.key); @@ -416,9 +425,11 @@ export function visibleGeneratedDocuments( documents: readonly GeneratedDocument[], messages: readonly ChatMessage[], ): GeneratedDocument[] { + const superseded = supersededOrchestrationRunIds(messages); const shown = new Set( messages .filter((message) => message?.id && !isSupersededByWorkflowReply(message) + && !isSupersededOrchestrationAttempt(message, superseded) && !readMaskState(message).fullyMasked) .map((message) => message.id), ); diff --git a/application/v2_ui/src/lib/m365Links.ts b/application/v2_ui/src/lib/m365Links.ts index 359662b04..480f006b1 100644 --- a/application/v2_ui/src/lib/m365Links.ts +++ b/application/v2_ui/src/lib/m365Links.ts @@ -8,4 +8,6 @@ export const M365_CHAT_CONNECTION_HREF = '/settings?tab=preferences§ion=m365-chat-connection'; export const M365_CONNECT_HREF = '/settings?tab=preferences§ion=m365-workflow-connection'; -export const M365_APPROVALS_HREF = '/approvals/m365'; \ No newline at end of file +export const M365_APPROVALS_HREF = '/approvals/m365'; +/** The sharing card, which also holds each file source's Extended analysis preference. */ +export const M365_SHARING_PREFERENCES_HREF = '/settings?tab=preferences§ion=m365-sharing'; \ No newline at end of file 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 693790752..e3bef167b 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.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 + 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,21 @@ 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.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.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, out of conversation exports +and out of the Documents drawer, 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..73ed5a717 --- /dev/null +++ b/docs/explanation/fixes/ORCHESTRATION_M365_APPROVAL_RETRY_FLOW_FIX.md @@ -0,0 +1,227 @@ +# Orchestration Microsoft 365 Approval and Retry Flow Fix + +**Version: 0.261.304** + +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 +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 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, +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. +- `collectConversationGeneratedFiles()` and `visibleGeneratedDocuments()` in + `lib/conversationGeneratedFiles.ts` skip replaced attempts too. The V2 Documents + drawer lists the files and agent documents of the replies the thread shows, so a + hidden attempt's files aren't listed either. +- 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. | +| `v2_ui/src/lib/m365Links.ts` | `M365_SHARING_PREFERENCES_HREF`, the card's in-app link to the Microsoft 365 sharing card in Settings. | +| `v2_ui/src/lib/conversationGeneratedFiles.ts` | The Documents drawer skips replaced attempts' files and agent documents. | +| `config.py` | Version `0.261.304`. | + +## 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. +- After integrating Microsoft 365 source citations, the Analyze route harness also + loads the real citation attachment and conversation aggregation helpers. Its 29 + tests pass independently, alongside the citation/retry backend and browser tests. +- `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. +- `functional_tests/test_v2_drawer_generated_files_logic.mjs` checks that a replaced + attempt's files and agent documents leave the Documents drawer once the retry exists. + +### 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, exports and Documents drawer; 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 c9ff25db6..62012a6f8 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.304)** + +#### 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, conversation exports and the **Documents** drawer, 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`, `conversationGeneratedFiles.ts`) + ### **(v0.261.303)** #### New Features diff --git a/docs/reference/chat-controls.md b/docs/reference/chat-controls.md index 39fe53a2e..d9f390e13 100644 --- a/docs/reference/chat-controls.md +++ b/docs/reference/chat-controls.md @@ -563,10 +563,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. For an attempt with files, only when a failed file can't be retried on its own and no file is still being prepared (since **0.261.303**) | -| 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.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. | 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 | @@ -574,6 +576,13 @@ 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.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, out of conversation exports and out of the +**Documents** drawer. 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..047405a7c 100644 --- a/functional_tests/test_analyze_backend_saved_integration.py +++ b/functional_tests/test_analyze_backend_saved_integration.py @@ -1,11 +1,12 @@ # test_analyze_backend_saved_integration.py """ Behavioral integration tests for Analyze presentation and saved-data chat reuse. -Version: 0.261.257 +Version: 0.261.304 Implemented in: 0.261.109 Container-only saved-result access covered in: 0.261.232 Message family constants added to the chat harness in: 0.261.234 Soft-deleted message filter added to the chat harness in: 0.261.257 +Microsoft 365 citation helpers added to the chat harness in: 0.261.304 Real adapter, artifact, history, chat route and shared section-reader functions run against serialized storage, Flask requests and deterministic model doubles. @@ -59,6 +60,8 @@ 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") +m365_citations = import_app_module("functions_m365_citations") def load_m365_runtime(): @@ -416,6 +419,9 @@ def read_m365_conversation(user_id, conversation_id): "initialize_m365_chat_context": m365_runtime.initialize_m365_chat_context, "get_m365_execution_context": m365_runtime.get_m365_execution_context, "attach_m365_message_provenance": m365_runtime.attach_m365_message_provenance, + "attach_m365_message_citations": m365_citations.attach_m365_message_citations, + "merge_m365_items_into_conversation": m365_citations.merge_m365_items_into_conversation, + "_get_current_message_plugin_invocations": lambda *args: [], "complete_m365_request": m365_runtime.complete_m365_request, "M365ApprovalRequired": m365_runtime.M365ApprovalRequired, "M365PolicyError": m365_runtime.M365PolicyError, @@ -483,6 +489,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, @@ -597,6 +604,7 @@ def rollback(*args, **kwargs): "_validate_reauthorized_manifest_finalization", "_build_generated_analysis_metadata", "_normalize_generated_analysis_artifact_metadata", "_persist_screened_assistant", + "_attach_m365_citations", "_merge_m365_conversation_items", "_refresh_workspace_linked_history_message", } load_functions("route_backend_chats.py", names, namespace) 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..2c66109d7 --- /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.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 +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.304") + + +# -------------------------------------------------------------------------------------- +# 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/functional_tests/test_v2_drawer_generated_files_logic.mjs b/functional_tests/test_v2_drawer_generated_files_logic.mjs index 7b99a6953..be7649abc 100644 --- a/functional_tests/test_v2_drawer_generated_files_logic.mjs +++ b/functional_tests/test_v2_drawer_generated_files_logic.mjs @@ -1,6 +1,7 @@ // test_v2_drawer_generated_files_logic.mjs -// Version: 0.261.302 +// Version: 0.261.304 // Implemented in: 0.261.302 +// Plan attempts a later retry replaced left out, as the thread leaves them out, in: 0.261.304 // Executes the real V2 helpers behind the Generated section of the conversation drawer: every // file a reply produced is listed for any conversation, not only shared ones. That covers each // format an orchestration plan can render, the screenshot case of a finished CSV, legacy @@ -285,6 +286,18 @@ try { ['doc-partial'], 'masking part of a reply keeps its documents, as the server does', ); + // A plan attempt that a later retry replaced is hidden from the thread, so the drawer leaves + // out its files and agent documents too; until a retry exists, they are listed. + const stoppedAttempt = planReply('a-stopped', 'run-stopped', [output('stopped_report', 'docx', 'completed')]); + const retriedAttempt = reply('a-retried', { orchestration: { run_id: 'run-retried', retry_of_run_id: 'run-stopped' } }); + assert.deepEqual(collectConversationGeneratedFiles([stoppedAttempt]).map((file) => file.messageId), ['a-stopped']); + assert.deepEqual(collectConversationGeneratedFiles([stoppedAttempt, retriedAttempt]), [], + 'a replaced attempt lists no files'); + assert.deepEqual( + visibleGeneratedDocuments([agentDocument('doc-stopped', 'a-stopped')], [stoppedAttempt, retriedAttempt]), [], + "a replaced attempt's agent documents are not listed", + ); + // Files and agent documents share one list in conversation order; a shared document id is listed once. const brief = { document_id: 'doc-brief', file_name: 'Brief.docx', workspace_scope: 'personal', preview: null, 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 51f831aa9..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 @@ -212,6 +213,7 @@ def test_fixed_link_values_stay_same_origin() -> None: 'M365_CHAT_CONNECTION_HREF': '/settings?tab=preferences§ion=m365-chat-connection', 'M365_CONNECT_HREF': '/settings?tab=preferences§ion=m365-workflow-connection', 'M365_APPROVALS_HREF': '/approvals/m365', + 'M365_SHARING_PREFERENCES_HREF': '/settings?tab=preferences§ion=m365-sharing', } assert m365_source.count('export ') == len(exported) 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..6ed1a3fde --- /dev/null +++ b/ui_tests/test_v2_orchestration_m365_inline_approval.py @@ -0,0 +1,297 @@ +# test_v2_orchestration_m365_inline_approval.py +""" +Real-component browser coverage for deciding a Microsoft 365 approval inside the chat. +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 +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_recovery as recovery_tests +from test_v2_orchestration_plan_editor import ( # noqa: F401 + RUN, + 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 == 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§ion=m365-sharing", + ) + # 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/m365", + ) + 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"**{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..946e7947d 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.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.304 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()